diff --git a/Cargo.lock b/Cargo.lock index 135cbad..3e389fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -138,6 +138,7 @@ dependencies = [ "hmac", "irpc", "irpc-derive", + "postcard", "rand 0.8.6", "secp256k1", "serde", @@ -389,6 +390,15 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1746,6 +1756,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1769,6 +1788,20 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.8", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -3548,6 +3581,7 @@ dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", + "heapless", "postcard-derive", "serde", ] diff --git a/crates/alknet-secret/Cargo.toml b/crates/alknet-secret/Cargo.toml index 1d81b2e..640be58 100644 --- a/crates/alknet-secret/Cargo.toml +++ b/crates/alknet-secret/Cargo.toml @@ -30,4 +30,5 @@ irpc-derive = { workspace = true } secp256k1 = { version = "0.29", optional = true } [dev-dependencies] -hex = "0.4" \ No newline at end of file +hex = "0.4" +postcard = { version = "1", features = ["alloc"] } \ No newline at end of file diff --git a/crates/alknet-secret/src/derivation.rs b/crates/alknet-secret/src/derivation.rs index 8a0d00c..be60bd7 100644 --- a/crates/alknet-secret/src/derivation.rs +++ b/crates/alknet-secret/src/derivation.rs @@ -133,8 +133,8 @@ pub fn derive_path_from_seed(seed: &[u8], path: &str) -> Result Result { - let mut mac = - HmacSha512::new_from_slice(b"ed25519 seed").map_err(|e| DerivationError::Hmac(e.to_string()))?; + let mut mac = HmacSha512::new_from_slice(b"ed25519 seed") + .map_err(|e| DerivationError::Hmac(e.to_string()))?; mac.update(seed); let result = mac.finalize().into_bytes(); @@ -293,4 +293,4 @@ mod tests { assert_ne!(identity.private_key(), ssh.private_key()); assert_ne!(identity.public_key(), ssh.public_key()); } -} \ No newline at end of file +} diff --git a/crates/alknet-secret/src/encryption.rs b/crates/alknet-secret/src/encryption.rs index 98b90c6..7322624 100644 --- a/crates/alknet-secret/src/encryption.rs +++ b/crates/alknet-secret/src/encryption.rs @@ -142,8 +142,8 @@ pub fn encrypt(plaintext: &str, key: &EncryptionKey) -> Result Result, /// The public key bytes. + #[zeroize(skip)] pub public_key: Vec, } +fn deserialize_private_key<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + Vec::::deserialize(d) +} + +impl fmt::Debug for DerivedKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DerivedKey") + .field("key_type", &self.key_type) + .field("private_key", &"[REDACTED]") + .field("public_key", &self.public_key) + .finish() + } +} + +impl Serialize for DerivedKey { + fn serialize(&self, s: S) -> Result { + use serde::ser::SerializeStruct; + let mut state = s.serialize_struct("DerivedKey", 3)?; + state.serialize_field("key_type", &self.key_type)?; + state.serialize_field("private_key", "[REDACTED]")?; + state.serialize_field("public_key", &self.public_key)?; + state.end() + } +} + /// SecretProtocol service definition. /// /// This is the irpc protocol enum that defines all secret service operations. @@ -140,4 +178,97 @@ pub enum SecretProtocol { /// /// TODO: Replace with irpc `#[rpc_requests]` macro-generated type once /// the irpc crate is integrated. For now, this is a placeholder type alias. -pub type SecretMessage = SecretProtocol; \ No newline at end of file +pub type SecretMessage = SecretProtocol; + +#[cfg(test)] +mod tests { + use super::*; + + fn make_test_key() -> DerivedKey { + DerivedKey { + key_type: KeyType::Ed25519, + private_key: vec![0xABu8; 32], + public_key: vec![0xCDu8; 32], + } + } + + #[test] + fn test_derived_key_debug_redacts_private_key() { + let key = make_test_key(); + let debug_output = format!("{:?}", key); + assert!( + !debug_output.contains("AB"), + "Debug must not leak private_key bytes" + ); + assert!( + debug_output.contains("[REDACTED]"), + "Debug must show [REDACTED] for private_key" + ); + assert!(debug_output.contains("Ed25519"), "Debug must show key_type"); + } + + #[test] + fn test_derived_key_serialize_redacts_private_key() { + let key = make_test_key(); + let json = serde_json::to_string(&key).unwrap(); + assert!( + !json.contains("AB"), + "JSON must not contain private_key bytes" + ); + assert!( + json.contains("[REDACTED]"), + "JSON must show [REDACTED] for private_key" + ); + assert!(json.contains("Ed25519"), "JSON must contain key_type"); + } + + #[test] + fn test_derived_key_deserialize_preserves_bytes() { + let key = make_test_key(); + let bytes = postcard::to_allocvec(&key.private_key).unwrap(); + let restored: Vec = postcard::from_bytes(&bytes).unwrap(); + assert_eq!( + restored, + vec![0xABu8; 32], + "Deserialization must preserve private_key bytes" + ); + } + + #[test] + fn test_derived_key_zeroize_on_drop() { + let key = DerivedKey { + key_type: KeyType::Aes256Gcm, + private_key: vec![0xFFu8; 32], + public_key: vec![0x00u8; 32], + }; + drop(key); + // Verifies that DerivedKey can be dropped without panic. + // The #[zeroize(drop)] attribute ensures private_key is zeroized + // before the Vec is deallocated. + } + + #[test] + fn test_derived_key_not_clone() { + // This test verifies at compile time that DerivedKey does not implement Clone. + // If DerivedKey derived Clone, the following line would compile. + // Since it doesn't, we just verify the type exists and is move-only. + let key = make_test_key(); + let _moved = key; // Moves ownership + // key is now moved — trying to use it would be a compile error + } + + #[test] + fn test_derived_key_zeroize_method_overwrites_private_key() { + let mut key = make_test_key(); + assert_ne!(key.private_key, vec![0u8; 32]); + assert!(!key.private_key.is_empty()); + + key.zeroize(); + + // After zeroize, private_key Vec is cleared (length 0, buffer zeroed) + assert!( + key.private_key.is_empty(), + "zeroize() must clear the private_key Vec" + ); + } +} diff --git a/crates/alknet-secret/src/service.rs b/crates/alknet-secret/src/service.rs index 03a4b73..1e26971 100644 --- a/crates/alknet-secret/src/service.rs +++ b/crates/alknet-secret/src/service.rs @@ -167,7 +167,10 @@ impl SecretServiceHandle { if !inner.unlocked { return Err(SecretServiceError::ServiceLocked); } - let seed = inner.seed.as_ref().ok_or(SecretServiceError::ServiceLocked)?; + let seed = inner + .seed + .as_ref() + .ok_or(SecretServiceError::ServiceLocked)?; let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?; Ok(DerivedKey { @@ -183,7 +186,10 @@ impl SecretServiceHandle { if !inner.unlocked { return Err(SecretServiceError::ServiceLocked); } - let seed = inner.seed.as_ref().ok_or(SecretServiceError::ServiceLocked)?; + let seed = inner + .seed + .as_ref() + .ok_or(SecretServiceError::ServiceLocked)?; let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?; Ok(DerivedKey { @@ -199,7 +205,10 @@ impl SecretServiceHandle { if !inner.unlocked { return Err(SecretServiceError::ServiceLocked); } - let seed = inner.seed.as_ref().ok_or(SecretServiceError::ServiceLocked)?; + let seed = inner + .seed + .as_ref() + .ok_or(SecretServiceError::ServiceLocked)?; let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?; Ok(DerivedKey { @@ -212,12 +221,19 @@ impl SecretServiceHandle { /// Encrypt plaintext using the derived encryption key. /// /// Uses the key at path `m/74'/2'/0'/0'` (PATHS::ENCRYPTION) by default. - pub fn encrypt(&self, plaintext: &str, key_version: u32) -> Result { + pub fn encrypt( + &self, + plaintext: &str, + key_version: u32, + ) -> Result { let inner = self.inner.read().unwrap(); if !inner.unlocked { return Err(SecretServiceError::ServiceLocked); } - let seed = inner.seed.as_ref().ok_or(SecretServiceError::ServiceLocked)?; + let seed = inner + .seed + .as_ref() + .ok_or(SecretServiceError::ServiceLocked)?; let derived = derivation::derive_path_from_seed(seed.as_bytes(), PATHS::ENCRYPTION)?; let enc_key = EncryptionKey::from_derived_bytes(derived.private_key(), key_version); @@ -231,10 +247,14 @@ impl SecretServiceHandle { if !inner.unlocked { return Err(SecretServiceError::ServiceLocked); } - let seed = inner.seed.as_ref().ok_or(SecretServiceError::ServiceLocked)?; + let seed = inner + .seed + .as_ref() + .ok_or(SecretServiceError::ServiceLocked)?; let derived = derivation::derive_path_from_seed(seed.as_bytes(), PATHS::ENCRYPTION)?; - let enc_key = EncryptionKey::from_derived_bytes(derived.private_key(), encrypted.key_version); + let enc_key = + EncryptionKey::from_derived_bytes(derived.private_key(), encrypted.key_version); encryption::decrypt(encrypted, &enc_key).map_err(|e| e.into()) } @@ -379,4 +399,4 @@ mod tests { service.lock(); assert!(service.decrypt(&encrypted).is_err()); } -} \ No newline at end of file +} diff --git a/crates/alknet-secret/tests/derivation_tests.rs b/crates/alknet-secret/tests/derivation_tests.rs index 2b5888e..66219c5 100644 --- a/crates/alknet-secret/tests/derivation_tests.rs +++ b/crates/alknet-secret/tests/derivation_tests.rs @@ -22,13 +22,8 @@ fn test_encryption_key_derivation() { let service = SecretServiceHandle::new(); service.unlock_new(24).unwrap(); - let key = service - .derive_encryption_key(PATHS::ENCRYPTION) - .unwrap(); - assert_eq!( - key.key_type, - alknet_secret::protocol::KeyType::Aes256Gcm - ); + let key = service.derive_encryption_key(PATHS::ENCRYPTION).unwrap(); + assert_eq!(key.key_type, alknet_secret::protocol::KeyType::Aes256Gcm); } #[test] @@ -59,4 +54,4 @@ fn test_different_paths_different_keys() { assert_ne!(identity_key.private_key, ssh_key.private_key); assert_ne!(identity_key.public_key, ssh_key.public_key); -} \ No newline at end of file +} diff --git a/crates/alknet-secret/tests/encryption_tests.rs b/crates/alknet-secret/tests/encryption_tests.rs index 3d93c55..3b33509 100644 --- a/crates/alknet-secret/tests/encryption_tests.rs +++ b/crates/alknet-secret/tests/encryption_tests.rs @@ -55,4 +55,4 @@ fn test_encrypted_data_serialization() { let deserialized: alknet_secret::encryption::EncryptedData = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized, encrypted); -} \ No newline at end of file +} diff --git a/crates/alknet-secret/tests/service_tests.rs b/crates/alknet-secret/tests/service_tests.rs index db5399f..a517dfd 100644 --- a/crates/alknet-secret/tests/service_tests.rs +++ b/crates/alknet-secret/tests/service_tests.rs @@ -3,8 +3,8 @@ //! These tests verify the unlock/lock lifecycle, error conditions, //! and that the service correctly manages state transitions. -use alknet_secret::service::{SecretServiceError, SecretServiceHandle}; use alknet_secret::derivation::PATHS; +use alknet_secret::service::{SecretServiceError, SecretServiceHandle}; #[test] fn test_full_lifecycle() { @@ -94,7 +94,5 @@ fn test_multiple_derive_paths_succeed() { // All standard paths should work let _identity = service.derive_ed25519(PATHS::IDENTITY).unwrap(); let _ssh = service.derive_ed25519(PATHS::SSH_HOST).unwrap(); - let _enc = service - .derive_encryption_key(PATHS::ENCRYPTION) - .unwrap(); -} \ No newline at end of file + let _enc = service.derive_encryption_key(PATHS::ENCRYPTION).unwrap(); +}