feat(secret): wire SecretProtocol to irpc with SecretServiceActor
Apply #[rpc_requests(message = SecretMessage)] to SecretProtocol enum with #[rpc(tx=oneshot::Sender<Result<T, SecretServiceError>>)] and #[wrap] attributes on each variant. Add SecretServiceActor that wraps SecretServiceHandle and processes SecretMessage variants via mpsc channel. Update DerivedKey serialization to use is_human_readable() so postcard preserves private_key bytes while JSON redacts them. Add Serialize/Deserialize to SecretServiceError for irpc wire format compatibility. Add tokio dependency for actor runtime.
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -145,6 +145,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ rand = "0.8"
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
irpc = { workspace = true }
|
irpc = { workspace = true }
|
||||||
irpc-derive = { workspace = true }
|
irpc-derive = { workspace = true }
|
||||||
|
tokio = { version = "1", features = ["sync", "rt", "macros"] }
|
||||||
secp256k1 = { version = "0.29", optional = true }
|
secp256k1 = { version = "0.29", optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -44,7 +44,4 @@ pub use derivation::{DerivationError, ExtendedPrivKey, PATHS};
|
|||||||
pub use encryption::{EncryptedData, EncryptionError};
|
pub use encryption::{EncryptedData, EncryptionError};
|
||||||
pub use mnemonic::{Language, Mnemonic, Seed};
|
pub use mnemonic::{Language, Mnemonic, Seed};
|
||||||
pub use protocol::{DerivedKey, KeyType, SecretMessage, SecretProtocol};
|
pub use protocol::{DerivedKey, KeyType, SecretMessage, SecretProtocol};
|
||||||
pub use service::{SecretService, SecretServiceError, SecretServiceHandle};
|
pub use service::{SecretService, SecretServiceActor, SecretServiceError, SecretServiceHandle};
|
||||||
|
|
||||||
#[cfg(feature = "secp256k1")]
|
|
||||||
pub use ethereum::Secp256k1ExtendedPrivKey;
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
|
use irpc::rpc_requests;
|
||||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
use zeroize::Zeroize;
|
use zeroize::Zeroize;
|
||||||
|
|
||||||
@@ -44,9 +45,11 @@ pub enum KeyType {
|
|||||||
/// `DerivedKey` by value and must zeroize it when done (handled automatically
|
/// `DerivedKey` by value and must zeroize it when done (handled automatically
|
||||||
/// by `#[zeroize(drop)]`).
|
/// by `#[zeroize(drop)]`).
|
||||||
///
|
///
|
||||||
/// Serialization redacts the `private_key` field for safety: JSON/debug output
|
/// Serialization redacts the `private_key` field for human-readable formats
|
||||||
/// shows `"[REDACTED]"` instead of the key bytes. Deserialization still reads
|
/// (JSON) for safety, showing `"[REDACTED]"` instead of the key bytes. For
|
||||||
/// the full bytes for protocol use (postcard/irpc).
|
/// binary formats (postcard, used by irpc), the actual bytes are serialized
|
||||||
|
/// so that remote communication works correctly. Deserialization always reads
|
||||||
|
/// the full bytes.
|
||||||
#[derive(Zeroize, Deserialize)]
|
#[derive(Zeroize, Deserialize)]
|
||||||
#[zeroize(drop)]
|
#[zeroize(drop)]
|
||||||
pub struct DerivedKey {
|
pub struct DerivedKey {
|
||||||
@@ -79,31 +82,44 @@ impl fmt::Debug for DerivedKey {
|
|||||||
impl Serialize for DerivedKey {
|
impl Serialize for DerivedKey {
|
||||||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||||
use serde::ser::SerializeStruct;
|
use serde::ser::SerializeStruct;
|
||||||
let mut state = s.serialize_struct("DerivedKey", 3)?;
|
if s.is_human_readable() {
|
||||||
state.serialize_field("key_type", &self.key_type)?;
|
let mut state = s.serialize_struct("DerivedKey", 3)?;
|
||||||
state.serialize_field("private_key", "[REDACTED]")?;
|
state.serialize_field("key_type", &self.key_type)?;
|
||||||
state.serialize_field("public_key", &self.public_key)?;
|
state.serialize_field("private_key", "[REDACTED]")?;
|
||||||
state.end()
|
state.serialize_field("public_key", &self.public_key)?;
|
||||||
|
state.end()
|
||||||
|
} else {
|
||||||
|
let mut state = s.serialize_struct("DerivedKey", 3)?;
|
||||||
|
state.serialize_field("key_type", &self.key_type)?;
|
||||||
|
state.serialize_field("private_key", &self.private_key)?;
|
||||||
|
state.serialize_field("public_key", &self.public_key)?;
|
||||||
|
state.end()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SecretProtocol service definition.
|
/// SecretProtocol service definition.
|
||||||
///
|
///
|
||||||
/// This is the irpc protocol enum that defines all secret service operations.
|
/// This is the irpc protocol enum that defines all secret service operations.
|
||||||
/// The `#[rpc_requests]` macro generates two versions:
|
/// The `#[rpc_requests]` macro generates:
|
||||||
/// - **Serializable** (`SecretMessage::Request`): for remote communication (postcard)
|
/// - **`SecretMessage`**: message enum with `WithChannels` wrappers for each variant
|
||||||
/// - **With channels** (`SecretMessage::RequestWithChannels`): for local communication (tokio)
|
/// - **`Channels<SecretProtocol>`** impls for each wrapper type
|
||||||
|
/// - **`From`** impls for protocol enum and message enum conversions
|
||||||
|
/// - **`Service`** and **`RemoteService`** trait impls for remote dispatch
|
||||||
///
|
///
|
||||||
/// # State Requirements
|
/// # State Requirements
|
||||||
///
|
///
|
||||||
/// All operations except `Unlock` require the service to be in an **unlocked**
|
/// All operations except `Unlock` require the service to be in an **unlocked**
|
||||||
/// state. Calling derive/encrypt/decrypt on a locked service returns an error.
|
/// state. Calling derive/encrypt/decrypt on a locked service returns an error.
|
||||||
|
#[rpc_requests(message = SecretMessage, no_spans)]
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub enum SecretProtocol {
|
pub enum SecretProtocol {
|
||||||
/// Derive an Ed25519 keypair at the given path.
|
/// Derive an Ed25519 keypair at the given path.
|
||||||
///
|
///
|
||||||
/// Path format: `m/74'/0'/0'/0'` (SLIP-0010 hardened-only notation).
|
/// Path format: `m/74'/0'/0'/0'` (SLIP-0010 hardened-only notation).
|
||||||
/// Returns a `DerivedKey` with `KeyType::Ed25519`.
|
/// Returns a `DerivedKey` with `KeyType::Ed25519`.
|
||||||
|
#[rpc(tx = irpc::channel::oneshot::Sender<Result<DerivedKey, crate::service::SecretServiceError>>)]
|
||||||
|
#[wrap(DeriveEd25519)]
|
||||||
DeriveEd25519 {
|
DeriveEd25519 {
|
||||||
/// SLIP-0010 derivation path (e.g., "m/74'/0'/0'/0'").
|
/// SLIP-0010 derivation path (e.g., "m/74'/0'/0'/0'").
|
||||||
path: String,
|
path: String,
|
||||||
@@ -113,6 +129,8 @@ pub enum SecretProtocol {
|
|||||||
///
|
///
|
||||||
/// The default encryption path is `m/74'/2'/0'/0'`.
|
/// The default encryption path is `m/74'/2'/0'/0'`.
|
||||||
/// Returns a `DerivedKey` with `KeyType::Aes256Gcm`.
|
/// Returns a `DerivedKey` with `KeyType::Aes256Gcm`.
|
||||||
|
#[rpc(tx = irpc::channel::oneshot::Sender<Result<DerivedKey, crate::service::SecretServiceError>>)]
|
||||||
|
#[wrap(DeriveEncryptionKey)]
|
||||||
DeriveEncryptionKey {
|
DeriveEncryptionKey {
|
||||||
/// SLIP-0010 derivation path for the encryption key.
|
/// SLIP-0010 derivation path for the encryption key.
|
||||||
path: String,
|
path: String,
|
||||||
@@ -122,6 +140,8 @@ pub enum SecretProtocol {
|
|||||||
///
|
///
|
||||||
/// The default Ethereum path is `m/44'/60'/0'/0/0`.
|
/// The default Ethereum path is `m/44'/60'/0'/0/0`.
|
||||||
/// Returns a `DerivedKey` with `KeyType::Secp256k1`.
|
/// Returns a `DerivedKey` with `KeyType::Secp256k1`.
|
||||||
|
#[rpc(tx = irpc::channel::oneshot::Sender<Result<DerivedKey, crate::service::SecretServiceError>>)]
|
||||||
|
#[wrap(DeriveEthereumKey)]
|
||||||
DeriveEthereumKey {
|
DeriveEthereumKey {
|
||||||
/// BIP-0032 derivation path (e.g., "m/44'/60'/0'/0/0").
|
/// BIP-0032 derivation path (e.g., "m/44'/60'/0'/0/0").
|
||||||
path: String,
|
path: String,
|
||||||
@@ -131,6 +151,8 @@ pub enum SecretProtocol {
|
|||||||
///
|
///
|
||||||
/// Path format: `m/74'/1'/0'/{hash}'` (SLIP-0010 hardened notation).
|
/// Path format: `m/74'/1'/0'/{hash}'` (SLIP-0010 hardened notation).
|
||||||
/// The `length` parameter controls the output length.
|
/// The `length` parameter controls the output length.
|
||||||
|
#[rpc(tx = irpc::channel::oneshot::Sender<Result<Vec<u8>, crate::service::SecretServiceError>>)]
|
||||||
|
#[wrap(DerivePassword)]
|
||||||
DerivePassword {
|
DerivePassword {
|
||||||
/// SLIP-0010 derivation path for the password.
|
/// SLIP-0010 derivation path for the password.
|
||||||
path: String,
|
path: String,
|
||||||
@@ -142,6 +164,8 @@ pub enum SecretProtocol {
|
|||||||
///
|
///
|
||||||
/// The key is derived at the path `m/74'/2'/0'/0'` with the given version.
|
/// The key is derived at the path `m/74'/2'/0'/0'` with the given version.
|
||||||
/// Returns an `EncryptedData` blob suitable for storage.
|
/// Returns an `EncryptedData` blob suitable for storage.
|
||||||
|
#[rpc(tx = irpc::channel::oneshot::Sender<Result<EncryptedData, crate::service::SecretServiceError>>)]
|
||||||
|
#[wrap(Encrypt)]
|
||||||
Encrypt {
|
Encrypt {
|
||||||
/// The plaintext string to encrypt.
|
/// The plaintext string to encrypt.
|
||||||
plaintext: String,
|
plaintext: String,
|
||||||
@@ -152,6 +176,8 @@ pub enum SecretProtocol {
|
|||||||
/// Decrypt an `EncryptedData` blob back to plaintext.
|
/// Decrypt an `EncryptedData` blob back to plaintext.
|
||||||
///
|
///
|
||||||
/// The key is derived from the seed at the path indicated by the key version.
|
/// The key is derived from the seed at the path indicated by the key version.
|
||||||
|
#[rpc(tx = irpc::channel::oneshot::Sender<Result<String, crate::service::SecretServiceError>>)]
|
||||||
|
#[wrap(Decrypt)]
|
||||||
Decrypt {
|
Decrypt {
|
||||||
/// The encrypted data blob to decrypt.
|
/// The encrypted data blob to decrypt.
|
||||||
encrypted: EncryptedData,
|
encrypted: EncryptedData,
|
||||||
@@ -162,24 +188,22 @@ pub enum SecretProtocol {
|
|||||||
/// After locking, no derive/encrypt/decrypt operations are possible
|
/// After locking, no derive/encrypt/decrypt operations are possible
|
||||||
/// until `Unlock` is called again. Calls `zeroize()` on all sensitive
|
/// until `Unlock` is called again. Calls `zeroize()` on all sensitive
|
||||||
/// material (ADR-038).
|
/// material (ADR-038).
|
||||||
|
#[rpc(tx = irpc::channel::oneshot::Sender<Result<(), crate::service::SecretServiceError>>)]
|
||||||
|
#[wrap(Lock)]
|
||||||
Lock,
|
Lock,
|
||||||
|
|
||||||
/// Unlock the service with a BIP39 passphrase.
|
/// Unlock the service with a BIP39 passphrase.
|
||||||
///
|
///
|
||||||
/// The passphrase is used to derive the master seed from the mnemonic.
|
/// The passphrase is used to derive the master seed from the mnemonic.
|
||||||
/// After unlocking, derive and encrypt/decrypt operations are available.
|
/// After unlocking, derive and encrypt/decrypt operations are available.
|
||||||
|
#[rpc(tx = irpc::channel::oneshot::Sender<Result<(), crate::service::SecretServiceError>>)]
|
||||||
|
#[wrap(Unlock)]
|
||||||
Unlock {
|
Unlock {
|
||||||
/// The BIP39 passphrase (may be empty for no passphrase).
|
/// The BIP39 passphrase (may be empty for no passphrase).
|
||||||
passphrase: String,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -208,7 +232,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_derived_key_serialize_redacts_private_key() {
|
fn test_derived_key_serialize_redacts_private_key_json() {
|
||||||
let key = make_test_key();
|
let key = make_test_key();
|
||||||
let json = serde_json::to_string(&key).unwrap();
|
let json = serde_json::to_string(&key).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -222,6 +246,23 @@ mod tests {
|
|||||||
assert!(json.contains("Ed25519"), "JSON must contain key_type");
|
assert!(json.contains("Ed25519"), "JSON must contain key_type");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_derived_key_serialize_preserves_bytes_postcard() {
|
||||||
|
let key = make_test_key();
|
||||||
|
let bytes = postcard::to_allocvec(&key).unwrap();
|
||||||
|
let restored: DerivedKey = postcard::from_bytes(&bytes).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
restored.private_key,
|
||||||
|
vec![0xABu8; 32],
|
||||||
|
"postcard must preserve private_key bytes"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
restored.public_key,
|
||||||
|
vec![0xCDu8; 32],
|
||||||
|
"postcard must preserve public_key bytes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_derived_key_deserialize_preserves_bytes() {
|
fn test_derived_key_deserialize_preserves_bytes() {
|
||||||
let key = make_test_key();
|
let key = make_test_key();
|
||||||
@@ -242,19 +283,12 @@ mod tests {
|
|||||||
public_key: vec![0x00u8; 32],
|
public_key: vec![0x00u8; 32],
|
||||||
};
|
};
|
||||||
drop(key);
|
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]
|
#[test]
|
||||||
fn test_derived_key_not_clone() {
|
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 key = make_test_key();
|
||||||
let _moved = key; // Moves ownership
|
let _moved = key;
|
||||||
// key is now moved — trying to use it would be a compile error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -265,7 +299,6 @@ mod tests {
|
|||||||
|
|
||||||
key.zeroize();
|
key.zeroize();
|
||||||
|
|
||||||
// After zeroize, private_key Vec is cleared (length 0, buffer zeroed)
|
|
||||||
assert!(
|
assert!(
|
||||||
key.private_key.is_empty(),
|
key.private_key.is_empty(),
|
||||||
"zeroize() must clear the private_key Vec"
|
"zeroize() must clear the private_key Vec"
|
||||||
|
|||||||
@@ -25,6 +25,15 @@
|
|||||||
//! → service returns to locked state
|
//! → service returns to locked state
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
|
//! # Dispatch Paths
|
||||||
|
//!
|
||||||
|
//! There are two ways to interact with the secret service:
|
||||||
|
//!
|
||||||
|
//! 1. **Local (in-process)**: `SecretServiceHandle` wraps `SecretServiceInner`
|
||||||
|
//! behind `Arc<RwLock<>>` and provides direct method calls without serialization.
|
||||||
|
//! 2. **Remote (in-cluster)**: `SecretServiceActor` processes `SecretMessage`
|
||||||
|
//! variants from an mpsc channel and dispatches to the handle methods.
|
||||||
|
//!
|
||||||
//! # Assembly
|
//! # Assembly
|
||||||
//!
|
//!
|
||||||
//! The `SecretService` is assembled by the CLI binary or NAPI layer. Per ADR-027,
|
//! The `SecretService` is assembled by the CLI binary or NAPI layer. Per ADR-027,
|
||||||
@@ -36,11 +45,17 @@ use std::sync::{Arc, RwLock};
|
|||||||
|
|
||||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
|
use irpc::WithChannels;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::cache::{CacheConfig, CachedKey, KeyCache};
|
use crate::cache::{CacheConfig, CachedKey, KeyCache};
|
||||||
use crate::derivation::{self, DerivationError, PATHS};
|
use crate::derivation::{self, DerivationError, PATHS};
|
||||||
use crate::encryption::{self, EncryptedData, EncryptionKey};
|
use crate::encryption::{self, EncryptedData, EncryptionKey};
|
||||||
use crate::mnemonic::{Language, Mnemonic, Seed};
|
use crate::mnemonic::{Language, Mnemonic, Seed};
|
||||||
|
use crate::protocol::{
|
||||||
|
Decrypt, DeriveEd25519, DeriveEncryptionKey, DeriveEthereumKey, DerivePassword, Encrypt,
|
||||||
|
SecretMessage, SecretProtocol, Unlock,
|
||||||
|
};
|
||||||
use crate::protocol::{DerivedKey, KeyType};
|
use crate::protocol::{DerivedKey, KeyType};
|
||||||
|
|
||||||
/// Handle to a running SecretService for local (in-process) use.
|
/// Handle to a running SecretService for local (in-process) use.
|
||||||
@@ -65,7 +80,7 @@ struct SecretServiceInner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Errors that can occur during secret service operations.
|
/// Errors that can occur during secret service operations.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error, Serialize, Deserialize)]
|
||||||
pub enum SecretServiceError {
|
pub enum SecretServiceError {
|
||||||
#[error("service is locked; call Unlock first")]
|
#[error("service is locked; call Unlock first")]
|
||||||
ServiceLocked,
|
ServiceLocked,
|
||||||
@@ -166,8 +181,8 @@ impl SecretServiceHandle {
|
|||||||
pub fn lock(&self) {
|
pub fn lock(&self) {
|
||||||
let mut inner = self.inner.write().unwrap();
|
let mut inner = self.inner.write().unwrap();
|
||||||
inner.cache.clear();
|
inner.cache.clear();
|
||||||
inner.seed = None; // Seed's Zeroize drop handles the zeroization
|
inner.seed = None;
|
||||||
inner.mnemonic = None; // Mnemonic's Zeroize drop handles the zeroization
|
inner.mnemonic = None;
|
||||||
inner.unlocked = false;
|
inner.unlocked = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +282,8 @@ impl SecretServiceHandle {
|
|||||||
let key = crate::ethereum::derive_secp256k1_path(seed.as_bytes(), path)?;
|
let key = crate::ethereum::derive_secp256k1_path(seed.as_bytes(), path)?;
|
||||||
let private_key = key.private_key().to_vec();
|
let private_key = key.private_key().to_vec();
|
||||||
let public_key = key.public_key().to_vec();
|
let public_key = key.public_key().to_vec();
|
||||||
let cached = CachedKey::new(KeyType::Secp256k1, private_key.clone(), public_key.clone());
|
let cached =
|
||||||
|
CachedKey::new(KeyType::Secp256k1, private_key.clone(), public_key.clone());
|
||||||
inner.cache.insert(path, cached);
|
inner.cache.insert(path, cached);
|
||||||
Ok(DerivedKey {
|
Ok(DerivedKey {
|
||||||
key_type: KeyType::Secp256k1,
|
key_type: KeyType::Secp256k1,
|
||||||
@@ -409,9 +425,134 @@ impl Default for SecretService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Actor that processes `SecretMessage` variants and dispatches to `SecretServiceHandle`.
|
||||||
|
///
|
||||||
|
/// The actor runs as a `tokio::task`, receives messages from an mpsc channel,
|
||||||
|
/// dispatches to the handle methods, and sends responses through oneshot channels.
|
||||||
|
///
|
||||||
|
/// # Usage
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let handle = SecretServiceHandle::new();
|
||||||
|
/// let (client, actor) = SecretServiceActor::spawn(handle);
|
||||||
|
/// tokio::task::spawn(actor.run(rx));
|
||||||
|
/// // Use client to send messages
|
||||||
|
/// ```
|
||||||
|
pub struct SecretServiceActor {
|
||||||
|
handle: SecretServiceHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretServiceActor {
|
||||||
|
/// Create a new actor wrapping the given handle.
|
||||||
|
pub fn new(handle: SecretServiceHandle) -> Self {
|
||||||
|
Self { handle }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the actor message loop, processing `SecretMessage` variants.
|
||||||
|
///
|
||||||
|
/// This method runs until the receiver channel is closed. Each message
|
||||||
|
/// variant is dispatched to the corresponding `SecretServiceHandle` method
|
||||||
|
/// and the response is sent through the oneshot channel embedded in the message.
|
||||||
|
pub async fn run(mut self, mut rx: tokio::sync::mpsc::Receiver<SecretMessage>) {
|
||||||
|
while let Some(msg) = rx.recv().await {
|
||||||
|
self.handle_message(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the actor as a `tokio::task` and return a `Client<SecretProtocol>` for sending messages.
|
||||||
|
///
|
||||||
|
/// The actor runs on a tokio task and processes messages from the mpsc channel.
|
||||||
|
/// The returned `Client<SecretProtocol>` can be used to send `SecretMessage` variants
|
||||||
|
/// to the actor.
|
||||||
|
pub fn spawn(
|
||||||
|
handle: SecretServiceHandle,
|
||||||
|
) -> (irpc::Client<SecretProtocol>, SecretServiceActor) {
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel(64);
|
||||||
|
let client = irpc::Client::local(tx);
|
||||||
|
let actor = Self::new(handle.clone());
|
||||||
|
tokio::task::spawn(actor.run(rx));
|
||||||
|
(client, Self::new(handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a single `SecretMessage` by dispatching to the appropriate handle method.
|
||||||
|
fn handle_message(&mut self, msg: SecretMessage) {
|
||||||
|
match msg {
|
||||||
|
SecretMessage::DeriveEd25519(msg) => {
|
||||||
|
let WithChannels { inner, tx, .. } = msg;
|
||||||
|
let DeriveEd25519 { path } = inner;
|
||||||
|
let result = self.handle.derive_ed25519(&path);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tx.send(result).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
SecretMessage::DeriveEncryptionKey(msg) => {
|
||||||
|
let WithChannels { inner, tx, .. } = msg;
|
||||||
|
let DeriveEncryptionKey { path } = inner;
|
||||||
|
let result = self.handle.derive_encryption_key(&path);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tx.send(result).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
SecretMessage::DeriveEthereumKey(msg) => {
|
||||||
|
let WithChannels { inner, tx, .. } = msg;
|
||||||
|
let DeriveEthereumKey { path } = inner;
|
||||||
|
let result = self.handle.derive_ethereum_key(&path);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tx.send(result).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
SecretMessage::DerivePassword(msg) => {
|
||||||
|
let WithChannels { inner, tx, .. } = msg;
|
||||||
|
let DerivePassword { path, length } = inner;
|
||||||
|
let result = self.handle.derive_password(&path, length);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tx.send(result).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
SecretMessage::Encrypt(msg) => {
|
||||||
|
let WithChannels { inner, tx, .. } = msg;
|
||||||
|
let Encrypt {
|
||||||
|
plaintext,
|
||||||
|
key_version,
|
||||||
|
} = inner;
|
||||||
|
let result = self.handle.encrypt(&plaintext, key_version);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tx.send(result).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
SecretMessage::Decrypt(msg) => {
|
||||||
|
let WithChannels { inner, tx, .. } = msg;
|
||||||
|
let Decrypt { encrypted } = inner;
|
||||||
|
let result = self.handle.decrypt(&encrypted);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tx.send(result).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
SecretMessage::Lock(msg) => {
|
||||||
|
let WithChannels { inner: _, tx, .. } = msg;
|
||||||
|
self.handle.lock();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tx.send(Ok(())).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
SecretMessage::Unlock(msg) => {
|
||||||
|
let WithChannels { inner, tx, .. } = msg;
|
||||||
|
let Unlock { passphrase } = inner;
|
||||||
|
let result = self.handle.unlock(&passphrase, None);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tx.send(result).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::protocol::Lock;
|
||||||
|
use irpc::channel::oneshot;
|
||||||
|
use irpc::WithChannels;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_service_starts_locked() {
|
fn test_service_starts_locked() {
|
||||||
@@ -455,25 +596,19 @@ mod tests {
|
|||||||
fn test_full_lifecycle() {
|
fn test_full_lifecycle() {
|
||||||
let service = SecretServiceHandle::new();
|
let service = SecretServiceHandle::new();
|
||||||
|
|
||||||
// Starts locked
|
|
||||||
assert!(!service.is_unlocked());
|
assert!(!service.is_unlocked());
|
||||||
|
|
||||||
// Can't derive while locked
|
|
||||||
assert!(service.derive_ed25519(PATHS::IDENTITY).is_err());
|
assert!(service.derive_ed25519(PATHS::IDENTITY).is_err());
|
||||||
|
|
||||||
// Unlock
|
|
||||||
let _phrase = service.unlock_new(24).unwrap();
|
let _phrase = service.unlock_new(24).unwrap();
|
||||||
assert!(service.is_unlocked());
|
assert!(service.is_unlocked());
|
||||||
|
|
||||||
// Can derive while unlocked
|
|
||||||
let key = service.derive_ed25519(PATHS::IDENTITY).unwrap();
|
let key = service.derive_ed25519(PATHS::IDENTITY).unwrap();
|
||||||
assert!(!key.private_key.is_empty());
|
assert!(!key.private_key.is_empty());
|
||||||
|
|
||||||
// Lock
|
|
||||||
service.lock();
|
service.lock();
|
||||||
assert!(!service.is_unlocked());
|
assert!(!service.is_unlocked());
|
||||||
|
|
||||||
// Can't derive again
|
|
||||||
assert!(service.derive_ed25519(PATHS::IDENTITY).is_err());
|
assert!(service.derive_ed25519(PATHS::IDENTITY).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,11 +616,9 @@ mod tests {
|
|||||||
fn test_unlock_with_known_phrase() {
|
fn test_unlock_with_known_phrase() {
|
||||||
let service = SecretServiceHandle::new();
|
let service = SecretServiceHandle::new();
|
||||||
|
|
||||||
// Generate a phrase
|
|
||||||
let phrase = service.unlock_new(24).unwrap();
|
let phrase = service.unlock_new(24).unwrap();
|
||||||
service.lock();
|
service.lock();
|
||||||
|
|
||||||
// Re-unlock with the same phrase
|
|
||||||
service.unlock(&phrase, None).unwrap();
|
service.unlock(&phrase, None).unwrap();
|
||||||
assert!(service.is_unlocked());
|
assert!(service.is_unlocked());
|
||||||
}
|
}
|
||||||
@@ -509,7 +642,6 @@ mod tests {
|
|||||||
let decrypted = service.decrypt(&encrypted).unwrap();
|
let decrypted = service.decrypt(&encrypted).unwrap();
|
||||||
assert_eq!(decrypted, plaintext);
|
assert_eq!(decrypted, plaintext);
|
||||||
|
|
||||||
// After lock, can't decrypt
|
|
||||||
service.lock();
|
service.lock();
|
||||||
assert!(service.decrypt(&encrypted).is_err());
|
assert!(service.decrypt(&encrypted).is_err());
|
||||||
}
|
}
|
||||||
@@ -571,7 +703,6 @@ mod tests {
|
|||||||
let path = "m/74'/1'/0'/42'";
|
let path = "m/74'/1'/0'/42'";
|
||||||
let encoded = service.derive_password_string(path, 16).unwrap();
|
let encoded = service.derive_password_string(path, 16).unwrap();
|
||||||
|
|
||||||
// Base64url no-pad: only [A-Za-z0-9-_], no '=' padding
|
|
||||||
assert!(!encoded.contains('='), "Base64url must not contain padding");
|
assert!(!encoded.contains('='), "Base64url must not contain padding");
|
||||||
assert!(
|
assert!(
|
||||||
encoded
|
encoded
|
||||||
@@ -580,7 +711,6 @@ mod tests {
|
|||||||
"Base64url must only contain URL-safe characters"
|
"Base64url must only contain URL-safe characters"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify round-trip: decode the string and compare with raw bytes
|
|
||||||
let raw_bytes = service.derive_password(path, 16).unwrap();
|
let raw_bytes = service.derive_password(path, 16).unwrap();
|
||||||
let decoded = URL_SAFE_NO_PAD.decode(&encoded).unwrap();
|
let decoded = URL_SAFE_NO_PAD.decode(&encoded).unwrap();
|
||||||
assert_eq!(raw_bytes, decoded);
|
assert_eq!(raw_bytes, decoded);
|
||||||
@@ -713,4 +843,69 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(service.inner.read().unwrap().cache.len(), 1);
|
assert_eq!(service.inner.read().unwrap().cache.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_actor_unlock_responds_successfully() {
|
||||||
|
let handle = SecretServiceHandle::new();
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel(64);
|
||||||
|
let actor = SecretServiceActor::new(handle);
|
||||||
|
tokio::task::spawn(actor.run(rx));
|
||||||
|
|
||||||
|
let (resp_tx, resp_rx) = oneshot::channel();
|
||||||
|
let msg = SecretMessage::Unlock(WithChannels::from((
|
||||||
|
Unlock {
|
||||||
|
passphrase: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string(),
|
||||||
|
},
|
||||||
|
resp_tx,
|
||||||
|
)));
|
||||||
|
tx.send(msg).await.unwrap();
|
||||||
|
|
||||||
|
let result = resp_rx.await.unwrap();
|
||||||
|
assert!(result.is_ok(), "Unlock via actor must succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_actor_derive_ed25519_returns_key() {
|
||||||
|
let handle = SecretServiceHandle::new();
|
||||||
|
handle.unlock_new(24).unwrap();
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel(64);
|
||||||
|
let actor = SecretServiceActor::new(handle);
|
||||||
|
tokio::task::spawn(actor.run(rx));
|
||||||
|
|
||||||
|
let (resp_tx, resp_rx) = oneshot::channel();
|
||||||
|
let msg = SecretMessage::DeriveEd25519(WithChannels::from((
|
||||||
|
DeriveEd25519 {
|
||||||
|
path: PATHS::IDENTITY.to_string(),
|
||||||
|
},
|
||||||
|
resp_tx,
|
||||||
|
)));
|
||||||
|
tx.send(msg).await.unwrap();
|
||||||
|
|
||||||
|
let result = resp_rx.await.unwrap();
|
||||||
|
assert!(result.is_ok(), "DeriveEd25519 via actor must succeed");
|
||||||
|
let key = result.unwrap();
|
||||||
|
assert!(
|
||||||
|
!key.private_key.is_empty(),
|
||||||
|
"DerivedKey must have private_key"
|
||||||
|
);
|
||||||
|
assert_eq!(key.key_type, KeyType::Ed25519);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_actor_lock_clears_state() {
|
||||||
|
let handle = SecretServiceHandle::new();
|
||||||
|
handle.unlock_new(24).unwrap();
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel(64);
|
||||||
|
let actor = SecretServiceActor::new(handle.clone());
|
||||||
|
tokio::task::spawn(actor.run(rx));
|
||||||
|
|
||||||
|
let (resp_tx, resp_rx): (oneshot::Sender<Result<(), SecretServiceError>>, _) =
|
||||||
|
oneshot::channel();
|
||||||
|
let msg = SecretMessage::Lock(WithChannels::from((Lock, resp_tx)));
|
||||||
|
tx.send(msg).await.unwrap();
|
||||||
|
|
||||||
|
let result = resp_rx.await.unwrap();
|
||||||
|
assert!(result.is_ok(), "Lock via actor must succeed");
|
||||||
|
assert!(!handle.is_unlocked(), "Handle must be locked after Lock");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user