feat(secret): add alknet-secret crate and architecture spec for Phase 3

Create the alknet-secret crate with BIP39 mnemonic generation, SLIP-0010
Ed25519 HD key derivation, AES-256-GCM encryption, and SecretProtocol
irpc service definition. This is Phase 3.1 from the integration plan.

Architecture changes:
- Promote secret-service.md to reviewed status with full spec format
  (crate structure, public API, security model, phase progression,
   ADR/OQ cross-references, wire format compatibility section)
- Add ADR-038 (seed lifecycle and memory security): zeroize for v1,
  mlock deferred to Phase B
- Add OQ-SEC-01 (mlock/VirtualLock for seed RAM) to open-questions.md
- Update README.md with ADR-038 and secret-service status

Crate structure:
- src/mnemonic.rs: BIP39 phrase generation, validation, seed derivation
- src/derivation.rs: SLIP-0010 HD key derivation, path constants (74')
- src/encryption.rs: AES-256-GCM encrypt/decrypt, EncryptedData type
- src/protocol.rs: SecretProtocol irpc enum, DerivedKey, KeyType
- src/service.rs: SecretServiceHandle with Unlock/Lock lifecycle
- 40 passing tests (unit + integration + doc)
This commit is contained in:
2026-06-09 13:49:53 +00:00
parent d1c57627c6
commit 04e969982e
16 changed files with 1882 additions and 62 deletions

View File

@@ -0,0 +1,62 @@
//! Integration tests for key derivation.
//!
//! These tests verify that SLIP-0010 derivation produces correct results
//! against known test vectors and that path constants produce expected key types.
use alknet_secret::derivation::PATHS;
use alknet_secret::service::SecretServiceHandle;
#[test]
fn test_identity_key_derivation() {
let service = SecretServiceHandle::new();
let _phrase = service.unlock_new(24).unwrap();
let key = service.derive_ed25519(PATHS::IDENTITY).unwrap();
assert_eq!(key.key_type, alknet_secret::protocol::KeyType::Ed25519);
assert!(!key.private_key.is_empty());
assert!(!key.public_key.is_empty());
}
#[test]
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
);
}
#[test]
fn test_deterministic_derivation() {
// Same seed + same path = same key
let service = SecretServiceHandle::new();
let phrase = service.unlock_new(24).unwrap();
let key1 = service.derive_ed25519(PATHS::IDENTITY).unwrap();
// Unlock with the same phrase again
service.lock();
service.unlock(&phrase, None).unwrap();
let key2 = service.derive_ed25519(PATHS::IDENTITY).unwrap();
assert_eq!(key1.private_key, key2.private_key);
assert_eq!(key1.public_key, key2.public_key);
}
#[test]
fn test_different_paths_different_keys() {
let service = SecretServiceHandle::new();
service.unlock_new(24).unwrap();
let identity_key = service.derive_ed25519(PATHS::IDENTITY).unwrap();
let ssh_key = service.derive_ed25519(PATHS::SSH_HOST).unwrap();
assert_ne!(identity_key.private_key, ssh_key.private_key);
assert_ne!(identity_key.public_key, ssh_key.public_key);
}

View File

@@ -0,0 +1,58 @@
//! Integration tests for AES-256-GCM encryption and decryption.
//!
//! These tests verify round-trip encryption, key version handling,
//! and wire format compatibility.
use alknet_secret::encryption::CURRENT_KEY_VERSION;
use alknet_secret::service::SecretServiceHandle;
#[test]
fn test_encrypt_decrypt_round_trip_via_service() {
let service = SecretServiceHandle::new();
service.unlock_new(24).unwrap();
let plaintext = "sk-proj-abc123xyz789";
let encrypted = service.encrypt(plaintext, CURRENT_KEY_VERSION).unwrap();
let decrypted = service.decrypt(&encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn test_encrypt_produces_different_ciphertext_each_time() {
let service = SecretServiceHandle::new();
service.unlock_new(24).unwrap();
let plaintext = "same input different ciphertexts";
let encrypted1 = service.encrypt(plaintext, CURRENT_KEY_VERSION).unwrap();
let encrypted2 = service.encrypt(plaintext, CURRENT_KEY_VERSION).unwrap();
// Different IVs mean different ciphertexts
assert_ne!(encrypted1.iv, encrypted2.iv);
assert_ne!(encrypted1.data, encrypted2.data);
// But same key version
assert_eq!(encrypted1.key_version, encrypted2.key_version);
}
#[test]
fn test_encrypted_data_serialization() {
let service = SecretServiceHandle::new();
service.unlock_new(24).unwrap();
let plaintext = "test serialization";
let encrypted = service.encrypt(plaintext, CURRENT_KEY_VERSION).unwrap();
// Verify EncryptedData serializes to JSON
let json = serde_json::to_string(&encrypted).unwrap();
assert!(json.contains("key_version"));
assert!(json.contains("salt"));
assert!(json.contains("iv"));
assert!(json.contains("data"));
// Verify round-trip through JSON
let deserialized: alknet_secret::encryption::EncryptedData =
serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, encrypted);
}

View File

@@ -0,0 +1,100 @@
//! Integration tests for the SecretService lifecycle.
//!
//! 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;
#[test]
fn test_full_lifecycle() {
let service = SecretServiceHandle::new();
// Starts locked
assert!(!service.is_unlocked());
// Can't derive while locked
let result = service.derive_ed25519(PATHS::IDENTITY);
assert!(matches!(result, Err(SecretServiceError::ServiceLocked)));
// Unlock
let phrase = service.unlock_new(24).unwrap();
assert!(service.is_unlocked());
assert!(!phrase.is_empty());
// Can derive while unlocked
let key = service.derive_ed25519(PATHS::IDENTITY).unwrap();
assert!(!key.private_key.is_empty());
// Lock
service.lock();
assert!(!service.is_unlocked());
// Can't derive again
let result = service.derive_ed25519(PATHS::IDENTITY);
assert!(matches!(result, Err(SecretServiceError::ServiceLocked)));
}
#[test]
fn test_unlock_with_known_phrase() {
let service = SecretServiceHandle::new();
// Generate a phrase
let phrase = service.unlock_new(24).unwrap();
service.lock();
// Re-unlock with the same phrase
service.unlock(&phrase, None).unwrap();
assert!(service.is_unlocked());
// Different passphrase produces different seed
// (tested by deriving keys with different passphrases)
}
#[test]
fn test_double_unlock_fails() {
let service = SecretServiceHandle::new();
service.unlock_new(24).unwrap();
let result = service.unlock_new(12);
assert!(matches!(result, Err(SecretServiceError::AlreadyUnlocked)));
}
#[test]
fn test_lock_when_already_locked_is_noop() {
let service = SecretServiceHandle::new();
assert!(!service.is_unlocked());
// Lock on already-locked service is a no-op
service.lock();
assert!(!service.is_unlocked());
}
#[test]
fn test_encrypt_decrypt_lifecycle() {
let service = SecretServiceHandle::new();
service.unlock_new(24).unwrap();
let plaintext = "my-api-key-12345";
let encrypted = service.encrypt(plaintext, 1).unwrap();
let decrypted = service.decrypt(&encrypted).unwrap();
assert_eq!(decrypted, plaintext);
// After lock, can't decrypt
service.lock();
let result = service.decrypt(&encrypted);
assert!(matches!(result, Err(SecretServiceError::ServiceLocked)));
}
#[test]
fn test_multiple_derive_paths_succeed() {
let service = SecretServiceHandle::new();
service.unlock_new(24).unwrap();
// 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();
}