use anyhow::Result;
use clap::{Parser, Subcommand};
use anubis_wormhole as core;
use core::Aes256GcmSivProvider;
use core::traits::Aead;
#[cfg(feature = "l5")]
use core::MlKem1024;
#[cfg(feature = "l5")]
use core::traits::Kem;
use core::aead_policy::{AAD, Role, RecordType, Flags, sc, SessionId, capabilities_hash16};
use core::frame::Frame;
#[cfg(feature = "mailbox-demo")]
// use core::control::{initiator_handshake_over, responder_handshake_over};
use core::sas::two_word_sas;
#[cfg(feature = "mailbox-demo")]
use anubis_wormhole::mailbox::{client::MailboxClient, phaseio::MailboxPhaseIO};
#[cfg(feature = "mailbox-demo")]
use anubis_wormhole::mailbox::supervisor::MailboxSupervisor;
#[cfg(feature = "mailbox-demo")]
use anubis_wormhole::transit;
#[cfg(feature = "mailbox-demo")]
use futures::{stream::FuturesUnordered, FutureExt, StreamExt};
#[cfg(feature = "mailbox-demo")]
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[cfg(feature = "mailbox-demo")]
use anubis_wormhole::mailbox::types::ServerMsg as MbServerMsg;
use core::transcript::Transcript;
use hex::ToHex;
use sha2::{Digest, Sha384};
use hkdf::Hkdf;
use sha2::{Sha256, Sha512};
use rand::RngCore;
use spake2::{Ed25519Group, Identity, Password, Spake2};
#[derive(Parser, Debug)]
#[command(name = "anubis-wormhole", version, about = "Anubis PQ Wormhole CLI (scaffold)")]
struct Cli {
/// Structured trace filter (e.g., anubis.mailbox=debug,anubis.cli=info)
#[arg(long, global=true)]
trace: Option<String>,
/// SAS wordlist file (1024 entries), overrides builtin across all commands
#[arg(long)]
sas_wordlist: Option<String>,
/// Enable Tor adapter (SOCKS5 at 127.0.0.1:9050)
#[arg(long, default_value_t=false)]
tor: bool,
/// Override Tor SOCKS endpoint (HOST:PORT)
#[arg(long)]
tor_socks: Option<String>,
/// Enforce NIST Category-5 suite at runtime (on by default)
#[arg(long, default_value_t=true)]
strict_l5: bool,
/// Relax L5 enforcement at runtime (overrides --strict-l5)
#[arg(long, default_value_t=false)]
no_strict_l5: bool,
/// Dump timing events to file (JSON lines)
#[arg(long)]
dump_timing: Option<String>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Show version and exit
Version,
/// Run a local hybrid handshake (demo)
DemoHandshake {
/// Human code
#[arg(short, long)]
code: String,
},
#[cfg(feature = "mailbox-demo")]
/// Run a control-channel hybrid handshake over an in-memory CONTROL stream (enforces SAS)
DemoControl {
/// Human code
#[arg(short, long)]
code: String,
/// Auto-confirm SAS (for non-interactive)
#[arg(long, default_value_t=false)]
auto_confirm: bool,
},
#[cfg(feature = "mailbox-demo")]
/// Connect to a mailbox and run CONTROL handshake (experimental, needs reachable server)
MailboxControlDemo {
/// Relay URL (ws/wss)
#[arg(long)]
relay_url: String,
/// AppID
#[arg(long, default_value = "lothar.com/wormhole/text-or-file-xfer")]
appid: String,
/// Human code
#[arg(short, long)]
code: String,
/// Phase name for control
#[arg(long, default_value = "control")]
phase: String,
/// Wormhole-compat: use 'pake' phase and numeric data phases
#[arg(long, default_value_t=false)]
compat_wormhole: bool,
/// Use SecretBox (XSalsa20-Poly1305) for mailbox phases (true interop)
#[arg(long, default_value_t=false)]
compat_secretbox: bool,
/// Auto confirm SAS
#[arg(long, default_value_t=false)]
auto_confirm: bool,
/// Trigger a rekey after handshake
#[arg(long, default_value_t=false)]
rekey: bool,
/// Rekey after N bytes (0=disabled)
#[arg(long, default_value_t=0u64)]
rekey_bytes: u64,
/// Rekey after N records (0=disabled)
#[arg(long, default_value_t=0u64)]
rekey_records: u64,
/// Rekey after N seconds (0=disabled)
#[arg(long, default_value_t=0u64)]
rekey_seconds: u64,
/// SAS wordlist file (1024 entries), overrides default
#[arg(long)]
sas_wordlist: Option<String>,
},
#[cfg(feature = "mailbox-demo")]
/// List available nameplates from the server (for shell completions)
ListNameplates {
/// Relay URL (ws/wss)
#[arg(long)]
relay_url: String,
/// AppID
#[arg(long, default_value = "lothar.com/wormhole/text-or-file-xfer")]
appid: String,
},
#[cfg(feature = "mailbox-demo")]
/// Send a short text message (mailbox + CONTROL handshake only)
SendText {
#[arg(long)] relay_url: String,
#[arg(long, default_value = "lothar.com/wormhole/text-or-file-xfer")] appid: String,
#[arg(short, long)] code: String,
#[arg(long, default_value = "control")] control_phase: String,
#[arg(long, default_value = "app-0")] data_phase: String,
/// Wormhole-compat: override control phase to 'pake' and data to '1'
#[arg(long, default_value_t=false)] compat_wormhole: bool,
/// Use SecretBox (XSalsa20-Poly1305) for mailbox phases (true interop)
#[arg(long, default_value_t=false)] compat_secretbox: bool,
/// Derive compat per-phase keys from SPAKE session key
#[arg(long, default_value_t=false)] compat_spake_ikm: bool,
#[arg(long)] message: String,
#[arg(long, default_value_t=false)] auto_confirm: bool,
/// Enforce PQ-only (always on for Anubis)
#[arg(long, default_value_t=true)] pq_only: bool,
},
#[cfg(feature = "mailbox-demo")]
/// Receive a short text message (mailbox + CONTROL handshake only)
RecvText {
#[arg(long)] relay_url: String,
#[arg(long, default_value = "lothar.com/wormhole/text-or-file-xfer")] appid: String,
#[arg(short, long)] code: String,
#[arg(long, default_value = "control")] control_phase: String,
#[arg(long, default_value = "app-0")] data_phase: String,
/// Wormhole-compat: override control phase to 'pake' and data to '1'
#[arg(long, default_value_t=false)] compat_wormhole: bool,
/// Use SecretBox (XSalsa20-Poly1305) for mailbox phases (true interop)
#[arg(long, default_value_t=false)] compat_secretbox: bool,
/// Derive compat per-phase keys from SPAKE session key
#[arg(long, default_value_t=false)] compat_spake_ikm: bool,
/// Number of numeric-phase messages to receive (if not set, uses idle timeout)
#[arg(long)] count: Option<u32>,
/// Idle timeout in ms to stop receiving when count is not set
#[arg(long, default_value_t=500u64)] idle_ms: u64,
#[arg(long, default_value_t=false)] auto_confirm: bool,
#[arg(long, default_value_t=true)] pq_only: bool,
},
#[cfg(feature = "mailbox-demo")]
/// Wormhole-compat SPAKE-only handshake + SecretBox phases (JSON pake_v1)
CompatPakeSendText {
#[arg(long)] relay_url: String,
#[arg(long, default_value = "lothar.com/wormhole/text-or-file-xfer")] appid: String,
#[arg(short, long)] code: String,
/// Numeric data phase (default "1")
#[arg(long, default_value = "1")] data_phase: String,
#[arg(long)] message: String,
/// Timeout seconds for waits (default 30)
#[arg(long, default_value_t=30u64)] timeout_secs: u64,
},
#[cfg(feature = "mailbox-demo")]
/// Wormhole-compat SPAKE-only handshake + SecretBox phases (JSON pake_v1)
CompatPakeRecvText {
#[arg(long)] relay_url: String,
#[arg(long, default_value = "lothar.com/wormhole/text-or-file-xfer")] appid: String,
#[arg(short, long)] code: String,
/// Numeric data phase (default "1")
#[arg(long, default_value = "1")] data_phase: String,
/// Timeout seconds for waits (default 30)
#[arg(long, default_value_t=30u64)] timeout_secs: u64,
},
#[cfg(feature = "mailbox-demo")]
/// Send a file over mailbox (demo fallback; not recommended for large files)
SendFile {
#[arg(long)] relay_url: String,
#[arg(long, default_value = "lothar.com/wormhole/text-or-file-xfer")] appid: String,
#[arg(short, long)] code: String,
#[arg(long, default_value = "control")] control_phase: String,
#[arg(long, default_value = "data-0")] data_phase: String,
#[arg(long)] path: String,
#[arg(long, default_value_t=false)] auto_confirm: bool,
#[arg(long, default_value_t=true)] pq_only: bool,
},
#[cfg(feature = "mailbox-demo")]
/// Receive a file over mailbox (demo fallback)
RecvFile {
#[arg(long)] relay_url: String,
#[arg(long, default_value = "lothar.com/wormhole/text-or-file-xfer")] appid: String,
#[arg(short, long)] code: String,
#[arg(long, default_value = "control")] control_phase: String,
#[arg(long, default_value = "data-0")] data_phase: String,
#[arg(long)] out: String,
#[arg(long, default_value_t=false)] auto_confirm: bool,
#[arg(long, default_value_t=true)] pq_only: bool,
},
#[cfg(feature = "mailbox-demo")]
/// Send a file over TRANSIT (relay)
SendFileTransit {
#[arg(long)] relay_url: String,
#[arg(long, default_value = "lothar.com/wormhole/text-or-file-xfer")] appid: String,
#[arg(short, long)] code: String,
#[arg(long, default_value = "control")] control_phase: String,
#[arg(long, default_value = "tcp:relay.magic-wormhole.io:4001")] transit: String,
#[arg(long)] path: String,
#[arg(long, default_value_t=false)] auto_confirm: bool,
/// Rekey after N bytes (0=disabled)
#[arg(long, default_value_t=0u64)] rekey_bytes: u64,
/// Rekey after N records (0=disabled)
#[arg(long, default_value_t=0u64)] rekey_records: u64,
/// Rekey after N seconds (0=disabled)
#[arg(long, default_value_t=0u64)] rekey_seconds: u64,
},
#[cfg(feature = "mailbox-demo")]
/// Receive a file over TRANSIT (relay)
RecvFileTransit {
#[arg(long)] relay_url: String,
#[arg(long, default_value = "lothar.com/wormhole/text-or-file-xfer")] appid: String,
#[arg(short, long)] code: String,
#[arg(long, default_value = "control")] control_phase: String,
#[arg(long, default_value = "tcp:relay.magic-wormhole.io:4001")] transit: String,
#[arg(long)] out: String,
#[arg(long, default_value_t=false)] auto_confirm: bool,
/// Auto-accept offer
#[arg(long, default_value_t=false)] yes: bool,
/// Rekey after N bytes (0=disabled)
#[arg(long, default_value_t=0u64)] rekey_bytes: u64,
/// Rekey after N records (0=disabled)
#[arg(long, default_value_t=0u64)] rekey_records: u64,
/// Rekey after N seconds (0=disabled)
#[arg(long, default_value_t=0u64)] rekey_seconds: u64,
},
}
#[tokio::main]
async fn main() -> Result<()> {
// init structured logging (RUST_LOG or ANUBIS_LOG), default to info
use tracing_subscriber::{EnvFilter, fmt};
// Parse CLI early to get trace if present
let pre = Cli::parse();
let filter = pre.trace.clone()
.or_else(|| std::env::var("ANUBIS_LOG").ok())
.or_else(|| std::env::var("RUST_LOG").ok())
.unwrap_or_else(|| "info".to_string());
let _ = fmt()
.with_env_filter(EnvFilter::new(filter))
.with_target(true)
.try_init();
// Use the parsed CLI
let cli = pre;
#[cfg(feature = "mailbox-demo")]
let sas_default = resolve_sas_wordlist(cli.sas_wordlist.as_deref());
if cli.tor { std::env::set_var("ANUBIS_TOR", "1"); }
if let Some(ep) = cli.tor_socks.as_deref() { if !ep.is_empty() { std::env::set_var("ANUBIS_TOR_SOCKS", ep); } }
if cli.no_strict_l5 { std::env::set_var("ANUBIS_RELAX_L5", "1"); } else if cli.strict_l5 { std::env::remove_var("ANUBIS_RELAX_L5"); #[cfg(not(feature = "l5"))] { anyhow::bail!("Strict L5 requested but this binary was not built with the 'l5' feature"); } }
#[cfg(feature = "mailbox-demo")]
{
run(cli, sas_default).await
}
#[cfg(not(feature = "mailbox-demo"))]
{
run(cli).await
}
}
#[cfg(feature = "mailbox-demo")]
async fn run(cli: Cli, sas_default: Option<String>) -> Result<()> {
run_impl(cli, Some(sas_default)).await
}
#[cfg(not(feature = "mailbox-demo"))]
async fn run(cli: Cli) -> Result<()> {
run_impl(cli, None).await
}
async fn run_impl(cli: Cli, _sas_default: Option<Option<String>>) -> Result<()> {
#[cfg(feature = "mailbox-demo")]
let sas_default = resolve_sas_wordlist(cli.sas_wordlist.as_deref());
if cli.tor { std::env::set_var("ANUBIS_TOR", "1"); }
if let Some(ep) = cli.tor_socks.as_deref() { if !ep.is_empty() { std::env::set_var("ANUBIS_TOR_SOCKS", ep); } }
// Runtime guard: if strict_l5 requested but binary not built with policy-l5, bail early
// Runtime relax/strict wiring
if cli.no_strict_l5 {
std::env::set_var("ANUBIS_RELAX_L5", "1");
} else if cli.strict_l5 {
// Ensure enforcement enabled (clear any inherited relax)
std::env::remove_var("ANUBIS_RELAX_L5");
#[cfg(not(feature = "l5"))]
{
anyhow::bail!("Strict L5 requested but this binary was not built with the 'l5' feature");
}
}
match cli.command {
Commands::Version => {
println!("anubis-wormhole CLI scaffold");
}
Commands::DemoHandshake { code } => {
demo_hybrid(&code)?;
}
#[cfg(feature = "mailbox-demo")]
Commands::DemoControl { code, auto_confirm } => {
demo_control(&code, auto_confirm, sas_default.as_deref())?;
}
#[cfg(feature = "mailbox-demo")]
Commands::MailboxControlDemo { relay_url, appid, code, phase, compat_wormhole, compat_secretbox: _, auto_confirm, rekey, rekey_bytes: _, rekey_records: _, rekey_seconds: _, sas_wordlist: _ } => {
let phase = if compat_wormhole { "pake".to_string() } else { phase };
mailbox_control(&relay_url, &appid, &code, &phase, auto_confirm, rekey, sas_default.as_deref()).await?;
}
#[cfg(feature = "mailbox-demo")]
Commands::ListNameplates { relay_url, appid } => {
list_nameplates(&relay_url, &appid).await?;
}
#[cfg(feature = "mailbox-demo")]
Commands::SendText { relay_url, appid, code, control_phase, data_phase, compat_wormhole, compat_secretbox, compat_spake_ikm, message, auto_confirm, pq_only: _ } => {
let control_phase = if compat_wormhole { "pake".to_string() } else { control_phase };
let data_phase = if compat_wormhole { "1".to_string() } else { data_phase };
send_text(&relay_url, &appid, &code, &control_phase, &data_phase, &message, compat_secretbox, compat_spake_ikm, auto_confirm, sas_default.as_deref()).await?;
}
#[cfg(feature = "mailbox-demo")]
Commands::RecvText { relay_url, appid, code, control_phase, data_phase, compat_wormhole, compat_secretbox, compat_spake_ikm, count, idle_ms, auto_confirm, pq_only: _ } => {
let control_phase = if compat_wormhole { "pake".to_string() } else { control_phase };
let data_phase = if compat_wormhole { "1".to_string() } else { data_phase };
recv_text(&relay_url, &appid, &code, &control_phase, &data_phase, compat_secretbox, compat_spake_ikm, count, idle_ms, auto_confirm, sas_default.as_deref()).await?;
}
#[cfg(feature = "mailbox-demo")]
Commands::CompatPakeSendText { relay_url, appid, code, data_phase, message, timeout_secs } => {
compat_pake_send_text(&relay_url, &appid, &code, &data_phase, &message, timeout_secs).await?;
}
#[cfg(feature = "mailbox-demo")]
Commands::CompatPakeRecvText { relay_url, appid, code, data_phase, timeout_secs } => {
compat_pake_recv_text(&relay_url, &appid, &code, &data_phase, timeout_secs).await?;
}
#[cfg(feature = "mailbox-demo")]
Commands::SendFile { relay_url, appid, code, control_phase, data_phase: _, path, auto_confirm, pq_only: _ } => {
let default_transit = "tcp:relay.magic-wormhole.io:4001".to_string();
send_file_transit(&relay_url, &appid, &code, &control_phase, &default_transit, &path, auto_confirm, sas_default.as_deref()).await?;
}
#[cfg(feature = "mailbox-demo")]
Commands::RecvFile { relay_url, appid, code, control_phase, data_phase: _, out, auto_confirm, pq_only: _ } => {
let default_transit = "tcp:relay.magic-wormhole.io:4001".to_string();
recv_file_transit(&relay_url, &appid, &code, &control_phase, &default_transit, &out, auto_confirm, sas_default.as_deref()).await?;
}
#[cfg(feature = "mailbox-demo")]
Commands::SendFileTransit { relay_url, appid, code, control_phase, transit, path, auto_confirm, rekey_bytes: _, rekey_records: _, rekey_seconds: _ } => {
send_file_transit(&relay_url, &appid, &code, &control_phase, &transit, &path, auto_confirm, sas_default.as_deref()).await?;
}
#[cfg(feature = "mailbox-demo")]
Commands::RecvFileTransit { relay_url, appid, code, control_phase, transit, out, auto_confirm, yes: _, rekey_bytes: _, rekey_records: _, rekey_seconds: _ } => {
recv_file_transit(&relay_url, &appid, &code, &control_phase, &transit, &out, auto_confirm, sas_default.as_deref()).await?;
}
}
Ok(())
}
fn demo_hybrid(code: &str) -> Result<()> {
// SPAKE2 over Ed25519
let pw = Password::new(code.as_bytes());
let (state_a, msg_a) = Spake2::<Ed25519Group>::start_a(&pw, &Identity::new(&[]), &Identity::new(&[]));
let (state_b, msg_b) = Spake2::<Ed25519Group>::start_b(&pw, &Identity::new(&[]), &Identity::new(&[]));
let key_a = state_a.finish(&msg_b).expect("finish a");
let key_b = state_b.finish(&msg_a).expect("finish b");
assert_eq!(key_a, key_b);
// Transcript (bind messages)
let mut tr = Transcript::new();
tr.absorb("spake.msg_a", &msg_a);
tr.absorb("spake.msg_b", &msg_b);
let th = tr.finish();
// KEM (if available) and derive keys
#[cfg(feature = "l5")]
let keys = {
let kem = MlKem1024;
let (pk_a, sk_a) = kem.keypair();
let (ct_b, ss_b) = kem.encapsulate(&pk_a);
let ss_a = kem.decapsulate(&sk_a, &ct_b);
assert_eq!(ss_a, ss_b);
core::handshake::derive_keys(&key_a, &ss_a, &th)
};
#[cfg(not(feature = "l5"))]
let keys = core::handshake::derive_keys(&key_a, &[], &th);
// SAS
let mut h = Sha384::new();
h.update(&keys.k_verify);
let sas = &h.finalize()[..8];
// Prefer two-word SAS for demos; also print hex for debugging
let dict = sas_dict(None);
let words = two_word_sas(&dict, &keys.k_verify);
println!("SAS: {} {}", words.w1, words.w2);
println!("SAS(hex): {}", sas.encode_hex::<String>());
// AEAD demo with spec AAD/nonce
let aead = Aes256GcmSivProvider;
let sid = SessionId(rand::random());
let cap_hash = capabilities_hash16(b"anubis/hkdf-sha512+ml-kem-1024+aes-256-gcm-siv");
let aad = AAD {
version: 1,
role: Role::Initiator,
record_type: RecordType::Control,
subchannel: sc::CONTROL,
seq: 1,
flags: Flags::empty(),
session_id: sid.0,
capabilities_hash16: Some(cap_hash),
};
let pt = b"hello anubis";
let mut key_arr = [0u8; 32];
key_arr.copy_from_slice(&keys.k_data[..32]);
let ct = Frame::seal(&aead, &key_arr, &aad, pt)?;
let pt2 = Frame::open(&aead, &key_arr, &aad, &ct)?;
assert_eq!(pt, &pt2[..]);
println!("AEAD roundtrip ok ({} bytes)", pt2.len());
Ok(())
}
fn sas_dict(path: Option<&str>) -> core::sas::Dictionary<'static> {
if let Some(p) = path { if let Some(d) = core::sas::load_dictionary_file(std::path::Path::new(p)) { return d; } }
core::sas::Dictionary::builtin()
}
#[cfg(feature = "mailbox-demo")]
fn resolve_sas_wordlist(cli_opt: Option<&str>) -> Option<String> {
// Priority: CLI arg -> env -> config files -> None
if let Some(p) = cli_opt { return Some(p.to_string()); }
if let Ok(envp) = std::env::var("ANUBIS_SAS_WORDLIST") { if !envp.is_empty() { return Some(envp); } }
// Check ~/.qseal/anubis.toml and ~/.config/anubis-wormhole/config.toml
if let Some(home) = std::env::var_os("HOME") {
let p1 = std::path::Path::new(&home).join(".qseal/anubis.toml");
if let Some(p) = parse_sas_from_config(&p1) { return Some(p); }
let p2 = std::path::Path::new(&home).join(".config/anubis-wormhole/config.toml");
if let Some(p) = parse_sas_from_config(&p2) { return Some(p); }
// Fallback to a conventional wordlist file name if present
let p3 = std::path::Path::new(&home).join(".qseal/sas_wordlist.txt");
if p3.exists() { return Some(p3.to_string_lossy().to_string()); }
}
None
}
#[cfg(feature = "mailbox-demo")]
fn tor_enabled() -> bool {
match std::env::var("ANUBIS_TOR") { Ok(v) => matches!(v.as_str(), "1"|"true"|"on"), Err(_) => false }
}
#[cfg(feature = "mailbox-demo")]
fn tor_socks() -> Option<(String,u16)> {
if !tor_enabled() { return None; }
if let Ok(ep) = std::env::var("ANUBIS_TOR_SOCKS") {
if let Some((h, p)) = ep.split_once(':') {
if let Ok(port) = p.parse::<u16>() { return Some((h.to_string(), port)); }
}
}
Some(("127.0.0.1".to_string(), 9050))
}
#[cfg(feature = "mailbox-demo")]
fn parse_nameplate_from_code(code: &str) -> Option<String> {
// Accept leading digits optionally followed by '-'
let mut digits = String::new();
for ch in code.chars() {
if ch.is_ascii_digit() { digits.push(ch); } else { break; }
}
if digits.is_empty() { None } else { Some(digits) }
}
#[cfg(feature = "mailbox-demo")]
fn split_nameplate_and_words(code: &str) -> (Option<String>, String) {
// If code starts with digits followed by '-', split; else return None and full code
let mut digits = String::new();
let mut chars = code.chars().peekable();
while let Some(&ch) = chars.peek() {
if ch.is_ascii_digit() { digits.push(ch); chars.next(); } else { break; }
}
if !digits.is_empty() {
if let Some('-') = chars.peek().copied() { chars.next(); }
let words: String = chars.collect();
(Some(digits), words)
} else {
(None, code.to_string())
}
}
#[cfg(feature = "mailbox-demo")]
#[allow(dead_code)]
fn timing(path: Option<&str>, name: &str, kv: serde_json::Value) {
if let Some(p) = path { let _ = std::fs::OpenOptions::new().create(true).append(true).open(p).and_then(|mut f| {
let line = serde_json::json!({"ts": format!("{:?}", std::time::SystemTime::now()), "event": name, "data": kv}).to_string();
use std::io::Write; f.write_all(line.as_bytes()).and_then(|_| f.write_all(b"\n"))
}); }
}
#[cfg(feature = "mailbox-demo")]
fn parse_sas_from_config(path: &std::path::Path) -> Option<String> {
let txt = std::fs::read_to_string(path).ok()?;
for line in txt.lines() {
let s = line.trim();
if s.starts_with("sas_wordlist") {
if let Some((_, rhs)) = s.split_once('=') {
let v = rhs.trim().trim_matches('"').trim_matches('\'');
if !v.is_empty() { return Some(v.to_string()); }
}
}
}
None
}
// Wormhole-compat helpers
fn compat_phase_key(k_verify: &[u8], side: &str, phase: &str, strict_l5: bool) -> [u8;32] {
let label = format!("wormhole:phase:{}", phase);
let salt = sha2::Sha256::digest(side.as_bytes());
if strict_l5 {
let hk = Hkdf::<Sha512>::new(Some(&salt), k_verify);
let mut okm=[0u8;32]; hk.expand(label.as_bytes(), &mut okm).expect("hkdf"); okm
} else {
// default to SHA-512; decryption path will additionally attempt SHA-256 if relaxed
let hk = Hkdf::<Sha512>::new(Some(&salt), k_verify);
let mut okm=[0u8;32]; hk.expand(label.as_bytes(), &mut okm).expect("hkdf"); okm
}
}
fn compat_phase_key_sha256(k_verify: &[u8], side: &str, phase: &str) -> [u8;32] {
let label = format!("wormhole:phase:{}", phase);
let salt = sha2::Sha256::digest(side.as_bytes());
let hk = Hkdf::<Sha256>::new(Some(&salt), k_verify);
let mut okm=[0u8;32]; hk.expand(label.as_bytes(), &mut okm).expect("hkdf"); okm
}
fn compat_secretbox_phase_key_sha256(k_base: &[u8], side: &str, phase: &str) -> [u8;32] {
// purpose = b"wormhole:phase:" + sha256(side) + sha256(phase)
let mut purpose: Vec<u8> = b"wormhole:phase:".to_vec();
let side_h = Sha256::digest(side.as_bytes());
let phase_h = Sha256::digest(phase.as_bytes());
purpose.extend_from_slice(&side_h);
purpose.extend_from_slice(&phase_h);
// HKDF-SHA256 with salt=None
let hk = Hkdf::<Sha256>::new(None, k_base);
let mut okm = [0u8; 32];
hk.expand(&purpose, &mut okm).expect("hkdf");
okm
}
#[cfg(test)]
mod tests {
use super::compat_secretbox_phase_key_sha256;
#[test]
fn secretbox_phase_key_matches_vector() {
let k_base: Vec<u8> = (0u8..32u8).collect();
let side = "abcde";
let phase = "version";
let key = compat_secretbox_phase_key_sha256(&k_base, side, phase);
assert_eq!(hex::encode(key), "5edaf5aec8ca47fb7dc8fb159c5a705209cd51f56bc26427b6ad196a44d97836");
}
}
fn compat_phase_encrypt(key: &[u8;32], pt: &[u8]) -> anyhow::Result<Vec<u8>> {
let aead = core::Aes256GcmSivProvider;
let mut nonce=[0u8;12]; rand::thread_rng().fill_bytes(&mut nonce);
let ct = aead.seal(key, &nonce, b"", pt)?;
let mut v=Vec::with_capacity(12+ct.len()); v.extend_from_slice(&nonce); v.extend_from_slice(&ct); Ok(v)
}
fn compat_phase_decrypt(key: &[u8;32], data: &[u8]) -> anyhow::Result<Vec<u8>> {
if data.len()<12 { anyhow::bail!("short"); }
let aead = core::Aes256GcmSivProvider;
let mut nonce=[0u8;12]; nonce.copy_from_slice(&data[..12]);
let ct=&data[12..];
Ok(aead.open(key, &nonce, b"", ct)?)
}
fn compat_secretbox_encrypt(key: &[u8;32], pt: &[u8]) -> anyhow::Result<Vec<u8>> {
use xsalsa20poly1305::{aead::Aead, KeyInit, XSalsa20Poly1305, Nonce};
let cipher = XSalsa20Poly1305::new_from_slice(key).map_err(|_| anyhow::anyhow!("key"))?;
let mut nonce = [0u8;24]; rand::thread_rng().fill_bytes(&mut nonce);
let ct = cipher.encrypt(Nonce::from_slice(&nonce), pt).map_err(|_| anyhow::anyhow!("encrypt"))?;
let mut v = Vec::with_capacity(24+ct.len()); v.extend_from_slice(&nonce); v.extend_from_slice(&ct); Ok(v)
}
fn compat_secretbox_decrypt(key: &[u8;32], data: &[u8]) -> anyhow::Result<Vec<u8>> {
use xsalsa20poly1305::{aead::Aead, KeyInit, XSalsa20Poly1305, Nonce};
if data.len()<24 { anyhow::bail!("short"); }
let cipher = XSalsa20Poly1305::new_from_slice(key).map_err(|_| anyhow::anyhow!("key"))?;
let nonce = Nonce::from_slice(&data[..24]);
let ct=&data[24..];
Ok(cipher.decrypt(nonce, ct).map_err(|_| anyhow::anyhow!("decrypt"))?)
}
#[cfg(feature = "mailbox-demo")]
async fn mailbox_control(relay_url: &str, appid: &str, code: &str, phase: &str, auto_confirm: bool, rekey: bool, sas_wordlist: Option<&str>) -> Result<()> {
// Connect with supervisor
let mut client = MailboxClient::new(relay_url.to_string(), appid.to_string());
if let Some((h,p)) = tor_socks() { client.set_socks(Some((h,p))); }
let mut sup = MailboxSupervisor::new(client, core::journal::NoopJournal);
sup.ensure_connected().await?;
sup.client_mut().allocate().await?;
let nameplate = loop { if let Some(MbServerMsg::NameplateAllocated { nameplate }) = sup.poll_next().await { break nameplate; } };
sup.client_mut().claim(&nameplate).await?;
let mailbox = loop { if let Some(MbServerMsg::Claimed { mailbox }) = sup.poll_next().await { break mailbox; } };
sup.client_mut().open(&mailbox).await?;
// PhaseIO wrapper
let our_side = sup.client_mut().side().to_string();
let mut pio = MailboxPhaseIO { client: sup.client_mut(), phase: phase.to_string(), our_side };
// Run responder first to be ready to handle initiator SPAKE
let versions = serde_json::json!({"can-dilate": []});
let caps = serde_json::json!({
"suite": "anubis",
"sec_cat": 5,
"kem": "ML-KEM-1024",
"aead": "AES-256-GCM-SIV",
"kdf": "HKDF-SHA512"
});
let (res, _sess) = core::control::responder_handshake_over(&mut pio, code, versions.clone(), caps.clone()).await;
let (ini, _sess2) = core::control::initiator_handshake_over(&mut pio, code, versions, caps).await;
// Enforce SAS
let dict = sas_dict(sas_wordlist);
let sas_a = two_word_sas(&dict, &ini.keys.k_verify);
let sas_b = two_word_sas(&dict, &res.keys.k_verify);
let s1 = format!("{} {}", sas_a.w1, sas_a.w2);
let s2 = format!("{} {}", sas_b.w1, sas_b.w2);
println!("SAS local: {} | peer: {}", s1, s2);
if !auto_confirm && s1 != s2 { anyhow::bail!("SAS mismatch"); }
if rekey {
println!("Starting rekey...");
let new_i = core::control::initiator_rekey(&mut pio, &ini.keys, 0).await;
let new_r = core::control::responder_rekey(&mut pio, &res.keys, 0).await;
println!("Rekeyed. New K_verify (initiator) len={}, (responder) len={}", new_i.k_verify.len(), new_r.k_verify.len());
}
Ok(())
}
#[cfg(feature = "mailbox-demo")]
async fn send_text(relay_url: &str, appid: &str, code: &str, control_phase: &str, data_phase: &str, message: &str, compat_secretbox: bool, compat_spake_ikm: bool, auto_confirm: bool, sas_wordlist: Option<&str>) -> Result<()> {
let mut client = MailboxClient::new(relay_url.to_string(), appid.to_string());
if let Some((h,p)) = tor_socks() { client.set_socks(Some((h,p))); }
let mut sup = MailboxSupervisor::new(client, core::journal::NoopJournal);
sup.ensure_connected().await?;
sup.client_mut().allocate().await?;
let nameplate = tokio::time::timeout(std::time::Duration::from_secs(30), async {
loop { if let Some(MbServerMsg::NameplateAllocated { nameplate }) = sup.poll_next().await { break nameplate; } }
}).await.map_err(|_| anyhow::anyhow!("timeout waiting for nameplate"))?;
println!("Nameplate allocated: {}", nameplate);
println!("Receiver code: {}-{}", nameplate, code);
sup.client_mut().claim(&nameplate).await?;
let mailbox = tokio::time::timeout(std::time::Duration::from_secs(30), async {
loop { if let Some(MbServerMsg::Claimed { mailbox }) = sup.poll_next().await { break mailbox; } }
}).await.map_err(|_| anyhow::anyhow!("timeout waiting for mailbox"))?;
sup.client_mut().open(&mailbox).await?;
// CONTROL handshake
let our_side = sup.client_mut().side().to_string();
let mut pio = MailboxPhaseIO { client: sup.client_mut(), phase: control_phase.to_string(), our_side: our_side.clone() };
let versions = serde_json::json!({"can-dilate": []});
let caps = serde_json::json!({"suite":"anubis","sec_cat":5,"kem":"ML-KEM-1024","aead":"AES-256-GCM-SIV","kdf":"HKDF-SHA512"});
let (ini, _sid) = core::control::initiator_handshake_over(&mut pio, code, versions, caps).await;
// SAS
let dict = sas_dict(sas_wordlist);
let sas = two_word_sas(&dict, &ini.keys.k_verify);
let s = format!("{} {}", sas.w1, sas.w2); println!("SAS: {}", s);
if !auto_confirm { use std::io::{self, Write}; print!("Type SAS to confirm: "); io::stdout().flush().ok(); let mut line=String::new(); io::stdin().read_line(&mut line).ok(); if line.trim()!=s { anyhow::bail!("SAS mismatch"); } }
// Optional: derive SPAKE-only session key for wormhole-compat IKM
let spake_ikm: Option<Vec<u8>> = if compat_secretbox && compat_spake_ikm {
let pw = Password::new(code.as_bytes());
let (st_a, msg_a) = Spake2::<Ed25519Group>::start_a(&pw, &Identity::new(&[]), &Identity::new(&[]));
// For mailbox demo, echo roundtrip locally via PhaseIO would complicate; we emulate by finishing with msg_a itself
// In practice, both sides compute their own SPAKE session from control prelude; using the same code yields same bytes.
// NOTE: This produces deterministic key material across both ends given the same code.
Some(st_a.finish(&msg_a).unwrap_or_default())
} else { None };
// compat 'version' phase: encrypted JSON with can-dilate
let compat_caps = serde_json::json!({"can-dilate": []});
let compat_caps_bytes = serde_json::to_vec(&compat_caps)?;
let vkey = if compat_secretbox {
let base = spake_ikm.as_deref().unwrap_or(&ini.keys.k_verify);
compat_secretbox_phase_key_sha256(base, &our_side, "version")
} else {
compat_phase_key(&ini.keys.k_verify, &our_side, "version", core::policy::l5_enforcement_enabled())
};
let venc = if compat_secretbox { compat_secretbox_encrypt(&vkey, &compat_caps_bytes)? } else { compat_phase_encrypt(&vkey, &compat_caps_bytes)? };
sup.client_mut().add("version", &hex::encode(venc)).await?;
// DATA send (single record) over mailbox data_phase
if data_phase.chars().all(|c| c.is_ascii_digit()) {
let dkey = if compat_secretbox {
let base = spake_ikm.as_deref().unwrap_or(&ini.keys.k_verify);
compat_secretbox_phase_key_sha256(base, &our_side, data_phase)
} else {
compat_phase_key(&ini.keys.k_verify, &our_side, data_phase, core::policy::l5_enforcement_enabled())
};
let den = if compat_secretbox { compat_secretbox_encrypt(&dkey, message.as_bytes())? } else { compat_phase_encrypt(&dkey, message.as_bytes())? };
sup.client_mut().add(data_phase, &hex::encode(den)).await?;
} else {
let mut key = [0u8;32]; key.copy_from_slice(&ini.keys.k_data[..32]);
let mut sc = core::subchannel::SubchSend::new(sc::DATA).with_capacity(8);
sc.enqueue(&key, [0u8;16], Role::Initiator, RecordType::Data, message.as_bytes(), true)?;
let ct = sc.dequeue().expect("queued");
let mut hdrct = Vec::with_capacity(1+8+ct.len());
hdrct.push(core::aead_policy::Flags::FIN.bits());
hdrct.extend_from_slice(&0u64.to_be_bytes());
hdrct.extend_from_slice(&ct);
sup.client_mut().add(data_phase, &hex::encode(hdrct)).await?;
}
println!("Sent.");
Ok(())
}
#[cfg(feature = "mailbox-demo")]
async fn recv_text(relay_url: &str, appid: &str, code: &str, control_phase: &str, data_phase: &str, compat_secretbox: bool, compat_spake_ikm: bool, count: Option<u32>, idle_ms: u64, auto_confirm: bool, sas_wordlist: Option<&str>) -> Result<()> {
let mut client = MailboxClient::new(relay_url.to_string(), appid.to_string());
if let Some((h,p)) = tor_socks() { client.set_socks(Some((h,p))); }
let mut sup = MailboxSupervisor::new(client, core::journal::NoopJournal);
sup.ensure_connected().await?;
let nameplate = parse_nameplate_from_code(code).ok_or_else(|| anyhow::anyhow!("code must start with digits (nameplate), e.g., '7-words'"))?;
sup.client_mut().claim(&nameplate).await?;
let mailbox = tokio::time::timeout(std::time::Duration::from_secs(30), async {
loop { if let Some(MbServerMsg::Claimed { mailbox }) = sup.poll_next().await { break mailbox; } }
}).await.map_err(|_| anyhow::anyhow!("timeout waiting for mailbox"))?;
sup.client_mut().open(&mailbox).await?;
// CONTROL handshake (responder)
let our_side = sup.client_mut().side().to_string();
let mut pio = MailboxPhaseIO { client: sup.client_mut(), phase: control_phase.to_string(), our_side: our_side.clone() };
// If code contains nameplate prefix, remove it for PAKE
let (_, code_words) = split_nameplate_and_words(code);
let versions = serde_json::json!({"can-dilate": []});
let caps = serde_json::json!({"suite":"anubis","sec_cat":5,"kem":"ML-KEM-1024","aead":"AES-256-GCM-SIV","kdf":"HKDF-SHA512"});
let (res, _sid) = core::control::responder_handshake_over(&mut pio, &code_words, versions, caps).await;
let dict = sas_dict(sas_wordlist);
let sas = two_word_sas(&dict, &res.keys.k_verify);
let s = format!("{} {}", sas.w1, sas.w2); println!("SAS: {}", s);
if !auto_confirm { use std::io::{self, Write}; print!("Type SAS to confirm: "); io::stdout().flush().ok(); let mut line=String::new(); io::stdin().read_line(&mut line).ok(); if line.trim()!=s { anyhow::bail!("SAS mismatch"); } }
// Optional: derive SPAKE-only session key for wormhole-compat IKM
let spake_ikm: Option<Vec<u8>> = if compat_secretbox && compat_spake_ikm {
let pw = Password::new(code.as_bytes());
let (st_a, msg_a) = Spake2::<Ed25519Group>::start_a(&pw, &Identity::new(&[]), &Identity::new(&[]));
Some(st_a.finish(&msg_a).unwrap_or_default())
} else { None };
// compat: receive 'version' phase encrypted; when compat_secretbox, use wormhole-accurate HKDF-SHA256 purpose
let vpt = tokio::time::timeout(std::time::Duration::from_secs(30), async {
loop {
if let Some(MbServerMsg::Message { side, phase, body }) = sup.poll_next().await {
if phase=="version" && side!=our_side {
let raw = match hex::decode(body) { Ok(v) => v, Err(_) => { tracing::warn!(target: "anubis.cli", "bad hex in version phase"); continue; } };
if compat_secretbox {
let base = spake_ikm.as_deref().unwrap_or(&res.keys.k_verify);
let k = compat_secretbox_phase_key_sha256(base, &our_side, "version");
if let Ok(pt) = compat_secretbox_decrypt(&k, &raw) { break pt; }
} else {
let key512 = compat_phase_key(&res.keys.k_verify, &our_side, "version", true);
if let Ok(pt) = compat_phase_decrypt(&key512, &raw) { break pt; }
if !core::policy::l5_enforcement_enabled() {
let key256 = compat_phase_key_sha256(&res.keys.k_verify, &our_side, "version");
if let Ok(pt) = compat_phase_decrypt(&key256, &raw) { break pt; }
}
}
}
}
}
}).await.map_err(|_| anyhow::anyhow!("timeout waiting for version message"))?;
let _peer_caps: serde_json::Value = serde_json::from_slice(&vpt).unwrap_or(serde_json::json!({}));
// Receive records on data phase (numeric-phase batching with in-order buffer)
if data_phase.chars().all(|c| c.is_ascii_digit()) {
use std::collections::BTreeMap;
let mut buf: BTreeMap<u64, Vec<u8>> = BTreeMap::new();
let mut delivered: u64 = data_phase.parse::<u64>().unwrap_or(1);
let mut remaining: Option<u64> = count.map(|c| c as u64);
let idle = std::time::Duration::from_millis(idle_ms);
let deadline = std::time::Instant::now() + idle;
loop {
if let Some(MbServerMsg::Message { side, phase, body }) = sup.poll_next().await {
if side != our_side {
if let Ok(n) = phase.parse::<u64>() {
let raw = match hex::decode(body) { Ok(v) => v, Err(_) => { tracing::warn!(target: "anubis.cli", phase=%phase, "bad hex in message phase"); continue; } };
let pt = if compat_secretbox {
let base = spake_ikm.as_deref().unwrap_or(&res.keys.k_verify);
let k = compat_secretbox_phase_key_sha256(base, &our_side, &phase);
compat_secretbox_decrypt(&k, &raw).ok()
} else {
let key512 = compat_phase_key(&res.keys.k_verify, &our_side, &phase, true);
compat_phase_decrypt(&key512, &raw)
.or_else(|_| if !core::policy::l5_enforcement_enabled() { let k256=compat_phase_key_sha256(&res.keys.k_verify, &our_side, &phase); compat_phase_decrypt(&k256, &raw) } else { Err(anyhow::anyhow!("dec")) })
.ok()
};
if let Some(pt) = pt { buf.insert(n, pt); }
}
}
}
// Deliver contiguous
while let Some(pt) = buf.remove(&delivered) {
println!("Received[{}]: {}", delivered, String::from_utf8_lossy(&pt));
delivered += 1;
if let Some(rem) = remaining.as_mut() { if *rem > 0 { *rem -= 1; if *rem == 0 { return Ok(()); } } }
}
if std::time::Instant::now() > deadline && buf.is_empty() { break; }
}
} else {
// Single AEAD-framed record
loop {
if let Some(MbServerMsg::Message { side, phase, body }) = sup.poll_next().await {
if phase==data_phase && side!=our_side {
let raw = hex::decode(body)?;
if raw.len() < 9 { continue; }
let flags = core::aead_policy::Flags::from_bits_truncate(raw[0]);
let mut seqb = [0u8;8]; seqb.copy_from_slice(&raw[1..9]); let seq = u64::from_be_bytes(seqb);
let ct = &raw[9..];
let mut recv = core::subchannel::SubchRecv::new(sc::DATA);
let mut key=[0u8;32]; key.copy_from_slice(&res.keys.k_data[..32]);
if let Ok(pt)=recv.open_with_journal(&key, [0u8;16], Role::Responder, RecordType::Data, ct, flags, seq, &mut core::journal::NoopJournal) {
println!("Received: {}", String::from_utf8_lossy(&pt));
break;
}
}
}
}
}
Ok(())
}
#[cfg(feature = "mailbox-demo")]
async fn list_nameplates(relay_url: &str, appid: &str) -> Result<()> {
let mut client = MailboxClient::new(relay_url.to_string(), appid.to_string());
if let Some((h,p)) = tor_socks() { client.set_socks(Some((h,p))); }
client.connect_with_backoff().await?;
client.list().await?;
// Wait for nameplates message
loop {
if let Some(MbServerMsg::Nameplates { ids }) = client.next().await {
for id in ids { println!("{}", id); }
break;
}
}
Ok(())
}
#[cfg(feature = "mailbox-demo")]
#[allow(dead_code)]
async fn send_file(relay_url: &str, appid: &str, code: &str, control_phase: &str, data_phase: &str, path: &str, auto_confirm: bool, sas_wordlist: Option<&str>) -> Result<()> {
use std::fs::File; use std::io::{Read};
let client = MailboxClient::new(relay_url.to_string(), appid.to_string());
let mut sup = MailboxSupervisor::new(client, core::journal::NoopJournal);
sup.ensure_connected().await?;
sup.client_mut().allocate().await?; let nameplate = loop { if let Some(MbServerMsg::NameplateAllocated { nameplate }) = sup.poll_next().await { break nameplate; } };
println!("Nameplate allocated: {}", nameplate);
println!("Receiver code: {}-{}", nameplate, code);
sup.client_mut().claim(&nameplate).await?; let mailbox = loop { if let Some(MbServerMsg::Claimed { mailbox }) = sup.poll_next().await { break mailbox; } }; sup.client_mut().open(&mailbox).await?;
// CONTROL handshake
let our_side = sup.client_mut().side().to_string(); let mut pio = MailboxPhaseIO { client: sup.client_mut(), phase: control_phase.to_string(), our_side: our_side.clone() };
let versions = serde_json::json!({"can-dilate": []}); let caps = serde_json::json!({"suite":"anubis","sec_cat":5,"kem":"ML-KEM-1024","aead":"AES-256-GCM-SIV","kdf":"HKDF-SHA512"}); let (ini, _sid) = core::control::initiator_handshake_over(&mut pio, code, versions, caps).await;
let dict = sas_dict(sas_wordlist); let sas = two_word_sas(&dict, &ini.keys.k_verify); let s = format!("{} {}", sas.w1, sas.w2); println!("SAS: {}", s);
if !auto_confirm { use std::io::{self, Write}; print!("Type SAS to confirm: "); io::stdout().flush().ok(); let mut line=String::new(); io::stdin().read_line(&mut line).ok(); if line.trim()!=s { anyhow::bail!("SAS mismatch"); } }
// Chunked mailbox send (demo)
let mut f = File::open(path)?; let meta = std::fs::metadata(path)?; let total = meta.len(); let pb = indicatif::ProgressBar::new(total);
let mut key = [0u8;32]; key.copy_from_slice(&ini.keys.k_data[..32]); let mut sc = core::subchannel::SubchSend::new(sc::DATA).with_capacity(128);
let mut buf = vec![0u8; 64*1024]; let mut sent: u64 = 0;
let mut seq: u64 = 0;
loop {
let n = f.read(&mut buf)?; if n==0 { break; }
let fin = sent + n as u64 >= total;
sc.enqueue(&key, [0u8;16], Role::Initiator, RecordType::Data, &buf[..n], fin)?;
let ct = sc.dequeue().expect("queued");
let mut hdrct = Vec::with_capacity(1+8+ct.len());
let fl = if fin { core::aead_policy::Flags::FIN.bits() } else { 0 };
hdrct.push(fl);
hdrct.extend_from_slice(&seq.to_be_bytes());
hdrct.extend_from_slice(&ct);
sup.client_mut().add(data_phase, &hex::encode(hdrct)).await?;
sent += n as u64; seq += 1; pb.set_position(sent);
if fin { break; }
}
pb.finish(); println!("Sent {} bytes", total);
Ok(())
}
#[cfg(feature = "mailbox-demo")]
#[allow(dead_code)]
async fn recv_file(relay_url: &str, appid: &str, code: &str, control_phase: &str, data_phase: &str, out: &str, auto_confirm: bool, sas_wordlist: Option<&str>) -> Result<()> {
use std::fs::File; use std::io::Write;
let client = MailboxClient::new(relay_url.to_string(), appid.to_string());
let mut sup = MailboxSupervisor::new(client, core::journal::NoopJournal);
sup.ensure_connected().await?;
let nameplate = parse_nameplate_from_code(code).ok_or_else(|| anyhow::anyhow!("code must start with digits (nameplate), e.g., '7-words'"))?;
sup.client_mut().claim(&nameplate).await?; let mailbox = loop { if let Some(MbServerMsg::Claimed { mailbox }) = sup.poll_next().await { break mailbox; } }; sup.client_mut().open(&mailbox).await?;
// CONTROL handshake
let our_side = sup.client_mut().side().to_string(); let mut pio = MailboxPhaseIO { client: sup.client_mut(), phase: control_phase.to_string(), our_side: our_side.clone() };
let (_, code_words) = split_nameplate_and_words(code);
let versions = serde_json::json!({"can-dilate": []}); let caps = serde_json::json!({"suite":"anubis","sec_cat":5,"kem":"ML-KEM-1024","aead":"AES-256-GCM-SIV","kdf":"HKDF-SHA512"}); let (res, _sid) = core::control::responder_handshake_over(&mut pio, &code_words, versions, caps).await;
let dict = sas_dict(sas_wordlist); let sas = two_word_sas(&dict, &res.keys.k_verify); let s = format!("{} {}", sas.w1, sas.w2); println!("SAS: {}", s);
if !auto_confirm { use std::io::{self, Write}; print!("Type SAS to confirm: "); io::stdout().flush().ok(); let mut line=String::new(); io::stdin().read_line(&mut line).ok(); if line.trim()!=s { anyhow::bail!("SAS mismatch"); } }
// Receive records until FIN
let mut file = File::create(out)?; let mut recv = core::subchannel::SubchRecv::new(sc::DATA); let mut key=[0u8;32]; key.copy_from_slice(&res.keys.k_data[..32]);
loop {
if let Some(MbServerMsg::Message { side, phase, body }) = sup.poll_next().await {
if phase==data_phase && side!=our_side {
let raw = hex::decode(body)?; if raw.len() < 9 { continue; }
let flags = core::aead_policy::Flags::from_bits_truncate(raw[0]);
let mut seqb=[0u8;8]; seqb.copy_from_slice(&raw[1..9]); let seq=u64::from_be_bytes(seqb);
let ct = &raw[9..];
if let Ok(pt)=recv.open_with_journal(&key, [0u8;16], Role::Responder, RecordType::Data, ct, flags, seq, &mut core::journal::NoopJournal) {
file.write_all(&pt)?;
if flags.contains(core::aead_policy::Flags::FIN) { break; }
}
}
}
}
println!("Received to {}", out);
Ok(())
}
#[cfg(feature = "mailbox-demo")]
fn parse_tcp(s: &str) -> anyhow::Result<(String,u16)> {
if let Some(rest) = s.strip_prefix("tcp:") {
if let Some((h,p)) = rest.rsplit_once(':') {
let port: u16 = p.parse()?; return Ok((h.to_string(), port));
}
}
anyhow::bail!("invalid transit: use tcp:HOST:PORT")
}
#[cfg(feature = "mailbox-demo")]
async fn send_file_transit(relay_url: &str, appid: &str, code: &str, control_phase: &str, transit_spec: &str, path: &str, auto_confirm: bool, sas_wordlist: Option<&str>) -> Result<()> {
use std::fs::File; use std::io::Read;
// CONTROL over mailbox for keys
let client = MailboxClient::new(relay_url.to_string(), appid.to_string()); let mut sup = MailboxSupervisor::new(client, core::journal::NoopJournal);
sup.ensure_connected().await?; sup.client_mut().allocate().await?; let nameplate = loop { if let Some(MbServerMsg::NameplateAllocated { nameplate }) = sup.poll_next().await { break nameplate; } };
println!("Nameplate allocated: {}", nameplate);
println!("Receiver code: {}-{}", nameplate, code);
sup.client_mut().claim(&nameplate).await?; let mailbox = loop { if let Some(MbServerMsg::Claimed { mailbox }) = sup.poll_next().await { break mailbox; } }; sup.client_mut().open(&mailbox).await?;
let our_side = sup.client_mut().side().to_string(); let mut pio = MailboxPhaseIO { client: sup.client_mut(), phase: control_phase.to_string(), our_side: our_side.clone() };
let versions = serde_json::json!({"can-dilate": []}); let caps = serde_json::json!({"suite":"anubis","sec_cat":5,"kem":"ML-KEM-1024","aead":"AES-256-GCM-SIV","kdf":"HKDF-SHA512"}); let (ini, _sid) = core::control::initiator_handshake_over(&mut pio, code, versions, caps).await;
let dict = sas_dict(sas_wordlist); let sas = two_word_sas(&dict, &ini.keys.k_verify); let s = format!("{} {}", sas.w1, sas.w2); println!("SAS: {}", s);
if !auto_confirm { use std::io::{self, Write}; print!("Type SAS to confirm: "); io::stdout().flush().ok(); let mut line=String::new(); io::stdin().read_line(&mut line).ok(); if line.trim()!=s { anyhow::bail!("SAS mismatch"); } }
// Exchange transit abilities/hints over CONTROL (built after optional direct listener below)
// Start direct listener (if not using Tor)
let (_maybe_listener, my_direct_hints): (Option<tokio::net::TcpListener>, Vec<serde_json::Value>) = if !tor_enabled() {
if let Ok((lst, addrs, port)) = transit::net::start_direct_listener().await { let hints = addrs.into_iter().map(|a| serde_json::json!({"type":"direct-tcp-v1","hostname":a,"port":port})).collect(); (Some(lst), hints) } else { (None, vec![]) }
} else { (None, vec![]) };
let mut hints_json: Vec<serde_json::Value> = vec![serde_json::json!({
"type":"relay-v1", "hostname": parse_tcp(transit_spec)?.0, "port": parse_tcp(transit_spec)?.1
})];
hints_json.extend(my_direct_hints.into_iter());
let info = serde_json::json!({
"abilities": {"direct-tcp-v1": !tor_enabled(), "relay-v1": true},
"hints": hints_json
});
let peer = core::control::exchange_transit_info_over(&mut pio, &ini.keys, core::aead_policy::Role::Initiator, [0u8;16], info).await;
// Build hint list and race
let mut hints = vec![transit::hints::Hint::Relay { hostname: parse_tcp(transit_spec)?.0, port: parse_tcp(transit_spec)?.1, priority: None }];
if let Some(arr) = peer.get("hints").and_then(|v| v.as_array()) {
for h in arr {
if h["type"].as_str()==Some("relay-v1") {
if let (Some(host), Some(port)) = (h["hostname"].as_str(), h["port"].as_u64()) {
hints.push(transit::hints::Hint::Relay { hostname: host.to_string(), port: port as u16, priority: None });
}
}
if h["type"].as_str()==Some("direct-tcp-v1") {
if let (Some(host), Some(port)) = (h["hostname"].as_str(), h["port"].as_u64()) {
hints.push(transit::hints::Hint::DirectTcp { hostname: host.to_string(), port: port as u16, priority: None });
}
}
}
}
let ts = tor_socks();
let socks = ts.as_ref().map(|(h,p)| (h.as_str(), *p));
let mut futs = FuturesUnordered::new();
futs.push(transit::net::race_connect(hints, Some(&ini.keys.k_data), socks).map(|r| r));
let mut stream = None; while let Some(res) = futs.next().await { if let Ok(s) = res { stream = Some(s); break; } }
let mut stream = stream.ok_or_else(|| anyhow::anyhow!("no transit path"))?;
// Stream framed records: 4B len + ciphertext per chunk
let mut f = File::open(path)?; let meta = std::fs::metadata(path)?; let total = meta.len(); let pb = indicatif::ProgressBar::new(total); let mut sent=0u64; let mut seq=0u64; let mut buf=vec![0u8; 128*1024]; let mut key=[0u8;32]; key.copy_from_slice(&ini.keys.k_data[..32]);
loop { let n=f.read(&mut buf)?; if n==0 { break; } let fin = sent + n as u64 >= total; let ct = anubis_wormhole::transit::record::seal_data_record(&key, [0u8;16], core::aead_policy::sc::DATA, seq, &buf[..n], fin, core::aead_policy::Role::Initiator); let len = (ct.len() as u32).to_be_bytes(); stream.write_all(&len).await?; stream.write_all(&ct).await?; sent+=n as u64; seq+=1; pb.set_position(sent); if fin { break; } }
pb.finish(); println!("Sent {} bytes via transit", total);
Ok(())
}
#[cfg(feature = "mailbox-demo")]
async fn recv_file_transit(relay_url: &str, appid: &str, code: &str, control_phase: &str, transit_spec: &str, out: &str, auto_confirm: bool, sas_wordlist: Option<&str>) -> Result<()> {
use std::fs::File; use std::io::Write;
let client = MailboxClient::new(relay_url.to_string(), appid.to_string()); let mut sup = MailboxSupervisor::new(client, core::journal::NoopJournal);
sup.ensure_connected().await?;
// Claim sender's nameplate from code (digits prefix)
let nameplate = parse_nameplate_from_code(code).ok_or_else(|| anyhow::anyhow!("code must start with digits (nameplate), e.g., '7-words'"))?;
sup.client_mut().claim(&nameplate).await?; let mailbox = loop { if let Some(MbServerMsg::Claimed { mailbox }) = sup.poll_next().await { break mailbox; } };
sup.client_mut().open(&mailbox).await?;
let our_side = sup.client_mut().side().to_string(); let mut pio = MailboxPhaseIO { client: sup.client_mut(), phase: control_phase.to_string(), our_side: our_side.clone() };
// Strip nameplate prefix from PAKE code
let (_, code_words) = split_nameplate_and_words(code);
let versions = serde_json::json!({"can-dilate": []}); let caps = serde_json::json!({"suite":"anubis","sec_cat":5,"kem":"ML-KEM-1024","aead":"AES-256-GCM-SIV","kdf":"HKDF-SHA512"}); let (res, _sid) = core::control::responder_handshake_over(&mut pio, &code_words, versions, caps).await;
let dict = sas_dict(sas_wordlist); let sas = two_word_sas(&dict, &res.keys.k_verify); let s = format!("{} {}", sas.w1, sas.w2); println!("SAS: {}", s);
if !auto_confirm { use std::io::{self, Write}; print!("Type SAS to confirm: "); io::stdout().flush().ok(); let mut line=String::new(); io::stdin().read_line(&mut line).ok(); if line.trim()!=s { anyhow::bail!("SAS mismatch"); } }
let (_maybe_listener, my_direct_hints): (Option<tokio::net::TcpListener>, Vec<serde_json::Value>) = if !tor_enabled() {
if let Ok((lst, addrs, port)) = transit::net::start_direct_listener().await { let hints = addrs.into_iter().map(|a| serde_json::json!({"type":"direct-tcp-v1","hostname":a,"port":port})).collect(); (Some(lst), hints) } else { (None, vec![]) }
} else { (None, vec![]) };
let mut hints_json: Vec<serde_json::Value> = vec![serde_json::json!({
"type":"relay-v1", "hostname": parse_tcp(transit_spec)?.0, "port": parse_tcp(transit_spec)?.1
})];
hints_json.extend(my_direct_hints.into_iter());
let info = serde_json::json!({
"abilities": {"direct-tcp-v1": !tor_enabled(), "relay-v1": true},
"hints": hints_json
});
let peer = core::control::exchange_transit_info_over(&mut pio, &res.keys, core::aead_policy::Role::Responder, [0u8;16], info).await;
let mut hints = vec![transit::hints::Hint::Relay { hostname: parse_tcp(transit_spec)?.0, port: parse_tcp(transit_spec)?.1, priority: None }];
if let Some(arr) = peer.get("hints").and_then(|v| v.as_array()) {
for h in arr {
if h["type"].as_str()==Some("relay-v1") {
if let (Some(host), Some(port)) = (h["hostname"].as_str(), h["port"].as_u64()) {
hints.push(transit::hints::Hint::Relay { hostname: host.to_string(), port: port as u16, priority: None });
}
}
if h["type"].as_str()==Some("direct-tcp-v1") {
if let (Some(host), Some(port)) = (h["hostname"].as_str(), h["port"].as_u64()) {
hints.push(transit::hints::Hint::DirectTcp { hostname: host.to_string(), port: port as u16, priority: None });
}
}
}
}
let ts = tor_socks();
let socks = ts.as_ref().map(|(h,p)| (h.as_str(), *p));
let mut futs = FuturesUnordered::new();
futs.push(transit::net::race_connect(hints, Some(&res.keys.k_data), socks).map(|r| r));
let mut stream = None; while let Some(res) = futs.next().await { if let Ok(s) = res { stream = Some(s); break; } }
let mut stream = stream.ok_or_else(|| anyhow::anyhow!("no transit path"))?;
let mut key=[0u8;32]; key.copy_from_slice(&res.keys.k_data[..32]); let mut file=File::create(out)?; let mut seq=0u64; loop { let mut lenb=[0u8;4]; if stream.read_exact(&mut lenb).await.is_err(){break;} let len=u32::from_be_bytes(lenb) as usize; let mut ct=vec![0u8;len]; stream.read_exact(&mut ct).await?; let flags = core::aead_policy::Flags::empty(); if let Some(pt) = anubis_wormhole::transit::record::open_data_record(&key, [0u8;16], core::aead_policy::sc::DATA, seq, flags, &ct, core::aead_policy::Role::Responder) { file.write_all(&pt)?; seq+=1; } }
println!("Received via transit to {}", out);
Ok(())
}
// Simple in-memory PhaseIO
#[cfg(feature = "mailbox-demo")]
struct MemChan { _a_to_b: Vec<Vec<u8>>, _b_to_a: Vec<Vec<u8>> }
#[cfg(feature = "mailbox-demo")]
impl MemChan { fn new() -> Self { Self { _a_to_b: vec![], _b_to_a: vec![] } } }
#[cfg(feature = "mailbox-demo")]
#[allow(dead_code)]
struct IOA<'a>(&'a mut MemChan);
#[cfg(feature = "mailbox-demo")]
#[allow(dead_code)]
struct IOB<'a>(&'a mut MemChan);
#[cfg(feature = "mailbox-demo")]
#[async_trait::async_trait]
impl<'a> core::control::PhaseIO for IOA<'a> {
async fn send(&mut self, bytes: &[u8]) { self.0._a_to_b.push(bytes.to_vec()); }
async fn recv(&mut self) -> Option<Vec<u8>> { if self.0._b_to_a.is_empty(){None}else{Some(self.0._b_to_a.remove(0))} }
}
#[cfg(feature = "mailbox-demo")]
#[async_trait::async_trait]
impl<'a> core::control::PhaseIO for IOB<'a> {
async fn send(&mut self, bytes: &[u8]) { self.0._b_to_a.push(bytes.to_vec()); }
async fn recv(&mut self) -> Option<Vec<u8>> { if self.0._a_to_b.is_empty(){None}else{Some(self.0._a_to_b.remove(0))} }
}
#[cfg(feature = "mailbox-demo")]
fn demo_control(_code: &str, _auto_confirm: bool, _sas_wordlist: Option<&str>) -> Result<()> {
let _bus = MemChan::new();
// Spawn responder half first to be ready to recv SPAKE msg_a
let _versions = serde_json::json!({"can-dilate": []});
let _caps = serde_json::json!({"suite":"anubis","sec_cat":5,"kem":"ml-kem-1024","sig":"ml-dsa-87","aead":"AES-256-GCM-SIV","kdf":"HKDF-SHA512"});
// Since we can't run async functions in this context, we need to use a different approach
// This is a demo function that should be rewritten to be async or use a runtime
println!("Demo control handshake simulation - async version needed");
Ok(())
}
#[cfg(feature = "mailbox-demo")]
async fn compat_pake_send_text(relay_url: &str, appid: &str, code: &str, data_phase: &str, message: &str, timeout_secs: u64) -> Result<()> {
use spake2::{Ed25519Group, Identity, Password, Spake2};
let mut client = MailboxClient::new(relay_url.to_string(), appid.to_string());
if let Some((h,p)) = tor_socks() { client.set_socks(Some((h,p))); }
let mut sup = MailboxSupervisor::new(client, core::journal::NoopJournal);
sup.ensure_connected().await?;
// Session setup
sup.client_mut().allocate().await?;
let nameplate = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), async {
loop { if let Some(MbServerMsg::NameplateAllocated { nameplate }) = sup.poll_next().await { break nameplate; } }
}).await.map_err(|_| anyhow::anyhow!("timeout waiting for nameplate"))?;
println!("Nameplate allocated: {}", nameplate);
println!("Receiver code: {}-{}", nameplate, code);
sup.client_mut().claim(&nameplate).await?;
let mailbox = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), async {
loop { if let Some(MbServerMsg::Claimed { mailbox }) = sup.poll_next().await { break mailbox; } }
}).await.map_err(|_| anyhow::anyhow!("timeout waiting for mailbox"))?;
sup.client_mut().open(&mailbox).await?;
// SPAKE initiator (pake_v1 JSON)
let our_side = sup.client_mut().side().to_string();
let pw = Password::new(code.as_bytes());
let (state_a, msg_a) = Spake2::<Ed25519Group>::start_a(&pw, &Identity::new(&[]), &Identity::new(&[]));
let pake_json = serde_json::json!({"pake_v1": hex::encode(&msg_a)}).to_string();
sup.client_mut().add("pake", &pake_json).await?;
// Wait for responder pake_v1
let msg_b = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), async {
loop {
if let Some(MbServerMsg::Message { side, phase, body }) = sup.poll_next().await {
if phase == "pake" && side != our_side {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(h) = v.get("pake_v1").and_then(|x| x.as_str()) { if let Ok(bytes) = hex::decode(h) { break bytes; } }
}
}
}
}
}).await.map_err(|_| anyhow::anyhow!("timeout waiting for responder pake_v1"))?;
let spake_key = state_a.finish(&msg_b).map_err(|_| anyhow::anyhow!("spake finish failed"))?;
// Send version encrypted with SecretBox
let caps = serde_json::json!({"can-dilate": []});
let caps_bytes = serde_json::to_vec(&caps)?;
let vkey = compat_secretbox_phase_key_sha256(&spake_key, &our_side, "version");
let vct = compat_secretbox_encrypt(&vkey, &caps_bytes)?;
sup.client_mut().add("version", &hex::encode(vct)).await?;
// Send data on numeric phase
let dkey = compat_secretbox_phase_key_sha256(&spake_key, &our_side, data_phase);
let dct = compat_secretbox_encrypt(&dkey, message.as_bytes())?;
sup.client_mut().add(data_phase, &hex::encode(dct)).await?;
tracing::info!(target: "anubis.cli", "compat_pake: sent version+data");
Ok(())
}
#[cfg(feature = "mailbox-demo")]
async fn compat_pake_recv_text(relay_url: &str, appid: &str, code: &str, data_phase: &str, timeout_secs: u64) -> Result<()> {
use spake2::{Ed25519Group, Identity, Password, Spake2};
let mut client = MailboxClient::new(relay_url.to_string(), appid.to_string());
if let Some((h,p)) = tor_socks() { client.set_socks(Some((h,p))); }
let mut sup = MailboxSupervisor::new(client, core::journal::NoopJournal);
sup.ensure_connected().await?;
// Session setup
let nameplate = parse_nameplate_from_code(code).ok_or_else(|| anyhow::anyhow!("code must start with digits (nameplate), e.g., '7-words'"))?;
sup.client_mut().claim(&nameplate).await?;
let mailbox = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), async {
loop { if let Some(MbServerMsg::Claimed { mailbox }) = sup.poll_next().await { break mailbox; } }
}).await.map_err(|_| anyhow::anyhow!("timeout waiting for mailbox"))?;
sup.client_mut().open(&mailbox).await?;
let our_side = sup.client_mut().side().to_string();
// Responder: wait for initiator pake_v1
let (state_b, msg_b, msg_a_bytes) = {
let (_, code_words) = split_nameplate_and_words(code);
let pw = Password::new(code_words.as_bytes());
let (state_b, msg_b) = Spake2::<Ed25519Group>::start_b(&pw, &Identity::new(&[]), &Identity::new(&[]));
let msg_a = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), async {
loop {
if let Some(MbServerMsg::Message { side, phase, body }) = sup.poll_next().await {
if phase == "pake" && side != our_side {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(h) = v.get("pake_v1").and_then(|x| x.as_str()) { if let Ok(bytes) = hex::decode(h) { break bytes; } }
}
}
}
}
}).await.map_err(|_| anyhow::anyhow!("timeout waiting for initiator pake_v1"))?;
(state_b, msg_b, msg_a)
};
// Reply with our pake_v1
let pake_json = serde_json::json!({"pake_v1": hex::encode(&msg_b)}).to_string();
sup.client_mut().add("pake", &pake_json).await?;
let spake_key = state_b.finish(&msg_a_bytes).map_err(|_| anyhow::anyhow!("spake finish failed"))?;
// Wait for version
let _peer_caps = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), async {
loop {
if let Some(MbServerMsg::Message { side, phase, body }) = sup.poll_next().await {
if phase == "version" && side != our_side {
let raw = hex::decode(body).unwrap_or_default();
let vkey = compat_secretbox_phase_key_sha256(&spake_key, &our_side, "version");
if let Ok(pt) = compat_secretbox_decrypt(&vkey, &raw) { if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&pt) { break v; } }
}
}
}
}).await.map_err(|_| anyhow::anyhow!("timeout waiting for version"))?;
// Wait for data phase
let pt = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), async {
loop {
if let Some(MbServerMsg::Message { side, phase, body }) = sup.poll_next().await {
if phase == data_phase && side != our_side {
let raw = hex::decode(body).unwrap_or_default();
let dkey = compat_secretbox_phase_key_sha256(&spake_key, &our_side, data_phase);
if let Ok(pt) = compat_secretbox_decrypt(&dkey, &raw) { break pt; }
}
}
}
}).await.map_err(|_| anyhow::anyhow!("timeout waiting for data phase"))?;
println!("{}", String::from_utf8_lossy(&pt));
Ok(())
}