diff --git a/Cargo.lock b/Cargo.lock index 7725491..3d1a082 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -669,6 +669,28 @@ dependencies = [ "typenum", ] +[[package]] +name = "cryptoki" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff765b99fc49f3116c9a908484486a2b92fd73c48da45c3a69716471c6cc56c6" +dependencies = [ + "bitflags 2.11.0", + "cryptoki-sys", + "libloading", + "log", + "secrecy", +] + +[[package]] +name = "cryptoki-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fd850498411e4057f1cba79e6e2bc7cbe960544c1046ab46d4685c403a1121" +dependencies = [ + "libloading", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1696,6 +1718,7 @@ dependencies = [ "coap-message", "coap-message-implementations", "criterion", + "cryptoki", "env_logger", "heapless", "hex", @@ -2156,6 +2179,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "semver" version = "1.0.26" diff --git a/Cargo.toml b/Cargo.toml index 7737ef9..7123710 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,8 @@ default = [ embedded = ["no-std", "heapless", "serde"] heapless = [] mqtt = ["std", "rumqttc", "tokio", "dep:rand", "dep:serde_json", "serde_json/std"] +# Gateway HSM integration (PKCS#11). This is feature-gated and vendor-specific. +hsm-pkcs11 = ["std", "dep:cryptoki"] # ... no-std = [] std = ["rand_core/getrandom", "log/std"] @@ -116,6 +118,7 @@ hmac = { version = "0.12", default-features = false } log = { version = "0.4.29", default-features = false } hex = { version = "0.4.3", default-features = false, features = ["alloc"] } x25519-dalek = { version = "2.0.1", default-features = false, features = ["static_secrets", "zeroize"] } +cryptoki = { version = "0.12.0", optional = true } [dev-dependencies] criterion = "0.5" diff --git a/SECURITY_INVARIANTS.md b/SECURITY_INVARIANTS.md index 53e7d74..92a8d00 100644 --- a/SECURITY_INVARIANTS.md +++ b/SECURITY_INVARIANTS.md @@ -69,6 +69,24 @@ Implementation note: - Software providers implement monotonic counters via sealed blobs (best-effort, rollbackable). - Hardware providers must override monotonic counter operations to use rollback-resistant primitives. +### 1.3 HSM secrecy boundaries are explicit (no accidental secret export) + +**Invariant:** When a provider claims a token-resident identity key, the protocol path MUST NOT +silently fall back to exporting derived shared secrets into host memory. + +Concrete case (gateway PKCS#11 HSM): + +- If the backend exposes a token-resident X25519 identity (`PqcHsmBackend::x25519_public_key() -> Some(_)`) + then hybrid v1 decryption MUST use the **full in-token** path: + - Kyber shared secret is derived as a non-extractable key object (vendor KEM `C_DeriveKey`) + - X25519 shared secret is derived as a non-extractable key object (`CKM_ECDH1_DERIVE`) + - concat + HKDF + AES-GCM decrypt occur inside the token +- If the backend cannot provide full in-token decrypt, provider construction MUST fail (fail closed). + +Regression coverage: + +- `/Users/mac/Projects/pqc-iiot/src/security/hsm.rs::gateway_hsm_provider_uses_full_in_token_decrypt_when_x25519_is_token_resident` + --- ## 2. Fleet Policy / Revocation Invariants (Partitions) diff --git a/examples/pkcs11_provision_x25519.rs b/examples/pkcs11_provision_x25519.rs new file mode 100644 index 0000000..356e2bf --- /dev/null +++ b/examples/pkcs11_provision_x25519.rs @@ -0,0 +1,202 @@ +#[cfg(feature = "hsm-pkcs11")] +fn main() -> pqc_iiot::Result<()> { + use cryptoki::context::{CInitializeArgs, CInitializeFlags, Pkcs11}; + use cryptoki::mechanism::Mechanism; + use cryptoki::object::{Attribute, AttributeType, KeyType, ObjectClass}; + use cryptoki::session::UserType; + use cryptoki::slot::Slot; + use cryptoki::types::AuthPin; + use pqc_iiot::{Error, Result}; + + fn getenv(name: &str) -> Result { + std::env::var(name).map_err(|_| Error::InvalidInput(format!("Missing env var: {}", name))) + } + + fn find_slot_by_token_label(pkcs11: &Pkcs11, want_label: &str) -> Result { + let slots = pkcs11.get_slots_with_token().map_err(|e| { + Error::ClientError(format!("PKCS#11 get_slots_with_token failed: {}", e)) + })?; + for slot in slots { + let info = pkcs11 + .get_token_info(slot) + .map_err(|e| Error::ClientError(format!("PKCS#11 token_info failed: {}", e)))?; + let label = info.label().trim(); + if label == want_label.trim() { + return Ok(slot); + } + } + Err(Error::ClientError(format!( + "PKCS#11 token not found (label={})", + want_label + ))) + } + + fn der_unwrap_octet_string(mut blob: &[u8]) -> Option<&[u8]> { + // Minimal DER parser: OCTET STRING tag (0x04), short-form length only. + if blob.len() < 2 { + return None; + } + if blob[0] != 0x04 { + return None; + } + let len = blob[1] as usize; + blob = &blob[2..]; + if blob.len() != len { + return None; + } + Some(blob) + } + + // Inputs (env-vars keep this zero-dependency; integrate into your provisioning tooling as needed). + let library_path = getenv("PQC_IIOT_PKCS11_LIBRARY")?; + let user_pin = getenv("PQC_IIOT_PKCS11_PIN")?; + let x25519_label = getenv("PQC_IIOT_PKCS11_X25519_LABEL")?; + + let slot = if let Ok(v) = std::env::var("PQC_IIOT_PKCS11_SLOT") { + let n: u64 = v + .parse() + .map_err(|_| Error::InvalidInput("Invalid PQC_IIOT_PKCS11_SLOT".into()))?; + Slot::try_from(n) + .map_err(|e| Error::InvalidInput(format!("Invalid PKCS#11 slot {}: {}", n, e)))? + } else { + let token_label = getenv("PQC_IIOT_PKCS11_TOKEN_LABEL")?; + let pkcs11 = Pkcs11::new(&library_path) + .map_err(|e| Error::ClientError(format!("PKCS#11 library load failed: {}", e)))?; + pkcs11 + .initialize(CInitializeArgs::new(CInitializeFlags::OS_LOCKING_OK)) + .map_err(|e| Error::ClientError(format!("PKCS#11 initialize failed: {}", e)))?; + let slot = find_slot_by_token_label(&pkcs11, &token_label)?; + pkcs11.finalize().ok(); + slot + }; + + let pkcs11 = Pkcs11::new(&library_path) + .map_err(|e| Error::ClientError(format!("PKCS#11 library load failed: {}", e)))?; + pkcs11 + .initialize(CInitializeArgs::new(CInitializeFlags::OS_LOCKING_OK)) + .map_err(|e| Error::ClientError(format!("PKCS#11 initialize failed: {}", e)))?; + + let session = pkcs11 + .open_rw_session(slot) + .map_err(|e| Error::ClientError(format!("PKCS#11 open session failed: {}", e)))?; + let user_pin = AuthPin::new(user_pin.into_boxed_str()); + session + .login(UserType::User, Some(&user_pin)) + .map_err(|e| Error::ClientError(format!("PKCS#11 login failed: {}", e)))?; + + let pub_label = format!("{}.pub", x25519_label); + + fn find_unique_object( + session: &cryptoki::session::Session, + label: &str, + class: ObjectClass, + key_type: KeyType, + ) -> Result> { + let template = [ + Attribute::Label(label.as_bytes().to_vec()), + Attribute::Class(class), + Attribute::KeyType(key_type), + ]; + let objects = session + .find_objects(&template) + .map_err(|e| Error::ClientError(format!("PKCS#11 find_objects failed: {}", e)))?; + match objects.as_slice() { + [] => Ok(None), + [one] => Ok(Some(*one)), + _ => Err(Error::ClientError(format!( + "PKCS#11 label is not unique (label={}, matches={})", + label, + objects.len() + ))), + } + } + + let mut priv_key = find_unique_object( + &session, + &x25519_label, + ObjectClass::PRIVATE_KEY, + KeyType::EC_MONTGOMERY, + )?; + let mut pub_key = find_unique_object( + &session, + &pub_label, + ObjectClass::PUBLIC_KEY, + KeyType::EC_MONTGOMERY, + )?; + + if priv_key.is_none() && pub_key.is_none() { + let (pubk, privk) = { + // Generate a token-resident X25519 keypair. + // OID: 1.3.101.110 (id-X25519) DER = 06 03 2B 65 6E + let ec_params = vec![0x06, 0x03, 0x2B, 0x65, 0x6E]; + let pub_t = [ + Attribute::Class(ObjectClass::PUBLIC_KEY), + Attribute::KeyType(KeyType::EC_MONTGOMERY), + Attribute::Token(true), + Attribute::Label(pub_label.as_bytes().to_vec()), + Attribute::EcParams(ec_params.clone()), + ]; + let priv_t = [ + Attribute::Class(ObjectClass::PRIVATE_KEY), + Attribute::KeyType(KeyType::EC_MONTGOMERY), + Attribute::Token(true), + Attribute::Private(true), + Attribute::Sensitive(true), + Attribute::Extractable(false), + Attribute::Derive(true), + Attribute::Label(x25519_label.as_bytes().to_vec()), + Attribute::EcParams(ec_params), + ]; + session + .generate_key_pair(&Mechanism::EccMontgomeryKeyPairGen, &pub_t, &priv_t) + .map_err(|e| Error::CryptoError(format!("PKCS#11 keypairgen failed: {}", e)))? + }; + priv_key = Some(privk); + pub_key = Some(pubk); + } + + let _priv_key = priv_key.ok_or_else(|| { + Error::ClientError(format!( + "Missing X25519 private key (label={}); reprovision recommended", + x25519_label + )) + })?; + let pub_key = pub_key.ok_or_else(|| { + Error::ClientError(format!( + "Missing X25519 public key (label={}); reprovision recommended", + pub_label + )) + })?; + + // Export public key bytes (this is not secret). + let attrs = session + .get_attributes(pub_key, &[AttributeType::EcPoint]) + .map_err(|e| Error::ClientError(format!("PKCS#11 get_attributes failed: {}", e)))?; + let pk_blob = match attrs.as_slice() { + [Attribute::EcPoint(b)] => b.as_slice(), + _ => { + return Err(Error::ClientError( + "Unexpected PKCS#11 attributes for EC public key".into(), + )) + } + }; + let raw = der_unwrap_octet_string(pk_blob).unwrap_or(pk_blob); + if raw.len() != 32 { + return Err(Error::ClientError(format!( + "Unexpected X25519 public key length from token: {}", + raw.len() + ))); + } + + println!("x25519_public_key_hex={}", hex::encode(raw)); + + session.logout().ok(); + session.close().ok(); + pkcs11.finalize().ok(); + Ok(()) +} + +#[cfg(not(feature = "hsm-pkcs11"))] +fn main() { + eprintln!("This example requires `--features hsm-pkcs11`."); +} diff --git a/src/security/hsm.rs b/src/security/hsm.rs new file mode 100644 index 0000000..2e4d81d --- /dev/null +++ b/src/security/hsm.rs @@ -0,0 +1,732 @@ +use crate::security::provider::SecurityProvider; +use crate::security::root_of_trust::RootOfTrust; +use crate::{Error, Result}; +use rand_core::OsRng; +use rand_core::RngCore; +use sha2::Digest; +use std::sync::Arc; +use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519StaticSecret}; +use zeroize::Zeroize; + +/// PQC-capable HSM backend boundary. +/// +/// This models the subset we need for gateway-grade deployments: +/// - non-exportable long-term signature key (`sign`) +/// - non-exportable long-term KEM secret key (`kem_decapsulate`) +/// - stable export of public keys (for certificates + announcements) +/// +/// Notes: +/// - KEM encapsulation does not require the HSM (recipient public key is public); we only require +/// decapsulation to keep the KEM secret key non-exportable. +/// - Many real HSMs expose PQC via PKCS#11 vendor-defined mechanisms. The `hsm_pkcs11` module +/// implements a concrete backend when enabled. +pub trait PqcHsmBackend: Send + Sync { + /// Human-readable backend kind string for observability. + fn hsm_kind(&self) -> &'static str; + + /// Return the PQC KEM public key bytes. + fn kem_public_key(&self) -> Result>; + + /// Return the PQC signature public key bytes. + fn sig_public_key(&self) -> Result>; + + /// Optional token-resident X25519 identity public key (32 bytes). + /// + /// When present, the gateway provider treats X25519 as a non-exportable HSM identity key. + /// That implies: + /// - the provider will not store an X25519 static secret in host memory, and + /// - decrypt must use `decrypt_hybrid_v1_full_in_token` for v1 packets (no exported `x25519_ss`). + fn x25519_public_key(&self) -> Result> { + Ok(None) + } + + /// Sign a message using the HSM-resident signature secret key. + fn sign(&self, message: &[u8]) -> Result>; + + /// Decapsulate a KEM capsule and return the 32-byte shared secret. + fn kem_decapsulate(&self, kem_ciphertext: &[u8]) -> Result<[u8; 32]>; + + /// Return `true` if this backend can decrypt hybrid v1 packets fully inside the token + /// (i.e., without exporting the KEM shared secret to host memory). + /// + /// This requires **more** than decapsulation: + /// - KEM decapsulation producing a non-extractable secret key object (`C_DeriveKey`-style), + /// - key derivation capabilities (concat + HKDF), + /// - AES-256-GCM decrypt on a non-extractable key. + /// + /// If `false`, callers must assume that KEM shared secrets will be materialized in-process. + fn supports_hybrid_v1_in_token_decrypt(&self) -> bool { + false + } + + /// Decrypt a hybrid v1 packet fully inside the token (no exported KEM shared secret). + /// + /// Callers must provide the X25519 shared secret bytes (derived from the peer ephemeral pubkey + /// in the packet and the local X25519 static secret). + /// + /// Backends that do not support this must leave the default implementation in place. + fn decrypt_hybrid_v1_in_token(&self, _packet: &[u8], _x25519_ss: [u8; 32]) -> Result> { + Err(Error::ClientError( + "HSM backend does not support in-token hybrid v1 decrypt".into(), + )) + } + + /// Return `true` if this backend can decrypt hybrid v1 packets fully inside the token + /// **including** X25519 ECDH derivation. + /// + /// This is the strictest mode: + /// - no KEM shared secret is exported to host memory + /// - no X25519 shared secret is exported to host memory + /// + /// Backends implementing this are expected to hold an X25519 (EC Montgomery) private key + /// inside the token and derive a non-extractable secret key via `C_DeriveKey` using ECDH. + fn supports_hybrid_v1_full_in_token_decrypt(&self) -> bool { + false + } + + /// Decrypt a hybrid v1 packet fully inside the token, including X25519 ECDH. + fn decrypt_hybrid_v1_full_in_token(&self, _packet: &[u8]) -> Result> { + Err(Error::ClientError( + "HSM backend does not support full in-token hybrid v1 decrypt".into(), + )) + } +} + +fn is_filesystem_safe_id(id: &str) -> bool { + id.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')) +} + +fn storage_id_for(client_id: &str) -> String { + let is_safe = is_filesystem_safe_id(client_id); + if is_safe && client_id.len() <= 128 { + client_id.to_string() + } else { + let digest = sha2::Sha256::digest(client_id.as_bytes()); + format!("id_{}", hex::encode(digest)) + } +} + +fn x25519_secret_label(storage_id: &str) -> String { + format!("pqc-iiot:x25519-sk:v1:{}", storage_id) +} + +/// Gateway-grade provider combining: +/// - PQC operations inside an HSM (`PqcHsmBackend`) +/// - rollback-resistant persistence via a Root-of-Trust (`RootOfTrust`) +/// - a classical X25519 static secret for the hybrid scheme (sealed via the RoT) +/// +/// This is the practical “market path” for Linux gateways: +/// - TPM2 provides anti-rollback counters/sealing for floors + replay-state. +/// - PKCS#11 HSM provides non-exportable PQC identity operations. +pub struct GatewayHsmSecurityProvider { + hsm: Arc, + rot: Arc, + kem_pk: Vec, + sig_pk: Vec, + x25519_sk: Option, + x25519_pk: [u8; 32], + storage_id: String, +} + +impl GatewayHsmSecurityProvider { + /// Create a gateway provider bound to `client_id`. + /// + /// The `client_id` is normalized into a stable `storage_id` to derive sealed labels. This must + /// match the `SecureMqttClient` storage identity derivation for operational cert binding. + pub fn new( + client_id: &str, + hsm: Arc, + rot: Arc, + ) -> Result { + let storage_id = storage_id_for(client_id); + let kem_pk = hsm.kem_public_key()?; + let sig_pk = hsm.sig_public_key()?; + + // X25519 identity key source: + // - if the HSM backend provides a token-resident X25519 public key, we treat X25519 as + // non-exportable and rely on `decrypt_hybrid_v1_full_in_token` for v1 packets. + // - otherwise, we keep a software X25519 static secret sealed by the RoT (legacy gateway + // mode; not "zero secrets in host"). + let (x25519_sk, x25519_pk) = match hsm.x25519_public_key()? { + Some(pk) => { + if !hsm.supports_hybrid_v1_full_in_token_decrypt() { + return Err(Error::ClientError( + "HSM provides X25519 identity but does not support full in-token decrypt" + .into(), + )); + } + (None, pk) + } + None => { + // Load or generate the X25519 static secret sealed by the RoT. + let label = x25519_secret_label(&storage_id); + let sk_bytes = match rot.unseal_data(&label) { + Ok(blob) => { + if blob.len() != 32 { + return Err(Error::CryptoError(format!( + "Invalid sealed x25519 secret length: {}", + blob.len() + ))); + } + let mut b = [0u8; 32]; + b.copy_from_slice(&blob); + b + } + Err(Error::IoError(e)) if e.kind() == std::io::ErrorKind::NotFound => { + let mut b = [0u8; 32]; + OsRng.fill_bytes(&mut b); + rot.seal_data(&label, &b)?; + b + } + Err(e) => return Err(e), + }; + let sk = X25519StaticSecret::from(sk_bytes); + let pk = X25519PublicKey::from(&sk).to_bytes(); + (Some(sk), pk) + } + }; + + Ok(Self { + hsm, + rot, + kem_pk, + sig_pk, + x25519_sk, + x25519_pk, + storage_id, + }) + } + + /// Observability: HSM kind string. + pub fn hsm_kind(&self) -> &'static str { + self.hsm.hsm_kind() + } + + /// Observability: RoT kind string. + pub fn rot_kind(&self) -> &'static str { + self.rot.rot_kind() + } + + /// Stable storage identifier derived from `client_id`. + pub fn storage_id(&self) -> &str { + &self.storage_id + } +} + +impl SecurityProvider for GatewayHsmSecurityProvider { + fn kem_public_key(&self) -> &[u8] { + &self.kem_pk + } + + fn sig_public_key(&self) -> &[u8] { + &self.sig_pk + } + + fn decrypt(&self, ciphertext: &[u8]) -> Result> { + // Prefer strict full in-token decrypt when available: + // - Kyber shared secret is never exported + // - X25519 shared secret is never exported + if self.hsm.supports_hybrid_v1_full_in_token_decrypt() + && ciphertext.first().copied() == Some(1) + { + return self.hsm.decrypt_hybrid_v1_full_in_token(ciphertext); + } + + // Prefer an in-token decrypt path when the backend guarantees that the KEM shared secret + // is never exported to host memory. + if self.hsm.supports_hybrid_v1_in_token_decrypt() + && ciphertext.first().copied() == Some(1) + && ciphertext.len() >= 4 + 32 + { + let capsule_len = u16::from_be_bytes([ciphertext[2], ciphertext[3]]) as usize; + let eph_pk_start = 4usize + .checked_add(capsule_len) + .ok_or_else(|| Error::CryptoError("Packet too short".into()))?; + let eph_pk_end = eph_pk_start + .checked_add(32) + .ok_or_else(|| Error::CryptoError("Packet too short".into()))?; + if ciphertext.len() < eph_pk_end { + return Err(Error::CryptoError("Packet too short for capsule".into())); + } + let mut peer_pk = [0u8; 32]; + peer_pk.copy_from_slice(&ciphertext[eph_pk_start..eph_pk_end]); + let x_ss = self.x25519_exchange(peer_pk)?; + return self.hsm.decrypt_hybrid_v1_in_token(ciphertext, x_ss); + } + + // Fallback: decrypt using the HSM KEM decapsulation (shared secret exported) + local X25519 exchange. + crate::security::hybrid::decrypt_with_kem_and_exchange( + ciphertext, + |ct| self.hsm.kem_decapsulate(ct), + |peer_pk| self.x25519_exchange(peer_pk), + ) + } + + fn kem_decapsulate(&self, kem_ciphertext: &[u8]) -> Result<[u8; 32]> { + self.hsm.kem_decapsulate(kem_ciphertext) + } + + fn sign(&self, message: &[u8]) -> Result> { + self.hsm.sign(message) + } + + fn export_secret_keys(&self) -> Option { + None + } + + fn provider_kind(&self) -> &'static str { + "gateway-hsm" + } + + fn is_rollback_resistant_storage(&self) -> bool { + self.rot.is_rollback_resistant_storage() + } + + fn seal_data(&self, label: &str, data: &[u8]) -> Result<()> { + self.rot.seal_data(label, data) + } + + fn unseal_data(&self, label: &str) -> Result> { + self.rot.unseal_data(label) + } + + fn sealed_monotonic_u64_get(&self, label: &str) -> Result> { + self.rot.sealed_monotonic_u64_get(label) + } + + fn sealed_monotonic_u64_advance_to(&self, label: &str, candidate: u64) -> Result { + self.rot.sealed_monotonic_u64_advance_to(label, candidate) + } + + fn sealed_monotonic_u64_increment(&self, label: &str) -> Result { + self.rot.sealed_monotonic_u64_increment(label) + } + + fn generate_quote( + &self, + _pcr_indices: &[u32], + _nonce: &[u8], + ) -> Result { + Err(Error::ClientError( + "Attestation quotes are not supported by GatewayHsmSecurityProvider".into(), + )) + } + + fn x25519_public_key(&self) -> [u8; 32] { + self.x25519_pk + } + + fn x25519_exchange(&self, peer_pk: [u8; 32]) -> Result<[u8; 32]> { + let sk = self.x25519_sk.as_ref().ok_or_else(|| { + Error::ClientError( + "X25519 exchange is not available (identity key is token-resident)".into(), + ) + })?; + let pk = X25519PublicKey::from(peer_pk); + Ok(sk.diffie_hellman(&pk).to_bytes()) + } +} + +impl Drop for GatewayHsmSecurityProvider { + fn drop(&mut self) { + // Best-effort memory scrubbing of cached public keys. HSM keys remain non-exportable. + self.kem_pk.zeroize(); + self.sig_pk.zeroize(); + self.storage_id.zeroize(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::falcon::Falcon; + use crate::crypto::kyber::Kyber; + use crate::crypto::traits::{PqcKEM, PqcSignature}; + use crate::security::root_of_trust::SoftwareRootOfTrust; + use aes_gcm::aead::{Aead, Payload}; + use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; + use hkdf::Hkdf; + use sha2::Sha256; + + struct MockHsm { + kem_pk: Vec, + kem_sk: Vec, + sig_pk: Vec, + sig_sk: Vec, + } + + impl MockHsm { + fn new() -> Self { + let kyber = Kyber::new(); + let falcon = Falcon::new(); + let (kem_pk, kem_sk) = kyber.generate_keypair().unwrap(); + let (sig_pk, sig_sk) = falcon.generate_keypair().unwrap(); + Self { + kem_pk, + kem_sk, + sig_pk, + sig_sk, + } + } + } + + impl PqcHsmBackend for MockHsm { + fn hsm_kind(&self) -> &'static str { + "mock-hsm" + } + + fn kem_public_key(&self) -> Result> { + Ok(self.kem_pk.clone()) + } + + fn sig_public_key(&self) -> Result> { + Ok(self.sig_pk.clone()) + } + + fn sign(&self, message: &[u8]) -> Result> { + let falcon = Falcon::new(); + falcon + .sign(&self.sig_sk, message) + .map_err(|e| Error::CryptoError(format!("MockHsm falcon sign failed: {:?}", e))) + } + + fn kem_decapsulate(&self, kem_ciphertext: &[u8]) -> Result<[u8; 32]> { + let kyber = Kyber::new(); + let mut ss = kyber + .decapsulate(&self.kem_sk, kem_ciphertext) + .map_err(|e| Error::CryptoError(format!("MockHsm kyber decap failed: {:?}", e)))?; + if ss.len() != 32 { + ss.zeroize(); + return Err(Error::CryptoError(format!( + "Unexpected shared secret length: {}", + ss.len() + ))); + } + let mut out = [0u8; 32]; + out.copy_from_slice(&ss); + ss.zeroize(); + Ok(out) + } + } + + struct MockInTokenHsm { + kem_pk: Vec, + kem_sk: Vec, + sig_pk: Vec, + sig_sk: Vec, + } + + impl MockInTokenHsm { + fn new() -> Self { + let kyber = Kyber::new(); + let falcon = Falcon::new(); + let (kem_pk, kem_sk) = kyber.generate_keypair().unwrap(); + let (sig_pk, sig_sk) = falcon.generate_keypair().unwrap(); + Self { + kem_pk, + kem_sk, + sig_pk, + sig_sk, + } + } + } + + impl PqcHsmBackend for MockInTokenHsm { + fn hsm_kind(&self) -> &'static str { + "mock-in-token" + } + + fn kem_public_key(&self) -> Result> { + Ok(self.kem_pk.clone()) + } + + fn sig_public_key(&self) -> Result> { + Ok(self.sig_pk.clone()) + } + + fn sign(&self, message: &[u8]) -> Result> { + let falcon = Falcon::new(); + falcon.sign(&self.sig_sk, message) + } + + fn kem_decapsulate(&self, _kem_ciphertext: &[u8]) -> Result<[u8; 32]> { + panic!("kem_decapsulate must not be called when in-token decrypt is supported"); + } + + fn supports_hybrid_v1_in_token_decrypt(&self) -> bool { + true + } + + fn decrypt_hybrid_v1_in_token( + &self, + packet: &[u8], + mut x25519_ss: [u8; 32], + ) -> Result> { + // Mirror `hybrid::decrypt_v1_with_kem` but take x25519_ss as input. + if packet.len() < 1 + 1 + 2 + 32 + 12 { + return Err(Error::CryptoError("Packet too short".into())); + } + let capsule_len = u16::from_be_bytes([packet[2], packet[3]]) as usize; + let header_len = 1 + 1 + 2 + capsule_len + 32; + if packet.len() < header_len + 12 + 16 { + return Err(Error::CryptoError("Packet too short for capsule".into())); + } + + let capsule_start = 4; + let capsule_end = capsule_start + capsule_len; + let nonce_start = header_len; + let nonce_end = nonce_start + 12; + + let capsule = &packet[capsule_start..capsule_end]; + let nonce_bytes = &packet[nonce_start..nonce_end]; + let ciphertext = &packet[nonce_end..]; + let aad = &packet[..header_len]; + + let kyber = match self.kem_sk.len() { + 1632 => Kyber::new_with_level(crate::KyberSecurityLevel::Kyber512), + 2400 => Kyber::new_with_level(crate::KyberSecurityLevel::Kyber768), + 3168 => Kyber::new_with_level(crate::KyberSecurityLevel::Kyber1024), + len => { + return Err(Error::CryptoError(alloc::format!( + "Invalid Kyber SK length: {}", + len + ))) + } + }; + let mut kyber_ss = kyber.decapsulate(&self.kem_sk, capsule)?; + if kyber_ss.len() != 32 { + kyber_ss.zeroize(); + return Err(Error::CryptoError(format!( + "Unexpected Kyber shared secret length: {}", + kyber_ss.len() + ))); + } + + let mut ikm = [0u8; 64]; + ikm[..32].copy_from_slice(&kyber_ss); + ikm[32..].copy_from_slice(&x25519_ss); + kyber_ss.zeroize(); + x25519_ss.zeroize(); + + let hk = Hkdf::::new(None, &ikm); + let mut key_bytes = [0u8; 32]; + hk.expand(b"pqc-iiot:hybrid:v1:aes-gcm-key", &mut key_bytes) + .map_err(|_| Error::CryptoError("HKDF expand failed".into()))?; + + let cipher = Aes256Gcm::new(aes_gcm::Key::::from_slice(&key_bytes)); + let out = cipher + .decrypt( + Nonce::from_slice(nonce_bytes), + Payload { + msg: ciphertext, + aad, + }, + ) + .map_err(|_| Error::CryptoError("AES-GCM decryption failed".into()))?; + + key_bytes.zeroize(); + ikm.zeroize(); + Ok(out) + } + } + + struct MockFullInTokenHsm { + kem_pk: Vec, + kem_sk: Vec, + sig_pk: Vec, + sig_sk: Vec, + x25519_sk: X25519StaticSecret, + x25519_pk: [u8; 32], + } + + impl MockFullInTokenHsm { + fn new() -> Self { + let kyber = Kyber::new(); + let falcon = Falcon::new(); + let (kem_pk, kem_sk) = kyber.generate_keypair().unwrap(); + let (sig_pk, sig_sk) = falcon.generate_keypair().unwrap(); + let x25519_sk = X25519StaticSecret::random_from_rng(OsRng); + let x25519_pk = X25519PublicKey::from(&x25519_sk).to_bytes(); + Self { + kem_pk, + kem_sk, + sig_pk, + sig_sk, + x25519_sk, + x25519_pk, + } + } + } + + impl PqcHsmBackend for MockFullInTokenHsm { + fn hsm_kind(&self) -> &'static str { + "mock-full-in-token" + } + + fn kem_public_key(&self) -> Result> { + Ok(self.kem_pk.clone()) + } + + fn sig_public_key(&self) -> Result> { + Ok(self.sig_pk.clone()) + } + + fn x25519_public_key(&self) -> Result> { + Ok(Some(self.x25519_pk)) + } + + fn sign(&self, message: &[u8]) -> Result> { + let falcon = Falcon::new(); + falcon.sign(&self.sig_sk, message) + } + + fn kem_decapsulate(&self, _kem_ciphertext: &[u8]) -> Result<[u8; 32]> { + panic!("kem_decapsulate must not be called when full in-token decrypt is supported"); + } + + fn supports_hybrid_v1_full_in_token_decrypt(&self) -> bool { + true + } + + fn decrypt_hybrid_v1_full_in_token(&self, packet: &[u8]) -> Result> { + if packet.len() < 1 + 1 + 2 + 32 + 12 { + return Err(Error::CryptoError("Packet too short".into())); + } + let capsule_len = u16::from_be_bytes([packet[2], packet[3]]) as usize; + let header_len = 1 + 1 + 2 + capsule_len + 32; + if packet.len() < header_len + 12 + 16 { + return Err(Error::CryptoError("Packet too short for capsule".into())); + } + + let capsule_start = 4; + let capsule_end = capsule_start + capsule_len; + let eph_pk_start = capsule_end; + let eph_pk_end = eph_pk_start + 32; + let nonce_start = eph_pk_end; + let nonce_end = nonce_start + 12; + + let capsule = &packet[capsule_start..capsule_end]; + let eph_pk_bytes = &packet[eph_pk_start..eph_pk_end]; + let nonce_bytes = &packet[nonce_start..nonce_end]; + let ciphertext = &packet[nonce_end..]; + let aad = &packet[..header_len]; + + let kyber = match self.kem_sk.len() { + 1632 => Kyber::new_with_level(crate::KyberSecurityLevel::Kyber512), + 2400 => Kyber::new_with_level(crate::KyberSecurityLevel::Kyber768), + 3168 => Kyber::new_with_level(crate::KyberSecurityLevel::Kyber1024), + len => { + return Err(Error::CryptoError(alloc::format!( + "Invalid Kyber SK length: {}", + len + ))) + } + }; + let mut kyber_ss = kyber.decapsulate(&self.kem_sk, capsule)?; + if kyber_ss.len() != 32 { + kyber_ss.zeroize(); + return Err(Error::CryptoError(format!( + "Unexpected Kyber shared secret length: {}", + kyber_ss.len() + ))); + } + + let mut eph_pk = [0u8; 32]; + eph_pk.copy_from_slice(eph_pk_bytes); + let peer_pk = X25519PublicKey::from(eph_pk); + let mut x_ss = self.x25519_sk.diffie_hellman(&peer_pk).to_bytes(); + + let mut ikm = [0u8; 64]; + ikm[..32].copy_from_slice(&kyber_ss); + ikm[32..].copy_from_slice(&x_ss); + kyber_ss.zeroize(); + x_ss.zeroize(); + + let hk = Hkdf::::new(None, &ikm); + let mut key_bytes = [0u8; 32]; + hk.expand(b"pqc-iiot:hybrid:v1:aes-gcm-key", &mut key_bytes) + .map_err(|_| Error::CryptoError("HKDF expand failed".into()))?; + + let cipher = Aes256Gcm::new(aes_gcm::Key::::from_slice(&key_bytes)); + let out = cipher + .decrypt( + Nonce::from_slice(nonce_bytes), + Payload { + msg: ciphertext, + aad, + }, + ) + .map_err(|_| Error::CryptoError("AES-GCM decryption failed".into()))?; + + key_bytes.zeroize(); + ikm.zeroize(); + Ok(out) + } + } + + #[test] + fn gateway_hsm_provider_roundtrips_hybrid_packets_and_signatures() { + let hsm = Arc::new(MockHsm::new()); + let rot = Arc::new(SoftwareRootOfTrust::new([0x55u8; 32])); + let provider = GatewayHsmSecurityProvider::new("gw-1", hsm, rot).unwrap(); + + // Hybrid encrypt to the provider's identity. + let pt = b"hello"; + let blob = crate::security::hybrid::encrypt( + provider.kem_public_key(), + &provider.x25519_public_key(), + pt, + ) + .unwrap(); + let out = provider.decrypt(&blob).unwrap(); + assert_eq!(out, pt); + + // Sign/verify (public key is exported; SK is non-exportable in the backend model). + let msg = b"pqc-iiot:test"; + let sig = provider.sign(msg).unwrap(); + let falcon = Falcon::new(); + let ok = falcon.verify(provider.sig_public_key(), msg, &sig).unwrap(); + assert!(ok); + } + + #[test] + fn gateway_hsm_provider_uses_in_token_decrypt_when_supported() { + let hsm = Arc::new(MockInTokenHsm::new()); + let rot = Arc::new(SoftwareRootOfTrust::new([0x55u8; 32])); + let provider = GatewayHsmSecurityProvider::new("gw_hsm_in_token", hsm, rot).unwrap(); + + let msg = b"hello"; + let blob = crate::security::hybrid::encrypt( + provider.kem_public_key(), + &provider.x25519_public_key(), + msg, + ) + .unwrap(); + + let out = provider.decrypt(&blob).unwrap(); + assert_eq!(out, msg); + } + + #[test] + fn gateway_hsm_provider_uses_full_in_token_decrypt_when_x25519_is_token_resident() { + let hsm = Arc::new(MockFullInTokenHsm::new()); + let rot = Arc::new(SoftwareRootOfTrust::new([0x55u8; 32])); + let provider = + GatewayHsmSecurityProvider::new("gw_hsm_full_in_token", hsm.clone(), rot).unwrap(); + + assert_eq!(provider.x25519_public_key(), hsm.x25519_pk); + assert!(provider.x25519_exchange([0u8; 32]).is_err()); + + let msg = b"hello"; + let blob = crate::security::hybrid::encrypt( + provider.kem_public_key(), + &provider.x25519_public_key(), + msg, + ) + .unwrap(); + + let out = provider.decrypt(&blob).unwrap(); + assert_eq!(out, msg); + } +} diff --git a/src/security/hsm_pkcs11.rs b/src/security/hsm_pkcs11.rs new file mode 100644 index 0000000..72164a9 --- /dev/null +++ b/src/security/hsm_pkcs11.rs @@ -0,0 +1,704 @@ +//! PKCS#11-backed HSM integration (gateway). +//! +//! This module is intentionally conservative: +//! - it treats PQC mechanisms as vendor-defined (`u64` mechanism IDs), +//! - it requires key objects to already exist on the token (generated inside the HSM), +//! - it never exports private keys. +//! +//! The runtime contract is minimal: +//! - `sign(message)` delegates to `C_Sign` with a configured mechanism and key handle. +//! - `kem_decapsulate(capsule)` delegates to `C_Decrypt` with a configured mechanism and key handle, +//! returning the 32-byte shared secret. +//! - optionally, `decrypt_hybrid_v1_in_token` can decrypt hybrid v1 packets fully inside the token +//! (no exported KEM shared secret) using a `C_DeriveKey`-style KEM mechanism + concat + HKDF +//! + AES-256-GCM. +//! - optionally, `decrypt_hybrid_v1_full_in_token` can decrypt hybrid v1 packets fully inside the +//! token **including** X25519 ECDH derivation (no `x25519_ss` materialized in host RAM). This +//! requires a token-resident EC Montgomery (X25519) private key with `CKA_DERIVE=true`. +//! +//! If your vendor exposes KEM decapsulation via `C_DeriveKey` (returning a non-extractable secret +//! key object) and then expects `C_Decrypt` with that derived key, you must implement a custom +//! backend adapter on top of `cryptoki`. + +use crate::security::hsm::PqcHsmBackend; +use crate::{Error, Result}; +use alloc::vec::Vec; +use cryptoki::context::{CInitializeArgs, CInitializeFlags, Pkcs11}; +use cryptoki::mechanism::aead::GcmParams; +use cryptoki::mechanism::elliptic_curve::{EcKdf, Ecdh1DeriveParams}; +use cryptoki::mechanism::hkdf::{HkdfParams, HkdfSalt}; +use cryptoki::mechanism::misc::KeyDerivationStringData; +use cryptoki::mechanism::vendor_defined::VendorDefinedMechanism; +use cryptoki::mechanism::{Mechanism, MechanismType}; +use cryptoki::object::{Attribute, KeyType, ObjectClass, ObjectHandle}; +use cryptoki::session::{Session, UserType}; +use cryptoki::slot::Slot; +use cryptoki::types::AuthPin; +use cryptoki::types::Ulong; +use std::sync::Mutex; +use zeroize::Zeroize; + +fn der_octet_string(inner: &[u8]) -> Vec { + // DER: OCTET STRING (0x04) + len + bytes + // X25519 public keys are 32 bytes, so short-form length is sufficient. + let mut out = Vec::with_capacity(2 + inner.len()); + out.push(0x04); + out.push(inner.len() as u8); + out.extend_from_slice(inner); + out +} + +/// PKCS#11 PQC mechanism identifiers. +/// +/// These are often vendor-defined today; treat them as configuration, not constants. +#[derive(Clone, Copy, Debug)] +pub struct Pkcs11PqcMechanisms { + /// Signature mechanism (used with `C_Sign*`). + pub sig_mechanism: u64, + /// KEM decapsulation mechanism (used with `C_Decrypt*`). + pub kem_decapsulate_mechanism: u64, + /// Optional KEM decapsulation mechanism for `C_DeriveKey` (no shared-secret export). + /// + /// Vendor contract: + /// - base key: `kem_key_label` private key object + /// - params: `CK_KEY_DERIVATION_STRING_DATA` pointing at the capsule bytes + /// - output: non-extractable 32-byte `CKK_GENERIC_SECRET` secret key object + pub kem_derive_mechanism: Option, +} + +/// Token + key selector. +#[derive(Clone, Debug)] +pub struct Pkcs11KeySelector { + /// Token label (preferred for stable deployments). If set, slot discovery searches for this label. + pub token_label: Option, + /// Explicit slot number. If set, takes precedence over `token_label`. + pub slot: Option, + /// Key object label for the signature key pair. + pub sig_key_label: String, + /// Key object label for the KEM secret key. + pub kem_key_label: String, + /// Optional key object label for the X25519 (EC Montgomery) private key. + /// + /// When set, the backend can provide a token-resident X25519 identity and support full + /// in-token hybrid decryption (no exported `x25519_ss`). + pub x25519_key_label: Option, +} + +/// PKCS#11-backed PQC HSM configuration. +#[derive(Clone, Debug)] +pub struct Pkcs11PqcConfig { + /// Path to the vendor PKCS#11 shared library (`.so`/`.dylib`/`.dll`). + pub library_path: String, + /// User PIN for token login (CKU_USER). + pub user_pin: String, + /// Mechanism identifiers for PQC operations. + pub mechanisms: Pkcs11PqcMechanisms, + /// Token + key selection parameters. + pub keys: Pkcs11KeySelector, + /// Public KEM key bytes (exported at provisioning time). + pub kem_public_key: Vec, + /// Public signature key bytes (exported at provisioning time). + pub sig_public_key: Vec, + /// Optional public X25519 (EC Montgomery) key bytes (32 bytes). + /// + /// If `keys.x25519_key_label` is set, this must also be set and have length 32. + pub x25519_public_key: Option>, +} + +/// PKCS#11-backed PQC HSM backend. +/// +/// Threading model: +/// - a single session is held behind a mutex (serialize crypto ops on one token). +/// - if your HSM supports parallel sessions, wrap this in a session pool. +pub struct Pkcs11PqcBackend { + pkcs11: Option, + slot: Slot, + session: Mutex>, + sig_key: ObjectHandle, + kem_key: ObjectHandle, + x25519_key: Option, + kem_pk: Vec, + sig_pk: Vec, + x25519_pk: Option<[u8; 32]>, + mechanisms: Pkcs11PqcMechanisms, +} + +impl Pkcs11PqcBackend { + /// Connect to a PKCS#11 token, login, and locate key objects. + pub fn new(cfg: Pkcs11PqcConfig) -> Result { + let pkcs11 = Pkcs11::new(&cfg.library_path) + .map_err(|e| Error::ClientError(format!("PKCS#11 library load failed: {}", e)))?; + pkcs11 + .initialize(CInitializeArgs::new(CInitializeFlags::OS_LOCKING_OK)) + .map_err(|e| Error::ClientError(format!("PKCS#11 initialize failed: {}", e)))?; + + let slot = match cfg.keys.slot { + Some(n) => Slot::try_from(n) + .map_err(|e| Error::ClientError(format!("Invalid PKCS#11 slot {}: {}", n, e)))?, + None => { + let want_label = cfg.keys.token_label.clone().ok_or_else(|| { + Error::InvalidInput("Pkcs11PqcConfig requires slot or token_label".into()) + })?; + find_slot_by_token_label(&pkcs11, &want_label)? + } + }; + + let session = pkcs11 + .open_rw_session(slot) + .map_err(|e| Error::ClientError(format!("PKCS#11 open session failed: {}", e)))?; + + // Login as user. + let user_pin = AuthPin::new(cfg.user_pin.into_boxed_str()); + session + .login(UserType::User, Some(&user_pin)) + .map_err(|e| Error::ClientError(format!("PKCS#11 login failed: {}", e)))?; + + let sig_key = find_key_by_label(&session, &cfg.keys.sig_key_label)?; + let kem_key = find_key_by_label(&session, &cfg.keys.kem_key_label)?; + + let (x25519_key, x25519_pk) = match cfg.keys.x25519_key_label.as_deref() { + Some(label) => { + let pk = cfg.x25519_public_key.ok_or_else(|| { + Error::InvalidInput( + "Pkcs11PqcConfig.x25519_public_key is required when x25519_key_label is set" + .into(), + ) + })?; + if pk.len() != 32 { + return Err(Error::InvalidInput(format!( + "Invalid X25519 public key length: {}", + pk.len() + ))); + } + let mut xpk = [0u8; 32]; + xpk.copy_from_slice(&pk); + let key = find_private_key_by_label(&session, label, KeyType::EC_MONTGOMERY)?; + (Some(key), Some(xpk)) + } + None => (None, None), + }; + + Ok(Self { + slot, + pkcs11: Some(pkcs11), + session: Mutex::new(Some(session)), + sig_key, + kem_key, + x25519_key, + kem_pk: cfg.kem_public_key, + sig_pk: cfg.sig_public_key, + x25519_pk, + mechanisms: cfg.mechanisms, + }) + } + + /// PKCS#11 slot backing this backend instance. + pub fn slot(&self) -> Slot { + self.slot + } + + fn with_session(&self, f: impl FnOnce(&Session) -> Result) -> Result { + let guard = self + .session + .lock() + .map_err(|_| Error::ClientError("PKCS#11 session mutex poisoned".into()))?; + let session = guard + .as_ref() + .ok_or_else(|| Error::ClientError("PKCS#11 session is closed".into()))?; + f(session) + } + + fn vendor_mechanism(&self, mechanism_id: u64) -> Result> { + let mech_type = MechanismType::new_vendor_defined(mechanism_id).map_err(|_| { + Error::InvalidInput(format!( + "Invalid vendor-defined PKCS#11 mechanism: {}", + mechanism_id + )) + })?; + Ok(Mechanism::VendorDefined(VendorDefinedMechanism::new( + mech_type, + None::<&()>, + ))) + } + + fn sign_inner(&self, msg: &[u8]) -> Result> { + let mech = self.vendor_mechanism(self.mechanisms.sig_mechanism)?; + self.with_session(|sess| { + sess.sign(&mech, self.sig_key, msg) + .map_err(|e| Error::CryptoError(format!("PKCS#11 sign failed: {}", e))) + }) + } + + fn kem_decapsulate_inner(&self, kem_ciphertext: &[u8]) -> Result<[u8; 32]> { + let mech = self.vendor_mechanism(self.mechanisms.kem_decapsulate_mechanism)?; + let mut out = self.with_session(|sess| { + sess.decrypt(&mech, self.kem_key, kem_ciphertext) + .map_err(|e| Error::CryptoError(format!("PKCS#11 decrypt failed: {}", e))) + })?; + if out.len() != 32 { + out.zeroize(); + return Err(Error::CryptoError(format!( + "Unexpected KEM shared secret length from HSM: {}", + out.len() + ))); + } + let mut ss = [0u8; 32]; + ss.copy_from_slice(&out); + out.zeroize(); + Ok(ss) + } + + fn decrypt_hybrid_v1_in_token_inner( + &self, + packet: &[u8], + mut x25519_ss: [u8; 32], + ) -> Result> { + let derive_mech_id = self.mechanisms.kem_derive_mechanism.ok_or_else(|| { + Error::ClientError("PKCS#11 KEM derive-key mechanism is not configured".into()) + })?; + + if packet.len() < 1 + 1 + 2 + 32 + 12 { + return Err(Error::CryptoError("Packet too short".into())); + } + if packet[0] != 1 { + return Err(Error::CryptoError(format!( + "Unsupported packet version: {}", + packet[0] + ))); + } + if packet[1] != 1 { + return Err(Error::CryptoError(format!( + "Unsupported hybrid suite: {}", + packet[1] + ))); + } + + let capsule_len = u16::from_be_bytes([packet[2], packet[3]]) as usize; + let header_len = 1 + 1 + 2 + capsule_len + 32; + if packet.len() < header_len + 12 + 16 { + return Err(Error::CryptoError("Packet too short for capsule".into())); + } + + let capsule_start = 4; + let capsule_end = capsule_start + capsule_len; + let nonce_start = header_len; + let nonce_end = nonce_start + 12; + + let capsule = &packet[capsule_start..capsule_end]; + let nonce_bytes = &packet[nonce_start..nonce_end]; + let ciphertext = &packet[nonce_end..]; + let aad = &packet[..header_len]; + + // All derived keys are session objects (CKA_TOKEN=false) and best-effort destroyed + // immediately after use. + let out = self.with_session(|sess| { + let mut derived: Vec = Vec::new(); + + let res = (|| -> Result> { + // 1) Derive a non-extractable secret key for Kyber shared-secret via vendor KEM derive. + let mech_type = + MechanismType::new_vendor_defined(derive_mech_id).map_err(|_| { + Error::InvalidInput(format!( + "Invalid vendor-defined PKCS#11 mechanism: {}", + derive_mech_id + )) + })?; + let kem_params = KeyDerivationStringData::new(capsule); + let kem_mech = Mechanism::VendorDefined(VendorDefinedMechanism::new( + mech_type, + Some(&kem_params), + )); + + let ss_template = [ + Attribute::Class(ObjectClass::SECRET_KEY), + Attribute::KeyType(KeyType::GENERIC_SECRET), + Attribute::ValueLen(Ulong::new(32)), + Attribute::Token(false), + Attribute::Private(true), + Attribute::Sensitive(true), + Attribute::Extractable(false), + Attribute::Derive(true), + ]; + let ss_key = sess + .derive_key(&kem_mech, self.kem_key, &ss_template) + .map_err(|e| { + Error::CryptoError(format!("PKCS#11 derive_key (KEM) failed: {}", e)) + })?; + derived.push(ss_key); + + // 2) Derive IKM key = kyber_ss || x25519_ss via standard concatenation KDF. + let concat_params = KeyDerivationStringData::new(&x25519_ss); + let concat_mech = Mechanism::ConcatenateBaseAndData(concat_params); + let ikm_template = [ + Attribute::Class(ObjectClass::SECRET_KEY), + Attribute::KeyType(KeyType::GENERIC_SECRET), + Attribute::ValueLen(Ulong::new(64)), + Attribute::Token(false), + Attribute::Private(true), + Attribute::Sensitive(true), + Attribute::Extractable(false), + Attribute::Derive(true), + ]; + let ikm_key = sess + .derive_key(&concat_mech, ss_key, &ikm_template) + .map_err(|e| { + Error::CryptoError(format!("PKCS#11 derive_key (concat) failed: {}", e)) + })?; + derived.push(ikm_key); + + // 3) Derive AES-256 key via HKDF-SHA256(ikm_key, salt=NULL, info=...). + let hkdf_params = HkdfParams::new( + MechanismType::SHA256_HMAC, + Some(HkdfSalt::Null), + Some(b"pqc-iiot:hybrid:v1:aes-gcm-key"), + ); + let hkdf_mech = Mechanism::HkdfDerive(hkdf_params); + let aes_template = [ + Attribute::Class(ObjectClass::SECRET_KEY), + Attribute::KeyType(KeyType::AES), + Attribute::ValueLen(Ulong::new(32)), + Attribute::Token(false), + Attribute::Private(true), + Attribute::Sensitive(true), + Attribute::Extractable(false), + Attribute::Encrypt(true), + Attribute::Decrypt(true), + ]; + let aes_key = sess + .derive_key(&hkdf_mech, ikm_key, &aes_template) + .map_err(|e| { + Error::CryptoError(format!("PKCS#11 derive_key (HKDF) failed: {}", e)) + })?; + derived.push(aes_key); + + // 4) AES-256-GCM decrypt with AAD bound to the packet header. + let mut iv = [0u8; 12]; + iv.copy_from_slice(nonce_bytes); + let gcm_params = GcmParams::new(&mut iv, aad, Ulong::new(128)) + .map_err(|e| Error::CryptoError(format!("PKCS#11 GCM params failed: {}", e)))?; + let aead_mech = Mechanism::AesGcm(gcm_params); + let plaintext = sess.decrypt(&aead_mech, aes_key, ciphertext).map_err(|e| { + Error::CryptoError(format!("PKCS#11 AES-GCM decrypt failed: {}", e)) + })?; + + Ok(plaintext) + })(); + + // Best-effort cleanup. + for h in derived.into_iter().rev() { + let _ = sess.destroy_object(h); + } + x25519_ss.zeroize(); + res + })?; + Ok(out) + } + + fn decrypt_hybrid_v1_full_in_token_inner(&self, packet: &[u8]) -> Result> { + let derive_mech_id = self.mechanisms.kem_derive_mechanism.ok_or_else(|| { + Error::ClientError("PKCS#11 KEM derive-key mechanism is not configured".into()) + })?; + let x25519_key = self.x25519_key.ok_or_else(|| { + Error::ClientError("PKCS#11 X25519 private key is not configured".into()) + })?; + + if packet.len() < 1 + 1 + 2 + 32 + 12 { + return Err(Error::CryptoError("Packet too short".into())); + } + if packet[0] != 1 { + return Err(Error::CryptoError(format!( + "Unsupported packet version: {}", + packet[0] + ))); + } + if packet[1] != 1 { + return Err(Error::CryptoError(format!( + "Unsupported hybrid suite: {}", + packet[1] + ))); + } + + let capsule_len = u16::from_be_bytes([packet[2], packet[3]]) as usize; + let header_len = 1 + 1 + 2 + capsule_len + 32; + if packet.len() < header_len + 12 + 16 { + return Err(Error::CryptoError("Packet too short for capsule".into())); + } + + let capsule_start = 4; + let capsule_end = capsule_start + capsule_len; + let eph_pk_start = capsule_end; + let eph_pk_end = eph_pk_start + 32; + let nonce_start = eph_pk_end; + let nonce_end = nonce_start + 12; + + let capsule = &packet[capsule_start..capsule_end]; + let eph_pk_bytes = &packet[eph_pk_start..eph_pk_end]; + let nonce_bytes = &packet[nonce_start..nonce_end]; + let ciphertext = &packet[nonce_end..]; + let aad = &packet[..header_len]; + + let out = self.with_session(|sess| { + let mut derived: Vec = Vec::new(); + + let res = (|| -> Result> { + // 1) Derive Kyber shared-secret key (non-extractable) via vendor KEM derive. + let mech_type = + MechanismType::new_vendor_defined(derive_mech_id).map_err(|_| { + Error::InvalidInput(format!( + "Invalid vendor-defined PKCS#11 mechanism: {}", + derive_mech_id + )) + })?; + let kem_params = KeyDerivationStringData::new(capsule); + let kem_mech = Mechanism::VendorDefined(VendorDefinedMechanism::new( + mech_type, + Some(&kem_params), + )); + + let ss_template = [ + Attribute::Class(ObjectClass::SECRET_KEY), + Attribute::KeyType(KeyType::GENERIC_SECRET), + Attribute::ValueLen(Ulong::new(32)), + Attribute::Token(false), + Attribute::Private(true), + Attribute::Sensitive(true), + Attribute::Extractable(false), + Attribute::Derive(true), + ]; + let kyber_ss_key = sess + .derive_key(&kem_mech, self.kem_key, &ss_template) + .map_err(|e| { + Error::CryptoError(format!("PKCS#11 derive_key (KEM) failed: {}", e)) + })?; + derived.push(kyber_ss_key); + + // 2) Derive X25519 shared-secret key (non-extractable) via ECDH1 derive. + let x_ss_template = [ + Attribute::Class(ObjectClass::SECRET_KEY), + Attribute::KeyType(KeyType::GENERIC_SECRET), + Attribute::ValueLen(Ulong::new(32)), + Attribute::Token(false), + Attribute::Private(true), + Attribute::Sensitive(true), + Attribute::Extractable(false), + Attribute::Derive(true), + ]; + // Tokens vary on how they expect an X25519 public key to be encoded in + // `CK_ECDH1_DERIVE_PARAMS.public_data`: + // - raw 32 bytes (RFC 7748) + // - DER-encoded OCTET STRING wrapping the 32 bytes (CKA_EC_POINT-like) + // + // Try raw first, then fall back to DER OCTET STRING. + let mut x_ss_key: Option = None; + let mut last_err: Option = None; + for cand in [eph_pk_bytes.to_vec(), der_octet_string(eph_pk_bytes)].iter() { + let ecdh_params = Ecdh1DeriveParams::new(EcKdf::null(), cand); + let ecdh_mech = Mechanism::Ecdh1Derive(ecdh_params); + match sess.derive_key(&ecdh_mech, x25519_key, &x_ss_template) { + Ok(h) => { + x_ss_key = Some(h); + break; + } + Err(e) => last_err = Some(e), + } + } + let x_ss_key = match x_ss_key { + Some(h) => h, + None => { + let e = last_err.unwrap_or(cryptoki::error::Error::InvalidValue); + return Err(Error::CryptoError(format!( + "PKCS#11 derive_key (ECDH) failed: {}", + e + ))); + } + }; + derived.push(x_ss_key); + + // 3) Derive IKM key = kyber_ss || x25519_ss via standard concatenation KDF (base+key). + let concat_mech = Mechanism::ConcatenateBaseAndKey(x_ss_key); + let ikm_template = [ + Attribute::Class(ObjectClass::SECRET_KEY), + Attribute::KeyType(KeyType::GENERIC_SECRET), + Attribute::ValueLen(Ulong::new(64)), + Attribute::Token(false), + Attribute::Private(true), + Attribute::Sensitive(true), + Attribute::Extractable(false), + Attribute::Derive(true), + ]; + let ikm_key = sess + .derive_key(&concat_mech, kyber_ss_key, &ikm_template) + .map_err(|e| { + Error::CryptoError(format!("PKCS#11 derive_key (concat) failed: {}", e)) + })?; + derived.push(ikm_key); + + // 4) Derive AES-256 key via HKDF-SHA256(ikm_key, salt=NULL, info=...). + let hkdf_params = HkdfParams::new( + MechanismType::SHA256_HMAC, + Some(HkdfSalt::Null), + Some(b"pqc-iiot:hybrid:v1:aes-gcm-key"), + ); + let hkdf_mech = Mechanism::HkdfDerive(hkdf_params); + let aes_template = [ + Attribute::Class(ObjectClass::SECRET_KEY), + Attribute::KeyType(KeyType::AES), + Attribute::ValueLen(Ulong::new(32)), + Attribute::Token(false), + Attribute::Private(true), + Attribute::Sensitive(true), + Attribute::Extractable(false), + Attribute::Encrypt(true), + Attribute::Decrypt(true), + ]; + let aes_key = sess + .derive_key(&hkdf_mech, ikm_key, &aes_template) + .map_err(|e| { + Error::CryptoError(format!("PKCS#11 derive_key (HKDF) failed: {}", e)) + })?; + derived.push(aes_key); + + // 5) AES-256-GCM decrypt with AAD bound to the packet header. + let mut iv = [0u8; 12]; + iv.copy_from_slice(nonce_bytes); + let gcm_params = GcmParams::new(&mut iv, aad, Ulong::new(128)) + .map_err(|e| Error::CryptoError(format!("PKCS#11 GCM params failed: {}", e)))?; + let aead_mech = Mechanism::AesGcm(gcm_params); + let plaintext = sess.decrypt(&aead_mech, aes_key, ciphertext).map_err(|e| { + Error::CryptoError(format!("PKCS#11 AES-GCM decrypt failed: {}", e)) + })?; + Ok(plaintext) + })(); + + for h in derived.into_iter().rev() { + let _ = sess.destroy_object(h); + } + res + })?; + Ok(out) + } +} + +impl Drop for Pkcs11PqcBackend { + fn drop(&mut self) { + // Best-effort logout + close session + finalize. Errors are ignored on drop. + if let Ok(mut guard) = self.session.lock() { + if let Some(sess) = guard.take() { + let _ = sess.logout(); + let _ = sess.close(); + } + } + if let Some(pkcs11) = self.pkcs11.take() { + let _ = pkcs11.finalize(); + } + } +} + +impl PqcHsmBackend for Pkcs11PqcBackend { + fn hsm_kind(&self) -> &'static str { + "pkcs11" + } + + fn kem_public_key(&self) -> Result> { + Ok(self.kem_pk.clone()) + } + + fn sig_public_key(&self) -> Result> { + Ok(self.sig_pk.clone()) + } + + fn x25519_public_key(&self) -> Result> { + Ok(self.x25519_pk) + } + + fn sign(&self, message: &[u8]) -> Result> { + self.sign_inner(message) + } + + fn kem_decapsulate(&self, kem_ciphertext: &[u8]) -> Result<[u8; 32]> { + self.kem_decapsulate_inner(kem_ciphertext) + } + + fn supports_hybrid_v1_in_token_decrypt(&self) -> bool { + self.mechanisms.kem_derive_mechanism.is_some() + } + + fn decrypt_hybrid_v1_in_token(&self, packet: &[u8], x25519_ss: [u8; 32]) -> Result> { + self.decrypt_hybrid_v1_in_token_inner(packet, x25519_ss) + } + + fn supports_hybrid_v1_full_in_token_decrypt(&self) -> bool { + self.mechanisms.kem_derive_mechanism.is_some() && self.x25519_key.is_some() + } + + fn decrypt_hybrid_v1_full_in_token(&self, packet: &[u8]) -> Result> { + self.decrypt_hybrid_v1_full_in_token_inner(packet) + } +} + +fn find_slot_by_token_label(pkcs11: &Pkcs11, want_label: &str) -> Result { + let slots = pkcs11 + .get_slots_with_token() + .map_err(|e| Error::ClientError(format!("PKCS#11 get_slots_with_token failed: {}", e)))?; + for slot in slots { + let info = pkcs11 + .get_token_info(slot) + .map_err(|e| Error::ClientError(format!("PKCS#11 token_info failed: {}", e)))?; + let label = info.label().trim(); + if label == want_label.trim() { + return Ok(slot); + } + } + Err(Error::ClientError(format!( + "PKCS#11 token not found (label={})", + want_label + ))) +} + +fn find_key_by_label(session: &Session, label: &str) -> Result { + let template = [Attribute::Label(label.as_bytes().to_vec())]; + let objects = session + .find_objects(&template) + .map_err(|e| Error::ClientError(format!("PKCS#11 find_objects failed: {}", e)))?; + + match objects.as_slice() { + [] => Err(Error::ClientError(format!( + "PKCS#11 key object not found (label={})", + label + ))), + [one] => Ok(*one), + _ => Err(Error::ClientError(format!( + "PKCS#11 key label is not unique (label={}, matches={})", + label, + objects.len() + ))), + } +} + +fn find_private_key_by_label( + session: &Session, + label: &str, + key_type: KeyType, +) -> Result { + let template = [ + Attribute::Label(label.as_bytes().to_vec()), + Attribute::Class(ObjectClass::PRIVATE_KEY), + Attribute::KeyType(key_type), + ]; + let objects = session + .find_objects(&template) + .map_err(|e| Error::ClientError(format!("PKCS#11 find_objects failed: {}", e)))?; + + match objects.as_slice() { + [] => Err(Error::ClientError(format!( + "PKCS#11 private key object not found (label={})", + label + ))), + [one] => Ok(*one), + _ => Err(Error::ClientError(format!( + "PKCS#11 private key label is not unique (label={}, matches={})", + label, + objects.len() + ))), + } +} diff --git a/src/security/hybrid.rs b/src/security/hybrid.rs index 4c37386..feb07f3 100644 --- a/src/security/hybrid.rs +++ b/src/security/hybrid.rs @@ -170,6 +170,144 @@ where } } +/// Decrypt a hybrid packet using a KEM decapsulation oracle (HSM/TPM/TEE) plus X25519 exchange. +/// +/// This is the gateway/hardware-provider entry point: the KEM secret key is never materialized as +/// bytes in process memory. Only the derived shared secret is returned. +pub fn decrypt_with_kem_and_exchange( + packet: &[u8], + kem_decapsulate: F, + x25519_exchange: G, +) -> Result> +where + F: FnOnce(&[u8]) -> Result<[u8; 32]>, + G: FnOnce([u8; 32]) -> Result<[u8; 32]>, +{ + if packet.is_empty() { + return Err(Error::CryptoError("Packet too short".into())); + } + if packet[0] == 1 { + decrypt_v1_with_kem(packet, kem_decapsulate, x25519_exchange) + } else { + decrypt_legacy_with_kem(packet, kem_decapsulate) + } +} + +fn decrypt_v1_with_kem( + packet: &[u8], + kem_decapsulate: F, + x25519_exchange: G, +) -> Result> +where + F: FnOnce(&[u8]) -> Result<[u8; 32]>, + G: FnOnce([u8; 32]) -> Result<[u8; 32]>, +{ + if packet.len() < 1 + 1 + 2 + X25519_PK_SIZE + NONCE_SIZE { + return Err(Error::CryptoError("Packet too short".into())); + } + + let version = packet[0]; + let suite = packet[1]; + if version != 1 { + return Err(Error::CryptoError(format!( + "Unsupported packet version: {}", + version + ))); + } + if suite != 1 { + return Err(Error::CryptoError(format!( + "Unsupported hybrid suite: {}", + suite + ))); + } + + let capsule_len = u16::from_be_bytes([packet[2], packet[3]]) as usize; + let header_len = 1 + 1 + 2 + capsule_len + X25519_PK_SIZE; + if packet.len() < header_len + NONCE_SIZE + 16 { + return Err(Error::CryptoError("Packet too short for capsule".into())); + } + + let capsule_start = 4; + let capsule_end = capsule_start + capsule_len; + let eph_pk_start = capsule_end; + let eph_pk_end = eph_pk_start + X25519_PK_SIZE; + let nonce_start = eph_pk_end; + let nonce_end = nonce_start + NONCE_SIZE; + + let capsule = &packet[capsule_start..capsule_end]; + let eph_pk_bytes = &packet[eph_pk_start..eph_pk_end]; + let nonce_bytes = &packet[nonce_start..nonce_end]; + let ciphertext = &packet[nonce_end..]; + + let mut kyber_ss = kem_decapsulate(capsule)?; + let mut eph_pk = [0u8; 32]; + eph_pk.copy_from_slice(eph_pk_bytes); + let mut x_ss = x25519_exchange(eph_pk)?; + + let mut ikm = [0u8; 64]; + ikm[..32].copy_from_slice(&kyber_ss); + ikm[32..].copy_from_slice(&x_ss); + kyber_ss.zeroize(); + x_ss.zeroize(); + + let hk = Hkdf::::new(None, &ikm); + let mut key_bytes = [0u8; 32]; + let expand_res = hk + .expand(b"pqc-iiot:hybrid:v1:aes-gcm-key", &mut key_bytes) + .map_err(|_| Error::CryptoError("HKDF expand failed".into())); + if let Err(e) = expand_res { + key_bytes.zeroize(); + ikm.zeroize(); + return Err(e); + } + + let key = aes_gcm::Key::::from_slice(&key_bytes); + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(nonce_bytes); + + let aad = &packet[..header_len]; + let out_res = cipher + .decrypt( + nonce, + Payload { + msg: ciphertext, + aad, + }, + ) + .map_err(|_| Error::CryptoError("AES-GCM decryption failed".into())); + key_bytes.zeroize(); + ikm.zeroize(); + out_res +} + +fn decrypt_legacy_with_kem(packet: &[u8], kem_decapsulate: F) -> Result> +where + F: FnOnce(&[u8]) -> Result<[u8; 32]>, +{ + if packet.len() < 2 { + return Err(Error::CryptoError("Packet too short".into())); + } + let (len_bytes, rest) = packet.split_at(2); + let capsule_len = u16::from_be_bytes([len_bytes[0], len_bytes[1]]) as usize; + + if rest.len() < capsule_len + NONCE_SIZE { + return Err(Error::CryptoError("Packet too short for capsule".into())); + } + let (capsule, rest) = rest.split_at(capsule_len); + let (nonce_bytes, ciphertext) = rest.split_at(NONCE_SIZE); + + let mut shared_secret = kem_decapsulate(capsule)?; + let key = aes_gcm::Key::::from_slice(&shared_secret); + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(nonce_bytes); + + let out_res = cipher + .decrypt(nonce, ciphertext) + .map_err(|_| Error::CryptoError("AES-GCM decryption failed".into())); + shared_secret.zeroize(); + out_res +} + fn decrypt_v1(my_kem_sk: &[u8], packet: &[u8], x25519_exchange: F) -> Result> where F: FnOnce([u8; 32]) -> Result<[u8; 32]>, diff --git a/src/security/mod.rs b/src/security/mod.rs index efa765e..5cd676e 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -2,6 +2,8 @@ pub mod audit; /// Denial of Service (DoS) Protection mechanisms (Client Puzzles, Rate Limiting). pub mod dos; +/// Gateway hardware security module integration (PQC in HSM + RoT-backed persistence). +pub mod hsm; /// Hybrid encryption (KEM + AES-GCM) pub mod hybrid; /// Key storage and management @@ -19,9 +21,19 @@ pub mod provider; pub mod revocation; /// Root-of-trust boundary (TPM/TEE/HSM) and composite providers. pub mod root_of_trust; +/// Remote append-only root-of-trust backend (anchor for anti-rollback). +pub mod rot_remote; +/// TEE root-of-trust backend interface (TrustZone/OP-TEE/SGX). +pub mod rot_tee; +/// TPM2 root-of-trust backend interface (TPM NV counters / sealing). +pub mod rot_tpm2; /// Secure time / monotonic floor helpers (best-effort without TPM/HSM). #[cfg(feature = "std")] pub mod time; /// TPM 2.0 implementation (Software-backed for Linux/Gateway) #[cfg(feature = "std")] pub mod tpm; + +/// PKCS#11 HSM backend (vendor-specific PQC mechanisms). +#[cfg(feature = "hsm-pkcs11")] +pub mod hsm_pkcs11; diff --git a/src/security/rot_remote.rs b/src/security/rot_remote.rs new file mode 100644 index 0000000..f38d62c --- /dev/null +++ b/src/security/rot_remote.rs @@ -0,0 +1,297 @@ +use crate::security::root_of_trust::{RootOfTrust, SoftwareRootOfTrust}; +use crate::{Error, Result}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; + +/// Remote append-only monotonic counter store. +/// +/// This represents an external *anti-rollback anchor* (HSM-backed service, WORM/append-only store, +/// etc). The semantics must be: +/// - values are monotonic per key +/// - rollback is not possible for an attacker with local filesystem access +/// +/// If this assumption is false, `RemoteAppendOnlyRootOfTrust` becomes best-effort and must not be +/// used to satisfy `require_rollback_resistant_storage` fleet policy. +pub trait AppendOnlyCounterStore: Send + Sync { + /// Human-readable store kind string for observability. + fn store_kind(&self) -> &'static str; + + /// Return the current counter value for `key`, or `None` when unset. + fn get(&self, key: &str) -> Result>; + + /// Advance the counter to `candidate` if `candidate > current`. + /// + /// Returns `Ok(true)` when advanced, `Ok(false)` otherwise. + fn advance_to(&self, key: &str, candidate: u64) -> Result; + + /// Increment the counter by 1 and return the new value. + fn increment(&self, key: &str) -> Result; +} + +/// A reference RoT that uses: +/// - local sealed blobs (confidentiality + integrity) and +/// - a remote append-only counter (anti-rollback anchor). +/// +/// Threat model and failure semantics: +/// - If the remote store is unreachable, sealing/unsealing and monotonic counter ops fail. +/// - A local attacker who rolls back `pqc-data/` blobs will be detected on `unseal_data()`. +/// - A local attacker who deletes blobs will induce a fail-closed condition. +pub struct RemoteAppendOnlyRootOfTrust { + local: SoftwareRootOfTrust, + remote: Arc, + namespace: String, +} + +impl RemoteAppendOnlyRootOfTrust { + /// Construct a remote-anchored RoT. + /// + /// `namespace` is a logical partitioning label (e.g. deployment/fleet id) and is incorporated + /// into remote keys to avoid collisions across environments. + pub fn new( + local_master_key: [u8; 32], + remote: Arc, + namespace: impl Into, + ) -> Self { + Self { + local: SoftwareRootOfTrust::new(local_master_key), + remote, + namespace: namespace.into(), + } + } + + fn remote_key(&self, kind: &str, label: &str) -> String { + // Hash the label to avoid exposing potentially sensitive path/id material to the remote + // store and to ensure a stable, bounded key. + let mut hasher = Sha256::new(); + hasher.update(self.namespace.as_bytes()); + hasher.update(b":"); + hasher.update(kind.as_bytes()); + hasher.update(b":"); + hasher.update(label.as_bytes()); + let digest = hasher.finalize(); + format!("pqc-iiot:rot:{}:{}", kind, hex::encode(digest)) + } + + fn local_label_for_seal(&self, label: &str) -> String { + // Keep a stable domain-separated label namespace for local sealing keys. + format!("pqc-iiot:rot-remote-seal:v1:{}", label) + } + + fn encode_envelope_v1(gen: u64, data: &[u8]) -> Result> { + if data.len() > u32::MAX as usize { + return Err(Error::InvalidInput("sealed data too large".into())); + } + let mut out = Vec::with_capacity(1 + 8 + 4 + data.len()); + out.push(1u8); + out.extend_from_slice(&gen.to_be_bytes()); + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(data); + Ok(out) + } + + fn decode_envelope_v1(blob: &[u8]) -> Result<(u64, Vec)> { + const MIN: usize = 1 + 8 + 4; + if blob.len() < MIN { + return Err(Error::CryptoError("sealed envelope too short".into())); + } + if blob[0] != 1u8 { + return Err(Error::CryptoError(format!( + "unsupported sealed envelope version: {}", + blob[0] + ))); + } + let mut gen_bytes = [0u8; 8]; + gen_bytes.copy_from_slice(&blob[1..9]); + let gen = u64::from_be_bytes(gen_bytes); + let len = u32::from_be_bytes([blob[9], blob[10], blob[11], blob[12]]) as usize; + if blob.len() != MIN + len { + return Err(Error::CryptoError("sealed envelope length mismatch".into())); + } + Ok((gen, blob[13..].to_vec())) + } +} + +impl RootOfTrust for RemoteAppendOnlyRootOfTrust { + fn rot_kind(&self) -> &'static str { + "remote-append-only" + } + + fn is_rollback_resistant_storage(&self) -> bool { + true + } + + fn seal_data(&self, label: &str, data: &[u8]) -> Result<()> { + let remote_key = self.remote_key("seal", label); + + // Two-phase update: + // 1) compute candidate generation (monotonic) + // 2) write local sealed blob tagged with candidate generation + // 3) advance remote floor to candidate generation + // + // If the process crashes between (2) and (3), `unseal_data()` will self-heal by advancing + // the remote floor to the local generation. + let current = self.remote.get(&remote_key)?.unwrap_or(0); + let candidate = current.saturating_add(1).max(1); + + let envelope = Self::encode_envelope_v1(candidate, data)?; + let local_label = self.local_label_for_seal(label); + self.local.seal_data(&local_label, &envelope)?; + + // Now commit the remote floor. + let _ = self.remote.advance_to(&remote_key, candidate)?; + Ok(()) + } + + fn unseal_data(&self, label: &str) -> Result> { + let remote_key = self.remote_key("seal", label); + let remote_gen = self.remote.get(&remote_key)?.ok_or_else(|| { + Error::IoError(std::io::Error::new( + std::io::ErrorKind::NotFound, + "remote sealed generation missing", + )) + })?; + + let local_label = self.local_label_for_seal(label); + let blob = self.local.unseal_data(&local_label)?; + let (local_gen, data) = Self::decode_envelope_v1(&blob)?; + + if local_gen > remote_gen { + // Crash between local seal and remote floor update: self-heal by advancing remote. + let _ = self.remote.advance_to(&remote_key, local_gen)?; + let healed = self.remote.get(&remote_key)?.unwrap_or(remote_gen); + if healed == local_gen { + return Ok(data); + } + } + + if local_gen != remote_gen { + return Err(Error::CryptoError(format!( + "sealed blob generation mismatch (rollback?): local_gen={} remote_gen={}", + local_gen, remote_gen + ))); + } + + Ok(data) + } + + fn sealed_monotonic_u64_get(&self, label: &str) -> Result> { + let key = self.remote_key("monotonic", label); + self.remote.get(&key) + } + + fn sealed_monotonic_u64_advance_to(&self, label: &str, candidate: u64) -> Result { + let key = self.remote_key("monotonic", label); + self.remote.advance_to(&key, candidate) + } + + fn sealed_monotonic_u64_increment(&self, label: &str) -> Result { + let key = self.remote_key("monotonic", label); + self.remote.increment(&key) + } +} + +/// In-memory append-only counter store (for tests/demos). +#[derive(Default)] +pub struct InMemoryAppendOnlyCounterStore { + inner: std::sync::Mutex>, +} + +impl InMemoryAppendOnlyCounterStore { + /// Create a new in-memory append-only counter store. + pub fn new() -> Self { + Self::default() + } +} + +impl AppendOnlyCounterStore for InMemoryAppendOnlyCounterStore { + fn store_kind(&self) -> &'static str { + "in-memory" + } + + fn get(&self, key: &str) -> Result> { + let map = self.inner.lock().map_err(|_| { + Error::ClientError("InMemoryAppendOnlyCounterStore mutex poisoned".into()) + })?; + Ok(map.get(key).copied()) + } + + fn advance_to(&self, key: &str, candidate: u64) -> Result { + let mut map = self.inner.lock().map_err(|_| { + Error::ClientError("InMemoryAppendOnlyCounterStore mutex poisoned".into()) + })?; + let current = map.get(key).copied().unwrap_or(0); + if candidate > current { + map.insert(key.to_string(), candidate); + return Ok(true); + } + Ok(false) + } + + fn increment(&self, key: &str) -> Result { + let mut map = self.inner.lock().map_err(|_| { + Error::ClientError("InMemoryAppendOnlyCounterStore mutex poisoned".into()) + })?; + let current = map.get(key).copied().unwrap_or(0); + let next = current.saturating_add(1).max(1); + map.insert(key.to_string(), next); + Ok(next) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn sealed_blob_path(label: &str) -> std::path::PathBuf { + let digest = Sha256::digest(label.as_bytes()); + Path::new("pqc-data").join(format!("sealed_{}.bin", hex::encode(digest))) + } + + #[test] + fn remote_rot_seal_unseal_roundtrip_and_rollback_detection() { + let remote = Arc::new(InMemoryAppendOnlyCounterStore::new()); + let rot = RemoteAppendOnlyRootOfTrust::new([0x11u8; 32], remote, "fleet-a"); + + let label = "pqc-iiot:test:remote-rot:blob"; + let local_label = rot.local_label_for_seal(label); + let path = sealed_blob_path(&local_label); + + // First seal. + rot.seal_data(label, b"v1").expect("seal v1"); + let first_blob = std::fs::read(&path).expect("read v1 blob"); + assert_eq!(rot.unseal_data(label).unwrap(), b"v1"); + + // Second seal advances remote generation. + rot.seal_data(label, b"v2").expect("seal v2"); + assert_eq!(rot.unseal_data(label).unwrap(), b"v2"); + + // Roll back local file: restore v1 blob. + std::fs::write(&path, first_blob).expect("rollback local blob"); + let err = rot + .unseal_data(label) + .expect_err("rollback must be detected"); + let msg = format!("{err:?}"); + assert!( + msg.contains("generation mismatch"), + "unexpected error: {msg}" + ); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn remote_rot_monotonic_counters_are_monotonic() { + let remote = Arc::new(InMemoryAppendOnlyCounterStore::new()); + let rot = RemoteAppendOnlyRootOfTrust::new([0x22u8; 32], remote, "fleet-b"); + + let label = "pqc-iiot:test:remote-rot:counter"; + assert_eq!(rot.sealed_monotonic_u64_get(label).unwrap(), None); + assert_eq!(rot.sealed_monotonic_u64_increment(label).unwrap(), 1); + assert_eq!(rot.sealed_monotonic_u64_get(label).unwrap(), Some(1)); + assert!(rot.sealed_monotonic_u64_advance_to(label, 10).unwrap()); + assert_eq!(rot.sealed_monotonic_u64_get(label).unwrap(), Some(10)); + assert!(!rot.sealed_monotonic_u64_advance_to(label, 9).unwrap()); + assert_eq!(rot.sealed_monotonic_u64_increment(label).unwrap(), 11); + } +} diff --git a/src/security/rot_tee.rs b/src/security/rot_tee.rs new file mode 100644 index 0000000..5578693 --- /dev/null +++ b/src/security/rot_tee.rs @@ -0,0 +1,165 @@ +use crate::security::root_of_trust::RootOfTrust; +use crate::Result; +use std::sync::Arc; + +/// TEE backend boundary (TrustZone/OP-TEE/SGX style). +/// +/// Production implementations typically cross an FFI boundary (TEE Client API) and provide: +/// - sealed storage bound to the TEE root key +/// - a rollback-resistant monotonic counter (TEE monotonic storage / RPMB-backed counter) +/// +/// This crate intentionally keeps the interface minimal and deterministic. +pub trait TeeBackend: Send + Sync { + /// Human-readable kind string for observability. + fn tee_kind(&self) -> &'static str; + + /// Whether the TEE provides rollback-resistant storage/counters. + fn is_rollback_resistant_storage(&self) -> bool; + + /// Seal data to persistent storage under `label`. + fn seal_data(&self, label: &str, data: &[u8]) -> Result<()>; + /// Unseal data from persistent storage under `label`. + fn unseal_data(&self, label: &str) -> Result>; + + /// Read the current monotonic counter value, or `None` when unset. + fn monotonic_u64_get(&self, label: &str) -> Result>; + /// Advance the monotonic counter to `candidate` when `candidate > current`. + fn monotonic_u64_advance_to(&self, label: &str, candidate: u64) -> Result; + /// Increment the monotonic counter and return the new value. + fn monotonic_u64_increment(&self, label: &str) -> Result; +} + +/// Root-of-trust wrapper that delegates to a TEE backend. +pub struct TeeRootOfTrust { + backend: Arc, +} + +impl TeeRootOfTrust { + /// Create a `RootOfTrust` wrapper around a concrete TEE backend. + pub fn new(backend: Arc) -> Self { + Self { backend } + } + + /// Observability: underlying TEE backend kind string. + pub fn tee_kind(&self) -> &'static str { + self.backend.tee_kind() + } +} + +impl RootOfTrust for TeeRootOfTrust { + fn rot_kind(&self) -> &'static str { + "tee" + } + + fn is_rollback_resistant_storage(&self) -> bool { + self.backend.is_rollback_resistant_storage() + } + + fn seal_data(&self, label: &str, data: &[u8]) -> Result<()> { + self.backend.seal_data(label, data) + } + + fn unseal_data(&self, label: &str) -> Result> { + self.backend.unseal_data(label) + } + + fn sealed_monotonic_u64_get(&self, label: &str) -> Result> { + self.backend.monotonic_u64_get(label) + } + + fn sealed_monotonic_u64_advance_to(&self, label: &str, candidate: u64) -> Result { + self.backend.monotonic_u64_advance_to(label, candidate) + } + + fn sealed_monotonic_u64_increment(&self, label: &str) -> Result { + self.backend.monotonic_u64_increment(label) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Error; + + #[derive(Default)] + struct MockTee { + blobs: std::sync::Mutex>>, + counters: std::sync::Mutex>, + } + + impl TeeBackend for MockTee { + fn tee_kind(&self) -> &'static str { + "mock-tee" + } + + fn is_rollback_resistant_storage(&self) -> bool { + true + } + + fn seal_data(&self, label: &str, data: &[u8]) -> Result<()> { + let mut blobs = self + .blobs + .lock() + .map_err(|_| Error::ClientError("mock tee blobs mutex poisoned".into()))?; + blobs.insert(label.to_string(), data.to_vec()); + Ok(()) + } + + fn unseal_data(&self, label: &str) -> Result> { + let blobs = self + .blobs + .lock() + .map_err(|_| Error::ClientError("mock tee blobs mutex poisoned".into()))?; + blobs.get(label).cloned().ok_or_else(|| { + Error::IoError(std::io::Error::new( + std::io::ErrorKind::NotFound, + "blob missing", + )) + }) + } + + fn monotonic_u64_get(&self, label: &str) -> Result> { + let counters = self + .counters + .lock() + .map_err(|_| Error::ClientError("mock tee counters mutex poisoned".into()))?; + Ok(counters.get(label).copied()) + } + + fn monotonic_u64_advance_to(&self, label: &str, candidate: u64) -> Result { + let mut counters = self + .counters + .lock() + .map_err(|_| Error::ClientError("mock tee counters mutex poisoned".into()))?; + let current = counters.get(label).copied().unwrap_or(0); + if candidate > current { + counters.insert(label.to_string(), candidate); + return Ok(true); + } + Ok(false) + } + + fn monotonic_u64_increment(&self, label: &str) -> Result { + let mut counters = self + .counters + .lock() + .map_err(|_| Error::ClientError("mock tee counters mutex poisoned".into()))?; + let current = counters.get(label).copied().unwrap_or(0); + let next = current.saturating_add(1).max(1); + counters.insert(label.to_string(), next); + Ok(next) + } + } + + #[test] + fn tee_rot_delegates() { + let tee = Arc::new(MockTee::default()); + let rot = TeeRootOfTrust::new(tee); + + rot.seal_data("x", b"y").unwrap(); + assert_eq!(rot.unseal_data("x").unwrap(), b"y"); + assert_eq!(rot.sealed_monotonic_u64_get("c").unwrap(), None); + assert_eq!(rot.sealed_monotonic_u64_increment("c").unwrap(), 1); + assert_eq!(rot.sealed_monotonic_u64_get("c").unwrap(), Some(1)); + } +} diff --git a/src/security/rot_tpm2.rs b/src/security/rot_tpm2.rs new file mode 100644 index 0000000..58a94e0 --- /dev/null +++ b/src/security/rot_tpm2.rs @@ -0,0 +1,168 @@ +use crate::security::root_of_trust::RootOfTrust; +use crate::Result; +use std::sync::Arc; + +/// TPM 2.0 backend boundary (tss-esapi / resource manager). +/// +/// This abstraction exists to keep the **Root-of-Trust** contract explicit: +/// - sealed storage (confidentiality + integrity) +/// - rollback-resistant monotonic counters (TPM NV counters / NV indices) +/// +/// A production implementation typically: +/// - talks to `/dev/tpmrm0` via `tss-esapi` +/// - uses an NV counter index per label namespace (or a keyed mapping) +/// - binds sealed blobs to the NV counter value (anti-rollback) +pub trait Tpm2Backend: Send + Sync { + /// Human-readable kind string for observability. + fn tpm_kind(&self) -> &'static str; + + /// Whether this backend provides rollback-resistant storage/counters. + fn is_rollback_resistant_storage(&self) -> bool; + + /// Seal data to persistent storage under `label`. + fn seal_data(&self, label: &str, data: &[u8]) -> Result<()>; + /// Unseal data from persistent storage under `label`. + fn unseal_data(&self, label: &str) -> Result>; + + /// Read the current TPM NV counter value, or `None` when unset. + fn nv_counter_get(&self, label: &str) -> Result>; + /// Advance the TPM NV counter to `candidate` when `candidate > current`. + fn nv_counter_advance_to(&self, label: &str, candidate: u64) -> Result; + /// Increment the TPM NV counter and return the new value. + fn nv_counter_increment(&self, label: &str) -> Result; +} + +/// Root-of-trust wrapper that delegates to a TPM2 backend. +pub struct Tpm2RootOfTrust { + backend: Arc, +} + +impl Tpm2RootOfTrust { + /// Create a `RootOfTrust` wrapper around a concrete TPM2 backend. + pub fn new(backend: Arc) -> Self { + Self { backend } + } + + /// Observability: underlying TPM backend kind string. + pub fn tpm_kind(&self) -> &'static str { + self.backend.tpm_kind() + } +} + +impl RootOfTrust for Tpm2RootOfTrust { + fn rot_kind(&self) -> &'static str { + "tpm2" + } + + fn is_rollback_resistant_storage(&self) -> bool { + self.backend.is_rollback_resistant_storage() + } + + fn seal_data(&self, label: &str, data: &[u8]) -> Result<()> { + self.backend.seal_data(label, data) + } + + fn unseal_data(&self, label: &str) -> Result> { + self.backend.unseal_data(label) + } + + fn sealed_monotonic_u64_get(&self, label: &str) -> Result> { + self.backend.nv_counter_get(label) + } + + fn sealed_monotonic_u64_advance_to(&self, label: &str, candidate: u64) -> Result { + self.backend.nv_counter_advance_to(label, candidate) + } + + fn sealed_monotonic_u64_increment(&self, label: &str) -> Result { + self.backend.nv_counter_increment(label) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Error; + + #[derive(Default)] + struct MockTpm2 { + blobs: std::sync::Mutex>>, + counters: std::sync::Mutex>, + } + + impl Tpm2Backend for MockTpm2 { + fn tpm_kind(&self) -> &'static str { + "mock-tpm2" + } + + fn is_rollback_resistant_storage(&self) -> bool { + true + } + + fn seal_data(&self, label: &str, data: &[u8]) -> Result<()> { + let mut blobs = self + .blobs + .lock() + .map_err(|_| Error::ClientError("mock tpm2 blobs mutex poisoned".into()))?; + blobs.insert(label.to_string(), data.to_vec()); + Ok(()) + } + + fn unseal_data(&self, label: &str) -> Result> { + let blobs = self + .blobs + .lock() + .map_err(|_| Error::ClientError("mock tpm2 blobs mutex poisoned".into()))?; + blobs.get(label).cloned().ok_or_else(|| { + Error::IoError(std::io::Error::new( + std::io::ErrorKind::NotFound, + "blob missing", + )) + }) + } + + fn nv_counter_get(&self, label: &str) -> Result> { + let counters = self + .counters + .lock() + .map_err(|_| Error::ClientError("mock tpm2 counters mutex poisoned".into()))?; + Ok(counters.get(label).copied()) + } + + fn nv_counter_advance_to(&self, label: &str, candidate: u64) -> Result { + let mut counters = self + .counters + .lock() + .map_err(|_| Error::ClientError("mock tpm2 counters mutex poisoned".into()))?; + let current = counters.get(label).copied().unwrap_or(0); + if candidate > current { + counters.insert(label.to_string(), candidate); + return Ok(true); + } + Ok(false) + } + + fn nv_counter_increment(&self, label: &str) -> Result { + let mut counters = self + .counters + .lock() + .map_err(|_| Error::ClientError("mock tpm2 counters mutex poisoned".into()))?; + let current = counters.get(label).copied().unwrap_or(0); + let next = current.saturating_add(1).max(1); + counters.insert(label.to_string(), next); + Ok(next) + } + } + + #[test] + fn tpm2_rot_delegates() { + let tpm = Arc::new(MockTpm2::default()); + let rot = Tpm2RootOfTrust::new(tpm); + + rot.seal_data("x", b"y").unwrap(); + assert_eq!(rot.unseal_data("x").unwrap(), b"y"); + assert_eq!(rot.sealed_monotonic_u64_get("c").unwrap(), None); + assert_eq!(rot.sealed_monotonic_u64_increment("c").unwrap(), 1); + assert_eq!(rot.sealed_monotonic_u64_get("c").unwrap(), Some(1)); + } +}