From 8fc34826aab7990b093b33f1cdff6242486809cd Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 9 Nov 2026 04:44:43 +0000 Subject: [PATCH] feat(secure-chat): p2p E2E messaging over mailboxes and direct delivery (DIP-0018) - c0mpute-secure-chat: addresses = hash of all four public keys, signed key/home records, sealed outer layer hiding sender and recipient, rotating recipient tags, replay guard, bounded mailbox store, rendezvous placement, and client state encrypted at rest. - c0mpute-net: /c0mpute/rpc/1.0.0 named-service request/response (RpcHandler, register_rpc, rpc, connected_peers). - c0mpute-core::dm: mailbox role (on by default, gateways too), gossip on c0mpute/dm/v1, direct delivery to a recipient's home node, store on 3 rendezvous-ranked mailboxes otherwise, fetch/ack, key publish and lookup by fingerprint. New Role::Mailbox. - c0mpute dm: send/pull/inbox/lookup/contacts/block/unblock wired to the network; old `c0mpute chat ` forms still work. - tests: unit tests plus dm_e2e (4 mailboxes, offline store-and-forward, ack deletes every copy, blocklist, fingerprint lookup, direct delivery). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + node/crates/c0mpute-cli/src/dm_cli.rs | 393 ++++++++ node/crates/c0mpute-cli/src/main.rs | 191 ++-- node/crates/c0mpute-core/Cargo.toml | 1 + node/crates/c0mpute-core/src/capabilities.rs | 1 + node/crates/c0mpute-core/src/config.rs | 1 + node/crates/c0mpute-core/src/dm.rs | 897 ++++++++++++++++++ node/crates/c0mpute-core/src/hosting/mod.rs | 2 +- node/crates/c0mpute-core/src/lib.rs | 1 + node/crates/c0mpute-core/src/supervisor.rs | 19 + node/crates/c0mpute-core/tests/dm_e2e.rs | 216 +++++ node/crates/c0mpute-net/src/lib.rs | 3 +- node/crates/c0mpute-net/src/swarm.rs | 272 +++++- node/crates/c0mpute-proto/src/lib.rs | 5 + .../c0mpute-secure-chat/src/identity.rs | 339 +++++++ node/crates/c0mpute-secure-chat/src/keys.rs | 11 +- node/crates/c0mpute-secure-chat/src/lib.rs | 32 +- .../crates/c0mpute-secure-chat/src/mailbox.rs | 421 ++++++++ .../crates/c0mpute-secure-chat/src/message.rs | 155 ++- .../c0mpute-secure-chat/src/placement.rs | 88 ++ node/crates/c0mpute-secure-chat/src/sealed.rs | 423 +++++++++ node/crates/c0mpute-secure-chat/src/state.rs | 268 ++++++ plugins/secure-chat/module.toml | 76 +- 23 files changed, 3607 insertions(+), 209 deletions(-) create mode 100644 node/crates/c0mpute-cli/src/dm_cli.rs create mode 100644 node/crates/c0mpute-core/src/dm.rs create mode 100644 node/crates/c0mpute-core/tests/dm_e2e.rs create mode 100644 node/crates/c0mpute-secure-chat/src/identity.rs create mode 100644 node/crates/c0mpute-secure-chat/src/mailbox.rs create mode 100644 node/crates/c0mpute-secure-chat/src/placement.rs create mode 100644 node/crates/c0mpute-secure-chat/src/sealed.rs create mode 100644 node/crates/c0mpute-secure-chat/src/state.rs diff --git a/Cargo.lock b/Cargo.lock index 5b9b7de0..246dc9ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -548,6 +548,7 @@ dependencies = [ "c0mpute-gateway", "c0mpute-net", "c0mpute-proto", + "c0mpute-secure-chat", "c0mpute-store", "c0mpute-transcode", "c0mpute-update", diff --git a/node/crates/c0mpute-cli/src/dm_cli.rs b/node/crates/c0mpute-cli/src/dm_cli.rs new file mode 100644 index 00000000..3a1b815d --- /dev/null +++ b/node/crates/c0mpute-cli/src/dm_cli.rs @@ -0,0 +1,393 @@ +//! `c0mpute dm`: the secure-chat client (DIP-0018). +//! +//! Network commands boot a short-lived libp2p client node (its own identity +//! under `/dm-client`), find mailbox nodes through the peers it +//! connects to, and talk to them over `/c0mpute/rpc/1.0.0`. When this +//! machine also runs a worker, the client signs a home record naming the +//! worker's peer id, so senders can deliver straight to that worker; the +//! client then reads the worker's spool on `dm pull`. +//! +//! The key password comes from `C0MPUTE_DM_PASSWORD` when set (scripts), +//! otherwise from a prompt. + +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use c0mpute_core::Config; +use c0mpute_core::config; +use c0mpute_core::dm::{self, Dm, Paths}; +use c0mpute_secure_chat::identity::{fingerprint_of, normalize_query}; +use c0mpute_secure_chat::mailbox::{Limits, Mailbox}; +use c0mpute_secure_chat::sealed::{MAX_TTL_SECS, pull_tags, seal}; +use c0mpute_secure_chat::state::{LocalState, Received, state_file_path}; +use c0mpute_secure_chat::{self as chat, ChatKey, HomeRecord, KeyFile, KeyRecord, PublicIdentity, unix_now}; + +use super::{ChatCmd, ContactsCmd, KeyCmd, prompt_new_password}; + +/// How long home and key records stay valid; refreshed on every network command. +const RECORD_SECS: u64 = 30 * 86_400; + +fn password(prompt: &str) -> Result { + if let Ok(p) = std::env::var("C0MPUTE_DM_PASSWORD") + && !p.is_empty() + { + return Ok(p); + } + Ok(rpassword::prompt_password(prompt)?) +} + +struct Session { + key: ChatKey, + state: LocalState, + state_path: std::path::PathBuf, + cfg: Config, +} + +impl Session { + fn open() -> Result { + let path = chat::key_file_path()?; + let kf = chat::load_keyfile(&path).with_context(|| "no chat key yet: run `c0mpute dm keygen`".to_string())?; + let key = chat::decrypt_keyfile(&kf, &password("Chat key password: ")?)?; + let state_path = state_file_path()?; + let state = LocalState::load(&state_path, &key)?; + let cfg = Config::load_or_default(&config::default_config_path())?; + Ok(Self { + key, + state, + state_path, + cfg, + }) + } + + fn save(&self) -> Result<()> { + self.state.save(&self.state_path, &self.key) + } + + fn paths(&self) -> Paths { + Paths::under(&self.cfg.storage.root) + } + + /// If this machine has a worker identity, make that worker this user's + /// home: it accepts direct deliveries and gossips the record while up. + fn register_home(&self) -> Option { + let dir = config::config_dir()?; + let peer = dm::worker_peer_id(&dir)?; + let rec = HomeRecord::sign(&self.key, &peer, unix_now(), RECORD_SECS).ok()?; + dm::write_home_record(&self.paths().homes, &rec).ok()?; + Some(peer) + } + + /// Resolve TO from local knowledge, then the network. + async fn resolve(&mut self, who: &str, net: Option<&Dm>) -> Result { + if let Some(id) = self.state.resolve(who) { + return Ok(id); + } + let Some(net) = net else { + bail!("unknown recipient {who}: use an address, fingerprint, contact name, or c0dm1. identity"); + }; + if normalize_query(who).is_none() { + bail!("unknown contact {who}"); + } + match net.lookup(who).await { + Some(id) => { + self.state.remember(&id); + Ok(id) + } + None => bail!( + "no key record found for {who}. Ask them for their identity string (`c0mpute dm key show --full`)" + ), + } + } +} + +/// Boot the client node and wait briefly for mailboxes. +async fn connect(s: &Session) -> Result { + let id_dir = config::data_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("dm-client"); + let net = dm::boot_client(&id_dir).await?; + let d = Dm::start(net, dm::Options::default()).await?; + let n = d.wait_for_mailboxes(1, Duration::from_secs(20)).await; + if n == 0 { + eprintln!("warning: no mailbox nodes reachable yet (is a c0mpute worker or gateway reachable?)"); + } + // Publish our key record so others can find us by fingerprint. + if let Ok(rec) = KeyRecord::sign(&s.key, unix_now(), RECORD_SECS) { + let _ = d.publish_key(&rec).await; + } + Ok(d) +} + +pub async fn run_chat(cmd: ChatCmd) -> Result<()> { + match cmd { + ChatCmd::Keygen => keygen(), + ChatCmd::Key { cmd } => key_cmd(cmd), + ChatCmd::Restore { from_backup } => { + let dest = chat::key_file_path()?; + if dest.exists() { + eprintln!("A keyfile already exists at {}. Remove it first.", dest.display()); + bail!("keyfile already exists"); + } + let raw = std::fs::read_to_string(&from_backup)?; + let kf: KeyFile = serde_json::from_str(&raw)?; + // Verify the password decrypts correctly before saving. + chat::decrypt_keyfile(&kf, &password("Password: ")?)?; + chat::save_keyfile(&dest, &kf)?; + println!("keyfile restored to {}", dest.display()); + Ok(()) + } + ChatCmd::Send { + to, + message, + ttl_days, + sealed: _, + } => { + let mut s = Session::open()?; + s.register_home(); + let net = connect(&s).await?; + let rcpt = s.resolve(&to, Some(&net)).await?; + let ttl = (ttl_days * 86_400).clamp(60, MAX_TTL_SECS); + let sealed = seal(&s.key, &rcpt, &message, ttl, unix_now())?; + let report = net.send(&sealed, &rcpt.address(), ttl).await?; + s.save()?; + let name = s.state.name_of(&rcpt.address()); + match report.direct { + Some(peer) => println!("delivered directly to {name}'s node ({peer})"), + None => println!( + "{name} is offline: held on {} mailbox node(s) for up to {} days", + report.stored_on.len(), + ttl / 86_400 + ), + } + Ok(()) + } + ChatCmd::Pull => { + let mut s = Session::open()?; + s.register_home(); + let now = unix_now(); + let me = s.key.address(); + let mut new = 0usize; + let mut dropped = 0usize; + let mut bad = 0usize; + // 1. Direct deliveries on this machine's node. + let spool_dir = s.paths().spool; + if spool_dir.exists() { + let mut spool = Mailbox::open(&spool_dir, Limits::default(), now); + for tag in pull_tags(&me, now) { + for (id, bytes) in spool.fetch(&tag, now) { + let res = chat::SealedMessage::parse(&bytes) + .and_then(|m| s.state.receive(&s.key, &m, "direct", now)); + match res { + Ok(r) => { + tally(&r, &mut new, &mut dropped); + spool.ack(&tag, &id, r.ack()); + } + Err(e) => { + bad += 1; + eprintln!("skipped a direct message: {e}"); + } + } + } + } + } + // 2. Mailboxes. + let net = connect(&s).await?; + for f in net.fetch(&me).await { + match s.state.receive(&s.key, &f.msg, "mailbox", unix_now()) { + Ok(r) => { + tally(&r, &mut new, &mut dropped); + net.ack(&f, r.ack()).await; + } + Err(e) => { + bad += 1; + eprintln!("skipped a mailbox message: {e}"); + } + } + } + s.save()?; + println!( + "{new} new message(s){}{}", + if dropped > 0 { format!(", {dropped} from blocked senders dropped") } else { String::new() }, + if bad > 0 { format!(", {bad} rejected") } else { String::new() } + ); + if new > 0 { + print_inbox(&s.state, new); + } + Ok(()) + } + ChatCmd::Inbox { limit } => { + let s = Session::open()?; + if s.state.inbox.is_empty() { + println!("inbox empty (run `c0mpute dm pull`)"); + } else { + print_inbox(&s.state, limit); + } + Ok(()) + } + ChatCmd::Lookup { query } => { + let mut s = Session::open()?; + let id = match s.state.resolve(&query) { + Some(id) => id, + None => { + let net = connect(&s).await?; + s.resolve(&query, Some(&net)).await? + } + }; + s.save()?; + println!("address : {}", id.address()); + println!("fingerprint : {}", id.fingerprint()); + println!("identity : {}", id.to_share_string()); + Ok(()) + } + ChatCmd::Contacts { cmd } => match cmd.unwrap_or(ContactsCmd::Ls) { + ContactsCmd::Ls => { + let s = Session::open()?; + if s.state.contacts.is_empty() { + println!("no contacts (add one: c0mpute dm contacts add --name alice)"); + } + for (addr, c) in &s.state.contacts { + let blocked = if s.state.blocked.contains(addr) { " [blocked]" } else { "" }; + println!("{:<16} {} {addr}{blocked}", c.name.as_deref().unwrap_or("-"), fingerprint_of(addr)); + } + Ok(()) + } + ContactsCmd::Add { who, name } => { + let mut s = Session::open()?; + let id = match s.state.resolve(&who) { + Some(id) => id, + None => { + let net = connect(&s).await?; + s.resolve(&who, Some(&net)).await? + } + }; + let addr = s.state.add_contact(id, name, unix_now()); + s.save()?; + println!("added {} ({})", s.state.name_of(&addr), fingerprint_of(&addr)); + Ok(()) + } + ContactsCmd::Rm { who } => { + let mut s = Session::open()?; + let id = s.state.resolve(&who).context("no such contact")?; + s.state.contacts.remove(&id.address()); + s.save()?; + println!("removed {}", fingerprint_of(&id.address())); + Ok(()) + } + }, + ChatCmd::Block { who } => { + let mut s = Session::open()?; + let addr = match s.state.resolve(&who) { + Some(id) => id.address(), + None => match normalize_query(&who) { + Some(q) if q.len() == 64 => q, + _ => bail!("block needs a full address, or a known contact/fingerprint"), + }, + }; + s.state.blocked.insert(addr.clone()); + s.save()?; + println!("blocked {}", fingerprint_of(&addr)); + Ok(()) + } + ChatCmd::Unblock { who } => { + let mut s = Session::open()?; + let addr = s + .state + .resolve(&who) + .map(|id| id.address()) + .or_else(|| normalize_query(&who).filter(|q| q.len() == 64)) + .context("unknown sender")?; + let was = s.state.blocked.remove(&addr); + s.save()?; + println!("{} {}", if was { "unblocked" } else { "was not blocked:" }, fingerprint_of(&addr)); + Ok(()) + } + } +} + +fn tally(r: &Received, new: &mut usize, dropped: &mut usize) { + match r { + Received::New(_) => *new += 1, + Received::Blocked(_) => *dropped += 1, + } +} + +fn print_inbox(state: &LocalState, limit: usize) { + let start = state.inbox.len().saturating_sub(limit); + for m in &state.inbox[start..] { + println!("[{}] {} ({}): {}", fmt_time(m.created_at), state.name_of(&m.from), m.via, m.text); + } +} + +fn fmt_time(t: u64) -> String { + let ago = unix_now().saturating_sub(t); + match ago { + 0..=59 => format!("{ago}s ago"), + 60..=3599 => format!("{}m ago", ago / 60), + 3600..=86_399 => format!("{}h ago", ago / 3600), + _ => format!("{}d ago", ago / 86_400), + } +} + +fn keygen() -> Result<()> { + let path = chat::key_file_path()?; + if path.exists() { + eprintln!( + "A keyfile already exists at {}.\n\ + Use `c0mpute dm key rotate` for how to replace it.", + path.display() + ); + bail!("keyfile already exists"); + } + let key = ChatKey::generate(); + let password = match std::env::var("C0MPUTE_DM_PASSWORD") { + Ok(p) if p.len() >= 10 => p, + _ => prompt_new_password()?, + }; + let kf = key.encrypt_to_keyfile(&password, None)?; + chat::save_keyfile(&path, &kf)?; + let id = key.public_identity(); + println!("keypair generated"); + println!(" address : {}", id.address()); + println!(" fingerprint : {}", id.fingerprint()); + println!(" keyfile : {}", path.display()); + println!(); + println!("Share your fingerprint (others look it up) or your full identity:"); + println!(" {}", id.to_share_string()); + println!(); + println!("── encrypted backup (save this somewhere safe) ──"); + println!("{}", serde_json::to_string_pretty(&kf)?); + println!("─────────────────────────────────────────────────"); + println!("restore with: c0mpute dm restore --from-backup "); + Ok(()) +} + +fn key_cmd(cmd: KeyCmd) -> Result<()> { + match cmd { + KeyCmd::Show { full } => { + let kf = chat::load_keyfile(&chat::key_file_path()?)?; + // Public fields only: no password needed. + let id = PublicIdentity::from_keyfile(&kf); + println!("address : {}", id.address()); + println!("fingerprint : {}", id.fingerprint()); + if let Some(did) = &kf.did { + println!("did : {did}"); + } + if full { + println!("identity : {}", id.to_share_string()); + } + Ok(()) + } + KeyCmd::Export => { + let kf = chat::load_keyfile(&chat::key_file_path()?)?; + println!("{}", serde_json::to_string_pretty(&kf)?); + Ok(()) + } + KeyCmd::Rotate => { + println!("To replace your keypair (your address changes; tell your contacts):"); + println!(" 1. c0mpute dm pull (collect anything still queued)"); + println!(" 2. move ~/.config/c0mpute/chat.key and chat-state.enc aside"); + println!(" 3. c0mpute dm keygen"); + Ok(()) + } + } +} diff --git a/node/crates/c0mpute-cli/src/main.rs b/node/crates/c0mpute-cli/src/main.rs index 5bcae116..0281b154 100644 --- a/node/crates/c0mpute-cli/src/main.rs +++ b/node/crates/c0mpute-cli/src/main.rs @@ -22,6 +22,8 @@ //! c0mpute.com/coinpay, c0mpute.com/infernet. mod ai_chat; +mod dm_cli; +use dm_cli::run_chat; mod gameserver; mod peers; mod storage; @@ -39,7 +41,6 @@ use c0mpute_core::{ run_register, }; use c0mpute_proto::Role; -use c0mpute_secure_chat as chat; use clap::{Parser, Subcommand}; #[derive(Parser, Debug)] @@ -588,7 +589,7 @@ enum SpeechCmd { /// `c0mpute dm` subcommands, still accepted after `c0mpute chat` for old scripts. const SECURE_CHAT_CMDS: &[&str] = &[ - "keygen", "key", "restore", "send", "pull", "lookup", "contacts", "block", + "keygen", "key", "restore", "send", "pull", "inbox", "lookup", "contacts", "block", "unblock", ]; #[derive(Parser, Debug)] @@ -1063,31 +1064,67 @@ enum ChatCmd { #[arg(long = "from-backup")] from_backup: PathBuf, }, - /// Send an encrypted DM to a recipient DID. [v0.2: transport not yet live] + /// Send an end-to-end encrypted message. TO is a contact name, an + /// address, a fingerprint, or a `c0dm1.` identity string. Delivered + /// directly to the recipient's node when it is online, otherwise held on + /// three mailbox nodes for up to --ttl-days. Send { to: String, message: String, - /// Hide sender identity from relay nodes. - #[arg(long)] + /// Days mailboxes keep the message for an offline recipient (max 30). + #[arg(long, default_value_t = 30)] + ttl_days: u64, + /// Accepted for old scripts; every message is sealed now. + #[arg(long, hide = true)] sealed: bool, }, - /// Fetch queued messages from the relay. [v0.2: relay not yet live] + /// Fetch new messages (this machine's node spool and the mailboxes), + /// decrypt, verify, and delete them from the network. Pull, - /// Resolve a DID to its chat public key. [v0.2: DHT not yet live] - Lookup { did: String }, - /// List saved contacts. - Contacts, - /// Block a DID (messages dropped locally). - Block { did: String }, + /// Show received messages. + Inbox { + /// Most recent N messages. + #[arg(long, default_value_t = 50)] + limit: usize, + }, + /// Resolve a fingerprint or address to a full public key. + Lookup { query: String }, + /// Contacts (stored locally, encrypted under your key). Lists by default. + Contacts { + #[command(subcommand)] + cmd: Option, + }, + /// Block a sender: their messages are dropped (and deleted) on receipt. + Block { who: String }, + /// Unblock a sender. + Unblock { who: String }, +} + +#[derive(Subcommand, Debug)] +enum ContactsCmd { + /// Add a contact by address, fingerprint, or `c0dm1.` identity string. + Add { + who: String, + #[arg(long)] + name: Option, + }, + /// List contacts. + Ls, + /// Remove a contact. + Rm { who: String }, } #[derive(Subcommand, Debug)] enum KeyCmd { - /// Print the public key, fingerprint, and DID. - Show, + /// Print your address and fingerprint (and, with --full, the shareable identity). + Show { + /// Also print the `c0dm1.` identity string others can paste. + #[arg(long)] + full: bool, + }, /// Re-export the encrypted backup JSON (pipe to a file for safe storage). Export, - /// Generate a new keypair and re-announce (old key still valid until revoked). + /// How to replace your keypair. Rotate, } @@ -1363,12 +1400,12 @@ async fn run_app(cli: Cli) -> Result<()> { Cmd::Podcasting { cmd } => run_podcasting(cmd).await, Cmd::Podcasts { cmd } => run_podcasts(cmd).await, Cmd::Ads { cmd } => run_ads(cmd).await, - Cmd::Dm { cmd } => run_chat(cmd), + Cmd::Dm { cmd } => run_chat(cmd).await, // The old secure-chat forms (`c0mpute chat keygen|send|...`) keep working. Cmd::Chat { args } if args.first().is_some_and(|a| SECURE_CHAT_CMDS.contains(&a.as_str())) => { let dm = DmCli::try_parse_from(std::iter::once("c0mpute dm".to_string()).chain(args)) .unwrap_or_else(|e| e.exit()); - run_chat(dm.cmd) + run_chat(dm.cmd).await } Cmd::Chat { args } => ai_chat::run(&args).await, Cmd::Tui { args } => run_tui(&args), @@ -1625,6 +1662,7 @@ async fn run_worker(cmd: WorkerCmd, config_path: &std::path::Path) -> Result<()> Role::Embed, Role::LiveStream, Role::Render, + Role::Mailbox, ] { if !cfg.roles.contains(&role) { cfg.roles.push(role); @@ -3856,124 +3894,6 @@ async fn run_jobs(cmd: JobsCmd) -> Result<()> { // secure-chat plugin (in-process) // ──────────────────────────────────────────────────────────────────────── -fn run_chat(cmd: ChatCmd) -> Result<()> { - match cmd { - ChatCmd::Keygen => { - let path = chat::key_file_path()?; - if path.exists() { - eprintln!( - "A keyfile already exists at {}.\n\ - Use `c0mpute dm key rotate` to generate a new one, or\n\ - delete the file manually to start fresh.", - path.display() - ); - anyhow::bail!("keyfile already exists"); - } - let key = chat::ChatKey::generate(); - let password = prompt_new_password()?; - let kf = key.encrypt_to_keyfile(&password, None)?; - chat::save_keyfile(&path, &kf)?; - - println!("keypair generated"); - println!(" enc pubkey : {}", kf.pubkey_enc); - println!(" sig pubkey : {}", kf.pubkey_sig); - println!(" fingerprint : {}", key.fingerprint()); - println!(" keyfile : {}", path.display()); - println!(); - println!("── encrypted backup (save this somewhere safe) ──"); - println!("{}", serde_json::to_string_pretty(&kf)?); - println!("─────────────────────────────────────────────────"); - println!(); - println!("restore with: c0mpute dm restore --from-backup "); - Ok(()) - } - - ChatCmd::Key { cmd } => match cmd { - KeyCmd::Show => { - let path = chat::key_file_path()?; - let kf = chat::load_keyfile(&path)?; - println!("enc pubkey : {}", kf.pubkey_enc); - println!("sig pubkey : {}", kf.pubkey_sig); - if let Some(did) = &kf.did { - println!("did : {did}"); - } - // Fingerprint needs decryption — show pubkey hash instead - use base64::prelude::*; - let enc_bytes = BASE64_URL_SAFE_NO_PAD.decode(&kf.pubkey_enc)?; - let fingerprint: String = enc_bytes[..8] - .iter() - .map(|b| format!("{b:02x}")) - .collect::>() - .join(":"); - println!("fingerprint : {fingerprint}"); - Ok(()) - } - KeyCmd::Export => { - let path = chat::key_file_path()?; - let kf = chat::load_keyfile(&path)?; - println!("{}", serde_json::to_string_pretty(&kf)?); - Ok(()) - } - KeyCmd::Rotate => { - println!("[v0.2] key rotation requires DHT re-announcement (not yet live)"); - println!(" Generate a new keypair manually:"); - println!(" 1. Delete ~/.config/c0mpute/chat.key"); - println!(" 2. Run: c0mpute dm keygen"); - Ok(()) - } - }, - - ChatCmd::Restore { from_backup } => { - let dest = chat::key_file_path()?; - if dest.exists() { - eprintln!( - "A keyfile already exists at {}. Remove it first.", - dest.display() - ); - anyhow::bail!("keyfile already exists"); - } - let raw = std::fs::read_to_string(&from_backup)?; - let kf: chat::KeyFile = serde_json::from_str(&raw)?; - let password = rpassword::prompt_password("Password: ")?; - // Verify the password decrypts correctly before saving. - chat::decrypt_keyfile(&kf, &password)?; - chat::save_keyfile(&dest, &kf)?; - println!("keyfile restored to {}", dest.display()); - Ok(()) - } - - ChatCmd::Send { - to, - message: _, - sealed: _, - } => { - println!("[v0.2] direct send to {to} requires p2p transport (not yet live)"); - println!(" DHT key lookup and gossip relay land in v0.2."); - Ok(()) - } - - ChatCmd::Pull => { - println!("[v0.2] pull requires relay node support (not yet live)"); - Ok(()) - } - - ChatCmd::Lookup { did } => { - println!("[v0.2] DHT lookup for {did} not yet live"); - Ok(()) - } - - ChatCmd::Contacts => { - println!("[v0.2] contact list not yet implemented"); - Ok(()) - } - - ChatCmd::Block { did } => { - println!("[v0.2] block list for {did} not yet implemented"); - Ok(()) - } - } -} - fn prompt_new_password() -> Result { loop { let pw = rpassword::prompt_password("New password (min 10 chars): ")?; @@ -4217,6 +4137,7 @@ fn parse_role(s: &str) -> Option { "gameserver" | "gameservers" | "games" => Some(Role::Gameserver), "live-stream" | "live_stream" | "livestream" | "live" | "stream" => Some(Role::LiveStream), "render" | "blender" => Some(Role::Render), + "mailbox" | "dm" | "secure-chat" | "chat-mailbox" => Some(Role::Mailbox), other => { eprintln!("warning: unknown role '{other}' ignored"); None diff --git a/node/crates/c0mpute-core/Cargo.toml b/node/crates/c0mpute-core/Cargo.toml index 2d6e5ddd..21ffe240 100644 --- a/node/crates/c0mpute-core/Cargo.toml +++ b/node/crates/c0mpute-core/Cargo.toml @@ -16,6 +16,7 @@ c0mpute-update = { workspace = true } c0mpute-doctor = { workspace = true } c0mpute-bench = { workspace = true } c0mpute-api = { workspace = true } +c0mpute-secure-chat = { workspace = true } axum = { workspace = true } tower-http = { workspace = true } rand = { workspace = true } diff --git a/node/crates/c0mpute-core/src/capabilities.rs b/node/crates/c0mpute-core/src/capabilities.rs index 24b5f772..8f71ecfa 100644 --- a/node/crates/c0mpute-core/src/capabilities.rs +++ b/node/crates/c0mpute-core/src/capabilities.rs @@ -109,6 +109,7 @@ pub fn role_tags(config: &Config) -> Vec { } } Role::LiveStream => tags.push(crate::live::ROLE_TAG.to_string()), + Role::Mailbox => tags.push(crate::dm::ROLE_TAG.to_string()), Role::Render => { // Only where `blender` runs. The probe (a few seconds) runs // once at worker start; until it finishes, nothing is shown. diff --git a/node/crates/c0mpute-core/src/config.rs b/node/crates/c0mpute-core/src/config.rs index 03b8ec86..2dde546a 100644 --- a/node/crates/c0mpute-core/src/config.rs +++ b/node/crates/c0mpute-core/src/config.rs @@ -465,6 +465,7 @@ impl Default for Config { Role::Embed, Role::LiveStream, Role::Render, + Role::Mailbox, ], update_channel: default_update_channel(), update_auto: default_auto_update(), diff --git a/node/crates/c0mpute-core/src/dm.rs b/node/crates/c0mpute-core/src/dm.rs new file mode 100644 index 00000000..9340eaa8 --- /dev/null +++ b/node/crates/c0mpute-core/src/dm.rs @@ -0,0 +1,897 @@ +//! Secure-chat transport (DIP-0018): mailboxes, direct delivery and key +//! lookup over gossip plus `/c0mpute/rpc/1.0.0`. No coordinator anywhere. +//! +//! * Every mailbox node (the `mailbox` role, on by default; gateways too) +//! gossips "mailbox here" on [`TOPIC`] once a minute. From that every node +//! knows the mailbox set. +//! * A sender seals the message ([`c0mpute_secure_chat::sealed`]) and first +//! looks for the recipient's home node: a [`HomeRecord`] signed by the +//! recipient naming a libp2p peer, gossiped by that peer while it is up. +//! If the home answers, the message is delivered directly into its spool, +//! where the recipient's local `c0mpute dm` reads it. +//! * Otherwise the sealed message goes to the [`STORE_REPLICAS`] mailbox +//! nodes that rank highest for its recipient tag (rendezvous hashing, the +//! same on every node). They keep it up to 30 days, within size, count and +//! per-sender rate caps. The recipient polls the top [`PULL_REPLICAS`] for +//! each of its tags, opens what it finds, and acks; the ack reveals a +//! secret only the recipient could read, which deletes the message. +//! * Key lookup: users publish a self-signed [`KeyRecord`]; mailbox nodes +//! cache it (gossip plus a direct copy to the nodes ranked for its +//! fingerprint). A lookup by fingerprint or address asks those nodes and +//! checks the answer against what was asked: the address hashes every +//! public key, so a node cannot substitute another key. +//! +//! Mailbox nodes see recipient tags, sizes and the peer that handed them a +//! message; never sender identities, recipient addresses or content. + +use std::collections::{HashMap, VecDeque}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow, bail}; +use base64::prelude::*; +use c0mpute_net::{GossipMessage, Libp2pNetwork, PeerId, RpcHandler}; +use c0mpute_secure_chat::identity::{matches_query, normalize_query}; +use c0mpute_secure_chat::mailbox::{Limits, Mailbox, Put}; +use c0mpute_secure_chat::placement::{PULL_REPLICAS, STORE_REPLICAS, place, rank}; +use c0mpute_secure_chat::sealed::{Sealed, SealedMessage, is_hex32, pull_tags}; +use c0mpute_secure_chat::{HomeRecord, KeyRecord, PublicIdentity, unix_now}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; +use tracing::{debug, info}; + +pub const TOPIC: &str = "c0mpute/dm/v1"; +pub const RPC_SERVICE: &str = "dm/1"; +pub const ROLE_TAG: &str = "c0mpute:role:mailbox"; +/// Key records a node caches. +const MAX_KEYS: usize = 20_000; +/// Tags per fetch request. +const MAX_FETCH_TAGS: usize = 16; +/// Direct deliveries accepted per sending peer per minute. +const DELIVER_PER_MIN: usize = 60; + +/// How often things happen. Tests shrink these. +#[derive(Clone, Debug)] +pub struct Timing { + pub announce_every: Duration, + /// A mailbox or home not heard from for this long is gone. + pub peer_ttl: Duration, + pub rpc_timeout: Duration, +} + +impl Default for Timing { + fn default() -> Self { + Self { + announce_every: Duration::from_secs(60), + peer_ttl: Duration::from_secs(200), + rpc_timeout: Duration::from_secs(20), + } + } +} + +/// Where a node keeps its secure-chat files, under its storage root. +#[derive(Clone, Debug)] +pub struct Paths { + /// Other people's sealed messages (the mailbox role). + pub mailbox: PathBuf, + /// Direct deliveries for the users whose home this node is. + pub spool: PathBuf, + /// Signed home records (`
.json`) the local `c0mpute dm` wrote. + pub homes: PathBuf, +} + +impl Paths { + pub fn under(root: &Path) -> Self { + let d = root.join("dm"); + Self { + mailbox: d.join("mailbox"), + spool: d.join("spool"), + homes: d.join("homes"), + } + } +} + +pub struct Options { + /// Hold messages for others (the mailbox role). + pub serve_mailbox: bool, + /// Persist under these paths; `None` = memory only, no home/spool. + pub paths: Option, + pub limits: Limits, + pub timing: Timing, +} + +impl Default for Options { + fn default() -> Self { + Self { + serve_mailbox: false, + paths: None, + limits: Limits::default(), + timing: Timing::default(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "t", rename_all = "snake_case")] +enum Gossip { + Mailbox { addrs: Vec }, + Key { record: KeyRecord }, + Home { + record: HomeRecord, + /// Dial hints for the home peer (unsigned: the peer id in the + /// signed record is what authenticates the connection). + #[serde(default)] + addrs: Vec, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "t", rename_all = "snake_case")] +pub enum Request { + Store { msg: String, ack_hash: String, ttl: u64 }, + Fetch { tags: Vec }, + Ack { tag: String, id: String, secret: String }, + Deliver { msg: String, ack_hash: String, ttl: u64 }, + Lookup { query: String }, + PublishKey { record: KeyRecord }, + Presence { address: String }, + Peers, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Item { + pub tag: String, + pub id: String, + pub msg: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PeerInfo { + pub peer: String, + pub addrs: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "t", rename_all = "snake_case")] +pub enum Response { + Stored { duplicate: bool }, + Messages { items: Vec }, + Acked { deleted: bool }, + Delivered, + Key { record: Option }, + Presence { + record: Option, + #[serde(default)] + addrs: Vec, + }, + Peers { peers: Vec }, + Ok, +} + +/// One message found by [`Dm::fetch`], with the mailboxes holding it. +#[derive(Clone, Debug)] +pub struct Fetched { + pub tag: String, + pub id: String, + pub msg: SealedMessage, + pub holders: Vec, +} + +/// What [`Dm::send`] did. +#[derive(Clone, Debug, Default)] +pub struct SendReport { + /// The recipient's home node, when it took the message directly. + pub direct: Option, + /// Mailboxes that stored it (empty after a direct delivery). + pub stored_on: Vec, + /// Why direct delivery or individual mailboxes failed. + pub errors: Vec, +} + +struct Inner { + net: Arc, + me: String, + serves: bool, + limits: Limits, + timing: Timing, + paths: Option, + mailbox: Mutex, + /// Mailbox peers: last heard, dial addresses. + mailboxes: RwLock)>>, + keys: RwLock>, + /// Remote presence: address -> (record, last gossiped by its peer). + homes: RwLock)>>, + deliver_rate: Mutex>>, + stopped: std::sync::atomic::AtomicBool, +} + +/// The secure-chat service: one per node (or per `c0mpute dm` client). +#[derive(Clone)] +pub struct Dm(Arc); + +struct Handler(Dm); + +#[async_trait::async_trait] +impl RpcHandler for Handler { + async fn handle(&self, peer: PeerId, body: Vec) -> std::result::Result, String> { + let req: Request = serde_json::from_slice(&body).map_err(|e| format!("bad request: {e}"))?; + let resp = self.0.serve(&peer.to_base58(), req).await.map_err(|e| e.to_string())?; + serde_json::to_vec(&resp).map_err(|e| e.to_string()) + } +} + +fn b64d(s: &str) -> Result> { + BASE64_URL_SAFE_NO_PAD.decode(s).context("bad base64") +} + +fn b64e(b: &[u8]) -> String { + BASE64_URL_SAFE_NO_PAD.encode(b) +} + +impl Dm { + pub async fn start(net: Arc, opts: Options) -> Result { + let now = unix_now(); + let mailbox = match (&opts.paths, opts.serve_mailbox) { + (Some(p), true) => Mailbox::open(&p.mailbox, opts.limits.clone(), now), + _ => Mailbox::new(opts.limits.clone()), + }; + let dm = Dm(Arc::new(Inner { + me: net.peer_id().to_base58(), + net: net.clone(), + serves: opts.serve_mailbox, + limits: opts.limits, + timing: opts.timing, + paths: opts.paths, + mailbox: Mutex::new(mailbox), + mailboxes: RwLock::new(HashMap::new()), + keys: RwLock::new(HashMap::new()), + homes: RwLock::new(HashMap::new()), + deliver_rate: Mutex::new(HashMap::new()), + stopped: std::sync::atomic::AtomicBool::new(false), + })); + net.subscribe(TOPIC).await.context("subscribe to dm topic")?; + net.register_rpc(RPC_SERVICE, Arc::new(Handler(dm.clone()))); + dm.spawn_gossip_reader(); + dm.spawn_announcer(); + if dm.0.serves { + info!("secure-chat: serving as a mailbox"); + } + Ok(dm) + } + + pub fn peer_id(&self) -> &str { + &self.0.me + } + + pub fn serves_mailbox(&self) -> bool { + self.0.serves + } + + pub fn stop(&self) { + self.0.stopped.store(true, std::sync::atomic::Ordering::Relaxed); + } + + /// Messages this node holds for others. + pub fn mailbox_count(&self) -> usize { + self.0.mailbox.lock().map(|m| m.count()).unwrap_or(0) + } + + // ── gossip ─────────────────────────────────────────────────────────── + + fn spawn_gossip_reader(&self) { + let dm = self.clone(); + let mut rx = self.0.net.messages(); + tokio::spawn(async move { + loop { + let msg: GossipMessage = match rx.recv().await { + Ok(m) => m, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(_) => return, + }; + if msg.topic != TOPIC { + continue; + } + let Some(source) = msg.source else { continue }; + let Ok(g) = serde_json::from_slice::(&msg.data) else { + continue; + }; + dm.apply_gossip(source, g).await; + } + }); + } + + async fn apply_gossip(&self, source: PeerId, g: Gossip) { + let src = source.to_base58(); + let now = unix_now(); + match g { + Gossip::Mailbox { addrs } => { + let addrs: Vec = addrs.into_iter().take(8).collect(); + for a in &addrs { + if let Ok(ma) = a.parse::() { + self.0.net.add_peer_address(source, ma).await; + } + } + self.0.mailboxes.write().await.insert(src, (Instant::now(), addrs)); + } + Gossip::Key { record } => { + if record.verify(now).is_ok() { + self.cache_key(record).await; + } + } + Gossip::Home { record, addrs } => { + // Presence counts only from the named peer itself. + if record.peer == src && record.verify(now).is_ok() { + self.learn_home(record, addrs).await; + } + } + } + } + + /// Remember a verified home record and teach libp2p how to dial its peer. + async fn learn_home(&self, record: HomeRecord, addrs: Vec) { + let addrs: Vec = addrs.into_iter().take(8).collect(); + if let Ok(pid) = record.peer.parse::() { + for a in &addrs { + if let Ok(ma) = a.parse::() { + self.0.net.add_peer_address(pid, ma).await; + } + } + } + let addr = record.address(); + self.0.homes.write().await.insert(addr, (record, Instant::now(), addrs)); + } + + async fn cache_key(&self, record: KeyRecord) { + let addr = record.address(); + let mut keys = self.0.keys.write().await; + if let Some(cur) = keys.get(&addr) + && cur.created_at >= record.created_at + { + return; + } + if keys.len() >= MAX_KEYS && !keys.contains_key(&addr) { + let now = unix_now(); + keys.retain(|_, r| r.expires_at > now); + if keys.len() >= MAX_KEYS + && let Some(oldest) = keys.iter().min_by_key(|(_, r)| r.expires_at).map(|(a, _)| a.clone()) + { + keys.remove(&oldest); + } + } + keys.insert(addr, record); + } + + async fn publish(&self, g: &Gossip) { + if let Ok(bytes) = serde_json::to_vec(g) + && let Err(e) = self.0.net.publish(TOPIC, bytes).await + { + debug!(err = %e, "dm: publish (mesh forming?)"); + } + } + + async fn my_addrs(&self) -> Vec { + self.0 + .net + .advertised_addrs() + .await + .into_iter() + .map(|a| a.to_string()) + .filter(|a| !a.contains("/ip4/0.0.0.0") && !a.contains("/ip6/::/")) + .take(8) + .collect() + } + + /// Home records in `/` naming this node, verified and live. + pub fn local_homes(&self) -> Vec { + let Some(p) = &self.0.paths else { return Vec::new() }; + let now = unix_now(); + let Ok(rd) = std::fs::read_dir(&p.homes) else { return Vec::new() }; + rd.flatten() + .filter_map(|e| std::fs::read(e.path()).ok()) + .filter_map(|b| serde_json::from_slice::(&b).ok()) + .filter(|r| r.peer == self.0.me && r.verify(now).is_ok()) + .collect() + } + + /// Make this node the home of `record`'s address (writes it under + /// `homes/`, then gossips it). The `c0mpute dm` client does the same by + /// writing the file; the announcer picks it up. + pub async fn add_local_home(&self, record: &HomeRecord) -> Result<()> { + let p = self.0.paths.as_ref().ok_or_else(|| anyhow!("no data dir for home records"))?; + anyhow::ensure!(record.peer == self.0.me, "home record names another peer"); + record.verify(unix_now())?; + write_home_record(&p.homes, record)?; + let addrs = self.my_addrs().await; + self.publish(&Gossip::Home { + record: record.clone(), + addrs, + }) + .await; + Ok(()) + } + + /// Gossip once: mailbox presence and the homes this node serves. + pub async fn announce(&self) { + if self.0.serves { + let addrs = self.my_addrs().await; + self.0 + .mailboxes + .write() + .await + .insert(self.0.me.clone(), (Instant::now(), addrs.clone())); + self.publish(&Gossip::Mailbox { addrs }).await; + } + let homes = self.local_homes(); + if !homes.is_empty() { + let addrs = self.my_addrs().await; + for record in homes { + self.publish(&Gossip::Home { + record, + addrs: addrs.clone(), + }) + .await; + } + } + } + + fn spawn_announcer(&self) { + let dm = self.clone(); + tokio::spawn(async move { + loop { + if dm.0.stopped.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + dm.announce().await; + let ttl = dm.0.timing.peer_ttl; + dm.0.mailboxes + .write() + .await + .retain(|_, (t, _)| t.elapsed() <= ttl); + dm.0.homes.write().await.retain(|_, (r, t, _)| t.elapsed() <= ttl && r.expires_at > unix_now()); + if let Ok(mut mb) = dm.0.mailbox.lock() { + mb.prune(unix_now()); + } + tokio::time::sleep(crate::hosting::jitter(dm.0.timing.announce_every)).await; + } + }); + } + + // ── serving ────────────────────────────────────────────────────────── + + async fn serve(&self, peer: &str, req: Request) -> Result { + let now = unix_now(); + match req { + Request::Store { msg, ack_hash, ttl } => { + if !self.0.serves { + bail!("not a mailbox"); + } + let bytes = b64d(&msg)?; + let r = self + .0 + .mailbox + .lock() + .map_err(|_| anyhow!("mailbox lock"))? + .put(peer, bytes, &ack_hash, ttl, now)?; + Ok(Response::Stored { + duplicate: r == Put::Duplicate, + }) + } + Request::Fetch { tags } => { + let mb = self.0.mailbox.lock().map_err(|_| anyhow!("mailbox lock"))?; + let mut items = Vec::new(); + let mut budget = self.0.limits.max_fetch_bytes; + for tag in tags.into_iter().take(MAX_FETCH_TAGS) { + for (id, bytes) in mb.fetch(&tag, now) { + if bytes.len() > budget && !items.is_empty() { + break; + } + budget = budget.saturating_sub(bytes.len()); + items.push(Item { + tag: tag.clone(), + id, + msg: b64e(&bytes), + }); + } + } + Ok(Response::Messages { items }) + } + Request::Ack { tag, id, secret } => { + let deleted = self + .0 + .mailbox + .lock() + .map_err(|_| anyhow!("mailbox lock"))? + .ack(&tag, &id, &secret); + Ok(Response::Acked { deleted }) + } + Request::Deliver { msg, ack_hash, ttl } => { + let p = self.0.paths.as_ref().ok_or_else(|| anyhow!("not a home node"))?; + let bytes = b64d(&msg)?; + let sealed = SealedMessage::parse(&bytes)?; + let mine = self + .local_homes() + .iter() + .any(|h| pull_tags(&h.address(), now).contains(&sealed.tag)); + if !mine { + bail!("no user here for that tag"); + } + { + let mut rate = self.0.deliver_rate.lock().map_err(|_| anyhow!("rate lock"))?; + let w = rate.entry(peer.to_string()).or_default(); + while w.front().is_some_and(|t| *t + 60 <= now) { + w.pop_front(); + } + if w.len() >= DELIVER_PER_MIN { + bail!("rate limited"); + } + w.push_back(now); + } + // The spool is shared with the local client process, which + // deletes what it has read: re-read it each time. + let mut spool = Mailbox::open(&p.spool, self.0.limits.clone(), now); + spool.put("direct", bytes, &ack_hash, ttl, now).map_err(|e| anyhow!("{e}"))?; + Ok(Response::Delivered) + } + Request::Lookup { query } => { + let q = normalize_query(&query).ok_or_else(|| anyhow!("bad query"))?; + let record = self + .0 + .keys + .read() + .await + .values() + .find(|r| matches_query(&r.identity, &q) && r.expires_at > now) + .cloned(); + Ok(Response::Key { record }) + } + Request::PublishKey { record } => { + record.verify(now)?; + self.cache_key(record).await; + Ok(Response::Ok) + } + Request::Presence { address } => { + let ttl = self.0.timing.peer_ttl; + let known = self + .0 + .homes + .read() + .await + .get(&address) + .filter(|(r, t, _)| t.elapsed() <= ttl && r.expires_at > now) + .map(|(r, _, a)| (r.clone(), a.clone())); + let (record, addrs) = match known { + Some((r, a)) => (Some(r), a), + None => match self.local_homes().into_iter().find(|h| h.address() == address) { + Some(r) => (Some(r), self.my_addrs().await), + None => (None, Vec::new()), + }, + }; + Ok(Response::Presence { record, addrs }) + } + Request::Peers => { + let ttl = self.0.timing.peer_ttl; + let mut peers: Vec = self + .0 + .mailboxes + .read() + .await + .iter() + .filter(|(_, (t, _))| t.elapsed() <= ttl) + .map(|(p, (_, a))| PeerInfo { + peer: p.clone(), + addrs: a.clone(), + }) + .take(256) + .collect(); + if self.0.serves && !peers.iter().any(|p| p.peer == self.0.me) { + peers.push(PeerInfo { + peer: self.0.me.clone(), + addrs: self.my_addrs().await, + }); + } + Ok(Response::Peers { peers }) + } + } + } + + // ── client ─────────────────────────────────────────────────────────── + + async fn call(&self, peer: &str, req: &Request) -> Result { + let pid: PeerId = peer.parse().map_err(|_| anyhow!("bad peer id {peer}"))?; + let body = serde_json::to_vec(req)?; + let out = tokio::time::timeout(self.0.timing.rpc_timeout, self.0.net.rpc(pid, RPC_SERVICE, body)) + .await + .map_err(|_| anyhow!("{peer}: timed out"))??; + Ok(serde_json::from_slice(&out)?) + } + + /// Live mailbox peers this node knows, itself included when it serves. + pub async fn mailboxes(&self) -> Vec { + let ttl = self.0.timing.peer_ttl; + let mut v: Vec = self + .0 + .mailboxes + .read() + .await + .iter() + .filter(|(_, (t, _))| t.elapsed() <= ttl) + .map(|(p, _)| p.clone()) + .collect(); + if self.0.serves && !v.contains(&self.0.me) { + v.push(self.0.me.clone()); + } + v + } + + /// Ask every connected peer for the mailboxes it knows (a client that + /// just joined learns the set without waiting for a gossip round). + pub async fn discover(&self) { + for pid in self.0.net.connected_peers().await { + let peer = pid.to_base58(); + if let Ok(Response::Peers { peers }) = self.call(&peer, &Request::Peers).await { + for p in peers.into_iter().take(256) { + let Ok(id) = p.peer.parse::() else { continue }; + for a in p.addrs.iter().take(8) { + if let Ok(ma) = a.parse::() { + self.0.net.add_peer_address(id, ma).await; + } + } + self.0 + .mailboxes + .write() + .await + .entry(p.peer) + .or_insert((Instant::now(), p.addrs)); + } + } + } + } + + /// Discover until at least `min` mailboxes are known or `wait` passes. + pub async fn wait_for_mailboxes(&self, min: usize, wait: Duration) -> usize { + let deadline = Instant::now() + wait; + loop { + let n = self.mailboxes().await.len(); + if n >= min || Instant::now() >= deadline { + return n; + } + self.discover().await; + let n = self.mailboxes().await.len(); + if n >= min { + return n; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + + /// The recipient's live home record, if any node has heard it lately. + pub async fn find_home(&self, address: &str) -> Option { + let now = unix_now(); + let ttl = self.0.timing.peer_ttl; + if let Some((r, t, _)) = self.0.homes.read().await.get(address) + && t.elapsed() <= ttl + && r.expires_at > now + { + return Some(r.clone()); + } + let peers = self.mailboxes().await; + for peer in place(address, &peers, PULL_REPLICAS) { + if peer == self.0.me { + continue; + } + if let Ok(Response::Presence { record: Some(r), addrs }) = self + .call( + &peer, + &Request::Presence { + address: address.to_string(), + }, + ) + .await + && r.address() == address + && r.verify(now).is_ok() + { + self.learn_home(r.clone(), addrs).await; + return Some(r); + } + } + None + } + + /// Deliver a sealed message: directly to the recipient's home node when + /// it is up, otherwise onto [`STORE_REPLICAS`] mailboxes. + pub async fn send(&self, sealed: &Sealed, recipient_address: &str, ttl: u64) -> Result { + let mut report = SendReport::default(); + let msg = b64e(&sealed.bytes); + if let Some(home) = self.find_home(recipient_address).await { + let req = Request::Deliver { + msg: msg.clone(), + ack_hash: sealed.ack_hash.clone(), + ttl, + }; + match self.call(&home.peer, &req).await { + Ok(Response::Delivered) => { + report.direct = Some(home.peer); + return Ok(report); + } + Ok(other) => report.errors.push(format!("home {}: unexpected {other:?}", home.peer)), + Err(e) => report.errors.push(format!("home {}: {e}", home.peer)), + } + } + let peers = self.mailboxes().await; + if peers.is_empty() { + bail!("no mailbox nodes known, and the recipient's home node is not reachable"); + } + let req = Request::Store { + msg, + ack_hash: sealed.ack_hash.clone(), + ttl, + }; + for peer in rank(&sealed.msg.tag, &peers) { + if report.stored_on.len() >= STORE_REPLICAS { + break; + } + match self.call(&peer, &req).await { + Ok(Response::Stored { .. }) => report.stored_on.push(peer), + Ok(other) => report.errors.push(format!("{peer}: unexpected {other:?}")), + Err(e) => report.errors.push(format!("{peer}: {e}")), + } + } + if report.stored_on.is_empty() { + bail!("no mailbox accepted the message: {}", report.errors.join("; ")); + } + Ok(report) + } + + /// Everything mailboxes hold for `address` (by its tags), deduplicated. + pub async fn fetch(&self, address: &str) -> Vec { + let tags = pull_tags(address, unix_now()); + let peers = self.mailboxes().await; + let mut by_peer: HashMap> = HashMap::new(); + for tag in &tags { + for p in place(tag, &peers, PULL_REPLICAS) { + by_peer.entry(p).or_default().push(tag.clone()); + } + } + let mut found: HashMap = HashMap::new(); + for (peer, tags) in by_peer { + let Ok(Response::Messages { items }) = self.call(&peer, &Request::Fetch { tags: tags.clone() }).await else { + continue; + }; + for it in items { + if !tags.contains(&it.tag) || !is_hex32(&it.id) { + continue; + } + let Ok(bytes) = b64d(&it.msg) else { continue }; + if c0mpute_secure_chat::sealed::sha256_hex(&bytes) != it.id { + continue; + } + let Ok(msg) = SealedMessage::parse(&bytes) else { continue }; + if msg.tag != it.tag { + continue; + } + found + .entry(it.id.clone()) + .or_insert_with(|| Fetched { + tag: it.tag, + id: it.id, + msg, + holders: Vec::new(), + }) + .holders + .push(peer.clone()); + } + } + found.into_values().collect() + } + + /// Delete a fetched message from its mailboxes. Returns how many did. + pub async fn ack(&self, f: &Fetched, secret: &str) -> usize { + let req = Request::Ack { + tag: f.tag.clone(), + id: f.id.clone(), + secret: secret.to_string(), + }; + let mut n = 0; + for peer in &f.holders { + if let Ok(Response::Acked { deleted: true }) = self.call(peer, &req).await { + n += 1; + } + } + n + } + + /// Publish a key record: cache, gossip, and hand it to the mailboxes + /// ranked for its fingerprint (where lookups ask first). + pub async fn publish_key(&self, record: &KeyRecord) -> Result { + record.verify(unix_now())?; + self.cache_key(record.clone()).await; + self.publish(&Gossip::Key { record: record.clone() }).await; + let fp = record.address()[..16].to_string(); + let peers = self.mailboxes().await; + let req = Request::PublishKey { record: record.clone() }; + let mut n = 0; + for peer in place(&fp, &peers, STORE_REPLICAS) { + if peer == self.0.me || matches!(self.call(&peer, &req).await, Ok(Response::Ok)) { + n += 1; + } + } + Ok(n) + } + + /// Resolve a fingerprint or address to a verified public identity. + pub async fn lookup(&self, query: &str) -> Option { + let q = normalize_query(query)?; + let now = unix_now(); + if let Some(r) = self + .0 + .keys + .read() + .await + .values() + .find(|r| matches_query(&r.identity, &q) && r.expires_at > now) + { + return Some(r.identity.clone()); + } + let peers = self.mailboxes().await; + let ranked = rank(&q[..16], &peers); + for peer in ranked.into_iter().take(8) { + if peer == self.0.me { + continue; + } + if let Ok(Response::Key { record: Some(r) }) = self.call(&peer, &Request::Lookup { query: q.clone() }).await + && matches_query(&r.identity, &q) + && r.verify(now).is_ok() + { + let id = r.identity.clone(); + self.cache_key(r).await; + return Some(id); + } + } + None + } +} + +/// Write a home record where the node's announcer reads it. +pub fn write_home_record(dir: &Path, record: &HomeRecord) -> Result<()> { + std::fs::create_dir_all(dir)?; + let path = dir.join(format!("{}.json", record.address())); + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, serde_json::to_vec_pretty(record)?)?; + std::fs::rename(&tmp, &path)?; + Ok(()) +} + +/// The worker's libp2p peer id on this machine (its identity lives in the +/// config dir), if it has ever run. +pub fn worker_peer_id(config_dir: &Path) -> Option { + c0mpute_net::identity::existing_peer_id(config_dir).map(|p| p.to_base58()) +} + +/// A bare libp2p node for the `c0mpute dm` client: its own identity, the +/// usual bootstrap list (plus mDNS for a local worker), no chunk store. +pub async fn boot_client(identity_dir: &Path) -> Result> { + std::fs::create_dir_all(identity_dir)?; + let bootstrap = c0mpute_net::bootstrap::fetch_or_fallback( + c0mpute_net::bootstrap::DEFAULT_BOOTSTRAP_URL, + c0mpute_net::bootstrap::default_seeds(), + ) + .await; + let cfg = c0mpute_net::NetworkConfig::for_dir(identity_dir).with_bootstrap(bootstrap); + Ok(Arc::new(Libp2pNetwork::spawn(cfg).await?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_formats_are_tagged() { + let r = serde_json::to_string(&Request::Fetch { tags: vec!["a".into()] }).unwrap(); + assert!(r.contains("\"t\":\"fetch\"")); + let p: Response = serde_json::from_str("{\"t\":\"delivered\"}").unwrap(); + assert!(matches!(p, Response::Delivered)); + let paths = Paths::under(Path::new("/x")); + assert_eq!(paths.spool, Path::new("/x/dm/spool")); + } +} diff --git a/node/crates/c0mpute-core/src/hosting/mod.rs b/node/crates/c0mpute-core/src/hosting/mod.rs index f2f60433..596fa351 100644 --- a/node/crates/c0mpute-core/src/hosting/mod.rs +++ b/node/crates/c0mpute-core/src/hosting/mod.rs @@ -1103,7 +1103,7 @@ impl Hosting { } } -fn jitter(d: Duration) -> Duration { +pub(crate) fn jitter(d: Duration) -> Duration { let ms = d.as_millis() as u64; let spread = ms / 10; let nanos = std::time::SystemTime::now() diff --git a/node/crates/c0mpute-core/src/lib.rs b/node/crates/c0mpute-core/src/lib.rs index 44bdcbbb..7d899e01 100644 --- a/node/crates/c0mpute-core/src/lib.rs +++ b/node/crates/c0mpute-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod capabilities; pub mod config; pub mod crawl; pub mod diffusion; +pub mod dm; pub mod music; pub mod hosting; pub mod live; diff --git a/node/crates/c0mpute-core/src/supervisor.rs b/node/crates/c0mpute-core/src/supervisor.rs index b56efc18..b9cdd31f 100644 --- a/node/crates/c0mpute-core/src/supervisor.rs +++ b/node/crates/c0mpute-core/src/supervisor.rs @@ -468,6 +468,25 @@ impl Supervisor { None }; + // Secure chat: mailbox nodes hold sealed messages for offline + // recipients; any node (gateways included) receives direct messages + // for this machine's own `c0mpute dm` user once it has a home record. + let roles = &self.config.roles; + let dm_mailbox = roles.contains(&c0mpute_proto::Role::Mailbox) || roles.contains(&c0mpute_proto::Role::Gateway); + match crate::dm::Dm::start( + self.libp2p.clone(), + crate::dm::Options { + serve_mailbox: dm_mailbox, + paths: Some(crate::dm::Paths::under(&self.config.storage.root)), + ..Default::default() + }, + ) + .await + { + Ok(_) => {} + Err(e) => tracing::warn!(err = %e, "secure-chat: not started"), + } + // Ads: every node that runs hosting keeps the campaign index and // relays c0mpute/ads/v1 (gossipsub forwards only through subscribed // peers); only gateways answer ad requests. diff --git a/node/crates/c0mpute-core/tests/dm_e2e.rs b/node/crates/c0mpute-core/tests/dm_e2e.rs new file mode 100644 index 00000000..97fe8967 --- /dev/null +++ b/node/crates/c0mpute-core/tests/dm_e2e.rs @@ -0,0 +1,216 @@ +//! Secure chat end to end on loopback libp2p, no coordinator: +//! +//! 1. Alice sends to Bob while Bob is offline: the sealed message lands on +//! exactly three of four mailbox nodes (rendezvous on Bob's tag). +//! 2. Bob comes online, pulls, decrypts, acks: every copy is deleted. +//! 3. A blocked sender's message is dropped on receipt (and still deleted). +//! 4. Bob looks up Alice by fingerprint from her self-published key record. +//! 5. Bob's home node is up: Alice's next message goes straight into its +//! spool, no mailbox involved, and Bob reads it from there. + +use std::sync::Arc; +use std::time::Duration; + +use c0mpute_core::dm::{Dm, Options, Paths, Timing}; +use c0mpute_net::{Libp2pNetwork, NetworkConfig}; +use c0mpute_secure_chat::mailbox::{Limits, Mailbox}; +use c0mpute_secure_chat::sealed::{pull_tags, seal}; +use c0mpute_secure_chat::state::{LocalState, Received}; +use c0mpute_secure_chat::{ChatKey, HomeRecord, KeyRecord, unix_now}; +use libp2p::multiaddr::Protocol; +use tempfile::tempdir; + +async fn net(dir: &std::path::Path, bootstrap: Vec) -> Arc { + Arc::new( + Libp2pNetwork::spawn( + NetworkConfig::for_dir(dir.join("id")) + .with_listen(vec!["/ip4/127.0.0.1/tcp/0".parse().unwrap()]) + .with_bootstrap(bootstrap), + ) + .await + .unwrap(), + ) +} + +async fn listen_addr(net: &Libp2pNetwork) -> libp2p::Multiaddr { + for _ in 0..50 { + if let Ok(addrs) = net.listen_addrs().await + && let Some(a) = addrs.into_iter().next() + { + return a.with(Protocol::P2p(net.peer_id())); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + panic!("no listen addr"); +} + +fn fast() -> Timing { + Timing { + announce_every: Duration::from_millis(500), + peer_ttl: Duration::from_secs(5), + rpc_timeout: Duration::from_secs(10), + } +} + +async fn wait_for(what: &str, secs: u64, mut f: F) +where + F: FnMut() -> Fut, + Fut: std::future::Future, +{ + let deadline = tokio::time::Instant::now() + Duration::from_secs(secs); + while tokio::time::Instant::now() < deadline { + if f().await { + return; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + panic!("timed out waiting for {what}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn offline_store_and_forward_then_direct_delivery() { + let _ = tracing_subscriber::fmt() + .with_env_filter("c0mpute_core=info,c0mpute_net=warn") + .with_test_writer() + .try_init(); + let tmp = tempdir().unwrap(); + let opts = |dir: &std::path::Path, serve: bool| Options { + serve_mailbox: serve, + paths: Some(Paths::under(dir)), + timing: fast(), + ..Default::default() + }; + + // Four mailbox nodes around a hub. + let hub_dir = tmp.path().join("m0"); + let hub_net = net(&hub_dir, vec![]).await; + let hub_addr = listen_addr(&hub_net).await; + let mut mailboxes = vec![Dm::start(hub_net, opts(&hub_dir, true)).await.unwrap()]; + for i in 1..4 { + let d = tmp.path().join(format!("m{i}")); + let n = net(&d, vec![hub_addr.clone()]).await; + mailboxes.push(Dm::start(n, opts(&d, true)).await.unwrap()); + } + + // Alice: a client node, no mailbox. + let alice_key = ChatKey::generate(); + let bob_key = ChatKey::generate(); + let ad = tmp.path().join("alice"); + let alice = Dm::start(net(&ad, vec![hub_addr.clone()]).await, opts(&ad, false)).await.unwrap(); + assert_eq!(alice.wait_for_mailboxes(4, Duration::from_secs(20)).await, 4); + // Every mailbox should see the full set before placement is compared. + for m in &mailboxes { + let m = m.clone(); + wait_for("mailbox gossip", 20, || { + let m = m.clone(); + async move { m.mailboxes().await.len() == 4 } + }) + .await; + } + + // 1. Bob is offline (no node at all): store-and-forward. + let now = unix_now(); + let s = seal(&alice_key, &bob_key.public_identity(), "are you there, bob?", 86_400, now).unwrap(); + let rep = alice.send(&s, &bob_key.address(), 86_400).await.unwrap(); + assert!(rep.direct.is_none()); + assert_eq!(rep.stored_on.len(), 3, "{rep:?}"); + let expected = c0mpute_secure_chat::placement::place(&s.msg.tag, &alice.mailboxes().await, 3); + let mut stored: Vec = rep.stored_on.clone(); + stored.sort(); + let mut exp = expected.clone(); + exp.sort(); + assert_eq!(stored, exp, "stored on the rendezvous-ranked nodes"); + let holding: usize = mailboxes.iter().map(|m| m.mailbox_count()).sum(); + assert_eq!(holding, 3); + + // A blocked sender's message too. + let mallory_key = ChatKey::generate(); + let spam = seal(&mallory_key, &bob_key.public_identity(), "spam", 86_400, now).unwrap(); + alice.send(&spam, &bob_key.address(), 86_400).await.unwrap(); + assert_eq!(mailboxes.iter().map(|m| m.mailbox_count()).sum::(), 6); + + // Alice publishes her key record so others can look her up. + let published = alice + .publish_key(&KeyRecord::sign(&alice_key, now, 86_400).unwrap()) + .await + .unwrap(); + assert!(published >= 1); + + // 2. Bob comes online and pulls. + let bd = tmp.path().join("bob"); + let bob = Dm::start(net(&bd, vec![hub_addr.clone()]).await, opts(&bd, false)).await.unwrap(); + assert_eq!(bob.wait_for_mailboxes(4, Duration::from_secs(20)).await, 4); + let mut state = LocalState::default(); + state.blocked.insert(mallory_key.address()); + let fetched = bob.fetch(&bob_key.address()).await; + assert_eq!(fetched.len(), 2, "both messages found"); + let mut texts = Vec::new(); + let mut blocked = 0; + for f in &fetched { + let got = state.receive(&bob_key, &f.msg, "mailbox", unix_now()).unwrap(); + match &got { + Received::New(o) => texts.push((o.text.clone(), o.sender_address.clone())), + Received::Blocked(_) => blocked += 1, + } + assert_eq!(f.holders.len(), 3); + assert_eq!(bob.ack(f, got.ack()).await, 3, "every copy deleted"); + } + assert_eq!(texts, vec![("are you there, bob?".to_string(), alice_key.address())]); + assert_eq!(blocked, 1); + assert_eq!(mailboxes.iter().map(|m| m.mailbox_count()).sum::(), 0); + assert!(bob.fetch(&bob_key.address()).await.is_empty()); + // A replayed copy (say a mailbox kept one) is refused. + assert!(state.receive(&bob_key, &fetched[0].msg, "mailbox", unix_now()).is_err()); + + // 3. Lookup by fingerprint: resolved from the key record, verified. + let found = bob.lookup(&alice_key.public_identity().fingerprint()).await.expect("lookup"); + assert_eq!(found.address(), alice_key.address()); + assert!(bob.lookup(&ChatKey::generate().public_identity().fingerprint()).await.is_none()); + + // 4. Bob's home node: a running node on Bob's machine. + let hd = tmp.path().join("bob-home"); + let home = Dm::start(net(&hd, vec![hub_addr.clone()]).await, opts(&hd, false)).await.unwrap(); + let rec = HomeRecord::sign(&bob_key, home.peer_id(), unix_now(), 3600).unwrap(); + home.add_local_home(&rec).await.unwrap(); + // A home record for someone else's peer is refused. + let wrong = HomeRecord::sign(&bob_key, mailboxes[0].peer_id(), unix_now(), 3600).unwrap(); + assert!(home.add_local_home(&wrong).await.is_err()); + { + let alice = alice.clone(); + let addr = bob_key.address(); + wait_for("bob's presence to reach alice", 20, || { + let alice = alice.clone(); + let addr = addr.clone(); + async move { alice.find_home(&addr).await.is_some() } + }) + .await; + } + let s2 = seal(&alice_key, &bob_key.public_identity(), "direct this time", 86_400, unix_now()).unwrap(); + let rep = alice.send(&s2, &bob_key.address(), 86_400).await.unwrap(); + assert_eq!(rep.direct.as_deref(), Some(home.peer_id()), "{rep:?}"); + assert!(rep.stored_on.is_empty()); + assert_eq!(mailboxes.iter().map(|m| m.mailbox_count()).sum::(), 0); + // Bob's local client reads the home node's spool. + let now = unix_now(); + let mut spool = Mailbox::open(&Paths::under(&hd).spool, Limits::default(), now); + let mut direct = Vec::new(); + for tag in pull_tags(&bob_key.address(), now) { + for (id, bytes) in spool.fetch(&tag, now) { + let msg = c0mpute_secure_chat::SealedMessage::parse(&bytes).unwrap(); + let got = state.receive(&bob_key, &msg, "direct", now).unwrap(); + assert!(spool.ack(&tag, &id, got.ack())); + if let Received::New(o) = got { + direct.push(o.text); + } + } + } + assert_eq!(direct, vec!["direct this time".to_string()]); + assert_eq!(spool.count(), 0); + + // Someone with no home node: back to the mailboxes. + let carol = ChatKey::generate(); + let s3 = seal(&alice_key, &carol.public_identity(), "x", 3600, unix_now()).unwrap(); + let rep = alice.send(&s3, &carol.address(), 3600).await.unwrap(); + assert!(rep.direct.is_none()); + assert_eq!(rep.stored_on.len(), 3); +} diff --git a/node/crates/c0mpute-net/src/lib.rs b/node/crates/c0mpute-net/src/lib.rs index 778ba524..62132efa 100644 --- a/node/crates/c0mpute-net/src/lib.rs +++ b/node/crates/c0mpute-net/src/lib.rs @@ -13,6 +13,7 @@ //! Protocol IDs: //! /c0mpute/kad/1.0.0 — DHT (peer + content discovery) //! /c0mpute/chunk-fetch/1.0.0 — request/response for chunk bytes +//! /c0mpute/rpc/1.0.0 — small named-service request/response pub mod bootstrap; pub mod identity; @@ -28,7 +29,7 @@ use c0mpute_proto::{ChunkRequest, Hash}; pub use libp2p::{Multiaddr, PeerId, multiaddr::Protocol}; /// The libp2p this crate is built on, for identity and key types. pub use libp2p; -pub use swarm::{GossipMessage, Libp2pNetwork, NetworkConfig}; +pub use swarm::{GossipMessage, Libp2pNetwork, NetworkConfig, RpcHandler}; /// Trait that abstracts the underlying network so the rest of the node can be /// developed and tested without booting libp2p. diff --git a/node/crates/c0mpute-net/src/swarm.rs b/node/crates/c0mpute-net/src/swarm.rs index ccdec1cc..56978d00 100644 --- a/node/crates/c0mpute-net/src/swarm.rs +++ b/node/crates/c0mpute-net/src/swarm.rs @@ -5,6 +5,9 @@ //! - kad DHT under `/c0mpute/kad/1.0.0` for peer + content discovery //! - identify + ping for housekeeping //! - request/response under `/c0mpute/chunk-fetch/1.0.0` for chunk bytes +//! - request/response under `/c0mpute/rpc/1.0.0`: small named-service +//! calls between peers (secure-chat mailboxes use it); services register +//! an [`RpcHandler`] with [`Libp2pNetwork::register_rpc`] //! //! The swarm runs on a tokio task; the `Network` trait impl talks to it //! through mpsc channels. @@ -30,6 +33,20 @@ use crate::{ChunkSource, Network, identity as id_mod}; const KAD_PROTOCOL: StreamProtocol = StreamProtocol::new("/c0mpute/kad/1.0.0"); const FETCH_PROTOCOL: StreamProtocol = StreamProtocol::new("/c0mpute/chunk-fetch/1.0.0"); +const RPC_PROTOCOL: StreamProtocol = StreamProtocol::new("/c0mpute/rpc/1.0.0"); +/// Largest RPC request body accepted. +pub const RPC_MAX_REQUEST: u64 = 1024 * 1024; +/// Largest RPC response body accepted. +pub const RPC_MAX_RESPONSE: u64 = 8 * 1024 * 1024; + +/// Serves one named RPC service (see [`Libp2pNetwork::register_rpc`]). +#[async_trait] +pub trait RpcHandler: Send + Sync + 'static { + /// Answer a request from `peer`. `Err` is sent back as an error string. + async fn handle(&self, peer: PeerId, body: Vec) -> std::result::Result, String>; +} + +type RpcHandlers = Arc>>>; const IDENTIFY_PROTOCOL: &str = "/c0mpute/id/1.0.0"; /// What the swarm task accepts from the public API. @@ -78,6 +95,19 @@ enum Cmd { Addrs { reply: oneshot::Sender>, }, + Rpc { + peer: PeerId, + service: String, + body: Vec, + reply: oneshot::Sender>>, + }, + RpcRespond { + channel: request_response::ResponseChannel, + resp: RpcResponse, + }, + ConnectedPeers { + reply: oneshot::Sender>, + }, Publish { topic: String, payload: Vec, @@ -150,6 +180,7 @@ pub struct Libp2pNetwork { cmd_tx: mpsc::Sender, gossip_tx: broadcast::Sender, peer_id: PeerId, + rpc_handlers: RpcHandlers, } impl Libp2pNetwork { @@ -162,7 +193,15 @@ impl Libp2pNetwork { let (cmd_tx, cmd_rx) = mpsc::channel::(64); let (gossip_tx, _) = broadcast::channel::(256); - let task = SwarmTask::new(keypair, config.local_source, gossip_tx.clone()).await?; + let rpc_handlers: RpcHandlers = Arc::new(std::sync::RwLock::new(HashMap::new())); + let task = SwarmTask::new( + keypair, + config.local_source, + gossip_tx.clone(), + rpc_handlers.clone(), + cmd_tx.downgrade(), + ) + .await?; for addr in &config.listen { task.swarm .lock() @@ -184,9 +223,50 @@ impl Libp2pNetwork { cmd_tx, gossip_tx, peer_id, + rpc_handlers, }) } + /// Serve `service` over `/c0mpute/rpc/1.0.0`. Replaces any handler + /// already registered under the name. + pub fn register_rpc(&self, service: &str, handler: Arc) { + if let Ok(mut h) = self.rpc_handlers.write() { + h.insert(service.to_string(), handler); + } + } + + /// Call `service` on `peer` (dialing it if needed) and wait for the + /// answer. The request times out after 20 s. + pub async fn rpc(&self, peer: PeerId, service: &str, body: Vec) -> Result> { + if peer == self.peer_id { + let handler = self.rpc_handlers.read().ok().and_then(|h| h.get(service).cloned()); + return match handler { + Some(h) => h.handle(peer, body).await.map_err(|e| anyhow!("{e}")), + None => Err(anyhow!("{service}: not served here")), + }; + } + let (tx, rx) = oneshot::channel(); + self.cmd_tx + .send(Cmd::Rpc { + peer, + service: service.to_string(), + body, + reply: tx, + }) + .await + .map_err(|_| anyhow!("swarm task closed"))?; + rx.await.map_err(|_| anyhow!("swarm task closed"))? + } + + /// Peers with an open connection now. + pub async fn connected_peers(&self) -> Vec { + let (tx, rx) = oneshot::channel(); + if self.cmd_tx.send(Cmd::ConnectedPeers { reply: tx }).await.is_err() { + return Vec::new(); + } + rx.await.unwrap_or_default() + } + /// Publish bytes to a gossipsub topic. The topic must have been /// `subscribe`d first (libp2p subscribes-to-publish to participate /// in the mesh). @@ -364,6 +444,7 @@ struct C0mputeBehaviour { identify: identify::Behaviour, ping: ping::Behaviour, fetch: request_response::cbor::Behaviour, + rpc: request_response::Behaviour>, gossipsub: gossipsub::Behaviour, mdns: mdns::tokio::Behaviour, } @@ -379,6 +460,58 @@ enum FetchResponse { NotFound, } +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +struct RpcRequest { + service: String, + #[serde(with = "cbor_bytes")] + body: Vec, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +enum RpcResponse { + Ok { + #[serde(with = "cbor_bytes")] + body: Vec, + }, + Err { + msg: String, + }, +} + +/// Encode `Vec` as a CBOR byte string (serde's default is an array of +/// integers, up to twice the size). +mod cbor_bytes { + use serde::{Deserializer, Serializer, de}; + + pub fn serialize(b: &[u8], s: S) -> Result { + s.serialize_bytes(b) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + struct V; + impl<'de> de::Visitor<'de> for V { + type Value = Vec; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("bytes") + } + fn visit_bytes(self, v: &[u8]) -> Result, E> { + Ok(v.to_vec()) + } + fn visit_byte_buf(self, v: Vec) -> Result, E> { + Ok(v) + } + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + let mut out = Vec::new(); + while let Some(b) = seq.next_element::()? { + out.push(b); + } + Ok(out) + } + } + d.deserialize_byte_buf(V) + } +} + // ──────────────────────────────────────────────────────────────────────── // Swarm task // ──────────────────────────────────────────────────────────────────────── @@ -395,6 +528,12 @@ struct SwarmTask { /// Track which chunk a fetch is for, so we can re-issue against another /// provider if the first one says NotFound. fetch_targets: HashMap, + /// Pending outbound RPC calls. + pending_rpcs: HashMap>>>, + rpc_handlers: RpcHandlers, + /// For handler tasks to hand their answer back to the swarm loop (weak, + /// so the loop still ends when the last `Libp2pNetwork` drops). + cmd_weak: mpsc::WeakSender, /// Broadcast channel for gossipsub messages delivered to subscribed /// topics. Senders are upstream consumers that called `messages()`. gossip_tx: broadcast::Sender, @@ -405,6 +544,8 @@ impl SwarmTask { keypair: identity::Keypair, local_source: Option>, gossip_tx: broadcast::Sender, + rpc_handlers: RpcHandlers, + cmd_weak: mpsc::WeakSender, ) -> Result { let swarm = libp2p::SwarmBuilder::with_existing_identity(keypair.clone()) .with_tokio() @@ -430,6 +571,13 @@ impl SwarmTask { [(FETCH_PROTOCOL, request_response::ProtocolSupport::Full)], request_response::Config::default(), ); + let rpc = request_response::Behaviour::with_codec( + request_response::cbor::codec::Codec::::default() + .set_request_size_maximum(RPC_MAX_REQUEST) + .set_response_size_maximum(RPC_MAX_RESPONSE), + [(RPC_PROTOCOL, request_response::ProtocolSupport::Full)], + request_response::Config::default().with_request_timeout(Duration::from_secs(20)), + ); let gossipsub_cfg = gossipsub::ConfigBuilder::default() .heartbeat_interval(Duration::from_secs(1)) .validation_mode(gossipsub::ValidationMode::Strict) @@ -446,6 +594,7 @@ impl SwarmTask { identify, ping, fetch, + rpc, gossipsub, mdns, }) @@ -460,6 +609,9 @@ impl SwarmTask { pending_kad_lookups: HashMap::new(), pending_fetches: HashMap::new(), fetch_targets: HashMap::new(), + pending_rpcs: HashMap::new(), + rpc_handlers, + cmd_weak, gossip_tx, }) } @@ -575,6 +727,28 @@ impl SwarmTask { self.fetch_targets.insert(request_id, req.chunk_hash); debug!(%peer, chunk = %req.chunk_hash, "direct chunk fetch issued"); } + Cmd::Rpc { + peer, + service, + body, + reply, + } => { + let id = self + .swarm + .lock() + .await + .behaviour_mut() + .rpc + .send_request(&peer, RpcRequest { service, body }); + self.pending_rpcs.insert(id, reply); + } + Cmd::RpcRespond { channel, resp } => { + let _ = self.swarm.lock().await.behaviour_mut().rpc.send_response(channel, resp); + } + Cmd::ConnectedPeers { reply } => { + let peers = self.swarm.lock().await.connected_peers().copied().collect(); + let _ = reply.send(peers); + } Cmd::Publish { topic, payload, @@ -657,6 +831,56 @@ impl SwarmTask { let _ = reply.send(Err(anyhow!("fetch failed: {error}"))); } } + SwarmEvent::Behaviour(C0mputeBehaviourEvent::Rpc(request_response::Event::Message { + peer, message, .. + })) => match message { + request_response::Message::Request { request, channel, .. } => { + let handler = self + .rpc_handlers + .read() + .ok() + .and_then(|h| h.get(&request.service).cloned()); + match handler { + Some(h) => { + let weak = self.cmd_weak.clone(); + tokio::spawn(async move { + let resp = match h.handle(peer, request.body).await { + Ok(body) => RpcResponse::Ok { body }, + Err(msg) => RpcResponse::Err { msg }, + }; + if let Some(tx) = weak.upgrade() { + let _ = tx.send(Cmd::RpcRespond { channel, resp }).await; + } + }); + } + None => { + let resp = RpcResponse::Err { + msg: format!("{}: not served here", request.service), + }; + let _ = self.swarm.lock().await.behaviour_mut().rpc.send_response(channel, resp); + } + } + } + request_response::Message::Response { + request_id, response, .. + } => { + if let Some(reply) = self.pending_rpcs.remove(&request_id) { + let _ = reply.send(match response { + RpcResponse::Ok { body } => Ok(body), + RpcResponse::Err { msg } => Err(anyhow!("{msg}")), + }); + } + } + }, + SwarmEvent::Behaviour(C0mputeBehaviourEvent::Rpc(request_response::Event::OutboundFailure { + request_id, + error, + .. + })) => { + if let Some(reply) = self.pending_rpcs.remove(&request_id) { + let _ = reply.send(Err(anyhow!("rpc failed: {error}"))); + } + } SwarmEvent::Behaviour(C0mputeBehaviourEvent::Gossipsub( gossipsub::Event::Message { propagation_source, @@ -903,6 +1127,52 @@ mod tests { panic!("node never bound a listen addr"); } + struct Echo; + + #[async_trait] + impl RpcHandler for Echo { + async fn handle(&self, _peer: PeerId, body: Vec) -> std::result::Result, String> { + if body == b"fail" { + return Err("asked to fail".into()); + } + let mut out = b"echo:".to_vec(); + out.extend(body); + Ok(out) + } + } + + /// Named-service RPC between two nodes: answers, errors, unknown service. + #[tokio::test] + async fn two_node_rpc_roundtrip() { + let net_a = Libp2pNetwork::spawn( + NetworkConfig::for_dir(tempdir("rpc-a")).with_listen(vec!["/ip4/127.0.0.1/tcp/0".parse().unwrap()]), + ) + .await + .unwrap(); + net_a.register_rpc("echo", Arc::new(Echo)); + let a_full = wait_for_listen(&net_a) + .await + .with(libp2p::multiaddr::Protocol::P2p(net_a.peer_id())); + let net_b = Libp2pNetwork::spawn( + NetworkConfig::for_dir(tempdir("rpc-b")) + .with_listen(vec!["/ip4/127.0.0.1/tcp/0".parse().unwrap()]) + .with_bootstrap(vec![a_full]), + ) + .await + .unwrap(); + let big = vec![7u8; 200_000]; + let got = net_b.rpc(net_a.peer_id(), "echo", big.clone()).await.unwrap(); + assert_eq!(&got[..5], b"echo:"); + assert_eq!(&got[5..], &big[..]); + let err = net_b.rpc(net_a.peer_id(), "echo", b"fail".to_vec()).await.unwrap_err(); + assert!(err.to_string().contains("asked to fail"), "{err}"); + let err = net_b.rpc(net_a.peer_id(), "nope", vec![]).await.unwrap_err(); + assert!(err.to_string().contains("not served"), "{err}"); + assert!(net_b.connected_peers().await.contains(&net_a.peer_id())); + // Calling yourself runs the local handler. + assert_eq!(net_a.rpc(net_a.peer_id(), "echo", b"x".to_vec()).await.unwrap(), b"echo:x"); + } + /// gossipsub publish/subscribe across two nodes. #[tokio::test] async fn two_node_gossipsub_publish_receive() { diff --git a/node/crates/c0mpute-proto/src/lib.rs b/node/crates/c0mpute-proto/src/lib.rs index cccbaf73..bea9e900 100644 --- a/node/crates/c0mpute-proto/src/lib.rs +++ b/node/crates/c0mpute-proto/src/lib.rs @@ -223,6 +223,11 @@ pub enum Role { /// Blender (Cycles on CPU, or on a GPU Blender finds). On by default; /// advertised only where `blender --version` works. Render, + /// Secure-chat mailbox (DIP-0018): hold sealed messages for offline + /// recipients (bounded: 256 MiB, 30 days), cache self-published chat + /// keys, and receive direct messages for this machine's own `c0mpute dm` + /// user. On by default; needs nothing. + Mailbox, } #[cfg(test)] diff --git a/node/crates/c0mpute-secure-chat/src/identity.rs b/node/crates/c0mpute-secure-chat/src/identity.rs new file mode 100644 index 00000000..e3a6ad6e --- /dev/null +++ b/node/crates/c0mpute-secure-chat/src/identity.rs @@ -0,0 +1,339 @@ +//! Public identities, addresses, and the signed records nodes gossip. +//! +//! A user's address is their public key: the SHA-256 of all four public keys +//! (X25519, Ed25519, ML-KEM-768, ML-DSA-65), shown as 64 hex characters. The +//! short fingerprint is the first 8 bytes of the address as colon-separated +//! hex pairs. Because the address commits to every key, a record fetched from +//! any node is checked against the address (or fingerprint) the user typed: +//! nobody can answer a lookup with a different key. +//! +//! * [`KeyRecord`]: a self-published, self-signed copy of a public identity. +//! Gossiped and cached by mailbox nodes; that is the whole "directory". +//! * [`HomeRecord`]: "messages for me may be delivered directly to libp2p peer +//! P until T", signed by the user. The node named there accepts direct +//! deliveries for the address and gossips the record while it is online. + +use base64::prelude::*; +use ed25519_dalek::{Signature as EdSig, Signer, Verifier, VerifyingKey as EdVk}; +use ml_dsa::{KeyExport as _, MlDsa65, VerifyingKey as MlDsaVk}; +use ml_kem::{EncapsulationKey, MlKem768}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use x25519_dalek::PublicKey as X25519Public; + +use crate::keys::{ChatKey, KeyFile}; + +/// Longest a key or home record may claim to be valid for. +pub const MAX_RECORD_SECS: u64 = 31 * 86_400; +/// Clock skew tolerated on record and message timestamps. +pub const CLOCK_SKEW_SECS: u64 = 300; + +/// The four public keys of a chat identity (same encoding as the keyfile's +/// `pubkey_*` fields: base64url, no padding). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PublicIdentity { + pub pubkey_enc: String, + pub pubkey_kem: String, + pub pubkey_sig: String, + pub pubkey_ml_dsa: String, +} + +/// Parsed public keys, ready for crypto. +pub struct PublicKeys { + pub x25519: X25519Public, + pub ed25519: EdVk, + pub ml_kem: EncapsulationKey, + pub ml_dsa: MlDsaVk, +} + +impl PublicIdentity { + pub fn of(key: &ChatKey) -> Self { + Self { + pubkey_enc: BASE64_URL_SAFE_NO_PAD.encode(key.x25519_public.as_bytes()), + pubkey_kem: BASE64_URL_SAFE_NO_PAD.encode(&key.ml_kem_ek().to_bytes()[..]), + pubkey_sig: BASE64_URL_SAFE_NO_PAD.encode(key.ed25519_public.as_bytes()), + pubkey_ml_dsa: BASE64_URL_SAFE_NO_PAD.encode(&key.ml_dsa_public.to_bytes()[..]), + } + } + + pub fn from_keyfile(kf: &KeyFile) -> Self { + Self { + pubkey_enc: kf.pubkey_enc.clone(), + pubkey_kem: kf.pubkey_kem.clone(), + pubkey_sig: kf.pubkey_sig.clone(), + pubkey_ml_dsa: kf.pubkey_ml_dsa.clone(), + } + } + + /// Decode and validate all four keys. + pub fn keys(&self) -> anyhow::Result { + let enc = BASE64_URL_SAFE_NO_PAD.decode(&self.pubkey_enc)?; + let x: [u8; 32] = enc.as_slice().try_into().map_err(|_| anyhow::anyhow!("bad x25519 key length"))?; + let sig = BASE64_URL_SAFE_NO_PAD.decode(&self.pubkey_sig)?; + let s: [u8; 32] = sig.as_slice().try_into().map_err(|_| anyhow::anyhow!("bad ed25519 key length"))?; + let ed25519 = EdVk::from_bytes(&s).map_err(|e| anyhow::anyhow!("bad ed25519 key: {e}"))?; + let kem = BASE64_URL_SAFE_NO_PAD.decode(&self.pubkey_kem)?; + let kem_arr: ml_kem::Key> = kem + .as_slice() + .try_into() + .map_err(|_| anyhow::anyhow!("bad ml-kem key length"))?; + let ml_kem = EncapsulationKey::::new(&kem_arr).map_err(|_| anyhow::anyhow!("bad ml-kem key"))?; + let dsa = BASE64_URL_SAFE_NO_PAD.decode(&self.pubkey_ml_dsa)?; + let dsa_arr: ml_dsa::EncodedVerifyingKey = dsa + .as_slice() + .try_into() + .map_err(|_| anyhow::anyhow!("bad ml-dsa key length"))?; + let ml_dsa = MlDsaVk::::decode(&dsa_arr); + Ok(PublicKeys { + x25519: X25519Public::from(x), + ed25519, + ml_kem, + ml_dsa, + }) + } + + /// The address: hex SHA-256 over all four public keys. + pub fn address(&self) -> String { + let mut h = Sha256::new(); + h.update(b"c0mpute-dm-address-v1"); + for part in [&self.pubkey_enc, &self.pubkey_sig, &self.pubkey_kem, &self.pubkey_ml_dsa] { + h.update((part.len() as u32).to_be_bytes()); + h.update(part.as_bytes()); + } + hex::encode(h.finalize()) + } + + pub fn fingerprint(&self) -> String { + fingerprint_of(&self.address()) + } + + /// A single pasteable string carrying the whole public identity, for + /// sharing a key out of band (`c0mpute dm key show --full`). + pub fn to_share_string(&self) -> String { + format!( + "c0dm1.{}", + BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(self).unwrap_or_default()) + ) + } + + pub fn from_share_string(s: &str) -> anyhow::Result { + let body = s.trim().strip_prefix("c0dm1.").ok_or_else(|| anyhow::anyhow!("not a c0dm1. identity"))?; + let id: Self = serde_json::from_slice(&BASE64_URL_SAFE_NO_PAD.decode(body)?)?; + id.keys()?; + Ok(id) + } +} + +/// `aa:bb:cc:dd:ee:ff:00:11` from a 64-hex address. +pub fn fingerprint_of(address: &str) -> String { + address + .as_bytes() + .chunks(2) + .take(8) + .map(|c| String::from_utf8_lossy(c).into_owned()) + .collect::>() + .join(":") +} + +/// What a user typed to name someone: a full address, or a fingerprint +/// (16 hex chars, colons optional). Lowercased, colons removed. +pub fn normalize_query(q: &str) -> Option { + let s: String = q.trim().chars().filter(|c| *c != ':').collect::().to_ascii_lowercase(); + let ok = (s.len() == 16 || s.len() == 64) && s.chars().all(|c| c.is_ascii_hexdigit()); + ok.then_some(s) +} + +/// Does `identity` answer `query` (normalized address or fingerprint)? +pub fn matches_query(identity: &PublicIdentity, query: &str) -> bool { + identity.address().starts_with(query) +} + +impl ChatKey { + pub fn public_identity(&self) -> PublicIdentity { + PublicIdentity::of(self) + } + + pub fn address(&self) -> String { + self.public_identity().address() + } + + /// Ed25519 + ML-DSA-65 signatures over `msg`, base64url. + pub fn sign_both(&self, msg: &[u8]) -> anyhow::Result<(String, String)> { + let ed: EdSig = self.ed25519_key.sign(msg); + let ml = ml_dsa::Signer::try_sign(&self.ml_dsa_key, msg).map_err(|e| anyhow::anyhow!("ml-dsa sign: {e}"))?; + Ok(( + BASE64_URL_SAFE_NO_PAD.encode(ed.to_bytes()), + BASE64_URL_SAFE_NO_PAD.encode(&ml.encode()[..]), + )) + } +} + +/// Check both signatures from [`ChatKey::sign_both`] against `keys`. +pub fn verify_both(keys: &PublicKeys, msg: &[u8], sig: &str, ml_dsa_sig: &str) -> anyhow::Result<()> { + let s = BASE64_URL_SAFE_NO_PAD.decode(sig)?; + let arr: [u8; 64] = s.as_slice().try_into().map_err(|_| anyhow::anyhow!("bad ed25519 signature length"))?; + keys.ed25519 + .verify(msg, &EdSig::from_bytes(&arr)) + .map_err(|_| anyhow::anyhow!("ed25519 signature invalid"))?; + let m = BASE64_URL_SAFE_NO_PAD.decode(ml_dsa_sig)?; + let enc: ml_dsa::EncodedSignature = + m.as_slice().try_into().map_err(|_| anyhow::anyhow!("bad ml-dsa signature length"))?; + let sig = ml_dsa::Signature::::decode(&enc).ok_or_else(|| anyhow::anyhow!("malformed ml-dsa signature"))?; + ml_dsa::Verifier::verify(&keys.ml_dsa, msg, &sig).map_err(|_| anyhow::anyhow!("ml-dsa signature invalid"))?; + Ok(()) +} + +/// A self-published public identity, signed by itself. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct KeyRecord { + pub v: u8, + pub identity: PublicIdentity, + pub created_at: u64, + pub expires_at: u64, + pub sig: String, + pub ml_dsa_sig: String, +} + +fn key_record_bytes(address: &str, created_at: u64, expires_at: u64) -> Vec { + format!("c0mpute-dm-keyrecord-v1|{address}|{created_at}|{expires_at}").into_bytes() +} + +impl KeyRecord { + pub fn sign(key: &ChatKey, now: u64, valid_secs: u64) -> anyhow::Result { + let identity = key.public_identity(); + let expires_at = now + valid_secs.min(MAX_RECORD_SECS); + let (sig, ml_dsa_sig) = key.sign_both(&key_record_bytes(&identity.address(), now, expires_at))?; + Ok(Self { + v: 1, + identity, + created_at: now, + expires_at, + sig, + ml_dsa_sig, + }) + } + + /// Signature valid, not expired, not from the future. + pub fn verify(&self, now: u64) -> anyhow::Result<()> { + anyhow::ensure!(self.v == 1, "unsupported key record version"); + anyhow::ensure!(self.created_at <= now + CLOCK_SKEW_SECS, "key record from the future"); + anyhow::ensure!(self.expires_at > now, "key record expired"); + anyhow::ensure!(self.expires_at - self.created_at <= MAX_RECORD_SECS, "key record validity too long"); + let keys = self.identity.keys()?; + verify_both( + &keys, + &key_record_bytes(&self.identity.address(), self.created_at, self.expires_at), + &self.sig, + &self.ml_dsa_sig, + ) + } + + pub fn address(&self) -> String { + self.identity.address() + } +} + +/// "Deliver my messages directly to libp2p peer `peer`", signed by the user. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct HomeRecord { + pub v: u8, + pub identity: PublicIdentity, + /// Base58 libp2p peer id of the node that receives for this address. + pub peer: String, + pub created_at: u64, + pub expires_at: u64, + pub sig: String, + pub ml_dsa_sig: String, +} + +fn home_record_bytes(address: &str, peer: &str, created_at: u64, expires_at: u64) -> Vec { + format!("c0mpute-dm-home-v1|{address}|{peer}|{created_at}|{expires_at}").into_bytes() +} + +impl HomeRecord { + pub fn sign(key: &ChatKey, peer: &str, now: u64, valid_secs: u64) -> anyhow::Result { + let identity = key.public_identity(); + let expires_at = now + valid_secs.min(MAX_RECORD_SECS); + let (sig, ml_dsa_sig) = key.sign_both(&home_record_bytes(&identity.address(), peer, now, expires_at))?; + Ok(Self { + v: 1, + identity, + peer: peer.to_string(), + created_at: now, + expires_at, + sig, + ml_dsa_sig, + }) + } + + pub fn verify(&self, now: u64) -> anyhow::Result<()> { + anyhow::ensure!(self.v == 1, "unsupported home record version"); + anyhow::ensure!(self.created_at <= now + CLOCK_SKEW_SECS, "home record from the future"); + anyhow::ensure!(self.expires_at > now, "home record expired"); + anyhow::ensure!(self.expires_at - self.created_at <= MAX_RECORD_SECS, "home record validity too long"); + let keys = self.identity.keys()?; + verify_both( + &keys, + &home_record_bytes(&self.identity.address(), &self.peer, self.created_at, self.expires_at), + &self.sig, + &self.ml_dsa_sig, + ) + } + + pub fn address(&self) -> String { + self.identity.address() + } + + /// The key record this home record implies (same identity), for caches. + pub fn identity(&self) -> &PublicIdentity { + &self.identity + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn address_and_fingerprint_are_stable_and_match_keyfile() { + let k = ChatKey::from_seed([7u8; 32]); + let id = k.public_identity(); + assert_eq!(id.address().len(), 64); + assert_eq!(id.address(), ChatKey::from_seed([7u8; 32]).address()); + assert_ne!(id.address(), ChatKey::from_seed([8u8; 32]).address()); + let fp = id.fingerprint(); + assert_eq!(fp.len(), 23); + assert_eq!(normalize_query(&fp).unwrap(), id.address()[..16]); + assert!(matches_query(&id, &normalize_query(&fp).unwrap())); + assert!(matches_query(&id, &normalize_query(&id.address().to_uppercase()).unwrap())); + assert!(normalize_query("zz").is_none()); + // Same identity from the (password-protected) keyfile's public fields. + let kf = k.encrypt_to_keyfile("correct horse battery", None).unwrap(); + assert_eq!(PublicIdentity::from_keyfile(&kf), id); + id.keys().unwrap(); + assert_eq!(PublicIdentity::from_share_string(&id.to_share_string()).unwrap(), id); + } + + #[test] + fn key_and_home_records_verify_and_reject_tampering() { + let k = ChatKey::generate(); + let now = 1_800_000_000; + let rec = KeyRecord::sign(&k, now, 86_400).unwrap(); + rec.verify(now).unwrap(); + assert!(rec.verify(now + 86_401).is_err(), "expired"); + let mut bad = rec.clone(); + bad.expires_at += 10; + assert!(bad.verify(now).is_err()); + // Someone else's identity with this signature. + let mut bad = rec.clone(); + bad.identity = ChatKey::generate().public_identity(); + assert!(bad.verify(now).is_err()); + + let home = HomeRecord::sign(&k, "12D3KooWpeer", now, 3600).unwrap(); + home.verify(now).unwrap(); + let mut bad = home.clone(); + bad.peer = "12D3KooWevil".into(); + assert!(bad.verify(now).is_err()); + assert!(home.verify(now - 10_000).is_err(), "from the future"); + } +} diff --git a/node/crates/c0mpute-secure-chat/src/keys.rs b/node/crates/c0mpute-secure-chat/src/keys.rs index db537b0d..e8ae8e74 100644 --- a/node/crates/c0mpute-secure-chat/src/keys.rs +++ b/node/crates/c0mpute-secure-chat/src/keys.rs @@ -125,13 +125,10 @@ impl ChatKey { self.ml_kem_dk.encapsulation_key() } - /// Hex fingerprint of the X25519 public key — 8 colon-separated byte pairs. + /// Short fingerprint: the first 8 bytes of the address (which hashes all + /// four public keys), as colon-separated hex pairs. pub fn fingerprint(&self) -> String { - self.x25519_public.as_bytes()[..8] - .iter() - .map(|b| format!("{b:02x}")) - .collect::>() - .join(":") + self.public_identity().fingerprint() } /// Produce the password-encrypted `KeyFile` (backup blob). @@ -225,7 +222,7 @@ pub fn save_keyfile(path: &Path, kf: &KeyFile) -> anyhow::Result<()> { } /// Write `content` to `path` with 0o600 permissions (unix) or plain write (other). -fn write_secret_file(path: &Path, content: &[u8]) -> anyhow::Result<()> { +pub(crate) fn write_secret_file(path: &Path, content: &[u8]) -> anyhow::Result<()> { #[cfg(unix)] { use std::io::Write; diff --git a/node/crates/c0mpute-secure-chat/src/lib.rs b/node/crates/c0mpute-secure-chat/src/lib.rs index df71fe31..a4cc603d 100644 --- a/node/crates/c0mpute-secure-chat/src/lib.rs +++ b/node/crates/c0mpute-secure-chat/src/lib.rs @@ -1,11 +1,37 @@ //! E2E encrypted p2p messaging for the c0mpute network (DIP-0018). //! -//! v0.1 scope: key generation, password-encrypted backups, and DM -//! encrypt/decrypt. Transport (DHT key distribution, gossip relay, -//! store-and-forward) lands in v0.2. +//! * [`keys`]: hybrid classical + post-quantum keys from one seed, with a +//! password-encrypted keyfile. +//! * [`message`]: the signed, hybrid-encrypted DM envelope. +//! * [`identity`]: addresses (= public keys), fingerprints, and the signed +//! key and home records nodes gossip instead of a directory. +//! * [`sealed`]: the outer layer that hides sender and recipient from the +//! network; recipient tags; replay protection. +//! * [`mailbox`]: the bounded store-and-forward store every mailbox node runs. +//! * [`placement`]: rendezvous hashing that picks mailbox nodes per tag. +//! * [`state`]: the client's contacts, blocklist and inbox, encrypted at rest. +//! +//! The network service (gossip + libp2p request/response) lives in +//! `c0mpute_core::dm`. +pub mod identity; pub mod keys; +pub mod mailbox; pub mod message; +pub mod placement; +pub mod sealed; +pub mod state; +pub use identity::{HomeRecord, KeyRecord, PublicIdentity}; pub use keys::{ChatKey, KeyFile, decrypt_keyfile, key_file_path, load_keyfile, save_keyfile}; pub use message::{DmEnvelope, DmPlaintext, decrypt_dm, encrypt_dm}; +pub use sealed::{Sealed, SealedMessage}; +pub use state::LocalState; + +/// Seconds since the Unix epoch. +pub fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} diff --git a/node/crates/c0mpute-secure-chat/src/mailbox.rs b/node/crates/c0mpute-secure-chat/src/mailbox.rs new file mode 100644 index 00000000..3ba97fa7 --- /dev/null +++ b/node/crates/c0mpute-secure-chat/src/mailbox.rs @@ -0,0 +1,421 @@ +//! The store-and-forward mailbox a node keeps for other people. +//! +//! It holds opaque sealed messages keyed by recipient tag, each until its +//! TTL, its recipient acks it, or the node needs the room. Everything is +//! bounded: message size, TTL, bytes and count per tag, total bytes, and +//! messages per minute from each sending peer. Messages are persisted one +//! file per message (`//.msg`) so a restart loses nothing. +//! +//! The mailbox never sees a sender, a recipient address or any plaintext: +//! only the tag, the bytes, and the hash of the ack secret that deletes them. + +use std::collections::{BTreeMap, HashMap, VecDeque}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::sealed::{MAX_SEALED_BYTES, MAX_TTL_SECS, SealedMessage, is_hex32, sha256_hex}; + +#[derive(Clone, Debug)] +pub struct Limits { + pub max_msg_bytes: usize, + pub max_ttl_secs: u64, + pub max_bytes_per_tag: u64, + pub max_msgs_per_tag: usize, + pub max_total_bytes: u64, + /// Messages accepted per sending peer per minute. + pub per_sender_per_min: usize, + /// Most bytes one fetch returns (the rest come on the next pull). + pub max_fetch_bytes: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { + max_msg_bytes: MAX_SEALED_BYTES, + max_ttl_secs: MAX_TTL_SECS, + max_bytes_per_tag: 10 * 1024 * 1024, + max_msgs_per_tag: 500, + max_total_bytes: 256 * 1024 * 1024, + per_sender_per_min: 60, + max_fetch_bytes: 4 * 1024 * 1024, + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Put { + Stored, + /// Already held (another sender's replica, or a retry). + Duplicate, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum PutError { + TooLarge, + BadMessage(String), + RateLimited, + TagFull, + MailboxFull, +} + +impl std::fmt::Display for PutError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PutError::TooLarge => write!(f, "message too large"), + PutError::BadMessage(m) => write!(f, "bad message: {m}"), + PutError::RateLimited => write!(f, "rate limited: too many messages from this peer"), + PutError::TagFull => write!(f, "this recipient's mailbox is full"), + PutError::MailboxFull => write!(f, "this node's mailbox is full"), + } + } +} + +impl std::error::Error for PutError {} + +#[derive(Clone, Debug)] +struct Entry { + bytes: Vec, + expires_at: u64, + ack_hash: String, +} + +#[derive(Serialize, Deserialize)] +struct Meta { + expires_at: u64, + ack_hash: String, +} + +pub struct Mailbox { + dir: Option, + limits: Limits, + tags: HashMap>, + total: u64, + rate: HashMap>, +} + +impl Mailbox { + /// In memory only. + pub fn new(limits: Limits) -> Self { + Self { + dir: None, + limits, + tags: HashMap::new(), + total: 0, + rate: HashMap::new(), + } + } + + /// Persisted under `dir`; loads what is there and drops what expired. + pub fn open(dir: &Path, limits: Limits, now: u64) -> Self { + let mut mb = Self::new(limits); + mb.dir = Some(dir.to_path_buf()); + let _ = std::fs::create_dir_all(dir); + let Ok(tags) = std::fs::read_dir(dir) else { + return mb; + }; + for t in tags.flatten() { + let tag = t.file_name().to_string_lossy().into_owned(); + if !is_hex32(&tag) { + continue; + } + let Ok(files) = std::fs::read_dir(t.path()) else { + continue; + }; + for f in files.flatten() { + let path = f.path(); + let Some(id) = path + .file_name() + .and_then(|n| n.to_str()) + .and_then(|n| n.strip_suffix(".msg")) + .map(str::to_string) + else { + continue; + }; + match read_entry(&path) { + Some(e) if e.expires_at > now && sha256_hex(&e.bytes) == id => { + mb.total += e.bytes.len() as u64; + mb.tags.entry(tag.clone()).or_default().insert(id, e); + } + _ => { + let _ = std::fs::remove_file(&path); + } + } + } + } + mb + } + + pub fn limits(&self) -> &Limits { + &self.limits + } + + pub fn total_bytes(&self) -> u64 { + self.total + } + + pub fn count(&self) -> usize { + self.tags.values().map(|m| m.len()).sum() + } + + pub fn count_for(&self, tag: &str) -> usize { + self.tags.get(tag).map(|m| m.len()).unwrap_or(0) + } + + /// Accept a sealed message from `sender` (the libp2p peer that sent it). + pub fn put(&mut self, sender: &str, bytes: Vec, ack_hash: &str, ttl: u64, now: u64) -> Result { + if bytes.len() > self.limits.max_msg_bytes { + return Err(PutError::TooLarge); + } + let msg = SealedMessage::parse(&bytes).map_err(|e| PutError::BadMessage(e.to_string()))?; + if !is_hex32(ack_hash) { + return Err(PutError::BadMessage("bad ack hash".into())); + } + let id = sha256_hex(&bytes); + if self.tags.get(&msg.tag).is_some_and(|m| m.contains_key(&id)) { + return Ok(Put::Duplicate); + } + // Rate limit per sending peer over a sliding minute. + { + let window = self.rate.entry(sender.to_string()).or_default(); + while window.front().is_some_and(|t| *t + 60 <= now) { + window.pop_front(); + } + if window.len() >= self.limits.per_sender_per_min { + return Err(PutError::RateLimited); + } + } + self.prune(now); + let size = bytes.len() as u64; + let (tag_bytes, tag_count) = self + .tags + .get(&msg.tag) + .map(|m| (m.values().map(|e| e.bytes.len() as u64).sum::(), m.len())) + .unwrap_or((0, 0)); + if tag_count >= self.limits.max_msgs_per_tag || tag_bytes + size > self.limits.max_bytes_per_tag { + return Err(PutError::TagFull); + } + if self.total + size > self.limits.max_total_bytes { + return Err(PutError::MailboxFull); + } + self.rate.entry(sender.to_string()).or_default().push_back(now); + let ttl = ttl.clamp(60, self.limits.max_ttl_secs); + let entry = Entry { + bytes, + expires_at: now + ttl, + ack_hash: ack_hash.to_string(), + }; + if let Some(dir) = &self.dir { + write_entry(&dir.join(&msg.tag), &id, &entry); + } + self.total += size; + self.tags.entry(msg.tag).or_default().insert(id, entry); + // Forget idle senders so the rate table stays small. + if self.rate.len() > 10_000 { + self.rate.retain(|_, w| w.back().is_some_and(|t| *t + 60 > now)); + } + Ok(Put::Stored) + } + + /// Live messages for `tag`, oldest id order, up to the fetch byte cap. + pub fn fetch(&self, tag: &str, now: u64) -> Vec<(String, Vec)> { + let mut out = Vec::new(); + let mut bytes = 0usize; + if let Some(m) = self.tags.get(tag) { + for (id, e) in m { + if e.expires_at <= now { + continue; + } + if bytes + e.bytes.len() > self.limits.max_fetch_bytes && !out.is_empty() { + break; + } + bytes += e.bytes.len(); + out.push((id.clone(), e.bytes.clone())); + } + } + out + } + + /// Delete `id` if `secret` (hex) hashes to its ack hash. + pub fn ack(&mut self, tag: &str, id: &str, secret: &str) -> bool { + let Ok(raw) = hex::decode(secret) else { + return false; + }; + let ok = self + .tags + .get(tag) + .and_then(|m| m.get(id)) + .is_some_and(|e| e.ack_hash == sha256_hex(&raw)); + if ok { + self.remove(tag, id); + } + ok + } + + fn remove(&mut self, tag: &str, id: &str) { + if let Some(m) = self.tags.get_mut(tag) + && let Some(e) = m.remove(id) + { + self.total -= e.bytes.len() as u64; + if let Some(dir) = &self.dir { + let _ = std::fs::remove_file(dir.join(tag).join(format!("{id}.msg"))); + } + if m.is_empty() { + self.tags.remove(tag); + if let Some(dir) = &self.dir { + let _ = std::fs::remove_dir(dir.join(tag)); + } + } + } + } + + /// Drop expired messages. Returns how many went. + pub fn prune(&mut self, now: u64) -> usize { + let dead: Vec<(String, String)> = self + .tags + .iter() + .flat_map(|(t, m)| { + m.iter() + .filter(|(_, e)| e.expires_at <= now) + .map(move |(id, _)| (t.clone(), id.clone())) + }) + .collect(); + for (t, id) in &dead { + self.remove(t, id); + } + dead.len() + } +} + +fn write_entry(dir: &Path, id: &str, e: &Entry) { + let _ = std::fs::create_dir_all(dir); + let meta = Meta { + expires_at: e.expires_at, + ack_hash: e.ack_hash.clone(), + }; + let mut buf = serde_json::to_vec(&meta).unwrap_or_default(); + buf.push(b'\n'); + buf.extend_from_slice(&e.bytes); + let path = dir.join(format!("{id}.msg")); + let tmp = dir.join(format!("{id}.tmp")); + if std::fs::write(&tmp, &buf).is_ok() { + let _ = std::fs::rename(&tmp, &path); + } +} + +fn read_entry(path: &Path) -> Option { + let raw = std::fs::read(path).ok()?; + let nl = raw.iter().position(|b| *b == b'\n')?; + let meta: Meta = serde_json::from_slice(&raw[..nl]).ok()?; + Some(Entry { + bytes: raw[nl + 1..].to_vec(), + expires_at: meta.expires_at, + ack_hash: meta.ack_hash, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::keys::ChatKey; + use crate::sealed::seal; + + const NOW: u64 = 1_800_000_000; + + fn msg_to(bob: &ChatKey) -> crate::sealed::Sealed { + seal(&ChatKey::from_seed([1; 32]), &bob.public_identity(), "hi", 3600, NOW).unwrap() + } + + #[test] + fn put_fetch_ack_and_ttl() { + let bob = ChatKey::from_seed([2; 32]); + let mut mb = Mailbox::new(Limits::default()); + let s = msg_to(&bob); + assert_eq!(mb.put("p1", s.bytes.clone(), &s.ack_hash, 3600, NOW), Ok(Put::Stored)); + assert_eq!(mb.put("p2", s.bytes.clone(), &s.ack_hash, 3600, NOW), Ok(Put::Duplicate)); + let got = mb.fetch(&s.msg.tag, NOW); + assert_eq!(got.len(), 1); + assert_eq!(got[0].0, s.id); + // Wrong secret: nothing deleted. + assert!(!mb.ack(&s.msg.tag, &s.id, &"00".repeat(32))); + assert_eq!(mb.count(), 1); + // TTL: gone after expiry. + assert!(mb.fetch(&s.msg.tag, NOW + 3601).is_empty()); + assert_eq!(mb.prune(NOW + 3601), 1); + assert_eq!(mb.total_bytes(), 0); + + // Ack with the secret only the recipient can read. + mb.put("p1", s.bytes.clone(), &s.ack_hash, 3600, NOW).unwrap(); + let o = crate::sealed::unseal(&bob, &s.msg, NOW).unwrap(); + assert!(mb.ack(&s.msg.tag, &s.id, &o.ack)); + assert_eq!(mb.count(), 0); + // TTL is capped at the maximum. + mb.put("p1", s.bytes.clone(), &s.ack_hash, 10 * MAX_TTL_SECS, NOW).unwrap(); + assert_eq!(mb.fetch(&s.msg.tag, NOW + MAX_TTL_SECS + 1).len(), 0); + } + + #[test] + fn caps_and_rate_limits() { + let bob = ChatKey::from_seed([3; 32]); + // Size cap and garbage. + let mut mb = Mailbox::new(Limits { + max_msg_bytes: 100, + ..Default::default() + }); + let s = msg_to(&bob); + assert_eq!(mb.put("p", s.bytes.clone(), &s.ack_hash, 3600, NOW), Err(PutError::TooLarge)); + let mut mb = Mailbox::new(Limits::default()); + assert!(matches!(mb.put("p", b"not json".to_vec(), &s.ack_hash, 3600, NOW), Err(PutError::BadMessage(_)))); + assert!(matches!(mb.put("p", s.bytes.clone(), "nothex", 3600, NOW), Err(PutError::BadMessage(_)))); + + // Per-sender rate limit, sliding minute. + let mut mb = Mailbox::new(Limits { + per_sender_per_min: 2, + ..Default::default() + }); + let m: Vec<_> = (0..4).map(|_| msg_to(&bob)).collect(); + assert_eq!(mb.put("spammer", m[0].bytes.clone(), &m[0].ack_hash, 3600, NOW), Ok(Put::Stored)); + assert_eq!(mb.put("spammer", m[1].bytes.clone(), &m[1].ack_hash, 3600, NOW + 1), Ok(Put::Stored)); + assert_eq!(mb.put("spammer", m[2].bytes.clone(), &m[2].ack_hash, 3600, NOW + 2), Err(PutError::RateLimited)); + assert_eq!(mb.put("someone-else", m[2].bytes.clone(), &m[2].ack_hash, 3600, NOW + 2), Ok(Put::Stored)); + assert_eq!(mb.put("spammer", m[3].bytes.clone(), &m[3].ack_hash, 3600, NOW + 61), Ok(Put::Stored)); + + // Per-tag count cap and total byte cap. + let mut mb = Mailbox::new(Limits { + max_msgs_per_tag: 2, + ..Default::default() + }); + for (i, s) in m.iter().enumerate() { + let r = mb.put("p", s.bytes.clone(), &s.ack_hash, 3600, NOW); + assert_eq!(r, if i < 2 { Ok(Put::Stored) } else { Err(PutError::TagFull) }); + } + let one = m[0].bytes.len() as u64; + let mut mb = Mailbox::new(Limits { + max_total_bytes: one * 2, + ..Default::default() + }); + let carol = ChatKey::from_seed([4; 32]); + let to_carol = msg_to(&carol); + mb.put("p", m[0].bytes.clone(), &m[0].ack_hash, 3600, NOW).unwrap(); + mb.put("p", m[1].bytes.clone(), &m[1].ack_hash, 3600, NOW).unwrap(); + assert_eq!(mb.put("p", to_carol.bytes.clone(), &to_carol.ack_hash, 3600, NOW), Err(PutError::MailboxFull)); + } + + #[test] + fn persists_across_restart_and_drops_expired() { + let dir = std::env::temp_dir().join(format!("c0dm-mb-{}", rand::random::())); + let bob = ChatKey::from_seed([5; 32]); + let a = msg_to(&bob); + let b = msg_to(&bob); + { + let mut mb = Mailbox::open(&dir, Limits::default(), NOW); + mb.put("p", a.bytes.clone(), &a.ack_hash, 3600, NOW).unwrap(); + mb.put("p", b.bytes.clone(), &b.ack_hash, 60, NOW).unwrap(); + } + let mb = Mailbox::open(&dir, Limits::default(), NOW + 100); + assert_eq!(mb.count(), 1, "the 60 s message expired across the restart"); + assert_eq!(mb.fetch(&a.msg.tag, NOW + 100)[0].0, a.id); + assert_eq!(mb.total_bytes(), a.bytes.len() as u64); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/node/crates/c0mpute-secure-chat/src/message.rs b/node/crates/c0mpute-secure-chat/src/message.rs index f15e7afe..817a1b89 100644 --- a/node/crates/c0mpute-secure-chat/src/message.rs +++ b/node/crates/c0mpute-secure-chat/src/message.rs @@ -6,6 +6,12 @@ //! read by an attacker. //! //! Signing: Ed25519 + ML-DSA-65 (FIPS 204). Both signatures must verify. +//! +//! Envelope v3 binds every field: the id is a hash over the header *and* the +//! hashes of both ciphertexts, the signatures cover the id, and verification +//! recomputes the id from the fields, so changing any byte (sender, recipient, +//! timestamp, TTL, nonce or ciphertext) fails verification. The header is also +//! the AES-GCM associated data. use aes_gcm::{ Aes256Gcm, Key, Nonce, @@ -30,7 +36,7 @@ use x25519_dalek::{PublicKey as X25519Public, StaticSecret as X25519Secret}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DmEnvelope { pub v: u8, - /// SHA-256 of `from|to|created_at|enc_nonce` (hex). + /// SHA-256 over the header and both ciphertext hashes (hex); see [`envelope_id`]. pub id: String, pub kind: String, pub from: String, @@ -42,7 +48,7 @@ pub struct DmEnvelope { /// Base64url ML-KEM-768 ciphertext (1088 bytes) for PQ shared-secret half. pub ml_kem_ct: String, pub created_at: u64, - /// Seconds the relay should keep this for an offline recipient (max 604800). + /// Seconds a mailbox should keep this for an offline recipient. pub ttl: u64, /// Base64url Ed25519 signature (64 bytes) over `id`. pub sig: String, @@ -70,8 +76,53 @@ pub fn encrypt_dm( sender_ml_dsa_key: &MlDsaSigningKey, ttl: u64, ) -> anyhow::Result { - let created_at = unix_now(); + encrypt_dm_at( + from_did, + to_did, + text, + sender_x25519_secret, + recipient_x25519_public, + recipient_ml_kem_ek, + sender_ed25519_key, + sender_ml_dsa_key, + ttl, + unix_now(), + ) +} + +/// Current envelope version. +pub const ENVELOPE_VERSION: u8 = 3; + +fn header_aad(from: &str, to: &str, created_at: u64, ttl: u64) -> Vec { + format!("c0mpute-dm-v3|{from}|{to}|{created_at}|{ttl}").into_bytes() +} +/// The id an envelope must carry: SHA-256 over its header and the SHA-256 of +/// each ciphertext field. +pub fn envelope_id(e: &DmEnvelope) -> String { + let ct = hex::encode(Sha256::digest(e.ciphertext.as_bytes())); + let kem = hex::encode(Sha256::digest(e.ml_kem_ct.as_bytes())); + let canonical = format!( + "c0mpute-dm-id-v3|{}|{}|{}|{}|{}|{}|{}|{}", + e.kind, e.from, e.to, e.created_at, e.ttl, e.enc_nonce, kem, ct + ); + hex::encode(Sha256::digest(canonical.as_bytes())) +} + +/// [`encrypt_dm`] with an explicit creation time (seconds since the epoch). +#[allow(clippy::too_many_arguments)] +pub fn encrypt_dm_at( + from_did: &str, + to_did: &str, + text: &str, + sender_x25519_secret: &X25519Secret, + recipient_x25519_public: &X25519Public, + recipient_ml_kem_ek: &EncapsulationKey, + sender_ed25519_key: &SigningKey, + sender_ml_dsa_key: &MlDsaSigningKey, + ttl: u64, + created_at: u64, +) -> anyhow::Result { let plaintext_bytes = serde_json::to_vec(&DmPlaintext { text: text.to_string(), thread_id: None, @@ -94,17 +145,37 @@ pub fn encrypt_dm( let aes_key = Key::::from_slice(&sym_key); let cipher = Aes256Gcm::new(aes_key); let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let aad = header_aad(from_did, to_did, created_at, ttl); let ciphertext = cipher - .encrypt(&nonce, plaintext_bytes.as_ref()) + .encrypt( + &nonce, + aes_gcm::aead::Payload { + msg: plaintext_bytes.as_ref(), + aad: &aad, + }, + ) .map_err(|e| anyhow::anyhow!("aes-gcm encrypt: {e}"))?; let ml_kem_ct_b64 = BASE64_URL_SAFE_NO_PAD.encode(&ml_kem_ct[..]); let enc_nonce_b64 = BASE64_URL_SAFE_NO_PAD.encode(nonce.as_slice()); let ciphertext_b64 = BASE64_URL_SAFE_NO_PAD.encode(&ciphertext); - // Message ID (replay-resistance) - let canonical = format!("{from_did}|{to_did}|{created_at}|{enc_nonce_b64}"); - let id = hex::encode(Sha256::digest(canonical.as_bytes())); + let mut env = DmEnvelope { + v: ENVELOPE_VERSION, + id: String::new(), + kind: "dm".into(), + from: from_did.to_string(), + to: to_did.to_string(), + ciphertext: ciphertext_b64, + enc_nonce: enc_nonce_b64, + ml_kem_ct: ml_kem_ct_b64, + created_at, + ttl, + sig: String::new(), + ml_dsa_sig: String::new(), + }; + // Message ID (replay protection + what the signatures cover). + let id = envelope_id(&env); // Ed25519 signature (classical) let ed_sig: Ed25519Sig = Ed25519Signer::sign(sender_ed25519_key, id.as_bytes()); @@ -117,20 +188,10 @@ pub fn encrypt_dm( let ml_dsa_sig_encoded = ml_dsa_sig.encode(); let ml_dsa_sig_b64 = BASE64_URL_SAFE_NO_PAD.encode(&ml_dsa_sig_encoded[..]); - Ok(DmEnvelope { - v: 2, - id, - kind: "dm".into(), - from: from_did.to_string(), - to: to_did.to_string(), - ciphertext: ciphertext_b64, - enc_nonce: enc_nonce_b64, - ml_kem_ct: ml_kem_ct_b64, - created_at, - ttl, - sig: sig_b64, - ml_dsa_sig: ml_dsa_sig_b64, - }) + env.id = id; + env.sig = sig_b64; + env.ml_dsa_sig = ml_dsa_sig_b64; + Ok(env) } pub fn decrypt_dm( @@ -144,6 +205,7 @@ pub fn decrypt_dm( let ml_kem_ct_bytes = BASE64_URL_SAFE_NO_PAD.decode(&envelope.ml_kem_ct)?; anyhow::ensure!(nonce_bytes.len() == 12, "invalid AES-GCM nonce length"); + anyhow::ensure!(envelope.v == ENVELOPE_VERSION, "unsupported envelope version {}", envelope.v); // Hybrid KEM decapsulation let ecdh_ss = recipient_x25519_secret.diffie_hellman(sender_x25519_public); @@ -163,20 +225,30 @@ pub fn decrypt_dm( let aes_key = Key::::from_slice(&sym_key); let cipher = Aes256Gcm::new(aes_key); let nonce = Nonce::from_slice(&nonce_bytes); + let aad = header_aad(&envelope.from, &envelope.to, envelope.created_at, envelope.ttl); let plaintext_bytes = cipher - .decrypt(nonce, ciphertext.as_ref()) + .decrypt( + nonce, + aes_gcm::aead::Payload { + msg: ciphertext.as_ref(), + aad: &aad, + }, + ) .map_err(|_| anyhow::anyhow!("decryption failed — wrong key or corrupted message"))?; Ok(serde_json::from_slice(&plaintext_bytes)?) } -/// Verify both the Ed25519 and ML-DSA-65 signatures on a `DmEnvelope`. -/// Both must pass for the message to be considered authentic. +/// Verify both the Ed25519 and ML-DSA-65 signatures on a `DmEnvelope`, after +/// recomputing its id from the fields. Both must pass for the message to be +/// considered authentic. pub fn verify_envelope_sig( envelope: &DmEnvelope, sender_ed25519_public: &VerifyingKey, sender_ml_dsa_public: &MlDsaVerifyingKey, ) -> anyhow::Result<()> { + anyhow::ensure!(envelope.v == ENVELOPE_VERSION, "unsupported envelope version {}", envelope.v); + anyhow::ensure!(envelope_id(envelope) == envelope.id, "envelope id does not match its fields (tampered)"); // Ed25519 signature (classical) let sig_bytes = BASE64_URL_SAFE_NO_PAD.decode(&envelope.sig)?; anyhow::ensure!(sig_bytes.len() == 64, "invalid ed25519 signature length"); @@ -290,4 +362,39 @@ mod tests { let result = decrypt_dm(&envelope, &bob.x25519_secret, &alice.x25519_public, &eve.ml_kem_dk); assert!(result.is_err()); } + + #[test] + fn tampered_fields_fail_verification() { + let alice = ChatKey::generate(); + let bob = ChatKey::generate(); + let env = encrypt_dm( + "alice", "bob", "pay 5", &alice.x25519_secret, &bob.x25519_public, bob.ml_kem_ek(), + &alice.ed25519_key, &alice.ml_dsa_key, 3600, + ) + .unwrap(); + let ok = |e: &DmEnvelope| verify_envelope_sig(e, &alice.ed25519_public, &alice.ml_dsa_public).is_ok(); + assert!(ok(&env)); + let mut t = env.clone(); + t.created_at += 1; + assert!(!ok(&t)); + let mut t = env.clone(); + t.to = "eve".into(); + assert!(!ok(&t)); + let mut t = env.clone(); + t.ttl = 1; + assert!(!ok(&t)); + // Flip one ciphertext character. + let mut t = env.clone(); + let mut c: Vec = t.ciphertext.chars().collect(); + c[3] = if c[3] == 'A' { 'B' } else { 'A' }; + t.ciphertext = c.into_iter().collect(); + assert!(!ok(&t)); + // Even with the id recomputed, the signatures no longer match. + t.id = envelope_id(&t); + assert!(!ok(&t)); + // And the AEAD rejects the header change on decrypt. + let mut t = env.clone(); + t.created_at += 1; + assert!(decrypt_dm(&t, &bob.x25519_secret, &alice.x25519_public, &bob.ml_kem_dk).is_err()); + } } diff --git a/node/crates/c0mpute-secure-chat/src/placement.rs b/node/crates/c0mpute-secure-chat/src/placement.rs new file mode 100644 index 00000000..d75bfd64 --- /dev/null +++ b/node/crates/c0mpute-secure-chat/src/placement.rs @@ -0,0 +1,88 @@ +//! Which mailbox nodes hold a key: rendezvous (highest random weight) +//! hashing, the same scheme hosting uses for site replicas. Sender and +//! recipient compute the same ranking from the same peer set with no +//! coordination; when the set changes, only the keys whose top nodes changed +//! move. + +use sha2::{Digest, Sha256}; + +/// Mailboxes a sender stores each message on. +pub const STORE_REPLICAS: usize = 3; +/// Mailboxes a recipient polls per tag: the store set plus two, so a pull +/// still finds messages when the peer set churned a little since the send. +pub const PULL_REPLICAS: usize = STORE_REPLICAS + 2; + +fn score(key: &str, peer: &str) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(b"c0mpute-dm-rendezvous-v1|"); + h.update(key.as_bytes()); + h.update(b"|"); + h.update(peer.as_bytes()); + h.finalize().into() +} + +/// All `peers`, best-ranked first, for `key`. Duplicates are removed. +pub fn rank>(key: &str, peers: &[S]) -> Vec { + let mut v: Vec<(String, [u8; 32])> = peers.iter().map(|p| (p.as_ref().to_string(), score(key, p.as_ref()))).collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + v.dedup_by(|a, b| a.0 == b.0); + v.into_iter().map(|(p, _)| p).collect() +} + +/// The top `n` of [`rank`]. +pub fn place>(key: &str, peers: &[S], n: usize) -> Vec { + let mut r = rank(key, peers); + r.truncate(n); + r +} + +#[cfg(test)] +mod tests { + use super::*; + + fn peers(n: usize) -> Vec { + (0..n).map(|i| format!("12D3KooPeer{i:03}")).collect() + } + + #[test] + fn placement_is_deterministic_and_order_independent() { + let p = peers(20); + let mut shuffled = p.clone(); + shuffled.reverse(); + shuffled.rotate_left(7); + let a = place("tag-1", &p, STORE_REPLICAS); + let b = place("tag-1", &shuffled, STORE_REPLICAS); + assert_eq!(a, b); + assert_eq!(a.len(), 3); + // Different keys land on different sets (overwhelmingly). + let spread: std::collections::HashSet> = + (0..50).map(|i| place(&format!("tag-{i}"), &p, 3)).collect(); + assert!(spread.len() > 40); + // Fewer peers than replicas: all of them. + assert_eq!(place("k", &peers(2), 3).len(), 2); + // Duplicates collapse. + let dup = vec!["a".to_string(), "a".to_string(), "b".to_string()]; + assert_eq!(rank("k", &dup).len(), 2); + } + + #[test] + fn churn_moves_only_affected_keys_and_pull_set_covers_store_set() { + let p = peers(20); + let mut grown = p.clone(); + grown.push("12D3KooNewcomer".into()); + let mut moved = 0; + for i in 0..200 { + let key = format!("tag-{i}"); + let before = place(&key, &p, STORE_REPLICAS); + let after_pull = place(&key, &grown, PULL_REPLICAS); + // A message stored before the newcomer joined is still found by + // a pull after: at most one slot shifts. + assert!(before.iter().all(|x| after_pull.contains(x)), "{key}"); + if place(&key, &grown, STORE_REPLICAS) != before { + moved += 1; + } + } + // Roughly 3/21 of keys should be affected. + assert!(moved < 60, "{moved} keys moved"); + } +} diff --git a/node/crates/c0mpute-secure-chat/src/sealed.rs b/node/crates/c0mpute-secure-chat/src/sealed.rs new file mode 100644 index 00000000..24f9b5e8 --- /dev/null +++ b/node/crates/c0mpute-secure-chat/src/sealed.rs @@ -0,0 +1,423 @@ +//! Sealed messages: what actually crosses the network. +//! +//! A [`DmEnvelope`] (signed, hybrid-encrypted, from [`crate::message`]) names +//! its sender and recipient, so it never travels bare. It is wrapped, with +//! the sender's public identity and a random ack secret, in a second +//! encryption layer to the recipient from a fresh ephemeral X25519 key plus a +//! fresh ML-KEM-768 encapsulation. The outer [`SealedMessage`] carries only: +//! +//! * `tag`: SHA-256 of the recipient address and the current epoch +//! ([`recipient_tag`]), the mailbox routing key. It changes every +//! [`EPOCH_SECS`], so a mailbox cannot link one recipient's messages across +//! epochs without already knowing the recipient's address; +//! * the ephemeral public key, KEM ciphertext, nonce and ciphertext. +//! +//! No sender, no timestamp, no plaintext length beyond the total size. The +//! mailbox stores the message under `id = SHA-256(bytes)` with `ack_hash = +//! SHA-256(ack secret)`; only the recipient can read the secret, so only the +//! recipient can delete the message. + +use std::collections::BTreeMap; + +use aes_gcm::{ + Aes256Gcm, Key, Nonce, + aead::{Aead, AeadCore, KeyInit, Payload}, +}; +use base64::prelude::*; +use hkdf::Hkdf; +use ml_kem::{Decapsulate, Encapsulate}; +use rand::RngCore; +use rand::rngs::OsRng; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use x25519_dalek::{PublicKey as X25519Public, StaticSecret as X25519Secret}; + +use crate::identity::{CLOCK_SKEW_SECS, PublicIdentity}; +use crate::keys::ChatKey; +use crate::message::{DmEnvelope, decrypt_dm, encrypt_dm_at, verify_envelope_sig}; + +/// Current sealed-message format. +pub const SEALED_VERSION: u8 = 1; +/// Recipient tags rotate this often. +pub const EPOCH_SECS: u64 = 7 * 86_400; +/// Longest a mailbox keeps a message, and the replay window. +pub const MAX_TTL_SECS: u64 = 30 * 86_400; +/// Default time a sender asks mailboxes to keep a message. +pub const DEFAULT_TTL_SECS: u64 = MAX_TTL_SECS; +/// Longest message text accepted. +pub const MAX_TEXT_BYTES: usize = 16 * 1024; +/// Largest sealed message on the wire (a 16 KiB text seals to about 40 KiB). +pub const MAX_SEALED_BYTES: usize = 64 * 1024; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SealedMessage { + pub v: u8, + /// Hex recipient tag (32 bytes). + pub tag: String, + /// Base64url ephemeral X25519 public key. + pub eph: String, + /// Base64url ML-KEM-768 ciphertext. + pub kem_ct: String, + /// Base64url AES-256-GCM nonce. + pub nonce: String, + /// Base64url AES-256-GCM ciphertext of [`SealedInner`]. + pub ct: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct SealedInner { + v: u8, + sender: PublicIdentity, + envelope: DmEnvelope, + /// Hex 32-byte secret whose SHA-256 the mailbox holds; revealing it + /// deletes the message. + ack: String, +} + +/// A sealed message ready to hand to a mailbox or a home node. +pub struct Sealed { + pub msg: SealedMessage, + pub bytes: Vec, + /// SHA-256 of `bytes` (hex): the mailbox id. + pub id: String, + /// SHA-256 of the ack secret (hex). + pub ack_hash: String, + /// The inner envelope id (what the recipient deduplicates on). + pub envelope_id: String, +} + +/// A message opened, verified and replay-checked. +#[derive(Clone, Debug)] +pub struct Opened { + pub sender: PublicIdentity, + pub sender_address: String, + pub text: String, + pub envelope_id: String, + pub created_at: u64, + /// Hex ack secret: send it to the mailbox to delete the message. + pub ack: String, +} + +pub fn epoch_of(t: u64) -> u64 { + t / EPOCH_SECS +} + +/// The mailbox routing key for `address` in `epoch`. +pub fn recipient_tag(address: &str, epoch: u64) -> String { + hex::encode(Sha256::digest(format!("c0mpute-dm-tag-v1|{address}|{epoch}").as_bytes())) +} + +/// Every tag a message to `address` could still be stored under at `now`: +/// from the epoch `MAX_TTL_SECS` ago through the next one (clock skew). +pub fn pull_tags(address: &str, now: u64) -> Vec { + let first = epoch_of(now.saturating_sub(MAX_TTL_SECS + CLOCK_SKEW_SECS)); + let last = epoch_of(now + CLOCK_SKEW_SECS); + (first..=last).map(|e| recipient_tag(address, e)).collect() +} + +pub fn sha256_hex(b: &[u8]) -> String { + hex::encode(Sha256::digest(b)) +} + +fn outer_key(eph_ss: &[u8; 32], kem_ss: &[u8], eph_pub: &[u8; 32], tag: &str) -> [u8; 32] { + let mut ikm = Vec::with_capacity(64); + ikm.extend_from_slice(eph_ss); + ikm.extend_from_slice(kem_ss); + let hk = Hkdf::::new(Some(eph_pub), &ikm); + let mut k = [0u8; 32]; + hk.expand(format!("c0mpute-dm-seal-v1|{tag}").as_bytes(), &mut k) + .expect("32 bytes is a valid HKDF length"); + k +} + +fn outer_aad(tag: &str) -> Vec { + format!("c0mpute-dm-sealed-v{SEALED_VERSION}|{tag}").into_bytes() +} + +/// Encrypt, sign and seal `text` from `sender` to `recipient` at time `now`. +pub fn seal(sender: &ChatKey, recipient: &PublicIdentity, text: &str, ttl: u64, now: u64) -> anyhow::Result { + anyhow::ensure!(text.len() <= MAX_TEXT_BYTES, "message longer than {MAX_TEXT_BYTES} bytes"); + let ttl = ttl.clamp(60, MAX_TTL_SECS); + let rk = recipient.keys()?; + let sender_id = sender.public_identity(); + let to = recipient.address(); + let envelope = encrypt_dm_at( + &sender_id.address(), + &to, + text, + &sender.x25519_secret, + &rk.x25519, + &rk.ml_kem, + &sender.ed25519_key, + &sender.ml_dsa_key, + ttl, + now, + )?; + let mut ack = [0u8; 32]; + OsRng.fill_bytes(&mut ack); + let inner = SealedInner { + v: SEALED_VERSION, + sender: sender_id, + + envelope: envelope.clone(), + ack: hex::encode(ack), + }; + let inner_bytes = serde_json::to_vec(&inner)?; + + let tag = recipient_tag(&to, epoch_of(now)); + let eph = X25519Secret::random_from_rng(OsRng); + let eph_pub = X25519Public::from(&eph); + let eph_ss = eph.diffie_hellman(&rk.x25519); + let (kem_ct, kem_ss) = rk.ml_kem.encapsulate(); + let key = outer_key(eph_ss.as_bytes(), kem_ss.as_ref(), eph_pub.as_bytes(), &tag); + let cipher = Aes256Gcm::new(Key::::from_slice(&key)); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let aad = outer_aad(&tag); + let ct = cipher + .encrypt( + &nonce, + Payload { + msg: &inner_bytes, + aad: &aad, + }, + ) + .map_err(|e| anyhow::anyhow!("seal: {e}"))?; + let msg = SealedMessage { + v: SEALED_VERSION, + tag, + eph: BASE64_URL_SAFE_NO_PAD.encode(eph_pub.as_bytes()), + kem_ct: BASE64_URL_SAFE_NO_PAD.encode(&kem_ct[..]), + nonce: BASE64_URL_SAFE_NO_PAD.encode(nonce.as_slice()), + ct: BASE64_URL_SAFE_NO_PAD.encode(&ct), + }; + let bytes = serde_json::to_vec(&msg)?; + anyhow::ensure!(bytes.len() <= MAX_SEALED_BYTES, "sealed message too large ({} bytes)", bytes.len()); + Ok(Sealed { + id: sha256_hex(&bytes), + ack_hash: sha256_hex(&ack), + envelope_id: envelope.id, + msg, + bytes, + }) +} + +impl SealedMessage { + /// Parse and sanity-check wire bytes (what a mailbox does: no keys). + pub fn parse(bytes: &[u8]) -> anyhow::Result { + anyhow::ensure!(bytes.len() <= MAX_SEALED_BYTES, "sealed message too large"); + let m: Self = serde_json::from_slice(bytes)?; + anyhow::ensure!(m.v == SEALED_VERSION, "unsupported sealed message version {}", m.v); + anyhow::ensure!(is_hex32(&m.tag), "bad tag"); + Ok(m) + } +} + +pub fn is_hex32(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Decrypt and verify a sealed message addressed to `me`, without replay +/// checks (see [`open`]). +pub fn unseal(me: &ChatKey, msg: &SealedMessage, now: u64) -> anyhow::Result { + anyhow::ensure!(msg.v == SEALED_VERSION, "unsupported sealed message version {}", msg.v); + let my_address = me.address(); + anyhow::ensure!( + pull_tags(&my_address, now).contains(&msg.tag), + "not addressed to this key (or outside the message window)" + ); + let eph = BASE64_URL_SAFE_NO_PAD.decode(&msg.eph)?; + let eph: [u8; 32] = eph.as_slice().try_into().map_err(|_| anyhow::anyhow!("bad ephemeral key"))?; + let eph_pub = X25519Public::from(eph); + let eph_ss = me.x25519_secret.diffie_hellman(&eph_pub); + let kem_ct = BASE64_URL_SAFE_NO_PAD.decode(&msg.kem_ct)?; + let kem_ss = me + .ml_kem_dk + .decapsulate_slice(&kem_ct) + .map_err(|_| anyhow::anyhow!("bad ml-kem ciphertext"))?; + let key = outer_key(eph_ss.as_bytes(), kem_ss.as_ref(), eph_pub.as_bytes(), &msg.tag); + let nonce = BASE64_URL_SAFE_NO_PAD.decode(&msg.nonce)?; + anyhow::ensure!(nonce.len() == 12, "bad nonce"); + let ct = BASE64_URL_SAFE_NO_PAD.decode(&msg.ct)?; + let cipher = Aes256Gcm::new(Key::::from_slice(&key)); + let aad = outer_aad(&msg.tag); + let inner_bytes = cipher + .decrypt(Nonce::from_slice(&nonce), Payload { msg: &ct, aad: &aad }) + .map_err(|_| anyhow::anyhow!("cannot open: not for this key, or tampered"))?; + let inner: SealedInner = serde_json::from_slice(&inner_bytes)?; + anyhow::ensure!(inner.v == SEALED_VERSION, "unsupported inner version"); + anyhow::ensure!(is_hex32(&inner.ack), "bad ack secret"); + + let env = &inner.envelope; + let sender_address = inner.sender.address(); + anyhow::ensure!(env.from == sender_address, "envelope sender does not match the enclosed identity"); + anyhow::ensure!(env.to == my_address, "envelope is addressed to someone else"); + let sk = inner.sender.keys()?; + verify_envelope_sig(env, &sk.ed25519, &sk.ml_dsa)?; + let plain = decrypt_dm(env, &me.x25519_secret, &sk.x25519, &me.ml_kem_dk)?; + Ok(Opened { + sender: inner.sender, + sender_address, + text: plain.text, + envelope_id: env.id.clone(), + created_at: env.created_at, + ack: inner.ack, + }) +} + +/// [`unseal`], then reject replays: a timestamp outside the window, or an +/// envelope id already seen. Records the id on success. +pub fn open(me: &ChatKey, msg: &SealedMessage, now: u64, guard: &mut ReplayGuard) -> anyhow::Result { + let o = unseal(me, msg, now)?; + guard.check(&o.envelope_id, o.created_at, now)?; + guard.record(&o.envelope_id, o.created_at); + Ok(o) +} + +/// Envelope ids seen inside the replay window. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct ReplayGuard { + pub seen: BTreeMap, +} + +impl ReplayGuard { + pub fn check(&self, id: &str, created_at: u64, now: u64) -> anyhow::Result<()> { + anyhow::ensure!(created_at <= now + CLOCK_SKEW_SECS, "message timestamp is in the future"); + anyhow::ensure!( + created_at + MAX_TTL_SECS + CLOCK_SKEW_SECS >= now, + "message is older than the replay window" + ); + anyhow::ensure!(!self.seen.contains_key(id), "replayed message (already received)"); + Ok(()) + } + + pub fn record(&mut self, id: &str, created_at: u64) { + self.seen.insert(id.to_string(), created_at); + } + + /// Forget ids older than the window: their timestamps alone reject them. + pub fn prune(&mut self, now: u64) { + let floor = now.saturating_sub(MAX_TTL_SECS + 2 * CLOCK_SKEW_SECS); + self.seen.retain(|_, t| *t >= floor); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const NOW: u64 = 1_800_000_000; + + #[test] + fn seal_open_roundtrip_hides_metadata() { + let alice = ChatKey::generate(); + let bob = ChatKey::generate(); + let s = seal(&alice, &bob.public_identity(), "hi bob", 3600, NOW).unwrap(); + let wire = String::from_utf8(s.bytes.clone()).unwrap(); + assert!(!wire.contains(&alice.address()), "sender address visible on the wire"); + assert!(!wire.contains(&bob.address()), "recipient address visible on the wire"); + assert!(!wire.contains("hi bob")); + assert_eq!(s.msg.tag, recipient_tag(&bob.address(), epoch_of(NOW))); + assert_eq!(s.id, sha256_hex(&s.bytes)); + + let parsed = SealedMessage::parse(&s.bytes).unwrap(); + let mut guard = ReplayGuard::default(); + let o = open(&bob, &parsed, NOW + 10, &mut guard).unwrap(); + assert_eq!(o.text, "hi bob"); + assert_eq!(o.sender_address, alice.address()); + assert_eq!(sha256_hex(&hex::decode(&o.ack).unwrap()), s.ack_hash); + + // Replay of the same message is rejected. + let err = open(&bob, &parsed, NOW + 20, &mut guard).unwrap_err(); + assert!(err.to_string().contains("replayed"), "{err}"); + // Eve cannot open it. + let eve = ChatKey::generate(); + assert!(unseal(&eve, &parsed, NOW).is_err()); + } + + #[test] + fn tampering_and_stale_messages_are_rejected() { + let alice = ChatKey::generate(); + let bob = ChatKey::generate(); + let s = seal(&alice, &bob.public_identity(), "hello", 3600, NOW).unwrap(); + // Flip a ciphertext byte. + let mut t = s.msg.clone(); + let mut ct = BASE64_URL_SAFE_NO_PAD.decode(&t.ct).unwrap(); + ct[10] ^= 1; + t.ct = BASE64_URL_SAFE_NO_PAD.encode(ct); + assert!(unseal(&bob, &t, NOW).is_err()); + // Move it to another tag (AAD-bound). + let mut t = s.msg.clone(); + t.tag = recipient_tag(&bob.address(), epoch_of(NOW) + 1); + assert!(unseal(&bob, &t, NOW).is_err()); + // Past the window: tag no longer pulled, and the guard says too old. + assert!(unseal(&bob, &s.msg, NOW + MAX_TTL_SECS + EPOCH_SECS * 2).is_err()); + let guard = ReplayGuard::default(); + assert!(guard.check("x", NOW, NOW + MAX_TTL_SECS + 10 * CLOCK_SKEW_SECS).is_err()); + assert!(guard.check("x", NOW + 2 * CLOCK_SKEW_SECS, NOW).is_err(), "future"); + assert!(guard.check("x", NOW, NOW + 100).is_ok()); + } + + #[test] + fn forged_sender_is_rejected() { + // Mallory seals a message whose inner identity claims to be Alice but + // signs with her own key: the signatures do not verify. + let alice = ChatKey::generate(); + let bob = ChatKey::generate(); + let mallory = ChatKey::generate(); + let s = seal(&mallory, &bob.public_identity(), "trust me", 3600, NOW).unwrap(); + // Re-seal with the inner sender swapped for Alice's identity. + let mut forged = s.msg.clone(); + { + // Decrypt the outer layer as Bob would, swap the sender, re-encrypt. + let o = unseal(&bob, &forged, NOW).unwrap(); + assert_eq!(o.sender_address, mallory.address()); + } + let inner_forged = { + let rk = bob.public_identity().keys().unwrap(); + let env = crate::message::encrypt_dm_at( + &alice.address(), &bob.address(), "trust me", &mallory.x25519_secret, &rk.x25519, &rk.ml_kem, + &mallory.ed25519_key, &mallory.ml_dsa_key, 3600, NOW, + ) + .unwrap(); + SealedInner { v: 1, sender: alice.public_identity(), envelope: env, ack: "00".repeat(32) } + }; + let rk = bob.public_identity().keys().unwrap(); + let eph = X25519Secret::random_from_rng(OsRng); + let eph_pub = X25519Public::from(&eph); + let (kem_ct, kem_ss) = rk.ml_kem.encapsulate(); + let key = outer_key(eph.diffie_hellman(&rk.x25519).as_bytes(), kem_ss.as_ref(), eph_pub.as_bytes(), &forged.tag); + let cipher = Aes256Gcm::new(Key::::from_slice(&key)); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let ct = cipher + .encrypt(&nonce, Payload { msg: &serde_json::to_vec(&inner_forged).unwrap(), aad: &outer_aad(&forged.tag) }) + .unwrap(); + forged.eph = BASE64_URL_SAFE_NO_PAD.encode(eph_pub.as_bytes()); + forged.kem_ct = BASE64_URL_SAFE_NO_PAD.encode(&kem_ct[..]); + forged.nonce = BASE64_URL_SAFE_NO_PAD.encode(nonce.as_slice()); + forged.ct = BASE64_URL_SAFE_NO_PAD.encode(ct); + let err = unseal(&bob, &forged, NOW).unwrap_err(); + assert!(err.to_string().contains("signature"), "{err}"); + } + + #[test] + fn tags_cover_the_whole_ttl() { + let addr = "ab".repeat(32); + let sent_at = NOW; + let tag = recipient_tag(&addr, epoch_of(sent_at)); + for later in [0, 3600, 7 * 86_400, MAX_TTL_SECS] { + assert!(pull_tags(&addr, sent_at + later).contains(&tag), "+{later}s"); + } + assert!(pull_tags(&addr, NOW).len() <= 7); + assert_ne!(recipient_tag(&addr, 1), recipient_tag(&addr, 2)); + assert!(is_hex32(&tag)); + } + + #[test] + fn oversized_text_is_refused() { + let a = ChatKey::generate(); + let b = ChatKey::generate(); + assert!(seal(&a, &b.public_identity(), &"x".repeat(MAX_TEXT_BYTES + 1), 3600, NOW).is_err()); + let s = seal(&a, &b.public_identity(), &"x".repeat(MAX_TEXT_BYTES), 3600, NOW).unwrap(); + assert!(s.bytes.len() <= MAX_SEALED_BYTES); + } +} diff --git a/node/crates/c0mpute-secure-chat/src/state.rs b/node/crates/c0mpute-secure-chat/src/state.rs new file mode 100644 index 00000000..3f443cd2 --- /dev/null +++ b/node/crates/c0mpute-secure-chat/src/state.rs @@ -0,0 +1,268 @@ +//! The client's local state: contacts, blocklist, cached keys, inbox and the +//! replay guard, encrypted at rest under a key derived from the chat seed +//! (AES-256-GCM, HKDF label `secure-chat-local-state-v1`). Without the key +//! password the file reveals nothing, not even who your contacts are. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use aes_gcm::{ + Aes256Gcm, Key, Nonce, + aead::{Aead, AeadCore, KeyInit}, +}; +use base64::prelude::*; +use hkdf::Hkdf; +use rand::rngs::OsRng; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use zeroize::Zeroizing; + +use crate::identity::{PublicIdentity, matches_query, normalize_query}; +use crate::keys::ChatKey; +use crate::sealed::{Opened, ReplayGuard, SealedMessage, open}; + +/// Inbox entries kept (oldest dropped first). +pub const MAX_INBOX: usize = 5_000; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Contact { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub identity: PublicIdentity, + pub added_at: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct InboxEntry { + /// Envelope id. + pub id: String, + /// Sender address. + pub from: String, + pub text: String, + pub created_at: u64, + pub received_at: u64, + /// `direct` (pushed to this user's home node) or `mailbox`. + pub via: String, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct LocalState { + #[serde(default)] + pub contacts: BTreeMap, + #[serde(default)] + pub blocked: BTreeSet, + /// Identities learned from lookups and received messages. + #[serde(default)] + pub known: BTreeMap, + #[serde(default)] + pub inbox: Vec, + #[serde(default)] + pub replay: ReplayGuard, +} + +#[derive(Serialize, Deserialize)] +struct EncryptedFile { + v: u8, + nonce: String, + ct: String, +} + +/// What happened to one received message. +#[derive(Debug)] +pub enum Received { + /// New message, now in the inbox. + New(Opened), + /// From a blocked sender: dropped (still acked so it is deleted). + Blocked(Opened), +} + +impl Received { + pub fn ack(&self) -> &str { + match self { + Received::New(o) | Received::Blocked(o) => &o.ack, + } + } +} + +impl ChatKey { + fn local_state_key(&self) -> Zeroizing<[u8; 32]> { + let hk = Hkdf::::new(None, self.seed.as_ref()); + let mut k = Zeroizing::new([0u8; 32]); + hk.expand(b"secure-chat-local-state-v1", k.as_mut()) + .expect("32 bytes is a valid HKDF length"); + k + } +} + +/// `chat-state.enc` next to `chat.key`. +pub fn state_file_path() -> anyhow::Result { + Ok(crate::keys::key_file_path()?.with_file_name("chat-state.enc")) +} + +impl LocalState { + /// Load and decrypt; a missing file is an empty state. + pub fn load(path: &Path, key: &ChatKey) -> anyhow::Result { + let raw = match std::fs::read(path) { + Ok(r) => r, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()), + Err(e) => return Err(anyhow::anyhow!("read {}: {e}", path.display())), + }; + let f: EncryptedFile = serde_json::from_slice(&raw)?; + anyhow::ensure!(f.v == 1, "unsupported state file version"); + let k = key.local_state_key(); + let cipher = Aes256Gcm::new(Key::::from_slice(k.as_ref())); + let nonce = BASE64_URL_SAFE_NO_PAD.decode(&f.nonce)?; + anyhow::ensure!(nonce.len() == 12, "bad nonce"); + let plain = Zeroizing::new( + cipher + .decrypt(Nonce::from_slice(&nonce), BASE64_URL_SAFE_NO_PAD.decode(&f.ct)?.as_ref()) + .map_err(|_| anyhow::anyhow!("cannot decrypt {} with this key", path.display()))?, + ); + Ok(serde_json::from_slice(&plain)?) + } + + /// Encrypt and write atomically (0600). + pub fn save(&self, path: &Path, key: &ChatKey) -> anyhow::Result<()> { + let plain = Zeroizing::new(serde_json::to_vec(self)?); + let k = key.local_state_key(); + let cipher = Aes256Gcm::new(Key::::from_slice(k.as_ref())); + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let ct = cipher + .encrypt(&nonce, plain.as_ref()) + .map_err(|e| anyhow::anyhow!("encrypt state: {e}"))?; + let f = EncryptedFile { + v: 1, + nonce: BASE64_URL_SAFE_NO_PAD.encode(nonce.as_slice()), + ct: BASE64_URL_SAFE_NO_PAD.encode(ct), + }; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let tmp = path.with_extension("tmp"); + crate::keys::write_secret_file(&tmp, &serde_json::to_vec(&f)?)?; + std::fs::rename(&tmp, path)?; + Ok(()) + } + + /// Resolve what a user typed (contact name, address, fingerprint, or a + /// `c0dm1.` share string) from local knowledge only. + pub fn resolve(&self, query: &str) -> Option { + if let Ok(id) = PublicIdentity::from_share_string(query) { + return Some(id); + } + if let Some(c) = self.contacts.values().find(|c| c.name.as_deref() == Some(query.trim())) { + return Some(c.identity.clone()); + } + let q = normalize_query(query)?; + self.contacts + .values() + .map(|c| &c.identity) + .chain(self.known.values()) + .find(|id| matches_query(id, &q)) + .cloned() + } + + pub fn remember(&mut self, id: &PublicIdentity) { + if self.known.len() >= 10_000 + && let Some(first) = self.known.keys().next().cloned() + { + self.known.remove(&first); + } + self.known.insert(id.address(), id.clone()); + } + + pub fn add_contact(&mut self, id: PublicIdentity, name: Option, now: u64) -> String { + let addr = id.address(); + self.contacts.insert( + addr.clone(), + Contact { + name, + identity: id, + added_at: now, + }, + ); + addr + } + + /// Display name for an address: contact name, else its fingerprint. + pub fn name_of(&self, address: &str) -> String { + match self.contacts.get(address).and_then(|c| c.name.clone()) { + Some(n) => n, + None => crate::identity::fingerprint_of(address), + } + } + + /// Open a sealed message for `me`: verify, replay-check, apply the + /// blocklist, and file it in the inbox. + pub fn receive(&mut self, me: &ChatKey, msg: &SealedMessage, via: &str, now: u64) -> anyhow::Result { + self.replay.prune(now); + let o = open(me, msg, now, &mut self.replay)?; + if self.blocked.contains(&o.sender_address) { + return Ok(Received::Blocked(o)); + } + self.remember(&o.sender); + self.inbox.push(InboxEntry { + id: o.envelope_id.clone(), + from: o.sender_address.clone(), + text: o.text.clone(), + created_at: o.created_at, + received_at: now, + via: via.to_string(), + }); + if self.inbox.len() > MAX_INBOX { + let extra = self.inbox.len() - MAX_INBOX; + self.inbox.drain(..extra); + } + Ok(Received::New(o)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sealed::seal; + + const NOW: u64 = 1_800_000_000; + + #[test] + fn state_is_encrypted_at_rest_and_roundtrips() { + let dir = std::env::temp_dir().join(format!("c0dm-state-{}", rand::random::())); + let path = dir.join("chat-state.enc"); + let me = ChatKey::generate(); + let friend = ChatKey::generate(); + let mut st = LocalState::default(); + st.add_contact(friend.public_identity(), Some("carol".into()), NOW); + st.save(&path, &me).unwrap(); + let raw = std::fs::read_to_string(&path).unwrap(); + assert!(!raw.contains("carol")); + assert!(!raw.contains(&friend.address())); + let back = LocalState::load(&path, &me).unwrap(); + assert_eq!(back.contacts.len(), 1); + assert!(LocalState::load(&path, &ChatKey::generate()).is_err(), "wrong key"); + // Resolution by name, fingerprint, address. + assert_eq!(back.resolve("carol").unwrap().address(), friend.address()); + assert_eq!(back.resolve(&friend.public_identity().fingerprint()).unwrap().address(), friend.address()); + assert_eq!(back.resolve(&friend.address()).unwrap().address(), friend.address()); + assert!(back.resolve("dave").is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn blocked_senders_are_dropped_and_replays_rejected() { + let bob = ChatKey::generate(); + let alice = ChatKey::generate(); + let mallory = ChatKey::generate(); + let mut st = LocalState::default(); + st.blocked.insert(mallory.address()); + + let good = seal(&alice, &bob.public_identity(), "hello", 3600, NOW).unwrap(); + let spam = seal(&mallory, &bob.public_identity(), "buy now", 3600, NOW).unwrap(); + assert!(matches!(st.receive(&bob, &good.msg, "mailbox", NOW).unwrap(), Received::New(_))); + assert!(matches!(st.receive(&bob, &spam.msg, "mailbox", NOW).unwrap(), Received::Blocked(_))); + assert_eq!(st.inbox.len(), 1); + assert_eq!(st.inbox[0].text, "hello"); + assert!(st.receive(&bob, &good.msg, "direct", NOW + 5).is_err(), "replay"); + assert_eq!(st.inbox.len(), 1); + assert!(st.known.contains_key(&alice.address())); + } +} diff --git a/plugins/secure-chat/module.toml b/plugins/secure-chat/module.toml index 68a78ca6..99836b31 100644 --- a/plugins/secure-chat/module.toml +++ b/plugins/secure-chat/module.toml @@ -1,19 +1,22 @@ -# c0mpute plugin manifest. See dips/0006-module-model.md. +# c0mpute plugin manifest. See dips/0006-module-model.md and +# dips/0018-secure-chat-plugin.md. # -# CANDIDATE plugin — implementation not yet scaffolded. -# See dips/0018-secure-chat-plugin.md for the full PRD. +# Built in: crypto + client state in node/crates/c0mpute-secure-chat, the +# network service in node/crates/c0mpute-core/src/dm.rs, the CLI in +# node/crates/c0mpute-cli/src/dm_cli.rs. # -# Brief for the implementer: -# E2E encrypted p2p messaging, no central database. -# Crypto stack: X25519 + Ed25519 (derived from a single seed), -# NaCl crypto_box for DMs, XChaCha20-Poly1305 for group keys, -# Argon2id-AES256-GCM for password-encrypted key backups. -# Transport: c0mpute gossip (message type 0x20) + WebSocket relay. -# Store-and-forward: nodes hold encrypted envelopes up to TTL (max 7d). -# Public key discovery: DHT keyed on SHA256("secure-chat-key:" + did). -# Identity: CoinPay DID (DIP-0007) is the canonical identity. -# First-party in-tree; compile into the c0mpute binary alongside transcode. -# Implementation target: node/crates/c0mpute-secure-chat +# What runs where: +# * Every worker/gateway serves the `mailbox` role by default: it holds +# sealed messages for offline recipients (bounded) and caches +# self-published key records. Mailboxes gossip on c0mpute/dm/v1 and +# answer over the /c0mpute/rpc/1.0.0 request/response protocol. +# * `c0mpute dm send` delivers straight to the recipient's home node when it +# is online, otherwise stores on 3 mailboxes picked by rendezvous hashing +# on the recipient tag. `c0mpute dm pull` polls the same nodes, opens, +# verifies, and acks (deletes). +# +# Not in this release (DIP-0018 later phases): group chats, attachments, +# web/TUI surfaces, CoinPay DID binding, Double Ratchet forward secrecy. [module] id = "secure-chat" @@ -21,43 +24,42 @@ name = "Secure Chat — E2E encrypted p2p messaging" version = "0.2.81" kind = "service" category = "applications" -status = "partial" -status_note = "Key generation works; sending and receiving messages is not live yet." -description = "End-to-end encrypted peer-to-peer messaging across the c0mpute network. No central server. Keys stay with the user. Identity and reputation via CoinPay DID. Group chats, sealed-sender mode, and offline store-and-forward included." +status = "available" +builtin = true +description = "End-to-end encrypted one-to-one messages between c0mpute users, peer to peer with no central server. Your address is your public key (hybrid X25519 + ML-KEM-768 encryption, Ed25519 + ML-DSA-65 signatures). Messages go straight to the recipient's node when it is online, or wait sealed on three mailbox nodes for up to 30 days; mailboxes never see who sent a message, who it is for, or what it says. Contacts, blocklist and inbox stay on your machine, encrypted under your key. Keys are found by fingerprint from self-signed records nodes gossip. One-to-one text only for now: no group chats, attachments or web UI yet." author = "Profullstack" license = "MIT" source = "https://github.com/profullstack/c0mpute/tree/master/plugins/secure-chat" -keywords = ["chat", "messaging", "e2e", "encryption", "p2p", "did", "privacy"] +keywords = ["chat", "messaging", "e2e", "encryption", "p2p", "post-quantum", "privacy", "mailbox"] [module.requirements] -c0mpute = ">=0.1.0" +c0mpute = ">=NEXT" os = ["linux", "darwin"] arch = ["x86_64", "aarch64"] capabilities = ["network:ingress", "network:egress", "storage:ephemeral"] [module.workloads] -# No one-shot workloads. secure-chat is a continuous service role. -# Node-side operations (relay, DHT key lookup) are triggered by the -# gossip layer, not the workload dispatcher. +# No one-shot workloads. secure-chat is a continuous service role +# (`mailbox`, on by default) driven by gossip and p2p request/response, +# not by the job dispatcher. [module.dispatch] mode = "in-process" -# First-party in-tree, compiled into the c0mpute binary. [module.surfaces] -cli = "c0mpute chat" -# web = "c0mpute.com/chat" # planned (Next.js, key material in sessionStorage only) -# tui = "chat panel" # planned (real-time WebSocket panel in apps/tui) +cli = "c0mpute dm" +# web = "c0mpute.com/chat" # planned +# tui = "chat panel" # planned [module.config.defaults] -# Maximum time a relay node holds an envelope for an offline recipient. -offline_ttl_secs = 86400 # 24 hours default, max 604800 (7 days) - -# Maximum encrypted storage per recipient DID on a relay node. -max_queue_bytes = 10_485_760 # 10 MB - -# DHT key announcement refresh interval. -announce_interval_secs = 43200 # 12 hours - -# Rate limit: max messages per sender DID per minute (node-enforced). -rate_limit_per_min = 60 +# Longest a mailbox holds a message for an offline recipient (30 days). +offline_ttl_secs = 2592000 +# Encrypted bytes a mailbox holds per recipient tag (10 MiB) and in total (256 MiB). +max_queue_bytes = 10485760 +max_total_bytes = 268435456 +# Largest sealed message (64 KiB; message text up to 16 KiB). +max_message_bytes = 65536 +# Messages a mailbox accepts per sending peer per minute. +rate_limit_per_min = 60 +# Mailboxes each message is stored on. +store_replicas = 3