DM channels (createChannel), channel authz, security/docs, future improvements

- Add createChannel RPC (node.capnp @18): create 1:1 channel, returns 16-byte channelId
- Store: create_channel(member_a, member_b), get_channel_members(channel_id)
- FileBackedStore: channels.bin; SqlStore: migration 003_channels, schema v4
- channel_ops: handle_create_channel (auth + identity, peerKey 32 bytes)
- Delivery authz: when channel_id.len() == 16, require caller and recipient are channel members (E022/E023)
- Error codes E022 CHANNEL_ACCESS_DENIED, E023 CHANNEL_NOT_FOUND
- SUMMARY: link Certificate lifecycle; security audit, future improvements, multi-agent plan docs
- Certificate lifecycle doc, SECURITY-AUDIT, FUTURE-IMPROVEMENTS, MULTI-AGENT-WORK-PLAN
- Client/core/tls/auth/server main: assorted fixes and updates from review and audit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-23 22:54:28 +01:00
parent 6b8b61c6ae
commit 750b794342
40 changed files with 4715 additions and 152 deletions

View File

@@ -38,6 +38,7 @@ thiserror = { workspace = true }
sha2 = { workspace = true }
argon2 = { workspace = true }
chacha20poly1305 = { workspace = true }
zeroize = { workspace = true }
quinn = { workspace = true }
quinn-proto = { workspace = true }
rustls = { workspace = true }
@@ -49,10 +50,12 @@ tracing-subscriber = { workspace = true }
# CLI
clap = { workspace = true }
# Hex encoding/decoding
hex = "0.4"
[dev-dependencies]
dashmap = { workspace = true }
assert_cmd = "2"
tempfile = "3"
portpicker = "0.1"
rand = "0.8"
hex = "0.4"

View File

