//! Layer 7 - Audit record signing. //! //! Bind a scuttle audit record to an operator identity via Ed25519 digital //! signatures. Per `docs/MANIFEST.md` §5 Layer 7 and §6.4, scuttle supports //! three signer backends: //! * Ed25519 (this crate, fully implemented using `ed25519-dalek`) //! * OpenPGP (backend not available in this release; uses `gpgme`) //! * X.509 (backend not available in this release; uses OpenSSL) //! //! v0.5 scope: Ed25519 sign + verify. The signer signs the canonical JSON //! serialization of the audit record. The signature is detached and //! serialized as a hex string for embedding in the certificate. //! //! Key management: scuttle does NOT generate or store private keys. The //! operator provides a key file (raw 32-byte seed) or a key fingerprint for //! lookup. This matches the manifest's "Key management — keys are supplied //! by Layer 18 (HSM/TPM/OpenSSL engine) or loaded from a configured path". use std::path::Path; use thiserror::Error; use ed25519_dalek::{Signer, Verifier, SigningKey, VerifyingKey, Signature}; use scuttle_audit::AuditRecord; #[derive(Debug, Error)] pub enum SigningError { #[error("I/O error: {0}")] Io(#[from] std::io::Error), #[error("ed25519 error: {0}")] Ed25519(String), #[error("invalid key length: expected 32, got {0}")] InvalidKeyLength(usize), #[error("OpenPGP signing is not available in this release")] OpenPgpNotImplemented, #[error("X.509 signing is not available in this release")] X509NotImplemented, } /// Signer backend kind. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SignerKind { Ed25519, OpenPgp, X509, } /// A signing result: the signature bytes + algorithm name + key fingerprint. #[derive(Debug, Clone)] pub struct SignatureResult { pub algorithm: String, // "ed25519", "openpgp", "x509" pub signature_hex: String, pub key_fingerprint: String, // hex SHA-256 of the public key pub signed_payload_hash: String, // hex SHA-256 of the canonical JSON that was signed } /// Load an Ed25519 signing key from a 32-byte seed file. pub fn load_ed25519_key(path: &Path) -> Result { let bytes = std::fs::read(path)?; if bytes.len() != 32 { return Err(SigningError::InvalidKeyLength(bytes.len())); } let seed: [u8; 32] = bytes.as_slice().try_into().unwrap(); Ok(SigningKey::from_bytes(&seed)) } /// Compute the SHA-256 fingerprint of a public key (hex-encoded). pub fn key_fingerprint(public_key: &VerifyingKey) -> String { use sha2::Digest; let mut h = sha2::Sha256::new(); h.update(public_key.to_bytes()); hex::encode(h.finalize()) } /// Sign an audit record with Ed25519. Returns the signature + key fingerprint. pub fn sign_ed25519(record: &AuditRecord, key: &SigningKey) -> Result { let canonical = record.to_canonical_json() .map_err(|e| SigningError::Ed25519(format!("canonical JSON: {e}")))?; let signature: Signature = key.sign(canonical.as_bytes()); let verifying = key.verifying_key(); let fp = key_fingerprint(&verifying); use sha2::Digest; let mut h = sha2::Sha256::new(); h.update(canonical.as_bytes()); let payload_hash = hex::encode(h.finalize()); Ok(SignatureResult { algorithm: "ed25519".into(), signature_hex: hex::encode(signature.to_bytes()), key_fingerprint: fp, signed_payload_hash: payload_hash, }) } /// Verify an Ed25519 signature against an audit record. /// /// Note: this function cannot verify without the public key. Use /// `verify_ed25519_with_key` instead, which takes the verifying key. pub fn verify_ed25519(_record: &AuditRecord, sig: &SignatureResult) -> Result { if sig.algorithm != "ed25519" { return Err(SigningError::Ed25519(format!("not an ed25519 signature: {}", sig.algorithm))); } Err(SigningError::Ed25519("use verify_ed25519_with_key (requires the public key)".into())) } /// Verify an Ed25519 signature against an audit record, given the verifying key. pub fn verify_ed25519_with_key( record: &AuditRecord, sig: &SignatureResult, public_key: &VerifyingKey, ) -> Result { let canonical = record.to_canonical_json() .map_err(|e| SigningError::Ed25519(format!("canonical JSON: {e}")))?; let sig_bytes = hex::decode(&sig.signature_hex) .map_err(|e| SigningError::Ed25519(format!("hex decode: {e}")))?; if sig_bytes.len() != 64 { return Err(SigningError::Ed25519(format!("bad signature length: {}", sig_bytes.len()))); } let sig_arr: [u8; 64] = sig_bytes.as_slice().try_into().unwrap(); let signature = Signature::from_bytes(&sig_arr); Ok(public_key.verify(canonical.as_bytes(), &signature).is_ok()) } /// Generate a new Ed25519 key pair (for testing — operators should supply /// their own keys). pub fn generate_ed25519_keypair() -> (SigningKey, VerifyingKey) { use rand::rngs::OsRng; let mut csprng = OsRng; let signing = SigningKey::generate(&mut csprng); let verifying = signing.verifying_key(); (signing, verifying) } /// Signer backend trait (for future OpenPGP / X.509 backends). pub trait SignerBackend: Send + Sync { fn kind(&self) -> SignerKind; fn sign(&self, record: &AuditRecord) -> Result; } /// Ed25519 signer backend (implements SignerBackend). pub struct Ed25519Signer { key: SigningKey, } impl Ed25519Signer { pub fn new(key: SigningKey) -> Self { Self { key } } } impl SignerBackend for Ed25519Signer { fn kind(&self) -> SignerKind { SignerKind::Ed25519 } fn sign(&self, record: &AuditRecord) -> Result { sign_ed25519(record, &self.key) } } /// OpenPGP signer backend (backend not available in this release). pub struct OpenPgpSigner; impl SignerBackend for OpenPgpSigner { fn kind(&self) -> SignerKind { SignerKind::OpenPgp } fn sign(&self, _record: &AuditRecord) -> Result { Err(SigningError::OpenPgpNotImplemented) } } /// X.509 signer backend (backend not available in this release). pub struct X509Signer; impl SignerBackend for X509Signer { fn kind(&self) -> SignerKind { SignerKind::X509 } fn sign(&self, _record: &AuditRecord) -> Result { Err(SigningError::X509NotImplemented) } } /// JSON-friendly mirror of `SignatureResult` for embedding in the audit record. #[derive(Debug, Clone, serde::Serialize)] pub struct SignatureJson { pub algorithm: String, pub signature_hex: String, pub key_fingerprint: String, pub signed_payload_hash: String, } impl From<&SignatureResult> for SignatureJson { fn from(s: &SignatureResult) -> Self { Self { algorithm: s.algorithm.clone(), signature_hex: s.signature_hex.clone(), key_fingerprint: s.key_fingerprint.clone(), signed_payload_hash: s.signed_payload_hash.clone(), } } } // We depend on sha2 directly for the key fingerprint + payload hash. // (Cargo.toml has sha2 as a dependency; no extern crate needed in 2021 edition.) #[cfg(test)] mod tests { use super::*; use scuttle_audit::AuditRecord; use scuttle_devices::{Bus, NwipeDevice}; use scuttle_media::{MediaDescriptor, PurgeMethod}; use scuttle_methods::zero; use std::sync::Arc; use scuttle_prng::ChaCha20Prng; fn fake_record() -> AuditRecord { let dev = NwipeDevice { path: "/dev/loop0".into(), model: "TestLoop".into(), serial: "TEST-SN".into(), wwn: String::new(), firmware_rev: "REV1".into(), bus: Bus::Loop, size_bytes: 1024 * 1024, logical_block_size: 512, physical_block_size: 512, rotational: false, removable: false, smart_health_ok: None, wear_level_pct: None, supports_ata_se: false, supports_ata_se_enhanced: false, supports_nvme_sanitize: false, supports_nvme_format: false, supports_scsi_sanitize: false, hpa_present: false, dco_present: false, media_class: "virtual".into(), sysfs_path: String::new(), driver: String::new(), }; let media = MediaDescriptor { media_class: "virtual".into(), media_subclass: "virtual".into(), recommends_clear: true, recommends_purge: false, recommends_destroy: false, purge_method: PurgeMethod::None, overwrite_recommended_after_purge: false, rationale: "test".into(), }; let prng: Arc = Arc::new(ChaCha20Prng); let method = zero(); let mut r = AuditRecord::new(&dev, &media, &method, "SHA-256", vec!["ChaCha20 (CSPRNG)".into()], "deadbeef".repeat(8)); r.result = "success".into(); r } #[test] fn ed25519_sign_and_verify_roundtrip() { let (signing, verifying) = generate_ed25519_keypair(); let record = fake_record(); let sig = sign_ed25519(&record, &signing).unwrap(); assert_eq!(sig.algorithm, "ed25519"); assert!(!sig.signature_hex.is_empty()); assert!(!sig.key_fingerprint.is_empty()); assert_eq!(sig.signed_payload_hash.len(), 64); // SHA-256 hex // Verify with the matching public key. let ok = verify_ed25519_with_key(&record, &sig, &verifying).unwrap(); assert!(ok, "signature must verify with matching key"); } #[test] fn ed25519_verify_fails_with_wrong_key() { let (signing, _) = generate_ed25519_keypair(); let (_, wrong_verifying) = generate_ed25519_keypair(); let record = fake_record(); let sig = sign_ed25519(&record, &signing).unwrap(); let ok = verify_ed25519_with_key(&record, &sig, &wrong_verifying).unwrap(); assert!(!ok, "signature must NOT verify with wrong key"); } #[test] fn ed25519_signature_changes_with_record() { let (signing, _) = generate_ed25519_keypair(); let mut record = fake_record(); let sig1 = sign_ed25519(&record, &signing).unwrap(); record.result = "failure".into(); let sig2 = sign_ed25519(&record, &signing).unwrap(); assert_ne!(sig1.signature_hex, sig2.signature_hex, "signatures must differ for different records"); } #[test] fn openpgp_returns_not_implemented() { let s = OpenPgpSigner; let r = s.sign(&fake_record()); assert!(matches!(r, Err(SigningError::OpenPgpNotImplemented))); } #[test] fn x509_returns_not_implemented() { let s = X509Signer; let r = s.sign(&fake_record()); assert!(matches!(r, Err(SigningError::X509NotImplemented))); } #[test] fn ed25519_signer_backend_trait() { let (signing, _) = generate_ed25519_keypair(); let backend = Ed25519Signer::new(signing); assert_eq!(backend.kind(), SignerKind::Ed25519); let sig = backend.sign(&fake_record()).unwrap(); assert_eq!(sig.algorithm, "ed25519"); } #[test] fn load_ed25519_key_rejects_wrong_length() { let path = std::env::temp_dir().join(format!("scuttle-key-{}.bin", uuid::Uuid::new_v4())); std::fs::write(&path, &[0u8; 16]).unwrap(); let r = load_ed25519_key(&path); assert!(matches!(r, Err(SigningError::InvalidKeyLength(16)))); std::fs::remove_file(&path).ok(); } #[test] fn load_ed25519_key_loads_32_bytes() { let path = std::env::temp_dir().join(format!("scuttle-key-{}.bin", uuid::Uuid::new_v4())); std::fs::write(&path, &[0x42u8; 32]).unwrap(); let key = load_ed25519_key(&path).unwrap(); let fp = key_fingerprint(&key.verifying_key()); assert_eq!(fp.len(), 64); // SHA-256 hex std::fs::remove_file(&path).ok(); } }