Files
alknet/crates/alknet-secret/src/protocol.rs
glm-5.1 eae47c366b feat(alknet-secret): make DerivedKey zeroize-on-drop, non-Clone, with redacted serialization
Per ADR-038, DerivedKey.private_key now derives Zeroize with #[zeroize(drop)]
ensuring sensitive key material is zeroized before deallocation. DerivedKey
is now move-only (no Clone), and JSON/debug output redacts private_key as
"[REDACTED]". Deserialization still works for postcard/irpc wire format.

Also fixes clippy needless_borrows_for_generic_args in encryption.rs and
applies cargo fmt to existing code.
2026-06-10 06:16:38 +00:00

275 lines
9.4 KiB
Rust

//! SecretProtocol irpc service definition and associated types.
//!
//! This module defines the `SecretProtocol` enum for irpc-based inter-service
//! communication. The protocol supports unlock/lock lifecycle, key derivation,
//! and encryption/decryption operations.
//!
//! # Protocol Operation
//!
//! The SecretProtocol follows a lifecycle: the service starts in a **locked**
//! state where no derivation or encryption operations are possible. The `Unlock`
//! call loads the seed into memory (derived from the mnemonic passphrase). After
//! that, derive and encrypt/decrypt operations are available. The `Lock` call
//! purges the seed and all cached keys.
//!
//! # Wire Format
//!
//! For local (in-process) calls, the protocol uses tokio channels directly.
//! For remote (in-cluster) calls, the protocol is serialized with postcard.
//! For cross-node (call protocol) exposure, the service is wrapped in an
//! operation that serializes to JSON.
use std::fmt;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use zeroize::Zeroize;
use crate::encryption::EncryptedData;
/// The type of a derived key.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum KeyType {
/// Ed25519 keypair (SLIP-0010 derivation).
Ed25519,
/// AES-256-GCM symmetric key (derived from seed, used for external credential encryption).
Aes256Gcm,
/// secp256k1 keypair (BIP-0032 derivation, for Ethereum signing).
Secp256k1,
}
/// A derived key pair (private key + public key).
///
/// The private key is sensitive material that is zeroized on drop (ADR-038).
/// This type is **not** `Clone` — it is move-only. Consumers receive a
/// `DerivedKey` by value and must zeroize it when done (handled automatically
/// by `#[zeroize(drop)]`).
///
/// Serialization redacts the `private_key` field for safety: JSON/debug output
/// shows `"[REDACTED]"` instead of the key bytes. Deserialization still reads
/// the full bytes for protocol use (postcard/irpc).
#[derive(Zeroize, Deserialize)]
#[zeroize(drop)]
pub struct DerivedKey {
/// The type of key that was derived.
#[zeroize(skip)]
pub key_type: KeyType,
/// The private key bytes (sensitive — zeroized on drop).
#[zeroize]
#[serde(deserialize_with = "deserialize_private_key")]
pub private_key: Vec<u8>,
/// The public key bytes.
#[zeroize(skip)]
pub public_key: Vec<u8>,
}
fn deserialize_private_key<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
Vec::<u8>::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<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
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.
/// The `#[rpc_requests]` macro generates two versions:
/// - **Serializable** (`SecretMessage::Request`): for remote communication (postcard)
/// - **With channels** (`SecretMessage::RequestWithChannels`): for local communication (tokio)
///
/// # State Requirements
///
/// All operations except `Unlock` require the service to be in an **unlocked**
/// state. Calling derive/encrypt/decrypt on a locked service returns an error.
#[derive(Debug, Serialize, Deserialize)]
pub enum SecretProtocol {
/// Derive an Ed25519 keypair at the given path.
///
/// Path format: `m/74'/0'/0'/0'` (SLIP-0010 hardened-only notation).
/// Returns a `DerivedKey` with `KeyType::Ed25519`.
DeriveEd25519 {
/// SLIP-0010 derivation path (e.g., "m/74'/0'/0'/0'").
path: String,
},
/// Derive an AES-256-GCM encryption key at the given path.
///
/// The default encryption path is `m/74'/2'/0'/0'`.
/// Returns a `DerivedKey` with `KeyType::Aes256Gcm`.
DeriveEncryptionKey {
/// SLIP-0010 derivation path for the encryption key.
path: String,
},
/// Derive a secp256k1 (Ethereum) keypair at the given path.
///
/// The default Ethereum path is `m/44'/60'/0'/0/0`.
/// Returns a `DerivedKey` with `KeyType::Secp256k1`.
DeriveEthereumKey {
/// BIP-0032 derivation path (e.g., "m/44'/60'/0'/0/0").
path: String,
},
/// Derive a deterministic password at the given path.
///
/// Path format: `m/74'/1'/0'/{hash}'` (SLIP-0010 hardened notation).
/// The `length` parameter controls the output length.
DerivePassword {
/// SLIP-0010 derivation path for the password.
path: String,
/// Desired password length in bytes.
length: usize,
},
/// Encrypt plaintext using a derived encryption key.
///
/// The key is derived at the path `m/74'/2'/0'/0'` with the given version.
/// Returns an `EncryptedData` blob suitable for storage.
Encrypt {
/// The plaintext string to encrypt.
plaintext: String,
/// The key version for rotation tracking.
key_version: u32,
},
/// Decrypt an `EncryptedData` blob back to plaintext.
///
/// The key is derived from the seed at the path indicated by the key version.
Decrypt {
/// The encrypted data blob to decrypt.
encrypted: EncryptedData,
},
/// Lock the service, purging the seed and all cached derived keys.
///
/// After locking, no derive/encrypt/decrypt operations are possible
/// until `Unlock` is called again. Calls `zeroize()` on all sensitive
/// material (ADR-038).
Lock,
/// Unlock the service with a BIP39 passphrase.
///
/// The passphrase is used to derive the master seed from the mnemonic.
/// After unlocking, derive and encrypt/decrypt operations are available.
Unlock {
/// The BIP39 passphrase (may be empty for no passphrase).
passphrase: String,
},
}
/// Message type for SecretProtocol irpc communication.
///
/// 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;
#[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<u8> = 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"
);
}
}