@@ -574,7 +574,7 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
.await?
.context("joiner hybrid key not found")?;
let wrapped_welcome =
hybrid_encrypt(&joiner_hybrid_pk, &welcome).context("hybrid encrypt welcome")?;
hybrid_encrypt(&joiner_hybrid_pk, &welcome, b"", b"").context("hybrid encrypt welcome")?;
enqueue(&creator_ds, &joiner_identity, &wrapped_welcome).await?;
let welcome_payloads = fetch_all(&joiner_ds, &joiner_identity).await?;
@@ -584,7 +584,7 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
.context("Welcome was not delivered to joiner via DS")?;
let welcome_bytes =
hybrid_decrypt(&joiner_hybrid, &raw_welcome).context("hybrid decrypt welcome failed")?;
hybrid_decrypt(&joiner_hybrid, &raw_welcome, b"", b"").context("hybrid decrypt welcome failed")?;
joiner
.join_group(&welcome_bytes)
.context("join_group failed")?;
@@ -593,7 +593,7 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
.send_message(b"hello")
.context("send_message failed")?;
let wrapped_creator_joiner =
hybrid_encrypt(&joiner_hybrid_pk, &ct_creator_to_joiner).context("hybrid encrypt failed")?;
hybrid_encrypt(&joiner_hybrid_pk, &ct_creator_to_joiner, b"", b"").context("hybrid encrypt failed")?;
enqueue(&creator_ds, &joiner_identity, &wrapped_creator_joiner).await?;
let joiner_msgs = fetch_all(&joiner_ds, &joiner_identity).await?;
@@ -601,7 +601,7 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
.first()
.context("joiner: missing ciphertext from DS")?;
let inner_creator_joiner =
hybrid_decrypt(&joiner_hybrid, raw_creator_joiner).context("hybrid decrypt failed")?;
hybrid_decrypt(&joiner_hybrid, raw_creator_joiner, b"", b"").context("hybrid decrypt failed")?;
let plaintext_creator_joiner = joiner
.receive_message(&inner_creator_joiner)?
.context("expected application message")?;
@@ -617,7 +617,7 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
.send_message(b"hello back")
.context("send_message failed")?;
let wrapped_joiner_creator =
hybrid_encrypt(&creator_hybrid_pk, &ct_joiner_to_creator).context("hybrid encrypt failed")?;
hybrid_encrypt(&creator_hybrid_pk, &ct_joiner_to_creator, b"", b"").context("hybrid encrypt failed")?;
enqueue(&joiner_ds, &creator_identity, &wrapped_joiner_creator).await?;
let creator_msgs = fetch_all(&creator_ds, &creator_identity).await?;
@@ -625,7 +625,7 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
.first()
.context("creator: missing ciphertext from DS")?;
let inner_joiner_creator =
hybrid_decrypt(&creator_hybrid, raw_joiner_creator).context("hybrid decrypt failed")?;
hybrid_decrypt(&creator_hybrid, raw_joiner_creator, b"", b"").context("hybrid decrypt failed")?;
let plaintext_joiner_creator = creator
.receive_message(&inner_joiner_creator)?
.context("expected application message")?;
@@ -701,7 +701,7 @@ pub async fn cmd_invite(
}
let peer_hpk = fetch_hybrid_key(&node_client, mk).await?;
let commit_payload = if let Some(ref pk) = peer_hpk {
hybrid_encrypt(pk, &commit).context("hybrid encrypt commit")?
hybrid_encrypt(pk, &commit, b"", b"").context("hybrid encrypt commit")?
} else {
commit.clone()
};
@@ -710,7 +710,7 @@ pub async fn cmd_invite(
let peer_hybrid_pk = fetch_hybrid_key(&node_client, &peer_key).await?;
let payload = if let Some(ref pk) = peer_hybrid_pk {
hybrid_encrypt(pk, &welcome).context("hybrid encrypt welcome failed")?
hybrid_encrypt(pk, &welcome, b"", b"").context("hybrid encrypt welcome failed")?
} else {
welcome
};
@@ -774,6 +774,15 @@ pub async fn cmd_join(
let _ = member.receive_message(&mls_payload);
}
// Auto-replenish KeyPackage after join consumed the original one.
let tls_bytes = member
.generate_key_package()
.context("KeyPackage replenishment failed")?;
upload_key_package(&node_client, &member.identity().public_key_bytes(), &tls_bytes)
.await
.context("KeyPackage replenishment upload failed")?;
println!("KeyPackage auto-replenished after join");
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
println!("joined group successfully");
Ok(())
@@ -820,7 +829,7 @@ pub async fn cmd_send(
for recipient in &recipients {
let peer_hybrid_pk = fetch_hybrid_key(&node_client, recipient).await?;
let payload = if let Some(ref pk) = peer_hybrid_pk {
hybrid_encrypt(pk, &ct).context("hybrid encrypt failed")?
hybrid_encrypt(pk, &ct, b"", b"").context("hybrid encrypt failed")?
} else {
ct.clone()
};
@@ -871,7 +880,7 @@ pub async fn cmd_recv(
// application messages that depend on the resulting epoch.
payloads.sort_by_key(|(seq, _)| *seq);
let mut retry_mls: Vec<Vec<u8>> = Vec::new();
let mut pending: Vec<(usize, Vec<u8>)> = Vec::new();
for (idx, (_, payload)) in payloads.iter().enumerate() {
let mls_payload = match try_hybrid_decrypt(hybrid_kp.as_ref(), payload) {
Ok(b) => b,
@@ -883,18 +892,32 @@ pub async fn cmd_recv(
match member.receive_message(&mls_payload) {
Ok(Some(pt)) => println!("[{idx}] plaintext: {}", String::from_utf8_lossy(&pt)),
Ok(None) => println!("[{idx}] commit applied"),
Err(_) => retry_mls.push(mls_payload),
Err(_) => pending.push((idx, mls_payload)),
}
}
// Retry messages that failed on the first pass (e.g. app messages whose
// epoch was not yet advanced until a commit earlier in the batch was applied).
for mls_payload in &retry_mls {
match member.receive_message(mls_payload) {
Ok(Some(pt)) => println!("[retry] plaintext: {}", String::from_utf8_lossy(&pt)),
Ok(None) => {}
Err(e) => println!("[retry] error: {e}"),
// Retry until no more progress (handles multi-epoch batches).
loop {
let before = pending.len();
pending.retain(|(idx, mls_payload)| {
match member.receive_message(mls_payload) {
Ok(Some(pt)) => {
println!("[{idx}/retry] plaintext: {}", String::from_utf8_lossy(&pt));
false
}
Ok(None) => {
println!("[{idx}/retry] commit applied");
false
}
Err(_) => true,
}
});
if pending.len() == before {
break; // No progress — remaining messages are unprocessable
}
}
for (idx, _) in &pending {
println!("[{idx}] error: unprocessable after all retries");
}
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
@@ -906,8 +929,8 @@ pub async fn cmd_recv(
/// Fetch pending payloads, process in order (merge commits, collect plaintexts), save state.
/// Returns only application-message plaintexts. Used by E2E tests and callers that need returned messages.
/// Uses two passes so that if the server delivers an application message before a Commit, the second pass
/// processes it after commits are merged.
/// Retries in a loop until no more progress, handling multi-epoch batches where commits must be
/// applied before later application messages can be decrypted.
pub async fn receive_pending_plaintexts(
state_path: &Path,
server: &str,
@@ -925,7 +948,7 @@ pub async fn receive_pending_plaintexts(
payloads.sort_by_key(|(seq, _)| *seq);
let mut plaintexts = Vec::new();
let mut retry_mls: Vec<Vec<u8>> = Vec::new();
let mut pending: Vec<Vec<u8>> = Vec::new();
for (_, payload) in &payloads {
let mls_payload = match try_hybrid_decrypt(hybrid_kp.as_ref(), payload) {
Ok(b) => b,
@@ -934,12 +957,24 @@ pub async fn receive_pending_plaintexts(
match member.receive_message(&mls_payload) {
Ok(Some(pt)) => plaintexts.push(pt),
Ok(None) => {}
Err(_) => retry_mls.push(mls_payload),
Err(_) => pending.push(mls_payload),
}
}
for mls_payload in &retry_mls {
if let Ok(Some(pt)) = member.receive_message(mls_payload) {
plaintexts.push(pt);
// Retry until no more progress (handles multi-epoch batches).
loop {
let before = pending.len();
pending.retain(|mls_payload| {
match member.receive_message(mls_payload) {
Ok(Some(pt)) => {
plaintexts.push(pt);
false
}
Ok(None) => false,
Err(_) => true,
}
});
if pending.len() == before {
break;
}
}
@@ -1069,7 +1104,7 @@ pub async fn cmd_chat(
.context("send_message failed")?;
let peer_hybrid_pk = fetch_hybrid_key(&client, &peer_key).await?;
let payload = if let Some(ref pk) = peer_hybrid_pk {
hybrid_encrypt(pk, &ct).context("hybrid encrypt failed")?
hybrid_encrypt(pk, &ct, b"", b"").context("hybrid encrypt failed")?
} else {
ct
};
@@ -1085,6 +1120,7 @@ pub async fn cmd_chat(
_ = poll.tick() => {
let mut payloads = fetch_wait(&client, &identity_bytes, 0).await?;
payloads.sort_by_key(|(seq, _)| *seq);
let mut retry_payloads: Vec<Vec<u8>> = Vec::new();
for (_, payload) in &payloads {
let mls_payload = match try_hybrid_decrypt(hybrid_kp.as_ref(), payload) {
Ok(b) => b,
@@ -1097,9 +1133,26 @@ pub async fn cmd_chat(
std::io::stdout().flush().context("flush stdout")?;
}
Ok(None) => {}
Err(_) => {}
Err(_) => retry_payloads.push(mls_payload),
}
}
// Retry failed messages (epoch may have advanced from commits in this batch)
loop {
let before = retry_payloads.len();
retry_payloads.retain(|mls_payload| {
match member.receive_message(mls_payload) {
Ok(Some(pt)) => {
let s = String::from_utf8_lossy(&pt);
println!("\r\n[peer] {s}\n> ");
let _ = std::io::stdout().flush();
false
}
Ok(None) => false,
Err(_) => true,
}
});
if retry_payloads.len() == before { break; }
}
if !payloads.is_empty() {
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
}

View File

@@ -1,13 +1,7 @@
pub fn encode(bytes: impl AsRef<[u8]>) -> String {
bytes.as_ref().iter().map(|b| format!("{b:02x}")).collect()
hex::encode(bytes)
}
pub fn decode(s: &str) -> Result<Vec<u8>, &'static str> {
if s.len() % 2 != 0 {
return Err("odd-length hex string");
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| "invalid hex character"))
.collect()
hex::decode(s).map_err(|_| "invalid hex string")
}

View File

@@ -48,7 +48,12 @@ where
}
}
}
Err(last_err.expect("retry_async: last_err set when we break after Err"))
match last_err {
Some(e) => Err(e),
None => unreachable!(
"retry_async: last_err is always Some when loop exits after an Err"
),
}
}
/// Classifies `anyhow::Error` for retry: returns `false` for auth or invalid-param

View File

@@ -17,6 +17,9 @@ use crate::AUTH_CONTEXT;
use super::retry::{anyhow_is_retriable, retry_async, DEFAULT_BASE_DELAY_MS, DEFAULT_MAX_RETRIES};
/// Cap'n Proto traversal limit (words). 4 Mi words = 32 MiB; bounds DoS from deeply nested or large messages.
const CAPNP_TRAVERSAL_LIMIT_WORDS: usize = 4 * 1024 * 1024;
/// Establish a QUIC/TLS connection and return a `NodeService` client.
///
/// Must be called from within a `LocalSet` because capnp-rpc is `!Send`.
@@ -55,11 +58,13 @@ pub async fn connect_node(
let (send, recv) = connection.open_bi().await.context("open bi stream")?;
let mut reader_opts = capnp::message::ReaderOptions::new();
reader_opts.traversal_limit_in_words(Some(CAPNP_TRAVERSAL_LIMIT_WORDS));
let network = twoparty::VatNetwork::new(
recv.compat(),
send.compat_write(),
Side::Client,
Default::default(),
reader_opts,
);
let mut rpc_system = RpcSystem::new(Box::new(network), None);
@@ -72,7 +77,9 @@ pub async fn connect_node(
pub fn set_auth(auth: &mut auth::Builder<'_>) -> anyhow::Result<()> {
let ctx = AUTH_CONTEXT.get().ok_or_else(|| {
anyhow::anyhow!("init_auth must be called with a non-empty token before RPCs")
anyhow::anyhow!(
"init_auth must be called before RPCs (use a bearer or session token for authenticated commands)"
)
})?;
auth.set_version(ctx.version);
auth.set_access_token(&ctx.access_token);
@@ -355,7 +362,216 @@ pub fn try_hybrid_decrypt(
payload: &[u8],
) -> anyhow::Result<Vec<u8>> {
let kp = hybrid_kp.ok_or_else(|| anyhow::anyhow!("hybrid key required for decryption"))?;
quicnprotochat_core::hybrid_decrypt(kp, payload).map_err(|e| anyhow::anyhow!("{e}"))
quicnprotochat_core::hybrid_decrypt(kp, payload, b"", b"").map_err(|e| anyhow::anyhow!("{e}"))
}
/// Peek at queued payloads without removing them.
/// Returns `(seq, payload)` pairs sorted by seq.
/// Retries on transient failures with exponential backoff.
pub async fn peek(
client: &node_service::Client,
recipient_key: &[u8],
) -> anyhow::Result<Vec<(u64, Vec<u8>)>> {
let client = client.clone();
let recipient_key = recipient_key.to_vec();
retry_async(
|| {
let client = client.clone();
let recipient_key = recipient_key.clone();
async move {
let mut req = client.peek_request();
{
let mut p = req.get();
p.set_recipient_key(&recipient_key);
p.set_channel_id(&[]);
p.set_version(1);
p.set_limit(0); // peek all
let mut auth = p.reborrow().init_auth();
set_auth(&mut auth)?;
}
let resp = req.send().promise.await.context("peek RPC failed")?;
let list = resp
.get()
.context("peek: bad response")?
.get_payloads()
.context("peek: missing payloads")?;
let mut payloads = Vec::with_capacity(list.len() as usize);
for i in 0..list.len() {
let entry = list.get(i);
let seq = entry.get_seq();
let data = entry
.get_data()
.context("peek: envelope data read failed")?
.to_vec();
payloads.push((seq, data));
}
Ok(payloads)
}
},
DEFAULT_MAX_RETRIES,
DEFAULT_BASE_DELAY_MS,
anyhow_is_retriable,
)
.await
}
/// Acknowledge all messages up to and including `seq_up_to`.
/// Retries on transient failures with exponential backoff.
pub async fn ack(
client: &node_service::Client,
recipient_key: &[u8],
seq_up_to: u64,
) -> anyhow::Result<()> {
let client = client.clone();
let recipient_key = recipient_key.to_vec();
retry_async(
|| {
let client = client.clone();
let recipient_key = recipient_key.clone();
async move {
let mut req = client.ack_request();
{
let mut p = req.get();
p.set_recipient_key(&recipient_key);
p.set_channel_id(&[]);
p.set_version(1);
p.set_seq_up_to(seq_up_to);
let mut auth = p.reborrow().init_auth();
set_auth(&mut auth)?;
}
req.send().promise.await.context("ack RPC failed")?;
Ok(())
}
},
DEFAULT_MAX_RETRIES,
DEFAULT_BASE_DELAY_MS,
anyhow_is_retriable,
)
.await
}
/// Fetch multiple peers' hybrid keys in a single round-trip.
/// Returns `None` for peers who have not uploaded a hybrid key.
/// Retries on transient failures with exponential backoff.
pub async fn fetch_hybrid_keys(
client: &node_service::Client,
identity_keys: &[&[u8]],
) -> anyhow::Result<Vec<Option<HybridPublicKey>>> {
let client = client.clone();
let identity_keys: Vec<Vec<u8>> = identity_keys.iter().map(|k| k.to_vec()).collect();
retry_async(
|| {
let client = client.clone();
let identity_keys = identity_keys.clone();
async move {
let mut req = client.fetch_hybrid_keys_request();
{
let mut p = req.get();
let mut list = p.reborrow().init_identity_keys(identity_keys.len() as u32);
for (i, ik) in identity_keys.iter().enumerate() {
list.set(i as u32, ik);
}
let mut auth = p.reborrow().init_auth();
set_auth(&mut auth)?;
}
let resp = req
.send()
.promise
.await
.context("fetch_hybrid_keys RPC failed")?;
let keys = resp
.get()
.context("fetch_hybrid_keys: bad response")?
.get_keys()
.context("fetch_hybrid_keys: missing keys")?;
let mut result = Vec::with_capacity(keys.len() as usize);
for i in 0..keys.len() {
let pk_bytes = keys
.get(i)
.context("fetch_hybrid_keys: key read failed")?
.to_vec();
if pk_bytes.is_empty() {
result.push(None);
} else {
let pk = HybridPublicKey::from_bytes(&pk_bytes)
.context("invalid hybrid public key")?;
result.push(Some(pk));
}
}
Ok(result)
}
},
DEFAULT_MAX_RETRIES,
DEFAULT_BASE_DELAY_MS,
anyhow_is_retriable,
)
.await
}
/// Enqueue the same payload to multiple recipients in a single round-trip.
/// Returns per-recipient sequence numbers.
/// Retries on transient failures with exponential backoff.
pub async fn batch_enqueue(
client: &node_service::Client,
recipient_keys: &[&[u8]],
payload: &[u8],
) -> anyhow::Result<Vec<u64>> {
let client = client.clone();
let recipient_keys: Vec<Vec<u8>> = recipient_keys.iter().map(|k| k.to_vec()).collect();
let payload = payload.to_vec();
retry_async(
|| {
let client = client.clone();
let recipient_keys = recipient_keys.clone();
let payload = payload.clone();
async move {
let mut req = client.batch_enqueue_request();
{
let mut p = req.get();
let mut list = p.reborrow().init_recipient_keys(recipient_keys.len() as u32);
for (i, rk) in recipient_keys.iter().enumerate() {
list.set(i as u32, rk);
}
p.set_payload(&payload);
p.set_channel_id(&[]);
p.set_version(1);
let mut auth = p.reborrow().init_auth();
set_auth(&mut auth)?;
}
let resp = req
.send()
.promise
.await
.context("batch_enqueue RPC failed")?;
let seqs = resp
.get()
.context("batch_enqueue: bad response")?
.get_seqs()
.context("batch_enqueue: missing seqs")?;
let mut result = Vec::with_capacity(seqs.len() as usize);
for i in 0..seqs.len() {
result.push(seqs.get(i));
}
Ok(result)
}
},
DEFAULT_MAX_RETRIES,
DEFAULT_BASE_DELAY_MS,
anyhow_is_retriable,
)
.await
}
/// Return the current Unix timestamp in milliseconds.

View File

@@ -2,7 +2,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Context;
use argon2::Argon2;
use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::{
aead::{Aead, KeyInit},
ChaCha20Poly1305, Key, Nonce,
@@ -62,10 +62,21 @@ impl StoredState {
}
}
/// Derive a 32-byte key from a password and salt using Argon2id.
/// Argon2id parameters for client state key derivation (auditable; matches argon2 crate defaults).
/// - Memory: 19 MiB (m_cost = 19*1024 KiB)
/// - Time: 2 iterations
/// - Parallelism: 1 lane
const ARGON2_STATE_M_COST: u32 = 19 * 1024;
const ARGON2_STATE_T_COST: u32 = 2;
const ARGON2_STATE_P_COST: u32 = 1;
/// Derive a 32-byte key from a password and salt using Argon2id with explicit parameters.
fn derive_state_key(password: &str, salt: &[u8]) -> anyhow::Result<[u8; 32]> {
let params = Params::new(ARGON2_STATE_M_COST, ARGON2_STATE_T_COST, ARGON2_STATE_P_COST, Some(32))
.map_err(|e| anyhow::anyhow!("argon2 params: {e}"))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::default(), params);
let mut key = [0u8; 32];
Argon2::default()
argon2
.hash_password_into(password.as_bytes(), salt, &mut key)
.map_err(|e| anyhow::anyhow!("argon2 key derivation failed: {e}"))?;
Ok(key)
@@ -79,8 +90,8 @@ pub fn encrypt_state(password: &str, plaintext: &[u8]) -> anyhow::Result<Vec<u8>
let mut nonce_bytes = [0u8; STATE_NONCE_LEN];
rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
let key = derive_state_key(password, &salt)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
let key = zeroize::Zeroizing::new(derive_state_key(password, &salt)?);
let cipher = ChaCha20Poly1305::new(Key::from_slice(&*key));
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
@@ -108,8 +119,8 @@ pub fn decrypt_state(password: &str, data: &[u8]) -> anyhow::Result<Vec<u8>> {
let nonce_bytes = &data[4 + STATE_SALT_LEN..header_len];
let ciphertext = &data[header_len..];
let key = derive_state_key(password, salt)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
let key = zeroize::Zeroizing::new(derive_state_key(password, salt)?);
let cipher = ChaCha20Poly1305::new(Key::from_slice(&*key));
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher
@@ -179,7 +190,9 @@ pub fn write_state(path: &Path, state: &StoredState, password: Option<&str>) ->
plaintext
};
std::fs::write(path, bytes).with_context(|| format!("write state {path:?}"))?;
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes).with_context(|| format!("write state temp {tmp:?}"))?;
std::fs::rename(&tmp, path).with_context(|| format!("rename state {tmp:?} -> {path:?}"))?;
Ok(())
}
@@ -222,4 +235,57 @@ mod tests {
let encrypted = encrypt_state("correct", plaintext).unwrap();
assert!(decrypt_state("wrong", &encrypted).is_err());
}
#[test]
fn state_encrypt_decrypt_round_trip() {
let state = StoredState {
identity_seed: [42u8; 32],
hybrid_key: None,
group: None,
member_keys: Vec::new(),
};
let password = "test-password";
let plaintext = bincode::serialize(&state).unwrap();
let encrypted = encrypt_state(password, &plaintext).unwrap();
let decrypted = decrypt_state(password, &encrypted).unwrap();
let recovered: StoredState = bincode::deserialize(&decrypted).unwrap();
assert_eq!(recovered.identity_seed, state.identity_seed);
assert!(recovered.hybrid_key.is_none());
assert!(recovered.group.is_none());
}
#[test]
fn state_encrypt_decrypt_with_hybrid_key() {
use zeroize::Zeroizing;
let state = StoredState {
identity_seed: [7u8; 32],
hybrid_key: Some(HybridKeypairBytes {
x25519_sk: Zeroizing::new([1u8; 32]),
mlkem_dk: Zeroizing::new(vec![3u8; 2400]),
mlkem_ek: vec![4u8; 1184],
}),
group: None,
member_keys: Vec::new(),
};
let password = "another-password";
let plaintext = bincode::serialize(&state).unwrap();
let encrypted = encrypt_state(password, &plaintext).unwrap();
let decrypted = decrypt_state(password, &encrypted).unwrap();
let recovered: StoredState = bincode::deserialize(&decrypted).unwrap();
assert_eq!(recovered.identity_seed, state.identity_seed);
assert!(recovered.hybrid_key.is_some());
}
#[test]
fn state_wrong_password_fails() {
let state = StoredState {
identity_seed: [99u8; 32],
hybrid_key: None,
group: None,
member_keys: Vec::new(),
};
let plaintext = bincode::serialize(&state).unwrap();
let encrypted = encrypt_state("correct", &plaintext).unwrap();
assert!(decrypt_state("wrong", &encrypted).is_err());
}
}