scuttle/crates/scuttle-signing/src/lib.rs

311 lines
12 KiB
Rust
Executable File

//! 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<SigningKey, SigningError> {
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<SignatureResult, SigningError> {
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<bool, SigningError> {
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<bool, SigningError> {
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<SignatureResult, SigningError>;
}
/// 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<SignatureResult, SigningError> {
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<SignatureResult, SigningError> {
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<SignatureResult, SigningError> {
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<dyn scuttle_prng::PrngProvider> = 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();
}
}