311 lines
12 KiB
Rust
Executable File
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();
|
|
}
|
|
}
|