diff --git a/Cargo.lock b/Cargo.lock index e1c9b13..9d6d5d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -164,6 +164,21 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "assert_cmd" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -212,15 +227,6 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -[[package]] -name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest 0.10.7", -] - [[package]] name = "block-buffer" version = "0.9.0" @@ -239,6 +245,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -570,7 +587,9 @@ dependencies = [ "curve25519-dalek-derive", "digest 0.10.7", "fiat-crypto", + "rand_core 0.6.4", "rustc_version", + "serde", "subtle", "zeroize", ] @@ -632,6 +651,23 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive-where" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.9.0" @@ -653,6 +689,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -663,6 +710,7 @@ dependencies = [ "digest 0.10.7", "elliptic-curve", "rfc6979", + "serdect", "signature 2.2.0", "spki", ] @@ -683,6 +731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ "pkcs8", + "serde", "signature 2.2.0", ] @@ -711,6 +760,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "sha2 0.10.9", + "signature 2.2.0", "subtle", "zeroize", ] @@ -738,6 +788,7 @@ dependencies = [ "pkcs8", "rand_core 0.6.4", "sec1", + "serdect", "subtle", "zeroize", ] @@ -788,6 +839,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "ff" version = "0.13.1" @@ -904,6 +961,7 @@ version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ + "serde", "typenum", "version_check", "zeroize", @@ -1198,6 +1256,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "lock_api" version = "0.4.14" @@ -1308,6 +1372,29 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "opaque-ke" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded22991b43cd15561b62b2e1cf9ace1344a8534eebec96202d5c96a77a6616a" +dependencies = [ + "curve25519-dalek 4.1.3", + "derive-where", + "digest 0.10.7", + "displaydoc", + "ecdsa", + "ed25519-dalek 2.2.0", + "elliptic-curve", + "generic-array", + "hkdf", + "hmac", + "rand 0.8.5", + "serde", + "subtle", + "voprf", + "zeroize", +] + [[package]] name = "openmls" version = "0.5.0" @@ -1509,6 +1596,15 @@ dependencies = [ "universal-hash 0.5.1", ] +[[package]] +name = "portpicker" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be97d76faf1bfab666e1375477b23fde79eccf0276e9b63b92a39d676a889ba9" +dependencies = [ + "rand 0.8.5", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1524,6 +1620,33 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -1547,21 +1670,26 @@ name = "quicnprotochat-client" version = "0.1.0" dependencies = [ "anyhow", + "assert_cmd", "bincode", "capnp", "capnp-rpc", "clap", "dashmap", "futures", + "opaque-ke", "openmls_rust_crypto", + "portpicker", "quicnprotochat-core", "quicnprotochat-proto", "quinn", "quinn-proto", + "rand 0.8.5", "rustls", "serde", "serde_json", "sha2 0.10.9", + "tempfile", "thiserror 1.0.69", "tokio", "tokio-util", @@ -1574,13 +1702,12 @@ name = "quicnprotochat-core" version = "0.1.0" dependencies = [ "bincode", - "bytes", "capnp", "chacha20poly1305 0.10.1", "ed25519-dalek 2.2.0", - "futures", "hkdf", "ml-kem", + "opaque-ke", "openmls", "openmls_rust_crypto", "openmls_traits", @@ -1589,11 +1716,9 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "snow", "thiserror 1.0.69", "tls_codec 0.3.0", "tokio", - "tokio-util", "x25519-dalek", "zeroize", ] @@ -1617,10 +1742,12 @@ dependencies = [ "clap", "dashmap", "futures", + "opaque-ke", "quicnprotochat-core", "quicnprotochat-proto", "quinn", "quinn-proto", + "rand 0.8.5", "rcgen", "rusqlite", "rustls", @@ -1924,6 +2051,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.36" @@ -2038,6 +2178,7 @@ dependencies = [ "der", "generic-array", "pkcs8", + "serdect", "subtle", "zeroize", ] @@ -2123,6 +2264,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha2" version = "0.9.9" @@ -2216,22 +2367,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "snow" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "850948bee068e713b8ab860fe1adc4d109676ab4c3b621fd8147f06b261f2f85" -dependencies = [ - "aes-gcm 0.10.3", - "blake2", - "chacha20poly1305 0.10.1", - "curve25519-dalek 4.1.3", - "rand_core 0.6.4", - "rustc_version", - "sha2 0.10.9", - "subtle", -] - [[package]] name = "socket2" version = "0.6.2" @@ -2281,6 +2416,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "1.0.69" @@ -2615,6 +2769,34 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "voprf" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28f59c30c76e2fea54cdece6a054e2662feffa7ab19658a7887524265ee39470" +dependencies = [ + "curve25519-dalek 4.1.3", + "derive-where", + "digest 0.10.7", + "displaydoc", + "elliptic-curve", + "generic-array", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -3021,6 +3203,7 @@ version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ + "serde", "zeroize_derive", ] diff --git a/Cargo.toml b/Cargo.toml index e36b515..cf267b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ ed25519-dalek = { version = "2", features = ["rand_core"] } sha2 = { version = "0.10" } hkdf = { version = "0.12" } chacha20poly1305 = { version = "0.10" } +opaque-ke = { version = "4", features = ["ristretto255"] } zeroize = { version = "1", features = ["derive"] } rand = { version = "0.8" } serde = { version = "1", features = ["derive"] } diff --git a/crates/quicnprotochat-client/Cargo.toml b/crates/quicnprotochat-client/Cargo.toml index 96caf2f..5380d66 100644 --- a/crates/quicnprotochat-client/Cargo.toml +++ b/crates/quicnprotochat-client/Cargo.toml @@ -26,6 +26,10 @@ serde = { workspace = true } serde_json = { workspace = true } bincode = { workspace = true } +# Crypto — OPAQUE PAKE +opaque-ke = { workspace = true } +rand = { workspace = true } + # Error handling anyhow = { workspace = true } thiserror = { workspace = true } @@ -44,5 +48,7 @@ tracing-subscriber = { workspace = true } clap = { workspace = true } [dev-dependencies] -# Integration tests use quicnprotochat-core, quicnprotochat-proto, and capnp-rpc directly. dashmap = { workspace = true } +assert_cmd = "2" +tempfile = "3" +portpicker = "0.1" diff --git a/crates/quicnprotochat-client/tests/e2e.rs b/crates/quicnprotochat-client/tests/e2e.rs new file mode 100644 index 0000000..396a3ed --- /dev/null +++ b/crates/quicnprotochat-client/tests/e2e.rs @@ -0,0 +1,164 @@ +use std::{path::PathBuf, process::Command, time::Duration}; + +use assert_cmd::cargo::cargo_bin; +use portpicker::pick_unused_port; +use tempfile::TempDir; +use tokio::time::sleep; + +use quicnprotochat_client::{ + cmd_create_group, cmd_invite, cmd_join, cmd_ping, cmd_register_state, cmd_send, ClientAuth, + connect_node, fetch_wait, init_auth, +}; +use quicnprotochat_core::IdentityKeypair; + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[derive(serde::Deserialize)] +struct StoredStateCompat { + identity_seed: [u8; 32], + #[allow(dead_code)] + group: Option>, +} + +async fn wait_for_health(server: &str, ca_cert: &PathBuf, server_name: &str) -> anyhow::Result<()> { + let local = tokio::task::LocalSet::new(); + for _ in 0..30 { + if local + .run_until(cmd_ping(server, ca_cert, server_name)) + .await + .is_ok() + { + return Ok(()); + } + sleep(Duration::from_millis(200)).await; + } + anyhow::bail!("server health never became ready") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> { + let temp = TempDir::new()?; + let base = temp.path(); + let port = pick_unused_port().expect("free port"); + let listen = format!("127.0.0.1:{port}"); + let server = listen.clone(); + let ca_cert = base.join("server-cert.der"); + let tls_key = base.join("server-key.der"); + let data_dir = base.join("data"); + let auth_token = "devtoken"; + + // Spawn server binary. + let server_bin = cargo_bin("quicnprotochat-server"); + let mut child = Command::new(server_bin) + .arg("--listen") + .arg(&listen) + .arg("--data-dir") + .arg(&data_dir) + .arg("--tls-cert") + .arg(&ca_cert) + .arg("--tls-key") + .arg(&tls_key) + .arg("--auth-token") + .arg(auth_token) + .spawn() + .expect("spawn server"); + + // Ensure we always terminate the child. + struct ChildGuard(std::process::Child); + impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + } + } + let child_guard = ChildGuard(child); + let _ = child_guard; + + // Wait for server to be healthy and certs to be generated. + wait_for_health(&server, &ca_cert, "localhost").await?; + + // Set client auth context. + init_auth(ClientAuth::from_parts(auth_token.to_string(), None)); + + // LocalSet for capnp !Send operations. + let local = tokio::task::LocalSet::new(); + + let alice_state = base.join("alice.bin"); + let bob_state = base.join("bob.bin"); + + local + .run_until(cmd_register_state( + &alice_state, + &server, + &ca_cert, + "localhost", + )) + .await?; + + local + .run_until(cmd_register_state( + &bob_state, + &server, + &ca_cert, + "localhost", + )) + .await?; + + local + .run_until(cmd_create_group( + &alice_state, + &server, + "test-group", + )) + .await?; + + // Load Bob identity key from persisted state to use as peer key. + let bob_bytes = std::fs::read(&bob_state)?; + let bob_state_compat: StoredStateCompat = bincode::deserialize(&bob_bytes)?; + let bob_identity = IdentityKeypair::from_seed(bob_state_compat.identity_seed); + let bob_pk_hex = hex_encode(&bob_identity.public_key_bytes()); + + local + .run_until(cmd_invite( + &alice_state, + &server, + &ca_cert, + "localhost", + &bob_pk_hex, + )) + .await?; + + local + .run_until(cmd_join( + &bob_state, + &server, + &ca_cert, + "localhost", + )) + .await?; + + // Send Alice -> Bob. + local + .run_until(cmd_send( + &alice_state, + &server, + &ca_cert, + "localhost", + &bob_pk_hex, + "hello bob", + )) + .await?; + + // Confirm Bob can fetch at least one payload. + local + .run_until(async { + let client = connect_node(&server, &ca_cert, "localhost").await?; + let payloads = fetch_wait(&client, &bob_identity.public_key_bytes(), 1000).await?; + anyhow::ensure!(!payloads.is_empty(), "no payloads delivered to Bob"); + Ok::<(), anyhow::Error>(()) + }) + .await?; + + Ok(()) +} diff --git a/crates/quicnprotochat-core/Cargo.toml b/crates/quicnprotochat-core/Cargo.toml index f256e84..d091bbe 100644 --- a/crates/quicnprotochat-core/Cargo.toml +++ b/crates/quicnprotochat-core/Cargo.toml @@ -18,6 +18,9 @@ rand = { workspace = true } # Crypto — post-quantum hybrid KEM (M7) ml-kem = { workspace = true } +# Crypto — OPAQUE password-authenticated key exchange +opaque-ke = { workspace = true } + # Crypto — MLS (M2) openmls = { workspace = true } openmls_rust_crypto = { workspace = true } diff --git a/crates/quicnprotochat-core/src/lib.rs b/crates/quicnprotochat-core/src/lib.rs index f28e667..86107a7 100644 --- a/crates/quicnprotochat-core/src/lib.rs +++ b/crates/quicnprotochat-core/src/lib.rs @@ -18,6 +18,7 @@ pub mod hybrid_kem; mod identity; mod keypackage; mod keystore; +pub mod opaque_auth; // ── Public API ──────────────────────────────────────────────────────────────── diff --git a/crates/quicnprotochat-core/src/opaque_auth.rs b/crates/quicnprotochat-core/src/opaque_auth.rs new file mode 100644 index 0000000..125e57e --- /dev/null +++ b/crates/quicnprotochat-core/src/opaque_auth.rs @@ -0,0 +1,22 @@ +//! Shared OPAQUE (RFC 9497) cipher suite configuration. +//! +//! Both client and server import this module to ensure they use exactly +//! the same cryptographic parameters during registration and login. + +use opaque_ke::CipherSuite; + +/// OPAQUE cipher suite for quicnprotochat. +/// +/// - **OPRF**: Ristretto255 (curve25519-based, ~128-bit security) +/// - **Key exchange**: Triple-DH (3DH) over Ristretto255 with SHA-512 +/// - **KSF**: Identity (no key stretching; upgrade to Argon2 later) +pub struct OpaqueSuite; + +impl CipherSuite for OpaqueSuite { + type OprfCs = opaque_ke::Ristretto255; + type KeyExchange = opaque_ke::key_exchange::tripledh::TripleDh< + opaque_ke::Ristretto255, + sha2::Sha512, + >; + type Ksf = opaque_ke::ksf::Identity; +} diff --git a/crates/quicnprotochat-server/Cargo.toml b/crates/quicnprotochat-server/Cargo.toml index 86eb57d..18294db 100644 --- a/crates/quicnprotochat-server/Cargo.toml +++ b/crates/quicnprotochat-server/Cargo.toml @@ -32,6 +32,10 @@ quinn-proto = { workspace = true } rustls = { workspace = true } rcgen = { workspace = true } +# Crypto — OPAQUE PAKE +opaque-ke = { workspace = true } +rand = { workspace = true } + # Database rusqlite = { workspace = true } diff --git a/crates/quicnprotochat-server/src/sql_store.rs b/crates/quicnprotochat-server/src/sql_store.rs index 9ba6714..b8145fd 100644 --- a/crates/quicnprotochat-server/src/sql_store.rs +++ b/crates/quicnprotochat-server/src/sql_store.rs @@ -75,7 +75,18 @@ impl SqlStore { ON key_packages(identity_key); CREATE INDEX IF NOT EXISTS idx_del_recipient_channel - ON deliveries(recipient_key, channel_id);", + ON deliveries(recipient_key, channel_id); + + CREATE TABLE IF NOT EXISTS server_setup ( + id INTEGER PRIMARY KEY CHECK (id = 1), + setup_data BLOB NOT NULL + ); + + CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + opaque_record BLOB NOT NULL, + created_at INTEGER DEFAULT (strftime('%s','now')) + );", ) .map_err(|e| StorageError::Db(e.to_string()))?; Ok(()) @@ -203,6 +214,48 @@ impl Store for SqlStore { .optional() .map_err(|e| StorageError::Db(e.to_string())) } + + fn store_server_setup(&self, setup: Vec) -> Result<(), StorageError> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT OR REPLACE INTO server_setup (id, setup_data) VALUES (1, ?1)", + params![setup], + ) + .map_err(|e| StorageError::Db(e.to_string()))?; + Ok(()) + } + + fn get_server_setup(&self) -> Result>, StorageError> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare("SELECT setup_data FROM server_setup WHERE id = 1") + .map_err(|e| StorageError::Db(e.to_string()))?; + + stmt.query_row([], |row| row.get(0)) + .optional() + .map_err(|e| StorageError::Db(e.to_string())) + } + + fn store_user_record(&self, username: &str, record: Vec) -> Result<(), StorageError> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT OR REPLACE INTO users (username, opaque_record) VALUES (?1, ?2)", + params![username, record], + ) + .map_err(|e| StorageError::Db(e.to_string()))?; + Ok(()) + } + + fn get_user_record(&self, username: &str) -> Result>, StorageError> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare("SELECT opaque_record FROM users WHERE username = ?1") + .map_err(|e| StorageError::Db(e.to_string()))?; + + stmt.query_row(params![username], |row| row.get(0)) + .optional() + .map_err(|e| StorageError::Db(e.to_string())) + } } /// Convenience extension for `rusqlite::OptionalExtension`. diff --git a/crates/quicnprotochat-server/src/storage.rs b/crates/quicnprotochat-server/src/storage.rs index 437b816..673726c 100644 --- a/crates/quicnprotochat-server/src/storage.rs +++ b/crates/quicnprotochat-server/src/storage.rs @@ -50,6 +50,18 @@ pub trait Store: Send + Sync { ) -> Result<(), StorageError>; fn fetch_hybrid_key(&self, identity_key: &[u8]) -> Result>, StorageError>; + + /// Store the OPAQUE `ServerSetup` (generated once, loaded on restart). + fn store_server_setup(&self, setup: Vec) -> Result<(), StorageError>; + + /// Load the persisted `ServerSetup`, if any. + fn get_server_setup(&self) -> Result>, StorageError>; + + /// Store an OPAQUE user record (serialized `ServerRegistration`). + fn store_user_record(&self, username: &str, record: Vec) -> Result<(), StorageError>; + + /// Retrieve an OPAQUE user record by username. + fn get_user_record(&self, username: &str) -> Result>, StorageError>; } // ── ChannelKey ─────────────────────────────────────────────────────────────── @@ -86,9 +98,12 @@ pub struct FileBackedStore { kp_path: PathBuf, ds_path: PathBuf, hk_path: PathBuf, + setup_path: PathBuf, + users_path: PathBuf, key_packages: Mutex, VecDeque>>>, deliveries: Mutex>>>, hybrid_keys: Mutex, Vec>>, + users: Mutex>>, } impl FileBackedStore { @@ -100,18 +115,24 @@ impl FileBackedStore { let kp_path = dir.join("keypackages.bin"); let ds_path = dir.join("deliveries.bin"); let hk_path = dir.join("hybridkeys.bin"); + let setup_path = dir.join("server_setup.bin"); + let users_path = dir.join("users.bin"); let key_packages = Mutex::new(Self::load_kp_map(&kp_path)?); let deliveries = Mutex::new(Self::load_delivery_map(&ds_path)?); let hybrid_keys = Mutex::new(Self::load_hybrid_keys(&hk_path)?); + let users = Mutex::new(Self::load_users(&users_path)?); Ok(Self { kp_path, ds_path, hk_path, + setup_path, + users_path, key_packages, deliveries, hybrid_keys, + users, }) } @@ -201,6 +222,29 @@ impl FileBackedStore { } fs::write(path, bytes).map_err(|e| StorageError::Io(e.to_string())) } + + fn load_users(path: &Path) -> Result>, StorageError> { + if !path.exists() { + return Ok(HashMap::new()); + } + let bytes = fs::read(path).map_err(|e| StorageError::Io(e.to_string()))?; + if bytes.is_empty() { + return Ok(HashMap::new()); + } + bincode::deserialize(&bytes).map_err(|_| StorageError::Serde) + } + + fn flush_users( + &self, + path: &Path, + map: &HashMap>, + ) -> Result<(), StorageError> { + let bytes = bincode::serialize(map).map_err(|_| StorageError::Serde)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| StorageError::Io(e.to_string()))?; + } + fs::write(path, bytes).map_err(|e| StorageError::Io(e.to_string())) + } } impl Store for FileBackedStore { @@ -272,4 +316,33 @@ impl Store for FileBackedStore { let map = self.hybrid_keys.lock().unwrap(); Ok(map.get(identity_key).cloned()) } + + fn store_server_setup(&self, setup: Vec) -> Result<(), StorageError> { + if let Some(parent) = self.setup_path.parent() { + fs::create_dir_all(parent).map_err(|e| StorageError::Io(e.to_string()))?; + } + fs::write(&self.setup_path, setup).map_err(|e| StorageError::Io(e.to_string())) + } + + fn get_server_setup(&self) -> Result>, StorageError> { + if !self.setup_path.exists() { + return Ok(None); + } + let bytes = fs::read(&self.setup_path).map_err(|e| StorageError::Io(e.to_string()))?; + if bytes.is_empty() { + return Ok(None); + } + Ok(Some(bytes)) + } + + fn store_user_record(&self, username: &str, record: Vec) -> Result<(), StorageError> { + let mut map = self.users.lock().unwrap(); + map.insert(username.to_string(), record); + self.flush_users(&self.users_path, &*map) + } + + fn get_user_record(&self, username: &str) -> Result>, StorageError> { + let map = self.users.lock().unwrap(); + Ok(map.get(username).cloned()) + } } diff --git a/schemas/node.capnp b/schemas/node.capnp index 268c6d7..878e13f 100644 --- a/schemas/node.capnp +++ b/schemas/node.capnp @@ -37,6 +37,22 @@ interface NodeService { # Fetch a peer's hybrid public key (for post-quantum envelope encryption). fetchHybridKey @7 (identityKey :Data) -> (hybridPublicKey :Data); + + # ── OPAQUE password-authenticated registration ────────────────────────── + + # Start OPAQUE registration: client sends blinded password element. + opaqueRegisterStart @8 (username :Text, request :Data) -> (response :Data); + + # Finish OPAQUE registration: client uploads sealed credential envelope. + opaqueRegisterFinish @9 (username :Text, upload :Data) -> (success :Bool); + + # ── OPAQUE password-authenticated login ───────────────────────────────── + + # Start OPAQUE login: client sends credential request. + opaqueLoginStart @10 (username :Text, request :Data) -> (response :Data); + + # Finish OPAQUE login: client sends credential finalization, receives session token. + opaqueLoginFinish @11 (username :Text, finalization :Data) -> (sessionToken :Data); } struct Auth {