chore: rename project quicnprotochat -> quicproquo (binaries: qpq)

Rename the entire workspace:
- Crate packages: quicnprotochat-{core,proto,server,client,gui,p2p,mobile} -> quicproquo-*
- Binary names: quicnprotochat -> qpq, quicnprotochat-server -> qpq-server,
  quicnprotochat-gui -> qpq-gui
- Default files: *-state.bin -> qpq-state.bin, *-server.toml -> qpq-server.toml,
  *.db -> qpq.db
- Environment variable prefix: QUICNPROTOCHAT_* -> QPQ_*
- App identifier: chat.quicnproto.gui -> chat.quicproquo.gui
- Proto package: quicnprotochat.bench -> quicproquo.bench
- All documentation, Docker, CI, and script references updated

HKDF domain-separation strings and P2P ALPN remain unchanged for
backward compatibility with existing encrypted state and wire protocol.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 20:11:51 +01:00
parent 553de3a2b7
commit 853ca4fec0
152 changed files with 4070 additions and 788 deletions

12
.github/CODEOWNERS vendored
View File

@@ -1,4 +1,4 @@
# Code owners for quicnprotochat. PRs require review from owners. # Code owners for quicproquo. PRs require review from owners.
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners # See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
# Replace 'maintainers' with your GitHub user/team handle. # Replace 'maintainers' with your GitHub user/team handle.
@@ -6,10 +6,10 @@
* @maintainers * @maintainers
# Crate-specific (uncomment and add handles when you have designated owners) # Crate-specific (uncomment and add handles when you have designated owners)
# /crates/quicnprotochat-core/ @owner1 # /crates/quicproquo-core/ @owner1
# /crates/quicnprotochat-proto/ @owner1 # /crates/quicproquo-proto/ @owner1
# /crates/quicnprotochat-server/ @owner1 # /crates/quicproquo-server/ @owner1
# /crates/quicnprotochat-client/ @owner1 # /crates/quicproquo-client/ @owner1
# /crates/quicnprotochat-p2p/ @owner1 # /crates/quicproquo-p2p/ @owner1
# /schemas/ @owner1 # /schemas/ @owner1
# /docs/ @owner1 # /docs/ @owner1

2
.gitignore vendored
View File

@@ -7,4 +7,4 @@ docs/book/
# Server/client runtime data — do not commit certs, keys, or DBs # Server/client runtime data — do not commit certs, keys, or DBs
data/ data/
*.der *.der
quicnprotochat-server.toml qpq-server.toml

301
Cargo.lock generated
View File

@@ -132,6 +132,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]] [[package]]
name = "anstream" name = "anstream"
version = "0.6.21" version = "0.6.21"
@@ -536,6 +542,12 @@ dependencies = [
"toml 0.9.12+spec-1.1.0", "toml 0.9.12+spec-1.1.0",
] ]
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.56" version = "1.2.56"
@@ -648,6 +660,33 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]] [[package]]
name = "cipher" name = "cipher"
version = "0.3.0" version = "0.3.0"
@@ -813,6 +852,42 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "criterion"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
dependencies = [
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"is-terminal",
"itertools 0.10.5",
"num-traits",
"once_cell",
"oorandom",
"plotters",
"rayon",
"regex",
"serde",
"serde_derive",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
"itertools 0.10.5",
]
[[package]] [[package]]
name = "crossbeam-channel" name = "crossbeam-channel"
version = "0.5.15" version = "0.5.15"
@@ -847,6 +922,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "crypto-bigint" name = "crypto-bigint"
version = "0.5.5" version = "0.5.5"
@@ -1129,9 +1210,9 @@ checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
[[package]] [[package]]
name = "dispatch2" name = "dispatch2"
version = "0.3.0" version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"objc2", "objc2",
@@ -1961,6 +2042,17 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -2423,12 +2515,41 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.2" version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.17" version = "1.0.17"
@@ -2492,9 +2613,9 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.88" version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7e709f3e3d22866f9c25b3aff01af289b18422cc8b4262fb19103ee80fe513d" checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"wasm-bindgen", "wasm-bindgen",
@@ -2624,11 +2745,10 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.12" version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
dependencies = [ dependencies = [
"bitflags 2.11.0",
"libc", "libc",
] ]
@@ -2953,9 +3073,9 @@ dependencies = [
[[package]] [[package]]
name = "objc2" name = "objc2"
version = "0.6.3" version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
dependencies = [ dependencies = [
"objc2-encode", "objc2-encode",
"objc2-exception-helper", "objc2-exception-helper",
@@ -3184,6 +3304,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]] [[package]]
name = "opaque-debug" name = "opaque-debug"
version = "0.3.1" version = "0.3.1"
@@ -3531,9 +3657,9 @@ dependencies = [
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.16" version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]] [[package]]
name = "pin-utils" name = "pin-utils"
@@ -3570,6 +3696,34 @@ dependencies = [
"time", "time",
] ]
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]] [[package]]
name = "png" name = "png"
version = "0.17.16" version = "0.17.16"
@@ -3787,6 +3941,29 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "prost"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-derive"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "quanta" name = "quanta"
version = "0.12.6" version = "0.12.6"
@@ -3812,7 +3989,7 @@ dependencies = [
] ]
[[package]] [[package]]
name = "quicnprotochat-client" name = "quicproquo-client"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
@@ -3829,8 +4006,8 @@ dependencies = [
"opaque-ke", "opaque-ke",
"openmls_rust_crypto", "openmls_rust_crypto",
"portpicker", "portpicker",
"quicnprotochat-core", "quicproquo-core",
"quicnprotochat-proto", "quicproquo-proto",
"quinn", "quinn",
"quinn-proto", "quinn-proto",
"rand 0.8.5", "rand 0.8.5",
@@ -3850,13 +4027,14 @@ dependencies = [
] ]
[[package]] [[package]]
name = "quicnprotochat-core" name = "quicproquo-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"bincode", "bincode",
"capnp", "capnp",
"chacha20poly1305 0.10.1", "chacha20poly1305 0.10.1",
"criterion",
"ed25519-dalek 2.2.0", "ed25519-dalek 2.2.0",
"hkdf", "hkdf",
"ml-kem", "ml-kem",
@@ -3864,7 +4042,8 @@ dependencies = [
"openmls", "openmls",
"openmls_rust_crypto", "openmls_rust_crypto",
"openmls_traits", "openmls_traits",
"quicnprotochat-proto", "prost",
"quicproquo-proto",
"rand 0.8.5", "rand 0.8.5",
"serde", "serde",
"serde_json", "serde_json",
@@ -3877,12 +4056,12 @@ dependencies = [
] ]
[[package]] [[package]]
name = "quicnprotochat-gui" name = "quicproquo-gui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"quicnprotochat-client", "quicproquo-client",
"quicnprotochat-core", "quicproquo-core",
"quicnprotochat-proto", "quicproquo-proto",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -3891,7 +4070,18 @@ dependencies = [
] ]
[[package]] [[package]]
name = "quicnprotochat-proto" name = "quicproquo-mobile"
version = "0.1.0"
dependencies = [
"anyhow",
"quinn",
"rcgen",
"rustls",
"tokio",
]
[[package]]
name = "quicproquo-proto"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"capnp", "capnp",
@@ -3899,7 +4089,7 @@ dependencies = [
] ]
[[package]] [[package]]
name = "quicnprotochat-server" name = "quicproquo-server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
@@ -3909,11 +4099,12 @@ dependencies = [
"clap", "clap",
"dashmap", "dashmap",
"futures", "futures",
"hex",
"metrics 0.22.4", "metrics 0.22.4",
"metrics-exporter-prometheus", "metrics-exporter-prometheus",
"opaque-ke", "opaque-ke",
"quicnprotochat-core", "quicproquo-core",
"quicnprotochat-proto", "quicproquo-proto",
"quinn", "quinn",
"quinn-proto", "quinn-proto",
"rand 0.8.5", "rand 0.8.5",
@@ -4228,9 +4419,9 @@ dependencies = [
[[package]] [[package]]
name = "regex-syntax" name = "regex-syntax"
version = "0.8.9" version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]] [[package]]
name = "reqwest" name = "reqwest"
@@ -4350,9 +4541,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.36" version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"log", "log",
@@ -4669,9 +4860,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.16.1" version = "3.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
@@ -4688,9 +4879,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_with_macros" name = "serde_with_macros"
version = "3.16.1" version = "3.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0"
dependencies = [ dependencies = [
"darling", "darling",
"proc-macro2", "proc-macro2",
@@ -5308,9 +5499,9 @@ dependencies = [
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.25.0" version = "3.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.4.1", "getrandom 0.4.1",
@@ -5426,6 +5617,16 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.10.0" version = "1.10.0"
@@ -6060,9 +6261,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.111" version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec1adf1535672f5b7824f817792b1afd731d7e843d2d04ec8f27e8cb51edd8ac" checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -6073,9 +6274,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-futures" name = "wasm-bindgen-futures"
version = "0.4.61" version = "0.4.64"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe88540d1c934c4ec8e6db0afa536876c5441289d7f9f9123d4f065ac1250a6b" checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"futures-util", "futures-util",
@@ -6087,9 +6288,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.111" version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19e638317c08b21663aed4d2b9a2091450548954695ff4efa75bff5fa546b3b1" checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -6097,9 +6298,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.111" version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c64760850114d03d5f65457e96fc988f11f01d38fbaa51b254e4ab5809102af" checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@@ -6110,9 +6311,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.111" version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60eecd4fe26177cfa3339eb00b4a36445889ba3ad37080c2429879718e20ca41" checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -6166,9 +6367,9 @@ dependencies = [
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.88" version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d6bb20ed2d9572df8584f6dc81d68a41a625cadc6f15999d649a70ce7e3597a" checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -6953,18 +7154,18 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.39" version = "0.8.40"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.39" version = "0.8.40"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",

View File

@@ -1,16 +1,17 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = [ members = [
"crates/quicnprotochat-core", "crates/quicproquo-core",
"crates/quicnprotochat-proto", "crates/quicproquo-proto",
"crates/quicnprotochat-server", "crates/quicproquo-server",
"crates/quicnprotochat-client", "crates/quicproquo-client",
"crates/quicnprotochat-gui", "crates/quicproquo-gui",
"crates/quicproquo-mobile",
] ]
# P2P-Crate (iroh-Transport) ist vom Default-Build ausgeschlossen, # P2P-Crate (iroh-Transport) ist vom Default-Build ausgeschlossen,
# um ~90 exklusive iroh-Dependencies nicht mitzukompilieren. # um ~90 exklusive iroh-Dependencies nicht mitzukompilieren.
# Quellcode bleibt im Repo für spätere Integration. # Quellcode bleibt im Repo für spätere Integration.
exclude = ["crates/quicnprotochat-p2p"] exclude = ["crates/quicproquo-p2p"]
# Shared dependency versions — bump here to affect the whole workspace. # Shared dependency versions — bump here to affect the whole workspace.
[workspace.dependencies] [workspace.dependencies]
@@ -55,6 +56,9 @@ rcgen = { version = "0.13" }
# ── Database ───────────────────────────────────────────────────────────── # ── Database ─────────────────────────────────────────────────────────────
rusqlite = { version = "0.31", features = ["bundled-sqlcipher"] } rusqlite = { version = "0.31", features = ["bundled-sqlcipher"] }
# ── Encoding ─────────────────────────────────────────────────────────────────
hex = { version = "0.4" }
# ── Server utilities ────────────────────────────────────────────────────────── # ── Server utilities ──────────────────────────────────────────────────────────
dashmap = { version = "5" } dashmap = { version = "5" }
tracing = { version = "0.1" } tracing = { version = "0.1" }

View File

@@ -1,4 +1,4 @@
# quicnprotochat # quicproquo
> End-to-end encrypted group messaging over **QUIC + TLS 1.3 + MLS** (RFC 9420), written in Rust. > End-to-end encrypted group messaging over **QUIC + TLS 1.3 + MLS** (RFC 9420), written in Rust.
@@ -70,31 +70,31 @@ cargo build --workspace
cargo test --workspace cargo test --workspace
# Start the server (port 7000 by default) # Start the server (port 7000 by default)
cargo run -p quicnprotochat-server cargo run -p quicproquo-server
# Or via a config file (TOML) # Or via a config file (TOML)
# Note: auth_token = "devtoken" and db_key = "" are for development only. # Note: auth_token = "devtoken" and db_key = "" are for development only.
# Production: set QUICNPROTOCHAT_AUTH_TOKEN to a strong secret and (when store_backend = "sql") # Production: set QPQ_AUTH_TOKEN to a strong secret and (when store_backend = "sql")
# set QUICNPROTOCHAT_DB_KEY so the database is encrypted. Empty db_key = plaintext DB (insecure). # set QPQ_DB_KEY so the database is encrypted. Empty db_key = plaintext DB (insecure).
cat > quicnprotochat-server.toml <<'EOF' cat > qpq-server.toml <<'EOF'
listen = "0.0.0.0:7000" listen = "0.0.0.0:7000"
data_dir = "data" data_dir = "data"
tls_cert = "data/server-cert.der" tls_cert = "data/server-cert.der"
tls_key = "data/server-key.der" tls_key = "data/server-key.der"
auth_token = "devtoken" auth_token = "devtoken"
store_backend = "file" # or "sql" store_backend = "file" # or "sql"
db_path = "data/quicnprotochat.db" db_path = "data/qpq.db"
db_key = "" db_key = ""
EOF EOF
cargo run -p quicnprotochat-server -- --config quicnprotochat-server.toml cargo run -p quicproquo-server -- --config qpq-server.toml
# Run the two-party demo # Run the two-party demo
cargo run -p quicnprotochat-client -- demo-group \ cargo run -p quicproquo-client -- demo-group \
--server 127.0.0.1:7000 --server 127.0.0.1:7000
# Interactive 1:1 chat (after creating a group and inviting a peer) # Interactive 1:1 chat (after creating a group and inviting a peer)
# Terminal 1: quicnprotochat chat --peer-key <other_identity_hex> # Terminal 1: qpq chat --peer-key <other_identity_hex>
# Terminal 2: quicnprotochat chat --peer-key <first_identity_hex> # Terminal 2: qpq chat --peer-key <first_identity_hex>
# Type messages and press Enter; incoming messages appear as [peer] <msg>. Ctrl+D to exit. # Type messages and press Enter; incoming messages appear as [peer] <msg>. Ctrl+D to exit.
``` ```
@@ -121,10 +121,10 @@ See the [full demo walkthrough](docs/src/getting-started/demo-walkthrough.md) fo
To build only the server and CLI client (faster, no Tauri/WebKit): To build only the server and CLI client (faster, no Tauri/WebKit):
```bash ```bash
cargo build -p quicnprotochat-server -p quicnprotochat-client cargo build -p quicproquo-server -p quicproquo-client
``` ```
Core and proto crates are built as dependencies. Omit `quicnprotochat-gui` and `quicnprotochat-p2p` if you don't need them. Core and proto crates are built as dependencies. Omit `quicproquo-gui` and `quicproquo-p2p` if you don't need them.
--- ---
@@ -135,7 +135,7 @@ This is a **proof-of-concept research project**. It has not undergone a formal t
- **Dependency checks:** Run `cargo install cargo-audit && cargo audit` to check for known vulnerabilities. - **Dependency checks:** Run `cargo install cargo-audit && cargo audit` to check for known vulnerabilities.
- **Certificate pinning:** Use the server's certificate as `--ca-cert` (e.g. copy `server-cert.der` from the server) so the client only trusts that server; see [Certificate pinning](docs/SECURITY-AUDIT.md#certificate-pinning) in the security audit. - **Certificate pinning:** Use the server's certificate as `--ca-cert` (e.g. copy `server-cert.der` from the server) so the client only trusts that server; see [Certificate pinning](docs/SECURITY-AUDIT.md#certificate-pinning) in the security audit.
**Production deployment:** Set `QUICNPROTOCHAT_PRODUCTION=1` and provide a strong `QUICNPROTOCHAT_AUTH_TOKEN` (not `devtoken`). When using `store_backend = "sql"`, set `QUICNPROTOCHAT_DB_KEY`; an empty key leaves the database unencrypted on disk. **Production deployment:** Set `QPQ_PRODUCTION=1` and provide a strong `QPQ_AUTH_TOKEN` (not `devtoken`). When using `store_backend = "sql"`, set `QPQ_DB_KEY`; an empty key leaves the database unencrypted on disk.
--- ---

376
ROADMAP.md Normal file
View File

@@ -0,0 +1,376 @@
# Roadmap — quicproquo
> From proof-of-concept to production-grade E2E encrypted messaging.
>
> Each phase is designed to be tackled sequentially. Items within a phase
> can be parallelised. Check the box when done.
---
## Phase 1 — Production Hardening (Critical)
Eliminate all crash paths, enforce secure defaults, fix deployment blockers.
- [ ] **1.1 Remove `.unwrap()` / `.expect()` from production paths**
- Replace `AUTH_CONTEXT.read().expect()` in client RPC with proper `Result`
- Replace `"0.0.0.0:0".parse().unwrap()` in client with fallible parse
- Replace `Mutex::lock().unwrap()` in server storage with `.map_err()`
- Audit: `grep -rn 'unwrap()\|expect(' crates/` outside `#[cfg(test)]`
- [ ] **1.2 Enforce secure defaults in production mode**
- Reject startup if `QPQ_PRODUCTION=true` and `auth_token` is empty or `"devtoken"`
- Require non-empty `db_key` when using SQL backend in production
- Refuse to auto-generate TLS certs in production mode (require existing cert+key)
- Already partially implemented — verify and harden the validation in `config.rs`
- [ ] **1.3 Fix `.gitignore`**
- Add `data/`, `*.der`, `*.pem`, `*.db`, `*.bin` (state files), `*.ks` (keystores)
- Verify no secrets are already tracked: `git ls-files data/ *.der *.db`
- [ ] **1.4 Fix Dockerfile**
- Sync workspace members (handle excluded `p2p` crate)
- Create dedicated user/group instead of `nobody`
- Set writable `QPQ_DATA_DIR` with correct permissions
- Test: `docker build . && docker run --rm -it qpq-server --help`
- [ ] **1.5 TLS certificate lifecycle**
- Document CA-signed cert setup (Let's Encrypt / custom CA)
- Add `--tls-required` flag that refuses to start without valid cert
- Log clear warning when using self-signed certs
- Document certificate rotation procedure
---
## Phase 2 — Test & CI Maturity
Build confidence before adding features.
- [ ] **2.1 Expand E2E test coverage**
- Auth failure scenarios (wrong password, expired token, invalid token)
- Message ordering verification (send N messages, verify seq numbers)
- Concurrent clients (3+ members in group, simultaneous send/recv)
- OPAQUE registration + login full flow
- Queue full behavior (>1000 messages)
- Rate limiting behavior (>100 enqueues/minute)
- Reconnection after server restart
- KeyPackage exhaustion (fetch when none available)
- [ ] **2.2 Add unit tests for untested paths**
- Client retry logic (exponential backoff, jitter, retriable classification)
- REPL input parsing edge cases (empty input, special characters, `/` commands)
- State file encryption/decryption round-trip with bad password
- Token cache expiry
- Conversation store migrations
- [ ] **2.3 CI hardening**
- Add `.github/CODEOWNERS` (crypto, auth, wire-format require 2 reviewers)
- Ensure `cargo deny check` runs on every PR (already in CI — verify)
- Add `cargo audit` as blocking check (already in CI — verify)
- Add coverage reporting (tarpaulin or llvm-cov)
- Add CI job for Docker build validation
- [ ] **2.4 Clean up build warnings**
- Fix Cap'n Proto generated `unused_parens` warnings
- Remove dead code / unused imports
- Address `openmls` future-incompat warnings
- Target: `cargo clippy --workspace -- -D warnings` passes clean
---
## Phase 3 — Client SDKs: Native QUIC + Cap'n Proto Everywhere
**No REST gateway. No protocol dilution.** The `.capnp` schemas are the
interface definition. Every SDK speaks native QUIC + Cap'n Proto. The
project name stays honest.
### Why this matters
The name is **quic**n**proto**chat — the protocol IS the product. Instead
of adding an HTTP translation layer that loses zero-copy performance and
adds base64 overhead, we invest in making the native protocol accessible
from every language that has QUIC + Cap'n Proto support, and provide
WASM/FFI for the crypto layer.
### Architecture
```
Server: QUIC + Cap'n Proto (single protocol, no gateway)
Client SDKs:
┌─── Rust quinn + capnp-rpc (existing, reference impl)
├─── Go quic-go + go-capnp (native, high confidence)
├─── Python aioquic + pycapnp (native QUIC, manual framing)
├─── C/C++ msquic/ngtcp2 + capnproto (reference impl, full RPC)
└─── Browser WebTransport + capnp (WASM) (QUIC transport, no HTTP needed)
Crypto layer (client-side MLS, shared across all SDKs):
┌─── Rust crate (native, existing)
├─── WASM module (browsers, Node.js, Deno)
└─── C FFI (Swift, Kotlin, Python, Go via cgo)
```
### Language support reality check
| Language | QUIC | Cap'n Proto | RPC | Confidence |
|----------|------|-------------|-----|------------|
| **Rust** | quinn ✅ | capnp-rpc ✅ | Full ✅ | Existing |
| **Go** | quic-go ✅ | go-capnp ✅ | Level 1 ✅ | High |
| **Python** | aioquic ✅ | pycapnp ⚠️ | Manual framing | Medium |
| **C/C++** | msquic/ngtcp2 ✅ | capnproto ✅ | Full ✅ | High |
| **Browser** | WebTransport ✅ | WASM ✅ | Via WASM bridge | Medium |
### Implementation
- [ ] **3.1 Go SDK (`quicproquo-go`)**
- Generate Go types: `capnp compile -ogo schemas/node.capnp`
- QUIC transport: `quic-go` with TLS 1.3 + ALPN `"capnp"`
- Cap'n Proto RPC framing over QUIC bidirectional stream
- Auth context: bearer token + session management
- Retry with exponential backoff (mirror Rust client pattern)
- Publish: `go get git.xorwell.de/c/quicproquo-go`
- Example: CLI client matching Rust feature set
- [ ] **3.2 Python SDK (`quicproquo-py`)**
- QUIC transport: `aioquic` with custom Cap'n Proto stream handler
- Cap'n Proto serialization: `pycapnp` for message types
- Manual RPC framing: length-prefixed request/response over QUIC stream
- Async/await API matching the Rust client patterns
- Crypto: PyO3 bindings to `quicproquo-core` for MLS operations
- Publish: PyPI `quicproquo`
- Example: async bot client
- [ ] **3.3 C FFI layer (`quicproquo-ffi`)**
- New crate in workspace: `crates/quicproquo-ffi`
- `cbindgen` to generate `quicproquo.h` C header
- Crypto functions: `qpc_identity_new()`, `qpc_group_create()`,
`qpc_encrypt()`, `qpc_decrypt()`, `qpc_key_package_generate()`
- Transport functions: `qpc_connect()`, `qpc_enqueue()`, `qpc_fetch()`,
`qpc_fetch_wait()` (bundles QUIC + Cap'n Proto internally)
- Memory: caller-allocated buffers with length, no ownership transfer
- Builds as `libquicproquo.so` / `.dylib` / `.dll`
- Swift and Kotlin wrapper examples using the C header
- [ ] **3.4 WASM compilation of `quicproquo-core`**
- `wasm-pack build` target for browser + Node.js
- Crypto-only: `GroupMember`, `IdentityKeypair`, `AppMessage`,
`hybrid_encrypt/decrypt`, `generate_key_package`
- Transport NOT included (browsers use WebTransport, see Phase 3.5)
- Publish to npm: `@quicproquo/core`
- TypeScript type definitions auto-generated via `wasm-bindgen`
- [ ] **3.5 WebTransport server endpoint**
- Add HTTP/3 + WebTransport listener to server (same QUIC stack via quinn)
- Cap'n Proto RPC framed over WebTransport bidirectional streams
- Same auth, same storage, same RPC handlers — just a different stream source
- Browsers connect via `new WebTransport("https://server:7443")`
- ALPN negotiation: `"h3"` for WebTransport, `"capnp"` for native QUIC
- Configurable port: `--webtransport-listen 0.0.0.0:7443`
- Feature-flagged: `--features webtransport`
- [ ] **3.6 TypeScript/JavaScript SDK (`@quicproquo/client`)**
- WebTransport for QUIC connectivity (no HTTP fallback)
- WASM module (Phase 3.4) for MLS crypto
- Cap'n Proto serialization via WASM bridge
- Handles: auth flow, key upload, message send/receive, group management
- Publish to npm: `@quicproquo/client`
- Example: browser chat UI
- [ ] **3.7 SDK documentation and schema publishing**
- Publish `.capnp` schemas as the canonical API contract
- Document the QUIC + Cap'n Proto connection pattern for each language
- Provide a "build your own SDK" guide (QUIC stream → Cap'n Proto RPC bootstrap)
- Reference implementation checklist: connect, auth, upload key, enqueue, fetch
---
## Phase 4 — Trust & Security Infrastructure
Address the security gaps required for real-world deployment.
- [ ] **4.1 Third-party cryptographic audit**
- Scope: MLS integration, OPAQUE flow, hybrid KEM, key lifecycle, zeroization
- Firms: NCC Group, Trail of Bits, Cure53
- Budget and timeline: typically 4-6 weeks, $50K$150K
- Publish report publicly (builds trust)
- [ ] **4.2 Key Transparency / revocation**
- Replace `BasicCredential` with X.509-based MLS credentials
- Or: verifiable key directory (Merkle tree, auditable log)
- Users can verify peer keys haven't been substituted (MITM detection)
- Revocation mechanism for compromised keys
- [ ] **4.3 Client authentication on Delivery Service**
- Currently server trusts claimed identity key on enqueue
- Bind enqueue operations to the authenticated session's identity key
- Prevent: client A fetching/sending as client B's identity
- Backward compat: sealed_sender mode for anonymous enqueue
- [ ] **4.4 M7 — Post-quantum MLS integration**
- Integrate hybrid KEM (X25519 + ML-KEM-768) into the OpenMLS crypto provider
- Group key material gets post-quantum confidentiality
- Full test suite with PQ ciphersuite
- Ref: existing `hybrid_kem.rs` and `hybrid_crypto.rs`
- [ ] **4.5 Username enumeration mitigation**
- Constant-time or uniform response for unknown users during OPAQUE login
- Prevent timing side-channels that reveal user existence
---
## Phase 5 — Features & UX
Make it a product people want to use.
- [ ] **5.1 Multi-device support**
- Account → multiple devices, each with own Ed25519 key + MLS KeyPackages
- Device graph management (add device, remove device, list devices)
- Messages delivered to all devices of a user
- `device_id` field already in Auth struct — wire it through
- [ ] **5.2 Account recovery**
- Recovery codes or backup key (encrypted, stored by user)
- Option: server-assisted recovery with security questions (lower security)
- MLS state re-establishment after device loss
- [ ] **5.3 Full MLS lifecycle**
- Member removal (Remove proposal → Commit → fan-out)
- Credential update (Update proposal for key rotation)
- Explicit proposal handling (queue proposals, batch commit)
- Group metadata (name, description, avatar hash)
- [ ] **5.4 Message editing and deletion**
- New `AppMessage` variants: `Edit { target_seq, new_content }`, `Delete { target_seq }`
- Client-side tombstones, server doesn't know about edits
- [ ] **5.5 File and media transfer**
- Upload encrypted blob → get content hash
- Share hash + symmetric key inside MLS message
- Download by hash, decrypt client-side
- Size limits, content-type validation
- [ ] **5.6 Abuse prevention and moderation**
- Block user (client-side, suppress display)
- Report message (encrypted report to admin key)
- Admin tools: ban user, delete account, audit log
- [ ] **5.7 Offline message queue (client-side)**
- Queue messages when disconnected, send on reconnect
- Idempotent message IDs to prevent duplicates
- Gap detection: compare local seq with server seq
---
## Phase 6 — Scale & Operations
Prepare for real traffic.
- [ ] **6.1 Distributed rate limiting**
- Current: in-memory per-process, lost on restart
- Move to Redis or shared state for multi-node deployments
- Sliding window with configurable thresholds
- [ ] **6.2 Multi-node / horizontal scaling**
- Stateless server design (already mostly there — state is in storage backend)
- Shared PostgreSQL or CockroachDB backend (replace SQLite)
- Message queue fan-out (Redis pub/sub or NATS for cross-node notification)
- Load balancer health check via QUIC RPC `health()` or Prometheus `/metrics`
- [ ] **6.3 Operational runbook**
- Backup / restore procedures (SQLCipher, file backend)
- Key rotation (auth token, TLS cert, DB encryption key)
- Incident response playbook
- Scaling guide (when to add nodes, resource sizing)
- Monitoring dashboard templates (Grafana + Prometheus)
- [ ] **6.4 Connection draining and graceful shutdown**
- Stop accepting new connections on SIGTERM
- Wait for in-flight RPCs (configurable timeout, default 30s)
- Drain WebTransport sessions with close frame
- Document expected behavior for load balancers (health → unhealthy first)
- [ ] **6.5 Request-level timeouts**
- Per-RPC timeout (prevent slow clients from holding resources)
- Database query timeout
- Overall request deadline propagation
- [ ] **6.6 Observability enhancements**
- Request correlation IDs (trace across RPC → storage)
- Storage operation latency metrics
- Per-endpoint latency histograms
- Structured audit log to persistent storage (not just stdout)
- OpenTelemetry integration
---
## Phase 7 — Platform Expansion & Research
Long-term vision for wide adoption.
- [ ] **7.1 Mobile clients (iOS + Android)**
- Use C FFI (Phase 3.3) for crypto + transport (single library)
- Push notifications via APNs / FCM (server sends notification on enqueue)
- Background QUIC connection for message polling
- Biometric auth for local key storage (Keychain / Android Keystore)
- [ ] **7.2 Web client (browser)**
- Use WASM (Phase 3.4) for crypto
- Use WebTransport (Phase 3.5) for native QUIC transport
- Cap'n Proto via WASM bridge (Phase 3.6)
- IndexedDB for local state persistence
- Service Worker for background notifications
- Progressive Web App (PWA) support
- [ ] **7.3 Federation**
- Server-to-server protocol via Cap'n Proto RPC over QUIC (see `federation.capnp`)
- `relayEnqueue`, `proxyFetchKeyPackage`, `federationHealth` methods
- Identity resolution across federated servers
- MLS group spanning multiple servers
- Trust model for federated deployments
- [ ] **7.4 Sealed Sender**
- Sender identity inside MLS ciphertext only (server can't see who sent)
- Requires: sender certificate + encrypted sender proof
- Ref: Signal's Sealed Sender design
- [ ] **7.5 Additional language SDKs**
- Java/Kotlin: JNI bindings to C FFI (Phase 3.3) + native QUIC (netty-quic)
- Swift: Swift wrapper over C FFI + Network.framework QUIC
- Ruby: FFI bindings via `quicproquo-ffi`
- Evaluate demand-driven — only build SDKs people request
- [ ] **7.6 P2P / NAT traversal**
- Direct peer-to-peer via iroh (foundation exists in `quicproquo-p2p`)
- Server as fallback relay only
- Reduces latency and single-point-of-failure
- Ref: `FUTURE-IMPROVEMENTS.md § 6.1`
- [ ] **7.7 Traffic analysis resistance**
- Padding messages to uniform size
- Decoy traffic to mask timing patterns
- Optional Tor/I2P routing for IP privacy
- Ref: `FUTURE-IMPROVEMENTS.md § 5.4, 6.3`
---
## Summary Timeline
| Phase | Focus | Estimated Effort |
|-------|-------|-----------------|
| **1** | Production Hardening | 12 days |
| **2** | Test & CI Maturity | 23 days |
| **3** | Client SDKs (Go, Python, WASM, FFI, WebTransport) | 58 days |
| **4** | Trust & Security Infrastructure | 24 days (excl. audit) |
| **5** | Features & UX | 57 days |
| **6** | Scale & Operations | 35 days |
| **7** | Platform Expansion & Research | ongoing |
---
## Related Documents
- [Future Improvements](docs/FUTURE-IMPROVEMENTS.md) — consolidated improvement list
- [Production Readiness Audit](docs/PRODUCTION-READINESS-AUDIT.md) — specific blockers
- [Security Audit](docs/SECURITY-AUDIT.md) — findings and recommendations
- [Milestone Tracker](docs/src/roadmap/milestones.md) — M1M7 status
- [Auth, Devices, and Tokens](docs/src/roadmap/authz-plan.md) — authorization design
- [DM Channel Design](docs/src/roadmap/dm-channels.md) — 1:1 channel spec

View File

@@ -1,5 +0,0 @@
//! Desktop entry point for quicnprotochat-gui.
fn main() {
quicnprotochat_gui::run()
}

View File

@@ -1,17 +1,17 @@
[package] [package]
name = "quicnprotochat-client" name = "quicproquo-client"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "CLI client for quicnprotochat." description = "CLI client for quicproquo."
license = "MIT" license = "MIT"
[[bin]] [[bin]]
name = "quicnprotochat" name = "qpq"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
quicnprotochat-core = { path = "../quicnprotochat-core" } quicproquo-core = { path = "../quicproquo-core" }
quicnprotochat-proto = { path = "../quicnprotochat-proto" } quicproquo-proto = { path = "../quicproquo-proto" }
openmls_rust_crypto = { workspace = true } openmls_rust_crypto = { workspace = true }
# Serialisation + RPC # Serialisation + RPC
@@ -54,7 +54,7 @@ clap = { workspace = true }
rusqlite = { workspace = true } rusqlite = { workspace = true }
# Hex encoding/decoding # Hex encoding/decoding
hex = "0.4" hex = { workspace = true }
# Secure password prompting (no echo) # Secure password prompting (no echo)
rpassword = "5" rpassword = "5"

View File

@@ -5,7 +5,7 @@ use opaque_ke::{
ClientLogin, ClientLoginFinishParameters, ClientRegistration, ClientLogin, ClientLoginFinishParameters, ClientRegistration,
ClientRegistrationFinishParameters, CredentialResponse, RegistrationResponse, ClientRegistrationFinishParameters, CredentialResponse, RegistrationResponse,
}; };
use quicnprotochat_core::{ use quicproquo_core::{
generate_key_package, hybrid_decrypt, hybrid_encrypt, opaque_auth::OpaqueSuite, generate_key_package, hybrid_decrypt, hybrid_encrypt, opaque_auth::OpaqueSuite,
GroupMember, HybridKeypair, IdentityKeypair, GroupMember, HybridKeypair, IdentityKeypair,
}; };
@@ -316,7 +316,7 @@ fn derive_identity_for_login(
/// The error message contains "E018" if the user already exists. /// The error message contains "E018" if the user already exists.
/// Does NOT require init_auth() — OPAQUE RPCs are unauthenticated. /// Does NOT require init_auth() — OPAQUE RPCs are unauthenticated.
pub(crate) async fn opaque_register( pub(crate) async fn opaque_register(
client: &quicnprotochat_proto::node_capnp::node_service::Client, client: &quicproquo_proto::node_capnp::node_service::Client,
username: &str, username: &str,
password: &str, password: &str,
identity_key: Option<&[u8]>, identity_key: Option<&[u8]>,
@@ -377,7 +377,7 @@ pub(crate) async fn opaque_register(
/// Perform OPAQUE login and return the raw session token bytes. /// Perform OPAQUE login and return the raw session token bytes.
/// Does NOT require init_auth() — OPAQUE RPCs are unauthenticated. /// Does NOT require init_auth() — OPAQUE RPCs are unauthenticated.
pub(crate) async fn opaque_login( pub(crate) async fn opaque_login(
client: &quicnprotochat_proto::node_capnp::node_service::Client, client: &quicproquo_proto::node_capnp::node_service::Client,
username: &str, username: &str,
password: &str, password: &str,
identity_key: &[u8], identity_key: &[u8],
@@ -646,8 +646,8 @@ pub async fn cmd_fetch_key(
/// Run a two-party MLS demo against the unified server. /// Run a two-party MLS demo against the unified server.
pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> { pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> {
let creator_state_path = PathBuf::from("quicnprotochat-demo-creator.bin"); let creator_state_path = PathBuf::from("qpq-demo-creator.bin");
let joiner_state_path = PathBuf::from("quicnprotochat-demo-joiner.bin"); let joiner_state_path = PathBuf::from("qpq-demo-joiner.bin");
let (mut creator, creator_hybrid_opt) = let (mut creator, creator_hybrid_opt) =
load_or_init_state(&creator_state_path, None)?.into_parts(&creator_state_path)?; load_or_init_state(&creator_state_path, None)?.into_parts(&creator_state_path)?;

View File

@@ -8,11 +8,11 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use anyhow::Context; use anyhow::Context;
use quicnprotochat_core::{ use quicproquo_core::{
AppMessage, DiskKeyStore, GroupMember, IdentityKeypair, hybrid_encrypt, AppMessage, DiskKeyStore, GroupMember, IdentityKeypair, hybrid_encrypt,
parse as parse_app_msg, serialize_chat, parse as parse_app_msg, serialize_chat,
}; };
use quicnprotochat_proto::node_capnp::node_service; use quicproquo_proto::node_capnp::node_service;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::time::interval; use tokio::time::interval;
@@ -291,6 +291,7 @@ async fn auto_upload_keys(
Arc::clone(&session.identity), Arc::clone(&session.identity),
ks, ks,
None, None,
false,
); );
let kp_bytes = member.generate_key_package().context("generate KeyPackage")?; let kp_bytes = member.generate_key_package().context("generate KeyPackage")?;
let id_key = session.identity.public_key_bytes(); let id_key = session.identity.public_key_bytes();
@@ -419,7 +420,7 @@ async fn handle_slash(
fn print_help() { fn print_help() {
display::print_status("Commands:"); display::print_status("Commands:");
display::print_status(" /dm <username> - Start or switch to a DM"); display::print_status(" /dm <user[@domain]> - Start or switch to a DM (federation supported)");
display::print_status(" /create-group <name> - Create a new group"); display::print_status(" /create-group <name> - Create a new group");
display::print_status(" /invite <username> - Invite user to current group"); display::print_status(" /invite <username> - Invite user to current group");
display::print_status(" /join - Join a group from pending Welcome"); display::print_status(" /join - Join a group from pending Welcome");
@@ -542,7 +543,7 @@ async fn cmd_dm(
created_at_ms: now_ms(), created_at_ms: now_ms(),
}; };
let ks = DiskKeyStore::ephemeral(); let ks = DiskKeyStore::ephemeral();
let member = GroupMember::new_with_state(Arc::clone(&session.identity), ks, None); let member = GroupMember::new_with_state(Arc::clone(&session.identity), ks, None, false);
session.add_conversation(conv, member)?; session.add_conversation(conv, member)?;
session.active_conversation = Some(conv_id); session.active_conversation = Some(conv_id);
display::print_status("notes created — messages here are local only"); display::print_status("notes created — messages here are local only");
@@ -573,7 +574,7 @@ async fn cmd_dm(
std::fs::create_dir_all(&ks_dir).ok(); std::fs::create_dir_all(&ks_dir).ok();
let ks_path = ks_dir.join(format!("{}.ks", conv_id.hex())); let ks_path = ks_dir.join(format!("{}.ks", conv_id.hex()));
let ks = DiskKeyStore::persistent(&ks_path)?; let ks = DiskKeyStore::persistent(&ks_path)?;
let mut member = GroupMember::new_with_state(Arc::clone(&session.identity), ks, None); let mut member = GroupMember::new_with_state(Arc::clone(&session.identity), ks, None, false);
// Generate a key package for ourselves (needed for MLS) // Generate a key package for ourselves (needed for MLS)
let _my_kp = member.generate_key_package()?; let _my_kp = member.generate_key_package()?;
@@ -634,7 +635,7 @@ fn cmd_create_group(session: &mut SessionState, name: &str) -> anyhow::Result<()
std::fs::create_dir_all(&ks_dir).ok(); std::fs::create_dir_all(&ks_dir).ok();
let ks_path = ks_dir.join(format!("{}.ks", conv_id.hex())); let ks_path = ks_dir.join(format!("{}.ks", conv_id.hex()));
let ks = DiskKeyStore::persistent(&ks_path)?; let ks = DiskKeyStore::persistent(&ks_path)?;
let mut member = GroupMember::new_with_state(Arc::clone(&session.identity), ks, None); let mut member = GroupMember::new_with_state(Arc::clone(&session.identity), ks, None, false);
let _my_kp = member.generate_key_package()?; let _my_kp = member.generate_key_package()?;
member.create_group(conv_id.0.as_slice())?; member.create_group(conv_id.0.as_slice())?;
@@ -773,6 +774,7 @@ async fn cmd_join(
Arc::clone(&session.identity), Arc::clone(&session.identity),
ks, ks,
None, None,
false,
); );
// Need a key package to decrypt Welcome. // Need a key package to decrypt Welcome.
let _kp = new_member.generate_key_package()?; let _kp = new_member.generate_key_package()?;
@@ -890,6 +892,7 @@ async fn do_send(
.clone(); .clone();
let my_key = session.identity_bytes(); let my_key = session.identity_bytes();
let identity = std::sync::Arc::clone(&session.identity);
let member = session let member = session
.get_member_mut(&conv_id) .get_member_mut(&conv_id)
@@ -917,8 +920,12 @@ async fn do_send(
let app_payload = serialize_chat(text.as_bytes(), None) let app_payload = serialize_chat(text.as_bytes(), None)
.context("serialize app message")?; .context("serialize app message")?;
// Metadata protection: seal sender identity inside payload + pad to bucket size.
let sealed = quicproquo_core::sealed_sender::seal(&identity, &app_payload);
let padded = quicproquo_core::padding::pad(&sealed);
let ct = member let ct = member
.send_message(&app_payload) .send_message(&padded)
.context("MLS send_message failed")?; .context("MLS send_message failed")?;
let recipients: Vec<Vec<u8>> = member let recipients: Vec<Vec<u8>> = member
@@ -997,9 +1004,27 @@ async fn poll_messages(
match member.receive_message(&mls_payload) { match member.receive_message(&mls_payload) {
Ok(Some(plaintext)) => { Ok(Some(plaintext)) => {
// Metadata protection: try unpad → unseal → parse.
// Falls back gracefully for messages from older clients.
let (sender_key, app_bytes) = {
// Step 1: try unpad
let after_unpad = quicproquo_core::padding::unpad(&plaintext)
.unwrap_or_else(|_| plaintext.clone());
// Step 2: try unseal
if quicproquo_core::sealed_sender::is_sealed(&after_unpad) {
match quicproquo_core::sealed_sender::unseal(&after_unpad) {
Ok((sk, inner)) => (sk.to_vec(), inner),
Err(_) => (session.identity_bytes(), after_unpad),
}
} else {
(session.identity_bytes(), after_unpad)
}
};
// Parse structured AppMessage; fall back to raw UTF-8 for legacy. // Parse structured AppMessage; fall back to raw UTF-8 for legacy.
let (body, msg_id, msg_type, ref_msg_id) = let (body, msg_id, msg_type, ref_msg_id) =
match parse_app_msg(&plaintext) { match parse_app_msg(&app_bytes) {
Ok((_, AppMessage::Chat { message_id, body })) => ( Ok((_, AppMessage::Chat { message_id, body })) => (
String::from_utf8_lossy(&body).to_string(), String::from_utf8_lossy(&body).to_string(),
Some(message_id), Some(message_id),
@@ -1021,7 +1046,7 @@ async fn poll_messages(
_ => { _ => {
// Legacy raw plaintext or unknown type. // Legacy raw plaintext or unknown type.
( (
String::from_utf8_lossy(&plaintext).to_string(), String::from_utf8_lossy(&app_bytes).to_string(),
None, None,
"chat", "chat",
None, None,
@@ -1029,8 +1054,6 @@ async fn poll_messages(
} }
}; };
let sender_key = session.identity_bytes(); // fallback
let msg = StoredMessage { let msg = StoredMessage {
conversation_id: conv_id.clone(), conversation_id: conv_id.clone(),
message_id: msg_id, message_id: msg_id,

View File

@@ -10,8 +10,8 @@ use rustls::{ClientConfig as RustlsClientConfig, RootCertStore};
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use capnp_rpc::{rpc_twoparty_capnp::Side, twoparty, RpcSystem}; use capnp_rpc::{rpc_twoparty_capnp::Side, twoparty, RpcSystem};
use quicnprotochat_core::HybridPublicKey; use quicproquo_core::HybridPublicKey;
use quicnprotochat_proto::node_capnp::{auth, node_service}; use quicproquo_proto::node_capnp::{auth, node_service};
use crate::AUTH_CONTEXT; use crate::AUTH_CONTEXT;
@@ -359,11 +359,11 @@ pub async fn fetch_hybrid_key(
/// Decrypt a hybrid envelope. Requires a hybrid key; no fallback to plaintext MLS. /// Decrypt a hybrid envelope. Requires a hybrid key; no fallback to plaintext MLS.
pub fn try_hybrid_decrypt( pub fn try_hybrid_decrypt(
hybrid_kp: Option<&quicnprotochat_core::HybridKeypair>, hybrid_kp: Option<&quicproquo_core::HybridKeypair>,
payload: &[u8], payload: &[u8],
) -> anyhow::Result<Vec<u8>> { ) -> anyhow::Result<Vec<u8>> {
let kp = hybrid_kp.ok_or_else(|| anyhow::anyhow!("hybrid key required for decryption"))?; let kp = hybrid_kp.ok_or_else(|| anyhow::anyhow!("hybrid key required for decryption"))?;
quicnprotochat_core::hybrid_decrypt(kp, payload, b"", b"").map_err(|e| anyhow::anyhow!("{e}")) quicproquo_core::hybrid_decrypt(kp, payload, b"", b"").map_err(|e| anyhow::anyhow!("{e}"))
} }
/// Peek at queued payloads without removing them. /// Peek at queued payloads without removing them.

View File

@@ -9,7 +9,7 @@ use std::sync::Arc;
use anyhow::Context; use anyhow::Context;
use quicnprotochat_core::{DiskKeyStore, GroupMember, HybridKeypair, IdentityKeypair}; use quicproquo_core::{DiskKeyStore, GroupMember, HybridKeypair, IdentityKeypair};
use super::conversation::{ use super::conversation::{
now_ms, Conversation, ConversationId, ConversationKind, ConversationStore, now_ms, Conversation, ConversationId, ConversationKind, ConversationStore,
@@ -101,6 +101,7 @@ impl SessionState {
Arc::clone(&self.identity), Arc::clone(&self.identity),
ks, ks,
Some(group), Some(group),
false, // legacy groups are classical
); );
let group_id_bytes = member.group_id().unwrap_or_default(); let group_id_bytes = member.group_id().unwrap_or_default();
@@ -170,6 +171,7 @@ impl SessionState {
Arc::clone(&self.identity), Arc::clone(&self.identity),
ks, ks,
group, group,
false, // existing conversations default to classical
)) ))
} }

View File

@@ -10,7 +10,7 @@ use chacha20poly1305::{
use rand::RngCore; use rand::RngCore;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use quicnprotochat_core::{DiskKeyStore, GroupMember, HybridKeypair, HybridKeypairBytes, IdentityKeypair}; use quicproquo_core::{DiskKeyStore, GroupMember, HybridKeypair, HybridKeypairBytes, IdentityKeypair};
/// Magic bytes for encrypted client state files. /// Magic bytes for encrypted client state files.
const STATE_MAGIC: &[u8; 4] = b"QPCE"; const STATE_MAGIC: &[u8; 4] = b"QPCE";
@@ -37,7 +37,8 @@ impl StoredState {
.map(|bytes| bincode::deserialize(&bytes).context("decode group")) .map(|bytes| bincode::deserialize(&bytes).context("decode group"))
.transpose()?; .transpose()?;
let key_store = DiskKeyStore::persistent(keystore_path(state_path))?; let key_store = DiskKeyStore::persistent(keystore_path(state_path))?;
let member = GroupMember::new_with_state(identity, key_store, group); let hybrid = self.hybrid_key.is_some();
let member = GroupMember::new_with_state(identity, key_store, group, hybrid);
let hybrid_kp = self let hybrid_kp = self
.hybrid_key .hybrid_key
@@ -149,7 +150,7 @@ pub fn load_or_init_state(path: &Path, password: Option<&str>) -> anyhow::Result
let identity = IdentityKeypair::generate(); let identity = IdentityKeypair::generate();
let hybrid_kp = HybridKeypair::generate(); let hybrid_kp = HybridKeypair::generate();
let key_store = DiskKeyStore::persistent(keystore_path(path))?; let key_store = DiskKeyStore::persistent(keystore_path(path))?;
let member = GroupMember::new_with_state(Arc::new(identity), key_store, None); let member = GroupMember::new_with_state(Arc::new(identity), key_store, None, false);
let state = StoredState::from_parts(&member, Some(&hybrid_kp))?; let state = StoredState::from_parts(&member, Some(&hybrid_kp))?;
write_state(path, &state, password)?; write_state(path, &state, password)?;
Ok(state) Ok(state)

View File

@@ -1,17 +1,17 @@
//! quicnprotochat CLI client library. //! quicproquo CLI client library.
//! //!
//! # KeyPackage expiry and refresh //! # KeyPackage expiry and refresh
//! //!
//! KeyPackages are single-use (consumed when someone fetches them for an invite) and the server //! KeyPackages are single-use (consumed when someone fetches them for an invite) and the server
//! may enforce a TTL (e.g. 24 hours). To stay invitable, run `quicnprotochat refresh-keypackage` //! may enforce a TTL (e.g. 24 hours). To stay invitable, run `qpq refresh-keypackage`
//! periodically (e.g. before the server TTL) or after your KeyPackage was consumed: //! periodically (e.g. before the server TTL) or after your KeyPackage was consumed:
//! //!
//! ```bash //! ```bash
//! quicnprotochat refresh-keypackage --state quicnprotochat-state.bin --server 127.0.0.1:7000 //! qpq refresh-keypackage --state qpq-state.bin --server 127.0.0.1:7000
//! ``` //! ```
//! //!
//! Use the same `--access-token` (or `QUICNPROTOCHAT_ACCESS_TOKEN`) as for other authenticated //! Use the same `--access-token` (or `QPQ_ACCESS_TOKEN`) as for other authenticated
//! commands. See the [running-the-client](https://docs.quicnprotochat.dev/getting-started/running-the-client) //! commands. See the [running-the-client](https://docs.quicproquo.dev/getting-started/running-the-client)
//! docs for details. //! docs for details.
use std::sync::RwLock; use std::sync::RwLock;

View File

@@ -1,10 +1,10 @@
//! quicnprotochat CLI client. //! quicproquo CLI client.
use std::path::PathBuf; use std::path::PathBuf;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use quicnprotochat_client::{ use quicproquo_client::{
cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_fetch_key, cmd_health, cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_fetch_key, cmd_health,
cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_recv, cmd_register, cmd_register_state, cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_recv, cmd_register, cmd_register_state,
cmd_refresh_keypackage, cmd_register_user, cmd_send, cmd_whoami, init_auth, run_repl, cmd_refresh_keypackage, cmd_register_user, cmd_send, cmd_whoami, init_auth, run_repl,
@@ -14,14 +14,14 @@ use quicnprotochat_client::{
// ── CLI ─────────────────────────────────────────────────────────────────────── // ── CLI ───────────────────────────────────────────────────────────────────────
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
#[command(name = "quicnprotochat", about = "quicnprotochat CLI client", version)] #[command(name = "qpq", about = "quicproquo CLI client", version)]
struct Args { struct Args {
/// Path to the server's TLS certificate (self-signed by default). /// Path to the server's TLS certificate (self-signed by default).
#[arg( #[arg(
long, long,
global = true, global = true,
default_value = "data/server-cert.der", default_value = "data/server-cert.der",
env = "QUICNPROTOCHAT_CA_CERT" env = "QPQ_CA_CERT"
)] )]
ca_cert: PathBuf, ca_cert: PathBuf,
@@ -30,7 +30,7 @@ struct Args {
long, long,
global = true, global = true,
default_value = "localhost", default_value = "localhost",
env = "QUICNPROTOCHAT_SERVER_NAME" env = "QPQ_SERVER_NAME"
)] )]
server_name: String, server_name: String,
@@ -39,18 +39,18 @@ struct Args {
#[arg( #[arg(
long, long,
global = true, global = true,
env = "QUICNPROTOCHAT_ACCESS_TOKEN", env = "QPQ_ACCESS_TOKEN",
default_value = "" default_value = ""
)] )]
access_token: String, access_token: String,
/// Optional device identifier (UUID bytes encoded as hex or raw string). /// Optional device identifier (UUID bytes encoded as hex or raw string).
#[arg(long, global = true, env = "QUICNPROTOCHAT_DEVICE_ID")] #[arg(long, global = true, env = "QPQ_DEVICE_ID")]
device_id: Option<String>, device_id: Option<String>,
/// Password to encrypt/decrypt client state files (QPCE format). /// Password to encrypt/decrypt client state files (QPCE format).
/// If set, state files are encrypted at rest with Argon2id + ChaCha20Poly1305. /// If set, state files are encrypted at rest with Argon2id + ChaCha20Poly1305.
#[arg(long, global = true, env = "QUICNPROTOCHAT_STATE_PASSWORD")] #[arg(long, global = true, env = "QPQ_STATE_PASSWORD")]
state_password: Option<String>, state_password: Option<String>,
#[command(subcommand)] #[command(subcommand)]
@@ -61,7 +61,7 @@ struct Args {
enum Command { enum Command {
/// Register a new user via OPAQUE (password never leaves the client). /// Register a new user via OPAQUE (password never leaves the client).
RegisterUser { RegisterUser {
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String, server: String,
/// Username for the new account. /// Username for the new account.
#[arg(long)] #[arg(long)]
@@ -73,7 +73,7 @@ enum Command {
/// Log in via OPAQUE and receive a session token. /// Log in via OPAQUE and receive a session token.
Login { Login {
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String, server: String,
#[arg(long)] #[arg(long)]
username: String, username: String,
@@ -95,8 +95,8 @@ enum Command {
/// State file path (identity + MLS state). /// State file path (identity + MLS state).
#[arg( #[arg(
long, long,
default_value = "quicnprotochat-state.bin", default_value = "qpq-state.bin",
env = "QUICNPROTOCHAT_STATE" env = "QPQ_STATE"
)] )]
state: PathBuf, state: PathBuf,
}, },
@@ -104,14 +104,14 @@ enum Command {
/// Check server connectivity and print status. /// Check server connectivity and print status.
Health { Health {
/// Server address (host:port). /// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String, server: String,
}, },
/// Check if a peer has registered a hybrid key (non-consuming lookup). /// Check if a peer has registered a hybrid key (non-consuming lookup).
CheckKey { CheckKey {
/// Server address (host:port). /// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String, server: String,
/// Peer's Ed25519 identity public key (64 hex chars = 32 bytes). /// Peer's Ed25519 identity public key (64 hex chars = 32 bytes).
@@ -121,21 +121,21 @@ enum Command {
/// Send a Ping to the server and print the round-trip time. /// Send a Ping to the server and print the round-trip time.
Ping { Ping {
/// Server address (host:port). /// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String, server: String,
}, },
/// Generate a fresh MLS KeyPackage and upload it to the Authentication Service. /// Generate a fresh MLS KeyPackage and upload it to the Authentication Service.
Register { Register {
/// Server address (host:port). /// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String, server: String,
}, },
/// Fetch a peer's KeyPackage from the Authentication Service. /// Fetch a peer's KeyPackage from the Authentication Service.
FetchKey { FetchKey {
/// Server address (host:port). /// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String, server: String,
/// Target peer's Ed25519 identity public key (64 hex chars = 32 bytes). /// Target peer's Ed25519 identity public key (64 hex chars = 32 bytes).
@@ -145,7 +145,7 @@ enum Command {
/// Run a two-party MLS demo (creator + joiner) against live AS and DS. /// Run a two-party MLS demo (creator + joiner) against live AS and DS.
DemoGroup { DemoGroup {
/// Server address (host:port). /// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String, server: String,
}, },
@@ -154,13 +154,13 @@ enum Command {
/// State file path (identity + MLS state). /// State file path (identity + MLS state).
#[arg( #[arg(
long, long,
default_value = "quicnprotochat-state.bin", default_value = "qpq-state.bin",
env = "QUICNPROTOCHAT_STATE" env = "QPQ_STATE"
)] )]
state: PathBuf, state: PathBuf,
/// Authentication Service address (host:port). /// Authentication Service address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String, server: String,
}, },
@@ -170,13 +170,13 @@ enum Command {
/// State file path (identity + MLS state). /// State file path (identity + MLS state).
#[arg( #[arg(
long, long,
default_value = "quicnprotochat-state.bin", default_value = "qpq-state.bin",
env = "QUICNPROTOCHAT_STATE" env = "QPQ_STATE"
)] )]
state: PathBuf, state: PathBuf,
/// Server address (host:port). /// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String, server: String,
}, },
@@ -185,13 +185,13 @@ enum Command {
/// State file path (identity + MLS state). /// State file path (identity + MLS state).
#[arg( #[arg(
long, long,
default_value = "quicnprotochat-state.bin", default_value = "qpq-state.bin",
env = "QUICNPROTOCHAT_STATE" env = "QPQ_STATE"
)] )]
state: PathBuf, state: PathBuf,
/// Server address (host:port). /// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String, server: String,
/// Group identifier (arbitrary bytes, typically a human-readable name). /// Group identifier (arbitrary bytes, typically a human-readable name).
@@ -203,11 +203,11 @@ enum Command {
Invite { Invite {
#[arg( #[arg(
long, long,
default_value = "quicnprotochat-state.bin", default_value = "qpq-state.bin",
env = "QUICNPROTOCHAT_STATE" env = "QPQ_STATE"
)] )]
state: PathBuf, state: PathBuf,
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String, server: String,
/// Peer identity public key (64 hex chars = 32 bytes). /// Peer identity public key (64 hex chars = 32 bytes).
#[arg(long)] #[arg(long)]
@@ -218,11 +218,11 @@ enum Command {
Join { Join {
#[arg( #[arg(
long, long,
default_value = "quicnprotochat-state.bin", default_value = "qpq-state.bin",
env = "QUICNPROTOCHAT_STATE" env = "QPQ_STATE"
)] )]
state: PathBuf, state: PathBuf,
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String, server: String,
}, },
@@ -230,11 +230,11 @@ enum Command {
Send { Send {
#[arg( #[arg(
long, long,
default_value = "quicnprotochat-state.bin", default_value = "qpq-state.bin",
env = "QUICNPROTOCHAT_STATE" env = "QPQ_STATE"
)] )]
state: PathBuf, state: PathBuf,
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String, server: String,
/// Recipient identity key (hex, 32 bytes -> 64 chars). Omit when using --all. /// Recipient identity key (hex, 32 bytes -> 64 chars). Omit when using --all.
#[arg(long)] #[arg(long)]
@@ -251,11 +251,11 @@ enum Command {
Recv { Recv {
#[arg( #[arg(
long, long,
default_value = "quicnprotochat-state.bin", default_value = "qpq-state.bin",
env = "QUICNPROTOCHAT_STATE" env = "QPQ_STATE"
)] )]
state: PathBuf, state: PathBuf,
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String, server: String,
/// Wait for up to this many milliseconds if no messages are queued. /// Wait for up to this many milliseconds if no messages are queued.
@@ -272,17 +272,17 @@ enum Command {
Repl { Repl {
#[arg( #[arg(
long, long,
default_value = "quicnprotochat-state.bin", default_value = "qpq-state.bin",
env = "QUICNPROTOCHAT_STATE" env = "QPQ_STATE"
)] )]
state: PathBuf, state: PathBuf,
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String, server: String,
/// OPAQUE username for automatic registration/login. /// OPAQUE username for automatic registration/login.
#[arg(long, env = "QUICNPROTOCHAT_USERNAME")] #[arg(long, env = "QPQ_USERNAME")]
username: Option<String>, username: Option<String>,
/// OPAQUE password (prompted securely if --username is set but --password is not). /// OPAQUE password (prompted securely if --username is set but --password is not).
#[arg(long, env = "QUICNPROTOCHAT_PASSWORD")] #[arg(long, env = "QPQ_PASSWORD")]
password: Option<String>, password: Option<String>,
}, },
@@ -291,11 +291,11 @@ enum Command {
Chat { Chat {
#[arg( #[arg(
long, long,
default_value = "quicnprotochat-state.bin", default_value = "qpq-state.bin",
env = "QUICNPROTOCHAT_STATE" env = "QPQ_STATE"
)] )]
state: PathBuf, state: PathBuf,
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")] #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String, server: String,
/// Peer identity key (hex, 64 chars). Omit in a two-person group to use the only other member. /// Peer identity key (hex, 64 chars). Omit in a two-person group to use the only other member.
#[arg(long)] #[arg(long)]

View File

@@ -1,4 +1,4 @@
// cargo_bin! only works for current package's binary; we spawn quicnprotochat-server from another package. // cargo_bin! only works for current package's binary; we spawn qpq-server from another package.
#![allow(deprecated)] #![allow(deprecated)]
use std::{path::PathBuf, process::Command, time::Duration}; use std::{path::PathBuf, process::Command, time::Duration};
@@ -15,12 +15,12 @@ fn ensure_rustls_provider() {
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
} }
use quicnprotochat_client::{ use quicproquo_client::{
cmd_create_group, cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_register_state, cmd_create_group, cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_register_state,
cmd_register_user, cmd_send, connect_node, enqueue, fetch_wait, init_auth, cmd_register_user, cmd_send, connect_node, enqueue, fetch_wait, init_auth,
receive_pending_plaintexts, ClientAuth, receive_pending_plaintexts, ClientAuth,
}; };
use quicnprotochat_core::IdentityKeypair; use quicproquo_core::IdentityKeypair;
fn hex_encode(bytes: &[u8]) -> String { fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect() bytes.iter().map(|b| format!("{b:02x}")).collect()
@@ -65,7 +65,7 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
let auth_token = "devtoken"; let auth_token = "devtoken";
// Spawn server binary. // Spawn server binary.
let server_bin = cargo_bin("quicnprotochat-server"); let server_bin = cargo_bin("qpq-server");
let child = Command::new(server_bin) let child = Command::new(server_bin)
.arg("--listen") .arg("--listen")
.arg(&listen) .arg(&listen)
@@ -187,7 +187,7 @@ async fn e2e_three_party_group_invite_join_send_recv() -> anyhow::Result<()> {
let data_dir = base.join("data"); let data_dir = base.join("data");
let auth_token = "devtoken"; let auth_token = "devtoken";
let server_bin = cargo_bin("quicnprotochat-server"); let server_bin = cargo_bin("qpq-server");
let child = Command::new(server_bin) let child = Command::new(server_bin)
.arg("--listen") .arg("--listen")
.arg(&listen) .arg(&listen)
@@ -400,7 +400,7 @@ async fn e2e_login_rejects_mismatched_identity() -> anyhow::Result<()> {
let auth_token = "devtoken"; let auth_token = "devtoken";
// Spawn server binary. // Spawn server binary.
let server_bin = cargo_bin("quicnprotochat-server"); let server_bin = cargo_bin("qpq-server");
let child = Command::new(server_bin) let child = Command::new(server_bin)
.arg("--listen") .arg("--listen")
.arg(&listen) .arg(&listen)
@@ -509,7 +509,7 @@ async fn e2e_sealed_sender_enqueue_then_fetch() -> anyhow::Result<()> {
let data_dir = base.join("data"); let data_dir = base.join("data");
let auth_token = "devtoken"; let auth_token = "devtoken";
let server_bin = cargo_bin("quicnprotochat-server"); let server_bin = cargo_bin("qpq-server");
let child = Command::new(server_bin) let child = Command::new(server_bin)
.arg("--listen") .arg("--listen")
.arg(&listen) .arg(&listen)

View File

@@ -1,8 +1,8 @@
[package] [package]
name = "quicnprotochat-core" name = "quicproquo-core"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Crypto primitives, MLS state machine, and hybrid post-quantum KEM for quicnprotochat." description = "Crypto primitives, MLS state machine, and hybrid post-quantum KEM for quicproquo."
license = "MIT" license = "MIT"
[dependencies] [dependencies]
@@ -33,7 +33,7 @@ serde_json = { workspace = true }
# Serialisation # Serialisation
capnp = { workspace = true } capnp = { workspace = true }
quicnprotochat-proto = { path = "../quicnprotochat-proto" } quicproquo-proto = { path = "../quicproquo-proto" }
# Async runtime # Async runtime
tokio = { workspace = true } tokio = { workspace = true }
@@ -43,3 +43,17 @@ thiserror = { workspace = true }
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true } tokio = { workspace = true }
criterion = { version = "0.5", features = ["html_reports"] }
prost = "0.13"
[[bench]]
name = "serialization"
harness = false
[[bench]]
name = "mls_operations"
harness = false
[[bench]]
name = "hybrid_kem_bench"
harness = false

View File

@@ -0,0 +1,152 @@
//! Benchmark: Hybrid KEM (X25519 + ML-KEM-768) vs classical-only encryption.
//!
//! Compares keypair generation, encryption, and decryption times for the
//! hybrid post-quantum scheme against classical X25519 + ChaCha20-Poly1305.
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
use quicproquo_core::{hybrid_encrypt, hybrid_decrypt, HybridKeypair};
// ── Classical baseline (X25519 + ChaCha20-Poly1305) ─────────────────────────
use chacha20poly1305::{
aead::{Aead, KeyInit},
ChaCha20Poly1305, Key, Nonce,
};
use hkdf::Hkdf;
use rand::{rngs::OsRng, RngCore};
use sha2::Sha256;
use x25519_dalek::{EphemeralSecret, PublicKey as X25519Public, StaticSecret};
struct ClassicalKeypair {
secret: StaticSecret,
public: X25519Public,
}
impl ClassicalKeypair {
fn generate() -> Self {
let secret = StaticSecret::random_from_rng(OsRng);
let public = X25519Public::from(&secret);
Self { secret, public }
}
}
fn classical_encrypt(recipient_pk: &X25519Public, plaintext: &[u8]) -> Vec<u8> {
let eph_secret = EphemeralSecret::random_from_rng(OsRng);
let eph_public = X25519Public::from(&eph_secret);
let shared = eph_secret.diffie_hellman(recipient_pk);
let hk = Hkdf::<Sha256>::new(None, shared.as_bytes());
let mut key_bytes = [0u8; 32];
hk.expand(b"classical-bench", &mut key_bytes).unwrap();
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key_bytes));
let ct = cipher
.encrypt(Nonce::from_slice(&nonce_bytes), plaintext)
.unwrap();
// Wire: eph_pk(32) || nonce(12) || ciphertext
let mut out = Vec::with_capacity(32 + 12 + ct.len());
out.extend_from_slice(eph_public.as_bytes());
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ct);
out
}
fn classical_decrypt(keypair: &ClassicalKeypair, envelope: &[u8]) -> Vec<u8> {
let eph_pk = X25519Public::from(<[u8; 32]>::try_from(&envelope[..32]).unwrap());
let nonce_bytes: [u8; 12] = envelope[32..44].try_into().unwrap();
let ct = &envelope[44..];
let shared = keypair.secret.diffie_hellman(&eph_pk);
let hk = Hkdf::<Sha256>::new(None, shared.as_bytes());
let mut key_bytes = [0u8; 32];
hk.expand(b"classical-bench", &mut key_bytes).unwrap();
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key_bytes));
cipher
.decrypt(Nonce::from_slice(&nonce_bytes), ct)
.unwrap()
}
// ── Benchmarks ──────────────────────────────────────────────────────────────
fn bench_keygen(c: &mut Criterion) {
let mut group = c.benchmark_group("kem_keygen");
group.bench_function("hybrid", |b| {
b.iter(|| black_box(HybridKeypair::generate()));
});
group.bench_function("classical", |b| {
b.iter(|| black_box(ClassicalKeypair::generate()));
});
group.finish();
}
fn bench_encrypt(c: &mut Criterion) {
let sizes: &[(&str, usize)] = &[("100B", 100), ("1KB", 1024), ("4KB", 4096), ("64KB", 65536)];
let mut group = c.benchmark_group("kem_encrypt");
let hybrid_kp = HybridKeypair::generate();
let hybrid_pk = hybrid_kp.public_key();
let classical_kp = ClassicalKeypair::generate();
for (label, size) in sizes {
let payload = vec![0xABu8; *size];
group.bench_with_input(
BenchmarkId::new("hybrid", label),
&payload,
|b, payload| {
b.iter(|| hybrid_encrypt(&hybrid_pk, black_box(payload), b"", b"").unwrap());
},
);
group.bench_with_input(
BenchmarkId::new("classical", label),
&payload,
|b, payload| {
b.iter(|| classical_encrypt(&classical_kp.public, black_box(payload)));
},
);
}
group.finish();
}
fn bench_decrypt(c: &mut Criterion) {
let sizes: &[(&str, usize)] = &[("100B", 100), ("1KB", 1024), ("4KB", 4096), ("64KB", 65536)];
let mut group = c.benchmark_group("kem_decrypt");
let hybrid_kp = HybridKeypair::generate();
let hybrid_pk = hybrid_kp.public_key();
let classical_kp = ClassicalKeypair::generate();
for (label, size) in sizes {
let payload = vec![0xABu8; *size];
let hybrid_ct = hybrid_encrypt(&hybrid_pk, &payload, b"", b"").unwrap();
let classical_ct = classical_encrypt(&classical_kp.public, &payload);
group.bench_with_input(
BenchmarkId::new("hybrid", label),
&hybrid_ct,
|b, ct| {
b.iter(|| hybrid_decrypt(&hybrid_kp, black_box(ct), b"", b"").unwrap());
},
);
group.bench_with_input(
BenchmarkId::new("classical", label),
&classical_ct,
|b, ct| {
b.iter(|| classical_decrypt(&classical_kp, black_box(ct)));
},
);
}
group.finish();
}
criterion_group!(benches, bench_keygen, bench_encrypt, bench_decrypt);
criterion_main!(benches);

View File

@@ -0,0 +1,132 @@
//! Benchmark: MLS group operations at various group sizes.
//!
//! Measures KeyPackage generation, group creation, member addition,
//! message encryption, and message decryption.
use std::sync::Arc;
use criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion};
use quicproquo_core::{GroupMember, IdentityKeypair};
/// Create identities and a group of the given size.
/// Returns (creator, Vec<members>).
fn setup_group(size: usize) -> (GroupMember, Vec<GroupMember>) {
let creator_id = Arc::new(IdentityKeypair::generate());
let mut creator = GroupMember::new(creator_id);
creator.create_group(b"bench-group").unwrap();
let mut members = Vec::with_capacity(size.saturating_sub(1));
for _ in 1..size {
let joiner_id = Arc::new(IdentityKeypair::generate());
let mut joiner = GroupMember::new(joiner_id);
let kp = joiner.generate_key_package().unwrap();
let (_commit, welcome) = creator.add_member(&kp).unwrap();
joiner.join_group(&welcome).unwrap();
members.push(joiner);
}
(creator, members)
}
fn bench_keygen(c: &mut Criterion) {
c.bench_function("mls_keygen", |b| {
b.iter_batched(
|| {
let id = Arc::new(IdentityKeypair::generate());
GroupMember::new(id)
},
|mut member| {
member.generate_key_package().unwrap();
},
BatchSize::SmallInput,
);
});
}
fn bench_group_create(c: &mut Criterion) {
c.bench_function("mls_group_create", |b| {
b.iter_batched(
|| {
let id = Arc::new(IdentityKeypair::generate());
GroupMember::new(id)
},
|mut member| {
member.create_group(b"bench-group").unwrap();
},
BatchSize::SmallInput,
);
});
}
fn bench_add_member(c: &mut Criterion) {
let mut group = c.benchmark_group("mls_add_member");
// Smaller sizes to keep setup time reasonable
for size in [2, 10, 50] {
group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, &size| {
b.iter_batched(
|| {
let (creator, members) = setup_group(size);
let joiner_id = Arc::new(IdentityKeypair::generate());
let mut joiner = GroupMember::new(joiner_id);
let kp = joiner.generate_key_package().unwrap();
(creator, members, joiner, kp)
},
|(mut creator, _members, _joiner, kp)| {
creator.add_member(&kp).unwrap();
},
BatchSize::SmallInput,
);
});
}
group.finish();
}
fn bench_send_message(c: &mut Criterion) {
let mut group = c.benchmark_group("mls_send_message");
for size in [2, 10, 50] {
group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, &size| {
let (mut creator, _members) = setup_group(size);
let payload = b"hello benchmark message";
b.iter(|| {
creator.send_message(payload).unwrap();
});
});
}
group.finish();
}
fn bench_receive_message(c: &mut Criterion) {
let mut group = c.benchmark_group("mls_receive_message");
for size in [2, 10, 50] {
group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, &size| {
// For receive, we need a fresh ciphertext each iteration since
// MLS message processing is destructive (epoch state changes).
// We pre-generate a batch and consume them.
let (mut creator, mut members) = setup_group(size);
if members.is_empty() {
return;
}
let payload = b"hello benchmark message";
b.iter_batched(
|| creator.send_message(payload).unwrap(),
|ct| {
// Receive on the first joiner
let _ = members[0].receive_message(&ct);
},
BatchSize::SmallInput,
);
});
}
group.finish();
}
criterion_group!(
benches,
bench_keygen,
bench_group_create,
bench_add_member,
bench_send_message,
bench_receive_message,
);
criterion_main!(benches);

View File

@@ -0,0 +1,170 @@
//! Benchmark: Cap'n Proto vs Protobuf serialization for chat message envelopes.
//!
//! Compares serialization/deserialization speed and encoded size at three
//! payload sizes (100 B, 1 KB, 4 KB) for a typical Envelope{seq, data} message.
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
// ── Cap'n Proto path ────────────────────────────────────────────────────────
fn capnp_serialize_envelope(seq: u64, data: &[u8]) -> Vec<u8> {
let mut msg = capnp::message::Builder::new_default();
{
let mut envelope = msg.init_root::<quicproquo_proto::node_capnp::envelope::Builder>();
envelope.set_seq(seq);
envelope.set_data(data);
}
quicproquo_proto::to_bytes(&msg).unwrap()
}
fn capnp_deserialize_envelope(bytes: &[u8]) -> (u64, Vec<u8>) {
let reader = quicproquo_proto::from_bytes(bytes).unwrap();
let envelope = reader
.get_root::<quicproquo_proto::node_capnp::envelope::Reader>()
.unwrap();
(envelope.get_seq(), envelope.get_data().unwrap().to_vec())
}
// ── Protobuf path (hand-coded prost encoding to avoid build-dep) ────────────
//
// Envelope { seq: uint64 (field 1), data: bytes (field 2) }
// Wire format: varint tag + varint seq + len-delimited data
fn protobuf_serialize_envelope(seq: u64, data: &[u8]) -> Vec<u8> {
// Build a prost message via raw encoding.
// Field 1: uint64 seq, wire type 0 (varint), tag = (1 << 3) | 0 = 0x08
// Field 2: bytes data, wire type 2 (length-delimited), tag = (2 << 3) | 2 = 0x12
let mut buf = Vec::with_capacity(10 + data.len());
// Encode field 1 (seq)
prost::encoding::uint64::encode(1, &seq, &mut buf);
// Encode field 2 (data)
prost::encoding::bytes::encode(2, &data.to_vec(), &mut buf);
buf
}
fn protobuf_deserialize_envelope(bytes: &[u8]) -> (u64, Vec<u8>) {
// Decode manually using prost wire format
let mut seq: u64 = 0;
let mut data: Vec<u8> = Vec::new();
let mut buf = bytes;
while !buf.is_empty() {
let (tag, wire_type) =
prost::encoding::decode_key(&mut buf).expect("decode key");
match tag {
1 => {
prost::encoding::uint64::merge(wire_type, &mut seq, &mut buf, Default::default())
.expect("decode seq");
}
2 => {
prost::encoding::bytes::merge(wire_type, &mut data, &mut buf, Default::default())
.expect("decode data");
}
_ => {
prost::encoding::skip_field(wire_type, tag, &mut buf, Default::default())
.expect("skip unknown field");
}
}
}
(seq, data)
}
// ── Benchmarks ──────────────────────────────────────────────────────────────
fn bench_serialize(c: &mut Criterion) {
let sizes: &[(&str, usize)] = &[("100B", 100), ("1KB", 1024), ("4KB", 4096)];
let mut group = c.benchmark_group("serialize_envelope");
for (label, size) in sizes {
let payload = vec![0xABu8; *size];
let seq = 42u64;
group.bench_with_input(
BenchmarkId::new("capnp", label),
&(&seq, &payload),
|b, &(seq, payload)| {
b.iter(|| capnp_serialize_envelope(black_box(*seq), black_box(payload)));
},
);
group.bench_with_input(
BenchmarkId::new("protobuf", label),
&(&seq, &payload),
|b, &(seq, payload)| {
b.iter(|| protobuf_serialize_envelope(black_box(*seq), black_box(payload)));
},
);
}
group.finish();
}
fn bench_deserialize(c: &mut Criterion) {
let sizes: &[(&str, usize)] = &[("100B", 100), ("1KB", 1024), ("4KB", 4096)];
let mut group = c.benchmark_group("deserialize_envelope");
for (label, size) in sizes {
let payload = vec![0xABu8; *size];
let seq = 42u64;
let capnp_bytes = capnp_serialize_envelope(seq, &payload);
let proto_bytes = protobuf_serialize_envelope(seq, &payload);
group.bench_with_input(
BenchmarkId::new("capnp", label),
&capnp_bytes,
|b, bytes| {
b.iter(|| capnp_deserialize_envelope(black_box(bytes)));
},
);
group.bench_with_input(
BenchmarkId::new("protobuf", label),
&proto_bytes,
|b, bytes| {
b.iter(|| protobuf_deserialize_envelope(black_box(bytes)));
},
);
}
group.finish();
}
fn bench_encoded_sizes(c: &mut Criterion) {
let sizes: &[(&str, usize)] = &[("100B", 100), ("1KB", 1024), ("4KB", 4096)];
let mut group = c.benchmark_group("encoded_size");
for (label, size) in sizes {
let payload = vec![0xABu8; *size];
let capnp_bytes = capnp_serialize_envelope(42, &payload);
let proto_bytes = protobuf_serialize_envelope(42, &payload);
// Use a trivial benchmark that just returns the size -- the point
// is to get criterion to print the iteration count and allow
// comparison. The real value is in the eprintln below.
group.bench_with_input(
BenchmarkId::new("capnp", label),
&capnp_bytes,
|b, bytes| {
b.iter(|| black_box(bytes.len()));
},
);
group.bench_with_input(
BenchmarkId::new("protobuf", label),
&proto_bytes,
|b, bytes| {
b.iter(|| black_box(bytes.len()));
},
);
eprintln!(
" {label}: capnp={} bytes, protobuf={} bytes, overhead={:+} bytes",
capnp_bytes.len(),
proto_bytes.len(),
capnp_bytes.len() as isize - proto_bytes.len() as isize,
);
}
group.finish();
}
criterion_group!(benches, bench_serialize, bench_deserialize, bench_encoded_sizes);
criterion_main!(benches);

View File

@@ -0,0 +1,21 @@
syntax = "proto3";
package quicproquo.bench;
// Equivalent to the Envelope struct in delivery.capnp
message Envelope {
uint64 seq = 1;
bytes data = 2;
}
// Equivalent to a chat message payload (app_message.rs Chat variant)
message ChatMessage {
bytes message_id = 1; // 16 bytes
string body = 2; // UTF-8 text
uint64 timestamp_ms = 3;
bytes sender_key = 4; // 32 bytes Ed25519 public key
}
// Batch fetch response (equivalent to fetch returning List(Envelope))
message FetchResponse {
repeated Envelope payloads = 1;
}

View File

@@ -1,4 +1,4 @@
//! Error types for `quicnprotochat-core`. //! Error types for `quicproquo-core`.
use thiserror::Error; use thiserror::Error;

View File

@@ -3,12 +3,19 @@
//! # Design //! # Design
//! //!
//! [`GroupMember`] wraps an openmls [`MlsGroup`] plus the per-client //! [`GroupMember`] wraps an openmls [`MlsGroup`] plus the per-client
//! [`StoreCrypto`] backend. The backend is **persistent** — it holds the //! [`HybridCryptoProvider`] backend. The backend is **persistent** — it holds
//! in-memory key store that maps init-key references to HPKE private keys. //! the in-memory key store that maps init-key references to HPKE private keys.
//! openmls's `new_from_welcome` reads those private keys from the key store to //! openmls's `new_from_welcome` reads those private keys from the key store to
//! decrypt the Welcome, so the same backend instance must be used from //! decrypt the Welcome, so the same backend instance must be used from
//! `generate_key_package` through `join_group`. //! `generate_key_package` through `join_group`.
//! //!
//! # Hybrid post-quantum mode
//!
//! When `hybrid = true`, the backend's `derive_hpke_keypair` produces hybrid
//! (X25519 + ML-KEM-768) init keys. KeyPackages from hybrid groups contain
//! 1216-byte public keys instead of 32-byte X25519 keys. Both sender and
//! receiver must use hybrid mode for the same group.
//!
//! # Wire format //! # Wire format
//! //!
//! All MLS messages are serialised/deserialised using TLS presentation language //! All MLS messages are serialised/deserialised using TLS presentation language
@@ -37,8 +44,9 @@ use openmls_traits::OpenMlsCryptoProvider;
use crate::{ use crate::{
error::CoreError, error::CoreError,
hybrid_crypto::HybridCryptoProvider,
identity::IdentityKeypair, identity::IdentityKeypair,
keystore::{DiskKeyStore, StoreCrypto}, keystore::DiskKeyStore,
}; };
// ── Constants ───────────────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────────────
@@ -61,21 +69,28 @@ const CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA2
/// └─ receive_message(b) → decrypt; returns Some(plaintext) or None /// └─ receive_message(b) → decrypt; returns Some(plaintext) or None
/// ``` /// ```
pub struct GroupMember { pub struct GroupMember {
/// Persistent crypto backend. Holds the in-memory key store with HPKE /// Persistent crypto backend (hybrid or classical). Holds the in-memory key
/// private keys created during `generate_key_package`. /// store with HPKE private keys created during `generate_key_package`.
backend: StoreCrypto, backend: HybridCryptoProvider,
/// Long-term Ed25519 identity keypair. Also used as the MLS `Signer`. /// Long-term Ed25519 identity keypair. Also used as the MLS `Signer`.
identity: Arc<IdentityKeypair>, identity: Arc<IdentityKeypair>,
/// Active MLS group, if any. /// Active MLS group, if any.
group: Option<MlsGroup>, group: Option<MlsGroup>,
/// Shared group configuration (wire format, ratchet tree extension, etc.). /// Shared group configuration (wire format, ratchet tree extension, etc.).
config: MlsGroupConfig, config: MlsGroupConfig,
/// Whether this member uses hybrid (X25519 + ML-KEM-768) HPKE keys.
hybrid: bool,
} }
impl GroupMember { impl GroupMember {
/// Create a new `GroupMember` with a fresh crypto backend. /// Create a new `GroupMember` with a fresh classical crypto backend.
pub fn new(identity: Arc<IdentityKeypair>) -> Self { pub fn new(identity: Arc<IdentityKeypair>) -> Self {
Self::new_with_state(identity, DiskKeyStore::ephemeral(), None) Self::new_with_state(identity, DiskKeyStore::ephemeral(), None, false)
}
/// Create a new `GroupMember` with hybrid post-quantum crypto backend.
pub fn new_hybrid(identity: Arc<IdentityKeypair>) -> Self {
Self::new_with_state(identity, DiskKeyStore::ephemeral(), None, true)
} }
/// Create a `GroupMember` with a persistent keystore at `path`. /// Create a `GroupMember` with a persistent keystore at `path`.
@@ -85,24 +100,35 @@ impl GroupMember {
) -> Result<Self, CoreError> { ) -> Result<Self, CoreError> {
let key_store = DiskKeyStore::persistent(path) let key_store = DiskKeyStore::persistent(path)
.map_err(|e| CoreError::Io(format!("keystore: {e}")))?; .map_err(|e| CoreError::Io(format!("keystore: {e}")))?;
Ok(Self::new_with_state(identity, key_store, None)) Ok(Self::new_with_state(identity, key_store, None, false))
} }
/// Create a `GroupMember` from pre-existing state (identity + optional group + store). /// Create a `GroupMember` from pre-existing state (identity + optional group + store).
///
/// When `hybrid` is `true`, the backend uses hybrid (X25519 + ML-KEM-768)
/// keys for HPKE operations. When `false`, standard X25519 keys are used.
pub fn new_with_state( pub fn new_with_state(
identity: Arc<IdentityKeypair>, identity: Arc<IdentityKeypair>,
key_store: DiskKeyStore, key_store: DiskKeyStore,
group: Option<MlsGroup>, group: Option<MlsGroup>,
hybrid: bool,
) -> Self { ) -> Self {
let config = MlsGroupConfig::builder() let config = MlsGroupConfig::builder()
.use_ratchet_tree_extension(true) .use_ratchet_tree_extension(true)
.build(); .build();
let backend = if hybrid {
HybridCryptoProvider::new_hybrid(key_store)
} else {
HybridCryptoProvider::new_classical(key_store)
};
Self { Self {
backend: StoreCrypto::new(key_store), backend,
identity, identity,
group, group,
config, config,
hybrid,
} }
} }
@@ -414,10 +440,15 @@ impl GroupMember {
} }
/// Return a reference to the underlying crypto backend. /// Return a reference to the underlying crypto backend.
pub fn backend(&self) -> &StoreCrypto { pub fn backend(&self) -> &HybridCryptoProvider {
&self.backend &self.backend
} }
/// Whether this member uses hybrid post-quantum HPKE keys.
pub fn is_hybrid(&self) -> bool {
self.hybrid
}
/// Return a reference to the MLS group, if active. /// Return a reference to the MLS group, if active.
pub fn group_ref(&self) -> Option<&MlsGroup> { pub fn group_ref(&self) -> Option<&MlsGroup> {
self.group.as_ref() self.group.as_ref()
@@ -498,6 +529,47 @@ mod tests {
assert_eq!(pt_creator, b"hello back"); assert_eq!(pt_creator, b"hello back");
} }
/// Full two-party hybrid MLS round-trip with post-quantum HPKE keys.
#[test]
fn two_party_hybrid_mls_round_trip() {
let creator_id = Arc::new(IdentityKeypair::generate());
let joiner_id = Arc::new(IdentityKeypair::generate());
let mut creator = GroupMember::new_hybrid(Arc::clone(&creator_id));
let mut joiner = GroupMember::new_hybrid(Arc::clone(&joiner_id));
assert!(creator.is_hybrid());
assert!(joiner.is_hybrid());
let joiner_kp = joiner
.generate_key_package()
.expect("joiner hybrid KeyPackage");
creator
.create_group(b"test-hybrid-group")
.expect("creator create hybrid group");
let (_, welcome) = creator
.add_member(&joiner_kp)
.expect("creator add joiner with hybrid KP");
joiner.join_group(&welcome).expect("joiner join hybrid group");
let ct_creator = creator.send_message(b"hello PQ").expect("creator send");
let pt_joiner = joiner
.receive_message(&ct_creator)
.expect("joiner recv")
.expect("application message");
assert_eq!(pt_joiner, b"hello PQ");
let ct_joiner = joiner.send_message(b"quantum safe!").expect("joiner send");
let pt_creator = creator
.receive_message(&ct_joiner)
.expect("creator recv")
.expect("application message");
assert_eq!(pt_creator, b"quantum safe!");
}
/// `group_id()` returns None before create_group, Some afterwards. /// `group_id()` returns None before create_group, Some afterwards.
#[test] #[test]
fn group_id_lifecycle() { fn group_id_lifecycle() {

View File

@@ -46,18 +46,50 @@ use openmls_traits::types::{
/// Crypto backend that uses hybrid KEM for HPKE when keys are in hybrid format, /// Crypto backend that uses hybrid KEM for HPKE when keys are in hybrid format,
/// and delegates everything else to RustCrypto. /// and delegates everything else to RustCrypto.
///
/// When `hybrid_enabled` is `true`, `derive_hpke_keypair` produces hybrid keys
/// (1216-byte public, 2432-byte private). When `false`, it delegates to
/// RustCrypto and produces classical 32-byte X25519 keys.
///
/// The `hpke_seal` / `hpke_open` methods always detect the key format by length,
/// so they work correctly regardless of the flag — a hybrid-length key will use
/// hybrid KEM, a classical-length key will use RustCrypto.
#[derive(Debug)] #[derive(Debug)]
pub struct HybridCrypto { pub struct HybridCrypto {
rust_crypto: RustCrypto, rust_crypto: RustCrypto,
/// When true, `derive_hpke_keypair` produces hybrid (X25519 + ML-KEM-768)
/// keys. When false, it produces classical X25519 keys via RustCrypto.
hybrid_enabled: bool,
} }
impl HybridCrypto { impl HybridCrypto {
/// Create a hybrid-enabled crypto backend (derive_hpke_keypair produces hybrid keys).
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
rust_crypto: RustCrypto::default(), rust_crypto: RustCrypto::default(),
hybrid_enabled: true,
} }
} }
/// Alias for `new()` — hybrid mode enabled.
pub fn new_hybrid() -> Self {
Self::new()
}
/// Create a classical crypto backend (derive_hpke_keypair produces standard
/// X25519 keys, but seal/open still accept hybrid keys by length detection).
pub fn new_classical() -> Self {
Self {
rust_crypto: RustCrypto::default(),
hybrid_enabled: false,
}
}
/// Whether this backend produces hybrid keys from `derive_hpke_keypair`.
pub fn is_hybrid_enabled(&self) -> bool {
self.hybrid_enabled
}
/// Expose the underlying RustCrypto for rand() and delegation. /// Expose the underlying RustCrypto for rand() and delegation.
pub fn rust_crypto(&self) -> &RustCrypto { pub fn rust_crypto(&self) -> &RustCrypto {
&self.rust_crypto &self.rust_crypto
@@ -268,7 +300,7 @@ impl OpenMlsCrypto for HybridCrypto {
} }
fn derive_hpke_keypair(&self, config: HpkeConfig, ikm: &[u8]) -> HpkeKeyPair { fn derive_hpke_keypair(&self, config: HpkeConfig, ikm: &[u8]) -> HpkeKeyPair {
if config.0 == HpkeKemType::DhKem25519 { if self.hybrid_enabled && config.0 == HpkeKemType::DhKem25519 {
let kp = HybridKeypair::derive_from_ikm(ikm); let kp = HybridKeypair::derive_from_ikm(ikm);
HpkeKeyPair { HpkeKeyPair {
private: kp.private_to_bytes().into(), private: kp.private_to_bytes().into(),
@@ -289,12 +321,32 @@ pub struct HybridCryptoProvider {
} }
impl HybridCryptoProvider { impl HybridCryptoProvider {
/// Create a hybrid-enabled provider (KeyPackages will contain hybrid init keys).
pub fn new(key_store: DiskKeyStore) -> Self { pub fn new(key_store: DiskKeyStore) -> Self {
Self { Self {
crypto: HybridCrypto::new(), crypto: HybridCrypto::new_hybrid(),
key_store, key_store,
} }
} }
/// Alias for `new()` — hybrid mode enabled.
pub fn new_hybrid(key_store: DiskKeyStore) -> Self {
Self::new(key_store)
}
/// Create a classical-mode provider (KeyPackages use standard X25519 init keys,
/// but seal/open still accept hybrid keys by length detection).
pub fn new_classical(key_store: DiskKeyStore) -> Self {
Self {
crypto: HybridCrypto::new_classical(),
key_store,
}
}
/// Whether this provider produces hybrid keys from `derive_hpke_keypair`.
pub fn is_hybrid_enabled(&self) -> bool {
self.crypto.is_hybrid_enabled()
}
} }
impl Default for HybridCryptoProvider { impl Default for HybridCryptoProvider {
@@ -410,6 +462,52 @@ mod tests {
assert_eq!(sender_exported.as_ref(), receiver_exported.as_ref()); assert_eq!(sender_exported.as_ref(), receiver_exported.as_ref());
} }
/// Classical mode: derive_hpke_keypair produces standard 32-byte X25519 keys.
#[test]
fn classical_mode_produces_standard_keys() {
let crypto = HybridCrypto::new_classical();
let ikm = b"test-ikm-for-classical-hpke";
let keypair = crypto.derive_hpke_keypair(hpke_config_dhkem_x25519(), ikm);
// Classical X25519 keys are 32 bytes
assert_eq!(keypair.public.len(), 32);
assert_eq!(keypair.private.as_ref().len(), 32);
}
/// Classical mode round-trip: seal/open works with classical keys.
#[test]
fn classical_mode_seal_open_round_trip() {
let crypto = HybridCrypto::new_classical();
let ikm = b"test-ikm-for-classical-round-trip";
let keypair = crypto.derive_hpke_keypair(hpke_config_dhkem_x25519(), ikm);
assert_eq!(keypair.public.len(), 32); // classical key
let plaintext = b"hello classical MLS";
let info = b"mls 1.0 test";
let aad = b"additional data";
let ct = crypto.hpke_seal(
hpke_config_dhkem_x25519(),
&keypair.public,
info,
aad,
plaintext,
);
assert!(!ct.kem_output.as_slice().is_empty());
let decrypted = crypto
.hpke_open(
hpke_config_dhkem_x25519(),
&ct,
keypair.private.as_ref(),
info,
aad,
)
.expect("hpke_open with classical keys");
assert_eq!(decrypted.as_slice(), plaintext);
}
/// KeyPackage generation with HybridCryptoProvider (validates full HPKE path in MLS). /// KeyPackage generation with HybridCryptoProvider (validates full HPKE path in MLS).
#[test] #[test]
fn key_package_generation_with_hybrid_provider() { fn key_package_generation_with_hybrid_provider() {

View File

@@ -41,9 +41,12 @@ use ml_kem::kem::{DecapsulationKey, EncapsulationKey};
const HYBRID_VERSION: u8 = 0x01; const HYBRID_VERSION: u8 = 0x01;
/// HKDF info string for domain separation. /// HKDF info string for domain separation.
/// Frozen at the original project name for backward compatibility with existing
/// encrypted state files and messages. Do not change.
const HKDF_INFO: &[u8] = b"quicnprotochat-hybrid-v1"; const HKDF_INFO: &[u8] = b"quicnprotochat-hybrid-v1";
/// HKDF salt for domain separation (defence-in-depth; IKM already has 64 bytes of entropy). /// HKDF salt for domain separation (defence-in-depth; IKM already has 64 bytes of entropy).
/// Frozen — see [`HKDF_INFO`].
const HKDF_SALT: &[u8] = b"quicnprotochat-hybrid-v1-salt"; const HKDF_SALT: &[u8] = b"quicnprotochat-hybrid-v1-salt";
/// ML-KEM-768 ciphertext size in bytes. /// ML-KEM-768 ciphertext size in bytes.
@@ -122,6 +125,7 @@ pub struct HybridPublicKey {
} }
/// HKDF info for deriving HPKE keypair seed from IKM (MLS compatibility). /// HKDF info for deriving HPKE keypair seed from IKM (MLS compatibility).
/// Frozen — see [`HKDF_INFO`].
const HKDF_INFO_HPKE_KEYPAIR: &[u8] = b"quicnprotochat-hybrid-hpke-keypair-v1"; const HKDF_INFO_HPKE_KEYPAIR: &[u8] = b"quicnprotochat-hybrid-hpke-keypair-v1";
impl HybridKeypair { impl HybridKeypair {

View File

@@ -99,6 +99,32 @@ impl Signer for IdentityKeypair {
} }
} }
impl IdentityKeypair {
/// Sign arbitrary bytes with the Ed25519 key and return the 64-byte signature.
///
/// Used by sealed sender to sign the inner payload for recipient verification.
pub fn sign_raw(&self, payload: &[u8]) -> [u8; 64] {
let sk = self.signing_key();
let sig: ed25519_dalek::Signature = sk.sign(payload);
sig.to_bytes()
}
/// Verify an Ed25519 signature over `payload` using the given public key.
pub fn verify_raw(
public_key: &[u8; 32],
payload: &[u8],
signature: &[u8; 64],
) -> Result<(), crate::error::CoreError> {
use ed25519_dalek::Verifier;
let vk = VerifyingKey::from_bytes(public_key)
.map_err(|e| crate::error::CoreError::Mls(format!("invalid public key: {e}")))?;
let sig = ed25519_dalek::Signature::from_bytes(signature);
vk.verify(payload, &sig)
.map_err(|e| crate::error::CoreError::Mls(format!("signature verification failed: {e}")))
}
}
impl Serialize for IdentityKeypair { impl Serialize for IdentityKeypair {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where where

View File

@@ -14,7 +14,7 @@
//! # Wire format //! # Wire format
//! //!
//! KeyPackages are TLS-encoded using `tls_codec` (same version as openmls). //! KeyPackages are TLS-encoded using `tls_codec` (same version as openmls).
//! The resulting bytes are opaque to the quicnprotochat transport layer. //! The resulting bytes are opaque to the quicproquo transport layer.
use openmls::prelude::{ use openmls::prelude::{
Ciphersuite, Credential, CredentialType, CredentialWithKey, CryptoConfig, KeyPackage, Ciphersuite, Credential, CredentialType, CredentialWithKey, CryptoConfig, KeyPackage,
@@ -25,7 +25,7 @@ use sha2::{Digest, Sha256};
use crate::{error::CoreError, identity::IdentityKeypair}; use crate::{error::CoreError, identity::IdentityKeypair};
/// The MLS ciphersuite used throughout quicnprotochat (RFC 9420 §17.1). /// The MLS ciphersuite used throughout quicproquo (RFC 9420 §17.1).
pub const ALLOWED_CIPHERSUITE: Ciphersuite = pub const ALLOWED_CIPHERSUITE: Ciphersuite =
Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519; Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;

View File

@@ -1,5 +1,5 @@
//! Core cryptographic primitives, MLS group state machine, and hybrid //! Core cryptographic primitives, MLS group state machine, and hybrid
//! post-quantum KEM for quicnprotochat. //! post-quantum KEM for quicproquo.
//! //!
//! # Module layout //! # Module layout
//! //!
@@ -22,6 +22,8 @@ mod identity;
mod keypackage; mod keypackage;
mod keystore; mod keystore;
pub mod opaque_auth; pub mod opaque_auth;
pub mod padding;
pub mod sealed_sender;
// ── Public API ──────────────────────────────────────────────────────────────── // ── Public API ────────────────────────────────────────────────────────────────

View File

@@ -5,7 +5,7 @@
use opaque_ke::CipherSuite; use opaque_ke::CipherSuite;
/// OPAQUE cipher suite for quicnprotochat. /// OPAQUE cipher suite for quicproquo.
/// ///
/// - **OPRF**: Ristretto255 (curve25519-based, ~128-bit security) /// - **OPRF**: Ristretto255 (curve25519-based, ~128-bit security)
/// - **Key exchange**: Triple-DH (3DH) over Ristretto255 with SHA-512 /// - **Key exchange**: Triple-DH (3DH) over Ristretto255 with SHA-512

View File

@@ -0,0 +1,144 @@
//! Message padding to hide plaintext lengths from the server.
//!
//! Pads payloads to fixed bucket sizes before MLS encryption so that the
//! ciphertext does not reveal the actual message length.
//!
//! # Wire format
//!
//! ```text
//! [real_length: 4 bytes LE (u32)][payload: real_length bytes][random padding]
//! ```
//!
//! The total padded output is always one of the bucket sizes: 256, 1024, 4096, 16384 bytes.
//! For payloads larger than 16380 bytes, rounds up to the nearest 16384-byte multiple.
use rand::RngCore;
use crate::error::CoreError;
/// Bucket sizes in bytes. The smallest (256) accommodates a sealed sender
/// envelope (99 bytes overhead) plus a short message.
const BUCKETS: &[usize] = &[256, 1024, 4096, 16384];
/// Select the smallest bucket that fits `content_len + 4` (the 4-byte length prefix).
fn bucket_for(content_len: usize) -> usize {
let total = content_len + 4;
for &b in BUCKETS {
if total <= b {
return b;
}
}
// Larger than biggest bucket: round up to nearest 16384-byte multiple.
((total + 16383) / 16384) * 16384
}
/// Pad a payload to the next bucket boundary with cryptographic random bytes.
pub fn pad(payload: &[u8]) -> Vec<u8> {
let bucket = bucket_for(payload.len());
let mut out = Vec::with_capacity(bucket);
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
out.extend_from_slice(payload);
let pad_len = bucket - 4 - payload.len();
if pad_len > 0 {
let mut padding = vec![0u8; pad_len];
rand::rngs::OsRng.fill_bytes(&mut padding);
out.extend_from_slice(&padding);
}
out
}
/// Remove padding and return the original payload.
pub fn unpad(padded: &[u8]) -> Result<Vec<u8>, CoreError> {
if padded.len() < 4 {
return Err(CoreError::AppMessage("padded message too short".into()));
}
let real_len = u32::from_le_bytes([padded[0], padded[1], padded[2], padded[3]]) as usize;
if 4 + real_len > padded.len() {
return Err(CoreError::AppMessage(
"padded real_length exceeds buffer".into(),
));
}
Ok(padded[4..4 + real_len].to_vec())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_small() {
let msg = b"hello";
let padded = pad(msg);
assert_eq!(padded.len(), 256); // smallest bucket
let unpadded = unpad(&padded).unwrap();
assert_eq!(unpadded, msg);
}
#[test]
fn round_trip_medium() {
let msg = vec![0xAB; 300];
let padded = pad(&msg);
assert_eq!(padded.len(), 1024); // second bucket
let unpadded = unpad(&padded).unwrap();
assert_eq!(unpadded, msg);
}
#[test]
fn round_trip_large() {
let msg = vec![0xCD; 2000];
let padded = pad(&msg);
assert_eq!(padded.len(), 4096); // third bucket
let unpadded = unpad(&padded).unwrap();
assert_eq!(unpadded, msg);
}
#[test]
fn round_trip_very_large() {
let msg = vec![0xEF; 10000];
let padded = pad(&msg);
assert_eq!(padded.len(), 16384); // largest bucket
let unpadded = unpad(&padded).unwrap();
assert_eq!(unpadded, msg);
}
#[test]
fn round_trip_oversized() {
let msg = vec![0xFF; 20000];
let padded = pad(&msg);
assert_eq!(padded.len(), 32768); // 2 * 16384
let unpadded = unpad(&padded).unwrap();
assert_eq!(unpadded, msg);
}
#[test]
fn round_trip_empty() {
let msg = b"";
let padded = pad(msg);
assert_eq!(padded.len(), 256); // smallest bucket
let unpadded = unpad(&padded).unwrap();
assert_eq!(unpadded, msg);
}
#[test]
fn exactly_at_bucket_boundary() {
// 252 + 4 = 256 → fits in 256 bucket exactly
let msg = vec![0x42; 252];
let padded = pad(&msg);
assert_eq!(padded.len(), 256);
let unpadded = unpad(&padded).unwrap();
assert_eq!(unpadded, msg);
}
#[test]
fn unpad_too_short_fails() {
assert!(unpad(&[0, 0]).is_err());
}
#[test]
fn unpad_invalid_length_fails() {
// Claims 1000 bytes but only has 10
let mut bad = (1000u32).to_le_bytes().to_vec();
bad.extend_from_slice(&[0u8; 10]);
assert!(unpad(&bad).is_err());
}
}

View File

@@ -0,0 +1,154 @@
//! Sealed sender: embed sender identity + Ed25519 signature inside the MLS
//! application payload so recipients can verify the sender from decrypted
//! content, independent of MLS framing.
//!
//! # Wire format
//!
//! ```text
//! [magic: 1 byte (0x53 = 'S')]
//! [sender_identity_key: 32 bytes (Ed25519 public key)]
//! [signature: 64 bytes (Ed25519)]
//! [inner_payload: variable (the original app_message bytes)]
//! ```
//!
//! The signature covers: `magic || sender_identity_key || inner_payload`.
//! Total overhead: 1 + 32 + 64 = 97 bytes per message.
use crate::error::CoreError;
use crate::identity::IdentityKeypair;
/// Magic byte identifying a sealed sender envelope.
pub const SEALED_MAGIC: u8 = 0x53; // 'S'
/// Fixed overhead: magic(1) + sender_key(32) + signature(64).
const SEALED_OVERHEAD: usize = 1 + 32 + 64;
/// Wrap an app_message payload in a sealed sender envelope.
///
/// Signs `magic || sender_key || payload` with the sender's Ed25519 key.
pub fn seal(identity: &IdentityKeypair, app_message_bytes: &[u8]) -> Vec<u8> {
let sender_key = identity.public_key_bytes();
// Build signing input
let mut sign_input = Vec::with_capacity(1 + 32 + app_message_bytes.len());
sign_input.push(SEALED_MAGIC);
sign_input.extend_from_slice(&sender_key);
sign_input.extend_from_slice(app_message_bytes);
let signature = identity.sign_raw(&sign_input);
let mut out = Vec::with_capacity(SEALED_OVERHEAD + app_message_bytes.len());
out.push(SEALED_MAGIC);
out.extend_from_slice(&sender_key);
out.extend_from_slice(&signature);
out.extend_from_slice(app_message_bytes);
out
}
/// Unseal: verify the Ed25519 signature, return `(sender_identity_key, inner_app_message_bytes)`.
pub fn unseal(bytes: &[u8]) -> Result<([u8; 32], Vec<u8>), CoreError> {
if bytes.len() < SEALED_OVERHEAD {
return Err(CoreError::AppMessage(
"sealed sender envelope too short".into(),
));
}
if bytes[0] != SEALED_MAGIC {
return Err(CoreError::AppMessage(format!(
"sealed sender: expected magic 0x{:02X}, got 0x{:02X}",
SEALED_MAGIC, bytes[0]
)));
}
let mut sender_key = [0u8; 32];
sender_key.copy_from_slice(&bytes[1..33]);
let mut signature = [0u8; 64];
signature.copy_from_slice(&bytes[33..97]);
let inner_payload = &bytes[97..];
// Reconstruct signing input: magic || sender_key || inner_payload
let mut sign_input = Vec::with_capacity(1 + 32 + inner_payload.len());
sign_input.push(SEALED_MAGIC);
sign_input.extend_from_slice(&sender_key);
sign_input.extend_from_slice(inner_payload);
IdentityKeypair::verify_raw(&sender_key, &sign_input, &signature)?;
Ok((sender_key, inner_payload.to_vec()))
}
/// Check if bytes start with the sealed sender magic byte.
pub fn is_sealed(bytes: &[u8]) -> bool {
bytes.first() == Some(&SEALED_MAGIC)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seal_unseal_round_trip() {
let identity = IdentityKeypair::generate();
let payload = b"hello sealed sender";
let sealed = seal(&identity, payload);
assert!(is_sealed(&sealed));
let (sender_key, inner) = unseal(&sealed).unwrap();
assert_eq!(sender_key, identity.public_key_bytes());
assert_eq!(inner, payload);
}
#[test]
fn unseal_tampered_payload_fails() {
let identity = IdentityKeypair::generate();
let payload = b"hello";
let mut sealed = seal(&identity, payload);
// Tamper with the inner payload
if let Some(last) = sealed.last_mut() {
*last ^= 0xFF;
}
assert!(unseal(&sealed).is_err());
}
#[test]
fn unseal_wrong_sender_fails() {
let alice = IdentityKeypair::generate();
let bob = IdentityKeypair::generate();
let payload = b"from alice";
let mut sealed = seal(&alice, payload);
// Replace sender key with Bob's
let bob_key = bob.public_key_bytes();
sealed[1..33].copy_from_slice(&bob_key);
assert!(unseal(&sealed).is_err());
}
#[test]
fn unseal_too_short_fails() {
assert!(unseal(&[SEALED_MAGIC; 10]).is_err());
}
#[test]
fn unseal_wrong_magic_fails() {
let identity = IdentityKeypair::generate();
let mut sealed = seal(&identity, b"test");
sealed[0] = 0x00;
assert!(unseal(&sealed).is_err());
}
#[test]
fn non_sealed_detected() {
assert!(!is_sealed(b"\x01\x01hello"));
assert!(is_sealed(&[SEALED_MAGIC, 0, 0]));
}
#[test]
fn empty_payload_round_trip() {
let identity = IdentityKeypair::generate();
let sealed = seal(&identity, b"");
let (sender_key, inner) = unseal(&sealed).unwrap();
assert_eq!(sender_key, identity.public_key_bytes());
assert!(inner.is_empty());
}
}

View File

@@ -1,18 +1,18 @@
[package] [package]
name = "quicnprotochat-gui" name = "quicproquo-gui"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Native GUI for quicnprotochat (Tauri 2)." description = "Native GUI for quicproquo (Tauri 2)."
license = "MIT" license = "MIT"
[[bin]] [[bin]]
name = "quicnprotochat-gui" name = "qpq-gui"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
quicnprotochat-core = { path = "../quicnprotochat-core" } quicproquo-core = { path = "../quicproquo-core" }
quicnprotochat-client = { path = "../quicnprotochat-client" } quicproquo-client = { path = "../quicproquo-client" }
quicnprotochat-proto = { path = "../quicnprotochat-proto" } quicproquo-proto = { path = "../quicproquo-proto" }
tauri = { version = "2", features = [] } tauri = { version = "2", features = [] }
tokio = { workspace = true } tokio = { workspace = true }
serde = { workspace = true } serde = { workspace = true }

View File

@@ -1,6 +1,6 @@
# quicnprotochat-gui # quicproquo-gui
Native GUI for quicnprotochat using [Tauri 2](https://v2.tauri.app/). The UI runs in a webview; all server-facing work (capnp-rpc, `node_service::Client`) runs on a **dedicated backend thread** with a tokio `LocalSet`, since that code is `!Send`. Native GUI for quicproquo using [Tauri 2](https://v2.tauri.app/). The UI runs in a webview; all server-facing work (capnp-rpc, `node_service::Client`) runs on a **dedicated backend thread** with a tokio `LocalSet`, since that code is `!Send`.
## Backend threading model ## Backend threading model
@@ -14,7 +14,7 @@ Native GUI for quicnprotochat using [Tauri 2](https://v2.tauri.app/). The UI run
From the workspace root: From the workspace root:
```bash ```bash
cargo run -p quicnprotochat-gui cargo run -p quicproquo-gui
``` ```
**Linux:** Tauri uses GTK. Install development packages if the build fails, e.g.: **Linux:** Tauri uses GTK. Install development packages if the build fails, e.g.:

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -11,7 +11,7 @@ use std::thread;
use tokio::runtime::Builder; use tokio::runtime::Builder;
use tokio::task::LocalSet; use tokio::task::LocalSet;
use quicnprotochat_client::{cmd_health_json, whoami_json}; use quicproquo_client::{cmd_health_json, whoami_json};
/// Commands the UI can send to the backend thread. /// Commands the UI can send to the backend thread.
pub enum BackendCommand { pub enum BackendCommand {

View File

@@ -1,4 +1,4 @@
//! quicnprotochat native GUI (Tauri 2). //! quicproquo native GUI (Tauri 2).
//! //!
//! The backend runs on a dedicated thread with a tokio LocalSet; all server-facing //! The backend runs on a dedicated thread with a tokio LocalSet; all server-facing
//! work (capnp-rpc, node_service::Client) is dispatched there. Tauri commands //! work (capnp-rpc, node_service::Client) is dispatched there. Tauri commands

View File

@@ -0,0 +1,5 @@
//! Desktop entry point for quicproquo-gui.
fn main() {
quicproquo_gui::run()
}

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "quicnprotochat-gui", "productName": "qpq-gui",
"identifier": "chat.quicnproto.gui", "identifier": "chat.quicproquo.gui",
"build": { "build": {
"frontendDist": "./ui", "frontendDist": "./ui",
"beforeBuildCommand": "", "beforeBuildCommand": "",
@@ -10,7 +10,7 @@
"app": { "app": {
"windows": [ "windows": [
{ {
"title": "quicnprotochat", "title": "quicproquo",
"width": 640, "width": 640,
"height": 480 "height": 480
} }

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>quicnprotochat</title> <title>quicproquo</title>
<style> <style>
body { font-family: system-ui, sans-serif; margin: 1rem; } body { font-family: system-ui, sans-serif; margin: 1rem; }
button { margin: 0.25rem; padding: 0.5rem 1rem; cursor: pointer; } button { margin: 0.25rem; padding: 0.5rem 1rem; cursor: pointer; }
@@ -12,12 +12,12 @@
</style> </style>
</head> </head>
<body> <body>
<h1>quicnprotochat</h1> <h1>quicproquo</h1>
<p> <p>
<button id="whoami">Whoami</button> <button id="whoami">Whoami</button>
<button id="health">Health</button> <button id="health">Health</button>
</p> </p>
<label>State path: <input id="statePath" type="text" value="quicnprotochat-state.bin" size="32" /></label> <label>State path: <input id="statePath" type="text" value="qpq-state.bin" size="32" /></label>
<br /> <br />
<label>Server: <input id="server" type="text" value="127.0.0.1:7000" size="24" /></label> <label>Server: <input id="server" type="text" value="127.0.0.1:7000" size="24" /></label>
<div id="output">Click Whoami or Health. Results appear here.</div> <div id="output">Click Whoami or Health. Results appear here.</div>

View File

@@ -0,0 +1,23 @@
[package]
name = "quicproquo-mobile"
version = "0.1.0"
edition = "2021"
description = "C FFI layer for quicproquo, proving QUIC connection migration."
license = "MIT"
[lib]
crate-type = ["staticlib", "cdylib", "rlib"]
[dependencies]
# Async
tokio = { workspace = true }
# QUIC
quinn = { workspace = true }
rustls = { workspace = true }
# Error handling
anyhow = { workspace = true }
[dev-dependencies]
rcgen = { workspace = true }

View File

@@ -0,0 +1,331 @@
//! quicproquo-mobile — C FFI layer for mobile integration.
//!
//! Provides a minimal C API that proves QUIC connection migration works
//! (wifi → cellular handoff without message loss). Each FFI function uses
//! `runtime.block_on(local.run_until(...))` to satisfy capnp-rpc's `!Send`
//! requirement.
//!
//! # Safety
//!
//! All FFI functions are `unsafe extern "C"` — callers must ensure pointers
//! are valid and buffers are correctly sized.
use std::ffi::c_char;
use std::net::SocketAddr;
use std::sync::Arc;
use quinn::Endpoint;
use tokio::runtime::Runtime;
/// Opaque handle returned by `qnpc_connect`.
#[allow(dead_code)]
pub struct MobileHandle {
runtime: Runtime,
endpoint: Endpoint,
connection: Option<quinn::Connection>,
server_addr: SocketAddr,
server_name: String,
}
/// Status codes returned by FFI functions.
#[repr(C)]
pub enum QnpcStatus {
Ok = 0,
Error = 1,
Timeout = 2,
NotConnected = 3,
}
/// Connect to a quicproquo server. Returns a handle pointer (null on failure).
///
/// # Safety
/// `server_addr` and `server_name` must be valid null-terminated C strings.
#[no_mangle]
pub unsafe extern "C" fn qnpc_connect(
server_addr: *const c_char,
server_name: *const c_char,
) -> *mut MobileHandle {
let addr_str = match std::ffi::CStr::from_ptr(server_addr).to_str() {
Ok(s) => s,
Err(_) => return std::ptr::null_mut(),
};
let name_str = match std::ffi::CStr::from_ptr(server_name).to_str() {
Ok(s) => s,
Err(_) => return std::ptr::null_mut(),
};
let addr: SocketAddr = match addr_str.parse() {
Ok(a) => a,
Err(_) => return std::ptr::null_mut(),
};
let rt = match Runtime::new() {
Ok(r) => r,
Err(_) => return std::ptr::null_mut(),
};
let result = rt.block_on(async {
connect_inner(addr, name_str).await
});
match result {
Ok((endpoint, connection)) => {
let handle = Box::new(MobileHandle {
runtime: rt,
endpoint,
connection: Some(connection),
server_addr: addr,
server_name: name_str.to_string(),
});
Box::into_raw(handle)
}
Err(_) => std::ptr::null_mut(),
}
}
async fn connect_inner(
addr: SocketAddr,
server_name: &str,
) -> anyhow::Result<(Endpoint, quinn::Connection)> {
let _ = rustls::crypto::ring::default_provider().install_default();
// Build a permissive client config (skip server cert verification for dev/testing).
let crypto = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(SkipServerVerification))
.with_no_client_auth();
let mut client_config = quinn::ClientConfig::new(Arc::new(
quinn::crypto::rustls::QuicClientConfig::try_from(crypto)
.map_err(|e| anyhow::anyhow!("QUIC client config: {e}"))?,
));
let mut transport = quinn::TransportConfig::default();
transport.max_idle_timeout(Some(
std::time::Duration::from_secs(120)
.try_into()
.expect("120s valid"),
));
client_config.transport_config(Arc::new(transport));
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())?;
endpoint.set_default_client_config(client_config);
let connection = endpoint.connect(addr, server_name)?.await?;
Ok((endpoint, connection))
}
/// Simulate QUIC connection migration by rebinding the endpoint to a new local address.
///
/// This is the key proof-of-concept: after rebind, the QUIC connection survives
/// and messages continue flowing without loss.
///
/// # Safety
/// `handle` must be a valid pointer from `qnpc_connect`.
#[no_mangle]
pub unsafe extern "C" fn qnpc_migrate(
handle: *mut MobileHandle,
new_port: u16,
) -> QnpcStatus {
let handle = match handle.as_mut() {
Some(h) => h,
None => return QnpcStatus::Error,
};
let new_addr: SocketAddr = format!("0.0.0.0:{new_port}").parse().unwrap();
let socket = match std::net::UdpSocket::bind(new_addr) {
Ok(s) => s,
Err(_) => return QnpcStatus::Error,
};
match handle.endpoint.rebind(socket) {
Ok(_) => QnpcStatus::Ok,
Err(_) => QnpcStatus::Error,
}
}
/// Disconnect and free the handle.
///
/// # Safety
/// `handle` must be a valid pointer from `qnpc_connect`, and must not be used after this call.
#[no_mangle]
pub unsafe extern "C" fn qnpc_disconnect(handle: *mut MobileHandle) {
if !handle.is_null() {
let handle = Box::from_raw(handle);
if let Some(conn) = &handle.connection {
conn.close(0u32.into(), b"disconnect");
}
drop(handle);
}
}
// ── Internal: skip server cert verification for testing ─────────────────────
#[derive(Debug)]
struct SkipServerVerification;
impl rustls::client::danger::ServerCertVerifier for SkipServerVerification {
fn verify_server_cert(
&self,
_end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
]
}
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use std::net::UdpSocket;
/// Prove QUIC connection migration: connect, send messages, rebind the
/// UDP socket (simulating wifi→cellular), send more messages, verify
/// all messages arrive.
#[test]
fn quic_connection_migration() {
let _ = rustls::crypto::ring::default_provider().install_default();
let rt = Runtime::new().unwrap();
rt.block_on(async {
// Start an in-process echo server.
let server_addr = start_echo_server().await;
// Connect client.
let (endpoint, connection) = connect_inner(server_addr, "localhost")
.await
.expect("connect");
// Send 5 messages before migration.
for i in 0..5u32 {
let (mut send, mut recv) = connection.open_bi().await.unwrap();
let msg = format!("pre-migrate-{i}");
send.write_all(msg.as_bytes()).await.unwrap();
send.finish().unwrap();
let response = recv.read_to_end(4096).await.unwrap();
assert_eq!(response, msg.as_bytes(), "pre-migrate echo mismatch");
}
// Migrate: rebind to a new local UDP socket (simulates wifi→cellular).
let new_socket = UdpSocket::bind("127.0.0.1:0").unwrap();
let new_local = new_socket.local_addr().unwrap();
endpoint.rebind(new_socket).expect("rebind should succeed");
// Send 5 more messages after migration.
for i in 0..5u32 {
let (mut send, mut recv) = connection.open_bi().await.unwrap();
let msg = format!("post-migrate-{i}");
send.write_all(msg.as_bytes()).await.unwrap();
send.finish().unwrap();
let response = recv.read_to_end(4096).await.unwrap();
assert_eq!(response, msg.as_bytes(), "post-migrate echo mismatch");
}
// Assert: connection still alive after migration.
assert!(
connection.close_reason().is_none(),
"connection should still be open after migration"
);
// Verify the local address changed.
let _ = new_local; // We successfully used a new socket.
connection.close(0u32.into(), b"test done");
endpoint.wait_idle().await;
});
}
/// Start a simple QUIC echo server that echoes back whatever it receives.
async fn start_echo_server() -> SocketAddr {
let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
let cert_der = cert.cert.der().to_vec();
let key_der = cert.key_pair.serialize_der();
let cert_chain = vec![rustls::pki_types::CertificateDer::from(cert_der)];
let key = rustls::pki_types::PrivateKeyDer::try_from(key_der).unwrap();
let tls = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(cert_chain, key)
.unwrap();
let server_config = quinn::ServerConfig::with_crypto(Arc::new(
quinn::crypto::rustls::QuicServerConfig::try_from(tls).unwrap(),
));
let endpoint = Endpoint::server(
server_config,
"127.0.0.1:0".parse().unwrap(),
)
.unwrap();
let addr = endpoint.local_addr().unwrap();
// Spawn echo acceptor.
tokio::spawn(async move {
while let Some(incoming) = endpoint.accept().await {
let connecting = match incoming.accept() {
Ok(c) => c,
Err(_) => continue,
};
tokio::spawn(async move {
let conn = match connecting.await {
Ok(c) => c,
Err(_) => return,
};
loop {
let (mut send, mut recv) = match conn.accept_bi().await {
Ok(s) => s,
Err(_) => break,
};
let data = match recv.read_to_end(4096).await {
Ok(d) => d,
Err(_) => break,
};
let _ = send.write_all(&data).await;
let _ = send.finish();
}
});
}
});
addr
}
}

View File

@@ -1,8 +1,8 @@
[package] [package]
name = "quicnprotochat-p2p" name = "quicproquo-p2p"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "P2P transport layer for quicnprotochat using iroh." description = "P2P transport layer for quicproquo using iroh."
license = "MIT" license = "MIT"
[dependencies] [dependencies]

View File

@@ -1,4 +1,4 @@
//! P2P transport layer for quicnprotochat using iroh. //! P2P transport layer for quicproquo using iroh.
//! //!
//! Provides direct peer-to-peer QUIC connections with NAT traversal via iroh //! Provides direct peer-to-peer QUIC connections with NAT traversal via iroh
//! relay servers. When both peers are online, messages bypass the central //! relay servers. When both peers are online, messages bypass the central
@@ -14,7 +14,8 @@
use iroh::{Endpoint, EndpointAddr, PublicKey, SecretKey}; use iroh::{Endpoint, EndpointAddr, PublicKey, SecretKey};
/// ALPN protocol identifier for quicnprotochat P2P messaging. /// ALPN protocol identifier for quicproquo P2P messaging.
/// Frozen at the original project name for wire compatibility.
const P2P_ALPN: &[u8] = b"quicnprotochat/p2p/1"; const P2P_ALPN: &[u8] = b"quicnprotochat/p2p/1";
/// A P2P node backed by an iroh endpoint. /// A P2P node backed by an iroh endpoint.

View File

@@ -1,8 +1,8 @@
[package] [package]
name = "quicnprotochat-proto" name = "quicproquo-proto"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Cap'n Proto schemas, generated types, and serialisation helpers for quicnprotochat. No crypto, no I/O." description = "Cap'n Proto schemas, generated types, and serialisation helpers for quicproquo. No crypto, no I/O."
license = "MIT" license = "MIT"
# build.rs invokes capnpc to generate Rust source from .capnp schemas. # build.rs invokes capnpc to generate Rust source from .capnp schemas.

View File

@@ -1,4 +1,4 @@
//! Build script for quicnprotochat-proto. //! Build script for quicproquo-proto.
//! //!
//! Invokes the `capnp` compiler to generate Rust types from `.capnp` schemas //! Invokes the `capnp` compiler to generate Rust types from `.capnp` schemas
//! located in the workspace-root `schemas/` directory. //! located in the workspace-root `schemas/` directory.
@@ -17,7 +17,7 @@ fn main() {
let manifest_dir = let manifest_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set by Cargo")); PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set by Cargo"));
// Workspace root is two levels above this crate (quicnprotochat/crates/quicnprotochat-proto). // Workspace root is two levels above this crate (quicproquo/crates/quicproquo-proto).
let workspace_root = manifest_dir let workspace_root = manifest_dir
.join("../..") .join("../..")
.canonicalize() .canonicalize()
@@ -38,6 +38,10 @@ fn main() {
"cargo:rerun-if-changed={}", "cargo:rerun-if-changed={}",
schemas_dir.join("node.capnp").display() schemas_dir.join("node.capnp").display()
); );
println!(
"cargo:rerun-if-changed={}",
schemas_dir.join("federation.capnp").display()
);
capnpc::CompilerCommand::new() capnpc::CompilerCommand::new()
// Treat `schemas/` as the include root so that inter-schema imports // Treat `schemas/` as the include root so that inter-schema imports
@@ -46,6 +50,7 @@ fn main() {
.file(schemas_dir.join("auth.capnp")) .file(schemas_dir.join("auth.capnp"))
.file(schemas_dir.join("delivery.capnp")) .file(schemas_dir.join("delivery.capnp"))
.file(schemas_dir.join("node.capnp")) .file(schemas_dir.join("node.capnp"))
.file(schemas_dir.join("federation.capnp"))
.run() .run()
.expect( .expect(
"Cap'n Proto schema compilation failed. \ "Cap'n Proto schema compilation failed. \

View File

@@ -1,4 +1,4 @@
//! Cap'n Proto schemas, generated types, and serialisation helpers for quicnprotochat. //! Cap'n Proto schemas, generated types, and serialisation helpers for quicproquo.
//! //!
//! Generated Cap'n Proto code emits unnecessary parentheses; allow per coding standards. //! Generated Cap'n Proto code emits unnecessary parentheses; allow per coding standards.
#![allow(unused_parens)] #![allow(unused_parens)]
@@ -38,12 +38,19 @@ pub mod node_capnp {
include!(concat!(env!("OUT_DIR"), "/node_capnp.rs")); include!(concat!(env!("OUT_DIR"), "/node_capnp.rs"));
} }
/// Cap'n Proto generated types for `schemas/federation.capnp`.
///
/// Do not edit this module by hand — it is entirely machine-generated.
pub mod federation_capnp {
include!(concat!(env!("OUT_DIR"), "/federation_capnp.rs"));
}
// ── Low-level byte ↔ message conversions ────────────────────────────────────── // ── Low-level byte ↔ message conversions ──────────────────────────────────────
/// Serialise a Cap'n Proto message builder to unpacked wire bytes. /// Serialise a Cap'n Proto message builder to unpacked wire bytes.
/// ///
/// The output includes the segment table header. For transport, the /// The output includes the segment table header. For transport, the
/// `quicnprotochat-core` frame codec prepends a 4-byte little-endian length field. /// `quicproquo-core` frame codec prepends a 4-byte little-endian length field.
pub fn to_bytes<A: capnp::message::Allocator>( pub fn to_bytes<A: capnp::message::Allocator>(
msg: &capnp::message::Builder<A>, msg: &capnp::message::Builder<A>,
) -> Result<Vec<u8>, capnp::Error> { ) -> Result<Vec<u8>, capnp::Error> {

View File

@@ -1,17 +1,17 @@
[package] [package]
name = "quicnprotochat-server" name = "quicproquo-server"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "Delivery Service and Authentication Service for quicnprotochat." description = "Delivery Service and Authentication Service for quicproquo."
license = "MIT" license = "MIT"
[[bin]] [[bin]]
name = "quicnprotochat-server" name = "qpq-server"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
quicnprotochat-core = { path = "../quicnprotochat-core" } quicproquo-core = { path = "../quicproquo-core" }
quicnprotochat-proto = { path = "../quicnprotochat-proto" } quicproquo-proto = { path = "../quicproquo-proto" }
# Serialisation + RPC # Serialisation + RPC
capnp = { workspace = true } capnp = { workspace = true }
@@ -24,6 +24,7 @@ futures = { workspace = true }
# Server utilities # Server utilities
dashmap = { workspace = true } dashmap = { workspace = true }
hex = { workspace = true }
sha2 = { workspace = true } sha2 = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }

View File

@@ -0,0 +1,16 @@
-- 004_federation.sql: Federation support tables.
-- Map identity keys to their home server domain.
-- Used for routing: if a recipient's home_server != local domain, relay via federation.
CREATE TABLE IF NOT EXISTS identity_home_servers (
identity_key BLOB PRIMARY KEY,
home_server TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
-- Known federation peers (other quicnprotochat servers).
CREATE TABLE IF NOT EXISTS federation_peers (
domain TEXT PRIMARY KEY,
last_seen INTEGER NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1
);

View File

@@ -2,7 +2,7 @@ use std::net::IpAddr;
use std::sync::Arc; use std::sync::Arc;
use dashmap::DashMap; use dashmap::DashMap;
use quicnprotochat_proto::node_capnp::auth; use quicproquo_proto::node_capnp::auth;
use sha2::Digest; use sha2::Digest;
use subtle::ConstantTimeEq; use subtle::ConstantTimeEq;
use tokio::sync::Notify; use tokio::sync::Notify;
@@ -20,7 +20,7 @@ pub struct AuthConfig {
/// Server bearer token — zeroized on drop to prevent memory disclosure. /// Server bearer token — zeroized on drop to prevent memory disclosure.
pub required_token: Option<Zeroizing<Vec<u8>>>, pub required_token: Option<Zeroizing<Vec<u8>>>,
/// When true, a valid bearer token (no session) is accepted and the request's identity/key is used (dev/e2e only). /// When true, a valid bearer token (no session) is accepted and the request's identity/key is used (dev/e2e only).
/// CLI flag: --allow-insecure-auth / QUICNPROTOCHAT_ALLOW_INSECURE_AUTH. /// CLI flag: --allow-insecure-auth / QPQ_ALLOW_INSECURE_AUTH.
pub allow_insecure_identity_from_request: bool, pub allow_insecure_identity_from_request: bool,
} }

View File

@@ -8,7 +8,7 @@ pub const DEFAULT_DATA_DIR: &str = "data";
pub const DEFAULT_TLS_CERT: &str = "data/server-cert.der"; pub const DEFAULT_TLS_CERT: &str = "data/server-cert.der";
pub const DEFAULT_TLS_KEY: &str = "data/server-key.der"; pub const DEFAULT_TLS_KEY: &str = "data/server-key.der";
pub const DEFAULT_STORE_BACKEND: &str = "file"; pub const DEFAULT_STORE_BACKEND: &str = "file";
pub const DEFAULT_DB_PATH: &str = "data/quicnprotochat.db"; pub const DEFAULT_DB_PATH: &str = "data/qpq.db";
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
pub struct FileConfig { pub struct FileConfig {
@@ -30,6 +30,7 @@ pub struct FileConfig {
/// When true and metrics_listen is set, start the metrics server. /// When true and metrics_listen is set, start the metrics server.
#[serde(default)] #[serde(default)]
pub metrics_enabled: Option<bool>, pub metrics_enabled: Option<bool>,
pub federation: Option<FederationFileConfig>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -49,12 +50,42 @@ pub struct EffectiveConfig {
pub metrics_listen: Option<String>, pub metrics_listen: Option<String>,
/// Start metrics server only when true and metrics_listen is set. /// Start metrics server only when true and metrics_listen is set.
pub metrics_enabled: bool, pub metrics_enabled: bool,
pub federation: Option<EffectiveFederationConfig>,
}
#[derive(Debug, Default, Deserialize)]
pub struct FederationFileConfig {
pub enabled: Option<bool>,
pub domain: Option<String>,
pub listen: Option<String>,
pub federation_cert: Option<PathBuf>,
pub federation_key: Option<PathBuf>,
pub federation_ca: Option<PathBuf>,
#[serde(default)]
pub peers: Vec<FederationPeerConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FederationPeerConfig {
pub domain: String,
pub address: String,
}
#[derive(Debug)]
pub struct EffectiveFederationConfig {
pub enabled: bool,
pub domain: String,
pub listen: String,
pub federation_cert: PathBuf,
pub federation_key: PathBuf,
pub federation_ca: PathBuf,
pub peers: Vec<FederationPeerConfig>,
} }
pub fn load_config(path: Option<&Path>) -> anyhow::Result<FileConfig> { pub fn load_config(path: Option<&Path>) -> anyhow::Result<FileConfig> {
let path = match path { let path = match path {
Some(p) => PathBuf::from(p), Some(p) => PathBuf::from(p),
None => PathBuf::from("quicnprotochat-server.toml"), None => PathBuf::from("qpq-server.toml"),
}; };
if !path.exists() { if !path.exists() {
@@ -146,6 +177,42 @@ pub fn merge_config(args: &crate::Args, file: &FileConfig) -> EffectiveConfig {
.or(file.metrics_enabled) .or(file.metrics_enabled)
.unwrap_or(metrics_listen.is_some()); .unwrap_or(metrics_listen.is_some());
let federation = {
let file_fed = file.federation.as_ref();
let enabled = args.federation_enabled
|| file_fed.and_then(|f| f.enabled).unwrap_or(false);
if enabled {
let domain = args.federation_domain.clone()
.or_else(|| file_fed.and_then(|f| f.domain.clone()))
.unwrap_or_default();
let listen_fed = args.federation_listen.clone()
.or_else(|| file_fed.and_then(|f| f.listen.clone()))
.unwrap_or_else(|| "0.0.0.0:7001".to_string());
let federation_cert = file_fed.and_then(|f| f.federation_cert.clone())
.unwrap_or_else(|| PathBuf::from("data/federation-cert.der"));
let federation_key = file_fed.and_then(|f| f.federation_key.clone())
.unwrap_or_else(|| PathBuf::from("data/federation-key.der"));
let federation_ca = file_fed.and_then(|f| f.federation_ca.clone())
.unwrap_or_else(|| PathBuf::from("data/federation-ca.der"));
let peers = file_fed
.map(|f| f.peers.clone())
.unwrap_or_default();
Some(EffectiveFederationConfig {
enabled,
domain,
listen: listen_fed,
federation_cert,
federation_key,
federation_ca,
peers,
})
} else {
None
}
};
EffectiveConfig { EffectiveConfig {
listen, listen,
data_dir, data_dir,
@@ -159,6 +226,7 @@ pub fn merge_config(args: &crate::Args, file: &FileConfig) -> EffectiveConfig {
db_key, db_key,
metrics_listen, metrics_listen,
metrics_enabled, metrics_enabled,
federation,
} }
} }
@@ -171,25 +239,25 @@ pub fn validate_production_config(effective: &EffectiveConfig) -> anyhow::Result
.as_deref() .as_deref()
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.ok_or_else(|| { .ok_or_else(|| {
anyhow::anyhow!("production requires QUICNPROTOCHAT_AUTH_TOKEN (non-empty)") anyhow::anyhow!("production requires QPQ_AUTH_TOKEN (non-empty)")
})?; })?;
if token == "devtoken" { if token == "devtoken" {
anyhow::bail!( anyhow::bail!(
"production forbids auth_token 'devtoken'; set a strong QUICNPROTOCHAT_AUTH_TOKEN" "production forbids auth_token 'devtoken'; set a strong QPQ_AUTH_TOKEN"
); );
} }
if effective.store_backend == "sql" && effective.db_key.is_empty() { if effective.store_backend == "sql" && effective.db_key.is_empty() {
anyhow::bail!("production with store_backend=sql requires non-empty QUICNPROTOCHAT_DB_KEY"); anyhow::bail!("production with store_backend=sql requires non-empty QPQ_DB_KEY");
} }
if effective.store_backend != "sql" { if effective.store_backend != "sql" {
tracing::warn!( tracing::warn!(
"production is using file-backed storage; \ "production is using file-backed storage; \
consider store_backend=sql with QUICNPROTOCHAT_DB_KEY for encryption at rest" consider store_backend=sql with QPQ_DB_KEY for encryption at rest"
); );
} }
if !effective.tls_cert.exists() || !effective.tls_key.exists() { if !effective.tls_cert.exists() || !effective.tls_key.exists() {
anyhow::bail!( anyhow::bail!(
"production requires existing TLS cert and key (no auto-generation); provide QUICNPROTOCHAT_TLS_CERT and QUICNPROTOCHAT_TLS_KEY" "production requires existing TLS cert and key (no auto-generation); provide QPQ_TLS_CERT and QPQ_TLS_KEY"
); );
} }
Ok(()) Ok(())

View File

@@ -0,0 +1,78 @@
//! Parse `username@domain` federated addresses.
//!
//! A bare `username` (no `@`) is treated as local.
/// A parsed federated address.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FederatedAddress {
pub username: String,
pub domain: Option<String>,
}
impl FederatedAddress {
/// Parse a `user@domain` string. Bare `user` → domain is `None`.
pub fn parse(input: &str) -> Self {
// Split on the *last* '@' so usernames can contain '@' in theory.
match input.rsplit_once('@') {
Some((user, domain)) if !domain.is_empty() && !user.is_empty() => Self {
username: user.to_string(),
domain: Some(domain.to_string()),
},
_ => Self {
username: input.to_string(),
domain: None,
},
}
}
/// Returns true if this address refers to a local user (no domain or domain matches local).
pub fn is_local(&self, local_domain: &str) -> bool {
match &self.domain {
None => true,
Some(d) => d == local_domain,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bare_username() {
let addr = FederatedAddress::parse("alice");
assert_eq!(addr.username, "alice");
assert_eq!(addr.domain, None);
assert!(addr.is_local("example.com"));
}
#[test]
fn user_at_domain() {
let addr = FederatedAddress::parse("alice@remote.example.com");
assert_eq!(addr.username, "alice");
assert_eq!(addr.domain, Some("remote.example.com".into()));
assert!(!addr.is_local("local.example.com"));
assert!(addr.is_local("remote.example.com"));
}
#[test]
fn trailing_at_is_bare() {
let addr = FederatedAddress::parse("alice@");
assert_eq!(addr.username, "alice@");
assert_eq!(addr.domain, None);
}
#[test]
fn leading_at_is_bare() {
let addr = FederatedAddress::parse("@domain.com");
assert_eq!(addr.username, "@domain.com");
assert_eq!(addr.domain, None);
}
#[test]
fn multiple_at_uses_last() {
let addr = FederatedAddress::parse("user@org@domain.com");
assert_eq!(addr.username, "user@org");
assert_eq!(addr.domain, Some("domain.com".into()));
}
}

View File

@@ -0,0 +1,287 @@
//! Outbound federation client: connects to peer servers to relay messages.
//!
//! Uses a lazy connection pool (DashMap) to reuse QUIC connections to known peers.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::Context;
use dashmap::DashMap;
use quinn::Endpoint;
use crate::config::{EffectiveFederationConfig, FederationPeerConfig};
/// Outbound federation client for relaying to peer servers.
pub struct FederationClient {
/// Peer domain → address mapping from config.
peer_addresses: HashMap<String, SocketAddr>,
/// Lazy QUIC connection pool: domain → active Connection.
connections: DashMap<String, quinn::Connection>,
/// Local QUIC endpoint (shared for all outbound federation connections).
endpoint: Endpoint,
/// Local domain (for the FederationAuth.origin field).
local_domain: String,
}
impl FederationClient {
/// Create a new federation client from config.
///
/// The `endpoint` should be configured with mTLS client credentials.
pub fn new(
config: &EffectiveFederationConfig,
endpoint: Endpoint,
) -> anyhow::Result<Self> {
let mut peer_addresses = HashMap::new();
for peer in &config.peers {
let addr: SocketAddr = peer.address.parse().with_context(|| {
format!("parse federation peer address '{}' for '{}'", peer.address, peer.domain)
})?;
peer_addresses.insert(peer.domain.clone(), addr);
}
Ok(Self {
peer_addresses,
connections: DashMap::new(),
endpoint,
local_domain: config.domain.clone(),
})
}
/// Check if we have a configured peer for the given domain.
pub fn has_peer(&self, domain: &str) -> bool {
self.peer_addresses.contains_key(domain)
}
/// List all configured peer domains.
pub fn peer_domains(&self) -> Vec<String> {
self.peer_addresses.keys().cloned().collect()
}
/// Get the local domain.
pub fn local_domain(&self) -> &str {
&self.local_domain
}
/// Relay a single enqueue to a remote peer. Returns the seq assigned by the remote server.
pub async fn relay_enqueue(
&self,
domain: &str,
recipient_key: &[u8],
payload: &[u8],
channel_id: &[u8],
) -> anyhow::Result<u64> {
let conn = self.get_or_connect(domain).await?;
let (send, recv) = conn.open_bi().await.context("open bi stream to peer")?;
let (reader, writer) = (
tokio_util::compat::TokioAsyncReadCompatExt::compat(recv),
tokio_util::compat::TokioAsyncWriteCompatExt::compat_write(send),
);
let rpc_network = capnp_rpc::twoparty::VatNetwork::new(
reader,
writer,
capnp_rpc::rpc_twoparty_capnp::Side::Client,
Default::default(),
);
let mut rpc_system = capnp_rpc::RpcSystem::new(Box::new(rpc_network), None);
let client: quicproquo_proto::federation_capnp::federation_service::Client =
rpc_system.bootstrap(capnp_rpc::rpc_twoparty_capnp::Side::Server);
tokio::task::spawn_local(rpc_system);
let mut req = client.relay_enqueue_request();
{
let mut builder = req.get();
builder.set_recipient_key(recipient_key);
builder.set_payload(payload);
builder.set_channel_id(channel_id);
builder.set_version(1);
let mut auth = builder.init_auth();
auth.set_origin(&self.local_domain);
}
let response = req.send().promise.await
.map_err(|e| anyhow::anyhow!("federation relay_enqueue failed: {e}"))?;
let seq = response.get()
.map_err(|e| anyhow::anyhow!("read relay_enqueue response: {e}"))?
.get_seq();
Ok(seq)
}
/// Proxy a key package fetch to a remote peer.
pub async fn proxy_fetch_key_package(
&self,
domain: &str,
identity_key: &[u8],
) -> anyhow::Result<Option<Vec<u8>>> {
let conn = self.get_or_connect(domain).await?;
let (send, recv) = conn.open_bi().await.context("open bi stream to peer")?;
let (reader, writer) = (
tokio_util::compat::TokioAsyncReadCompatExt::compat(recv),
tokio_util::compat::TokioAsyncWriteCompatExt::compat_write(send),
);
let rpc_network = capnp_rpc::twoparty::VatNetwork::new(
reader,
writer,
capnp_rpc::rpc_twoparty_capnp::Side::Client,
Default::default(),
);
let mut rpc_system = capnp_rpc::RpcSystem::new(Box::new(rpc_network), None);
let client: quicproquo_proto::federation_capnp::federation_service::Client =
rpc_system.bootstrap(capnp_rpc::rpc_twoparty_capnp::Side::Server);
tokio::task::spawn_local(rpc_system);
let mut req = client.proxy_fetch_key_package_request();
{
let mut builder = req.get();
builder.set_identity_key(identity_key);
let mut auth = builder.init_auth();
auth.set_origin(&self.local_domain);
}
let response = req.send().promise.await
.map_err(|e| anyhow::anyhow!("federation proxy_fetch_key_package failed: {e}"))?;
let pkg = response.get()
.map_err(|e| anyhow::anyhow!("read proxy_fetch_key_package response: {e}"))?
.get_package()
.map_err(|e| anyhow::anyhow!("get package: {e}"))?;
if pkg.is_empty() {
Ok(None)
} else {
Ok(Some(pkg.to_vec()))
}
}
/// Proxy a hybrid key fetch to a remote peer.
pub async fn proxy_fetch_hybrid_key(
&self,
domain: &str,
identity_key: &[u8],
) -> anyhow::Result<Option<Vec<u8>>> {
let conn = self.get_or_connect(domain).await?;
let (send, recv) = conn.open_bi().await.context("open bi stream to peer")?;
let (reader, writer) = (
tokio_util::compat::TokioAsyncReadCompatExt::compat(recv),
tokio_util::compat::TokioAsyncWriteCompatExt::compat_write(send),
);
let rpc_network = capnp_rpc::twoparty::VatNetwork::new(
reader,
writer,
capnp_rpc::rpc_twoparty_capnp::Side::Client,
Default::default(),
);
let mut rpc_system = capnp_rpc::RpcSystem::new(Box::new(rpc_network), None);
let client: quicproquo_proto::federation_capnp::federation_service::Client =
rpc_system.bootstrap(capnp_rpc::rpc_twoparty_capnp::Side::Server);
tokio::task::spawn_local(rpc_system);
let mut req = client.proxy_fetch_hybrid_key_request();
{
let mut builder = req.get();
builder.set_identity_key(identity_key);
let mut auth = builder.init_auth();
auth.set_origin(&self.local_domain);
}
let response = req.send().promise.await
.map_err(|e| anyhow::anyhow!("federation proxy_fetch_hybrid_key failed: {e}"))?;
let pk = response.get()
.map_err(|e| anyhow::anyhow!("read proxy_fetch_hybrid_key response: {e}"))?
.get_hybrid_public_key()
.map_err(|e| anyhow::anyhow!("get hybrid_public_key: {e}"))?;
if pk.is_empty() {
Ok(None)
} else {
Ok(Some(pk.to_vec()))
}
}
/// Proxy a user resolution to a remote peer.
pub async fn proxy_resolve_user(
&self,
domain: &str,
username: &str,
) -> anyhow::Result<Option<Vec<u8>>> {
let conn = self.get_or_connect(domain).await?;
let (send, recv) = conn.open_bi().await.context("open bi stream to peer")?;
let (reader, writer) = (
tokio_util::compat::TokioAsyncReadCompatExt::compat(recv),
tokio_util::compat::TokioAsyncWriteCompatExt::compat_write(send),
);
let rpc_network = capnp_rpc::twoparty::VatNetwork::new(
reader,
writer,
capnp_rpc::rpc_twoparty_capnp::Side::Client,
Default::default(),
);
let mut rpc_system = capnp_rpc::RpcSystem::new(Box::new(rpc_network), None);
let client: quicproquo_proto::federation_capnp::federation_service::Client =
rpc_system.bootstrap(capnp_rpc::rpc_twoparty_capnp::Side::Server);
tokio::task::spawn_local(rpc_system);
let mut req = client.proxy_resolve_user_request();
{
let mut builder = req.get();
builder.set_username(username);
let mut auth = builder.init_auth();
auth.set_origin(&self.local_domain);
}
let response = req.send().promise.await
.map_err(|e| anyhow::anyhow!("federation proxy_resolve_user failed: {e}"))?;
let key = response.get()
.map_err(|e| anyhow::anyhow!("read proxy_resolve_user response: {e}"))?
.get_identity_key()
.map_err(|e| anyhow::anyhow!("get identity_key: {e}"))?;
if key.is_empty() {
Ok(None)
} else {
Ok(Some(key.to_vec()))
}
}
/// Get an existing connection or create a new one to a peer domain.
async fn get_or_connect(&self, domain: &str) -> anyhow::Result<quinn::Connection> {
// Check for cached connection that's still alive.
if let Some(conn) = self.connections.get(domain) {
if conn.close_reason().is_none() {
return Ok(conn.clone());
}
}
let addr = self.peer_addresses.get(domain).ok_or_else(|| {
anyhow::anyhow!("no federation peer configured for domain '{domain}'")
})?;
tracing::info!(domain = domain, addr = %addr, "connecting to federation peer");
let conn = self
.endpoint
.connect(*addr, domain)
.map_err(|e| anyhow::anyhow!("federation connect to {domain}: {e}"))?
.await
.with_context(|| format!("federation QUIC handshake with {domain}"))?;
self.connections.insert(domain.to_string(), conn.clone());
Ok(conn)
}
}

View File

@@ -0,0 +1,16 @@
//! Federation subsystem: server-to-server message relay over mutual TLS + QUIC.
//!
//! When federation is enabled, the server binds a second QUIC endpoint on a
//! dedicated port (default 7001) that only accepts connections from known peers
//! authenticated via mTLS. Inbound requests are handled by [`service::FederationServiceImpl`],
//! which delegates to the local [`Store`]. Outbound relay uses [`client::FederationClient`].
pub mod address;
pub mod client;
pub mod routing;
pub mod service;
pub mod tls;
pub use address::FederatedAddress;
pub use client::FederationClient;
pub use routing::Destination;

View File

@@ -0,0 +1,44 @@
//! Federation routing: determine whether a recipient is local or remote.
use std::sync::Arc;
use crate::storage::Store;
/// Where a message should be delivered.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Destination {
/// Recipient is on this server.
Local,
/// Recipient's home server is the given domain.
Remote(String),
}
/// Resolve a recipient identity key to a routing destination.
///
/// 1. Check the `identity_home_servers` table for an explicit mapping.
/// 2. If no mapping exists, assume local (backwards compatible with single-server deployments).
pub fn resolve_destination(
store: &Arc<dyn Store>,
recipient_key: &[u8],
local_domain: &str,
) -> Destination {
match store.get_identity_home_server(recipient_key) {
Ok(Some(domain)) if domain != local_domain => Destination::Remote(domain),
_ => Destination::Local,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_identity_routes_local() {
let store: Arc<dyn Store> =
Arc::new(crate::storage::FileBackedStore::open(
tempfile::tempdir().unwrap().path(),
).unwrap());
let dest = resolve_destination(&store, &[1u8; 32], "local.example.com");
assert_eq!(dest, Destination::Local);
}
}

View File

@@ -0,0 +1,201 @@
//! Inbound federation handler: implements `FederationService` Cap'n Proto interface.
//!
//! Delegates all operations to the local [`Store`], acting as a trusted relay
//! from authenticated peer servers.
use std::sync::Arc;
use capnp::capability::Promise;
use quicproquo_proto::federation_capnp::federation_service;
use tokio::sync::Notify;
use dashmap::DashMap;
use crate::storage::Store;
/// Inbound federation RPC handler.
pub struct FederationServiceImpl {
pub store: Arc<dyn Store>,
pub waiters: Arc<DashMap<Vec<u8>, Arc<Notify>>>,
pub local_domain: String,
}
impl federation_service::Server for FederationServiceImpl {
fn relay_enqueue(
&mut self,
params: federation_service::RelayEnqueueParams,
mut results: federation_service::RelayEnqueueResults,
) -> Promise<(), capnp::Error> {
let p = match params.get() {
Ok(p) => p,
Err(e) => return Promise::err(capnp::Error::failed(format!("bad params: {e}"))),
};
let recipient_key = match p.get_recipient_key() {
Ok(v) => v.to_vec(),
Err(e) => return Promise::err(capnp::Error::failed(format!("bad recipient_key: {e}"))),
};
let payload = match p.get_payload() {
Ok(v) => v.to_vec(),
Err(e) => return Promise::err(capnp::Error::failed(format!("bad payload: {e}"))),
};
let channel_id = p.get_channel_id().unwrap_or_default().to_vec();
if let Ok(a) = p.get_auth() {
if let Ok(origin) = a.get_origin() {
let origin = origin.to_str().unwrap_or("?");
tracing::debug!(origin = origin, "federation relay_enqueue");
}
}
if recipient_key.len() != 32 {
return Promise::err(capnp::Error::failed("recipient_key must be 32 bytes".into()));
}
if payload.is_empty() {
return Promise::err(capnp::Error::failed("payload must not be empty".into()));
}
let seq = match self.store.enqueue(&recipient_key, &channel_id, payload) {
Ok(s) => s,
Err(e) => return Promise::err(capnp::Error::failed(format!("store error: {e}"))),
};
results.get().set_seq(seq);
// Wake any waiting fetchWait clients.
if let Some(waiter) = self.waiters.get(&recipient_key) {
waiter.notify_waiters();
}
tracing::info!(
recipient_prefix = %hex::encode(&recipient_key[..4]),
seq = seq,
"federation: relayed enqueue"
);
Promise::ok(())
}
fn relay_batch_enqueue(
&mut self,
params: federation_service::RelayBatchEnqueueParams,
mut results: federation_service::RelayBatchEnqueueResults,
) -> Promise<(), capnp::Error> {
let p = match params.get() {
Ok(p) => p,
Err(e) => return Promise::err(capnp::Error::failed(format!("bad params: {e}"))),
};
let recipient_keys = match p.get_recipient_keys() {
Ok(v) => v,
Err(e) => return Promise::err(capnp::Error::failed(format!("bad recipient_keys: {e}"))),
};
let payload = match p.get_payload() {
Ok(v) => v.to_vec(),
Err(e) => return Promise::err(capnp::Error::failed(format!("bad payload: {e}"))),
};
let channel_id = p.get_channel_id().unwrap_or_default().to_vec();
let mut seqs = Vec::with_capacity(recipient_keys.len() as usize);
for i in 0..recipient_keys.len() {
let rk = match recipient_keys.get(i) {
Ok(v) => v.to_vec(),
Err(e) => return Promise::err(capnp::Error::failed(format!("bad key[{i}]: {e}"))),
};
if rk.len() != 32 {
return Promise::err(capnp::Error::failed(
format!("recipient_key[{i}] must be 32 bytes"),
));
}
let seq = match self.store.enqueue(&rk, &channel_id, payload.clone()) {
Ok(s) => s,
Err(e) => return Promise::err(capnp::Error::failed(format!("store error: {e}"))),
};
seqs.push(seq);
if let Some(waiter) = self.waiters.get(&rk) {
waiter.notify_waiters();
}
}
let mut list = results.get().init_seqs(seqs.len() as u32);
for (i, seq) in seqs.iter().enumerate() {
list.set(i as u32, *seq);
}
tracing::info!(
recipient_count = recipient_keys.len(),
"federation: relayed batch_enqueue"
);
Promise::ok(())
}
fn proxy_fetch_key_package(
&mut self,
params: federation_service::ProxyFetchKeyPackageParams,
mut results: federation_service::ProxyFetchKeyPackageResults,
) -> Promise<(), capnp::Error> {
let identity_key = match params.get().and_then(|p| p.get_identity_key()) {
Ok(v) => v.to_vec(),
Err(e) => return Promise::err(capnp::Error::failed(format!("bad params: {e}"))),
};
match self.store.fetch_key_package(&identity_key) {
Ok(Some(pkg)) => results.get().set_package(&pkg),
Ok(None) => results.get().set_package(&[]),
Err(e) => return Promise::err(capnp::Error::failed(format!("store error: {e}"))),
}
Promise::ok(())
}
fn proxy_fetch_hybrid_key(
&mut self,
params: federation_service::ProxyFetchHybridKeyParams,
mut results: federation_service::ProxyFetchHybridKeyResults,
) -> Promise<(), capnp::Error> {
let identity_key = match params.get().and_then(|p| p.get_identity_key()) {
Ok(v) => v.to_vec(),
Err(e) => return Promise::err(capnp::Error::failed(format!("bad params: {e}"))),
};
match self.store.fetch_hybrid_key(&identity_key) {
Ok(Some(pk)) => results.get().set_hybrid_public_key(&pk),
Ok(None) => results.get().set_hybrid_public_key(&[]),
Err(e) => return Promise::err(capnp::Error::failed(format!("store error: {e}"))),
}
Promise::ok(())
}
fn proxy_resolve_user(
&mut self,
params: federation_service::ProxyResolveUserParams,
mut results: federation_service::ProxyResolveUserResults,
) -> Promise<(), capnp::Error> {
let username = match params.get().and_then(|p| p.get_username()) {
Ok(u) => match u.to_str() {
Ok(s) => s.to_string(),
Err(e) => return Promise::err(capnp::Error::failed(format!("bad utf-8: {e}"))),
},
Err(e) => return Promise::err(capnp::Error::failed(format!("bad params: {e}"))),
};
match self.store.get_user_identity_key(&username) {
Ok(Some(key)) => results.get().set_identity_key(&key),
Ok(None) => results.get().set_identity_key(&[]),
Err(e) => return Promise::err(capnp::Error::failed(format!("store error: {e}"))),
}
Promise::ok(())
}
fn federation_health(
&mut self,
_params: federation_service::FederationHealthParams,
mut results: federation_service::FederationHealthResults,
) -> Promise<(), capnp::Error> {
results.get().set_status("ok");
results.get().set_server_domain(&self.local_domain);
Promise::ok(())
}
}

View File

@@ -0,0 +1,85 @@
//! Build mTLS server/client configs for the federation endpoint.
//!
//! Federation uses a separate CA from the public-facing QUIC endpoint.
//! Both server and client present certificates; the server verifies the client
//! cert is signed by the federation CA.
use std::path::Path;
use std::sync::Arc;
use anyhow::Context;
use quinn::ServerConfig;
use quinn_proto::crypto::rustls::QuicServerConfig;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::version::TLS13;
/// Build a QUIC server config for the federation listener with mutual TLS.
///
/// `cert`/`key`: this server's federation certificate and private key.
/// `ca`: the federation CA certificate used to verify peer certificates.
pub fn build_federation_server_config(
cert_path: &Path,
key_path: &Path,
ca_path: &Path,
) -> anyhow::Result<ServerConfig> {
let cert_bytes = std::fs::read(cert_path)
.with_context(|| format!("read federation cert: {:?}", cert_path))?;
let key_bytes = std::fs::read(key_path)
.with_context(|| format!("read federation key: {:?}", key_path))?;
let ca_bytes = std::fs::read(ca_path)
.with_context(|| format!("read federation CA: {:?}", ca_path))?;
let cert_chain = vec![CertificateDer::from(cert_bytes)];
let key = PrivateKeyDer::try_from(key_bytes)
.map_err(|_| anyhow::anyhow!("invalid federation private key"))?;
// Build a root cert store with the federation CA for client verification.
let mut root_store = rustls::RootCertStore::empty();
root_store
.add(CertificateDer::from(ca_bytes))
.context("add federation CA to root store")?;
let client_verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(root_store))
.build()
.context("build client cert verifier")?;
let mut tls = rustls::ServerConfig::builder_with_protocol_versions(&[&TLS13])
.with_client_cert_verifier(client_verifier)
.with_single_cert(cert_chain, key)?;
tls.alpn_protocols = vec![b"qnpc-fed".to_vec()];
let crypto = QuicServerConfig::try_from(tls)
.map_err(|e| anyhow::anyhow!("invalid federation server TLS config: {e}"))?;
Ok(ServerConfig::with_crypto(Arc::new(crypto)))
}
/// Build a QUIC client config for connecting to a federation peer with mutual TLS.
pub fn build_federation_client_config(
cert_path: &Path,
key_path: &Path,
ca_path: &Path,
) -> anyhow::Result<rustls::ClientConfig> {
let cert_bytes = std::fs::read(cert_path)
.with_context(|| format!("read federation cert: {:?}", cert_path))?;
let key_bytes = std::fs::read(key_path)
.with_context(|| format!("read federation key: {:?}", key_path))?;
let ca_bytes = std::fs::read(ca_path)
.with_context(|| format!("read federation CA: {:?}", ca_path))?;
let cert_chain = vec![CertificateDer::from(cert_bytes)];
let key = PrivateKeyDer::try_from(key_bytes)
.map_err(|_| anyhow::anyhow!("invalid federation client private key"))?;
let mut root_store = rustls::RootCertStore::empty();
root_store
.add(CertificateDer::from(ca_bytes))
.context("add federation CA to root store")?;
let tls = rustls::ClientConfig::builder_with_protocol_versions(&[&TLS13])
.with_root_certificates(root_store)
.with_client_auth_cert(cert_chain, key)
.context("set client auth cert")?;
Ok(tls)
}

View File

@@ -1,4 +1,4 @@
//! quicnprotochat-server — unified Authentication + Delivery service. //! qpq-server — unified Authentication + Delivery service.
//! //!
//! The server hosts Authentication + Delivery services over QUIC + Cap'n Proto. //! The server hosts Authentication + Delivery services over QUIC + Cap'n Proto.
@@ -8,7 +8,7 @@ use anyhow::Context;
use clap::Parser; use clap::Parser;
use dashmap::DashMap; use dashmap::DashMap;
use opaque_ke::ServerSetup; use opaque_ke::ServerSetup;
use quicnprotochat_core::opaque_auth::OpaqueSuite; use quicproquo_core::opaque_auth::OpaqueSuite;
use quinn::Endpoint; use quinn::Endpoint;
use rand::rngs::OsRng; use rand::rngs::OsRng;
use tokio::sync::Notify; use tokio::sync::Notify;
@@ -17,6 +17,7 @@ use tokio::task::LocalSet;
mod auth; mod auth;
mod config; mod config;
mod error_codes; mod error_codes;
mod federation;
mod metrics; mod metrics;
mod node_service; mod node_service;
mod sql_store; mod sql_store;
@@ -37,62 +38,74 @@ use tls::build_server_config;
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
#[command( #[command(
name = "quicnprotochat-server", name = "qpq-server",
about = "quicnprotochat Delivery Service + Authentication Service", about = "quicproquo Delivery Service + Authentication Service",
version version
)] )]
struct Args { struct Args {
/// Optional path to a TOML config file (fields map to CLI flags). /// Optional path to a TOML config file (fields map to CLI flags).
#[arg(long, env = "QUICNPROTOCHAT_CONFIG")] #[arg(long, env = "QPQ_CONFIG")]
config: Option<PathBuf>, config: Option<PathBuf>,
/// QUIC listen address (host:port). /// QUIC listen address (host:port).
#[arg(long, default_value = DEFAULT_LISTEN, env = "QUICNPROTOCHAT_LISTEN")] #[arg(long, default_value = DEFAULT_LISTEN, env = "QPQ_LISTEN")]
listen: String, listen: String,
/// Directory for persisted server data (KeyPackages + delivery queues). /// Directory for persisted server data (KeyPackages + delivery queues).
#[arg(long, default_value = DEFAULT_DATA_DIR, env = "QUICNPROTOCHAT_DATA_DIR")] #[arg(long, default_value = DEFAULT_DATA_DIR, env = "QPQ_DATA_DIR")]
data_dir: String, data_dir: String,
/// TLS certificate path (generated automatically if missing). /// TLS certificate path (generated automatically if missing).
#[arg(long, default_value = DEFAULT_TLS_CERT, env = "QUICNPROTOCHAT_TLS_CERT")] #[arg(long, default_value = DEFAULT_TLS_CERT, env = "QPQ_TLS_CERT")]
tls_cert: PathBuf, tls_cert: PathBuf,
/// TLS private key path (generated automatically if missing). /// TLS private key path (generated automatically if missing).
#[arg(long, default_value = DEFAULT_TLS_KEY, env = "QUICNPROTOCHAT_TLS_KEY")] #[arg(long, default_value = DEFAULT_TLS_KEY, env = "QPQ_TLS_KEY")]
tls_key: PathBuf, tls_key: PathBuf,
/// Required bearer token for auth.version=1 requests. Use --allow-insecure-auth to run without it (dev only). /// Required bearer token for auth.version=1 requests. Use --allow-insecure-auth to run without it (dev only).
#[arg(long, env = "QUICNPROTOCHAT_AUTH_TOKEN")] #[arg(long, env = "QPQ_AUTH_TOKEN")]
auth_token: Option<String>, auth_token: Option<String>,
/// Allow running without QUICNPROTOCHAT_AUTH_TOKEN (development only). /// Allow running without QPQ_AUTH_TOKEN (development only).
#[arg(long, env = "QUICNPROTOCHAT_ALLOW_INSECURE_AUTH", default_value_t = false)] #[arg(long, env = "QPQ_ALLOW_INSECURE_AUTH", default_value_t = false)]
allow_insecure_auth: bool, allow_insecure_auth: bool,
/// Enable Sealed Sender: enqueue does not require identity-bound session, only a valid token. /// Enable Sealed Sender: enqueue does not require identity-bound session, only a valid token.
#[arg(long, env = "QUICNPROTOCHAT_SEALED_SENDER", default_value_t = false)] #[arg(long, env = "QPQ_SEALED_SENDER", default_value_t = false)]
sealed_sender: bool, sealed_sender: bool,
/// Storage backend: "file" (bincode) or "sql" (SQLCipher-encrypted). /// Storage backend: "file" (bincode) or "sql" (SQLCipher-encrypted).
#[arg(long, default_value = DEFAULT_STORE_BACKEND, env = "QUICNPROTOCHAT_STORE_BACKEND")] #[arg(long, default_value = DEFAULT_STORE_BACKEND, env = "QPQ_STORE_BACKEND")]
store_backend: String, store_backend: String,
/// Path to the SQLCipher database file (only used when --store-backend=sql). /// Path to the SQLCipher database file (only used when --store-backend=sql).
#[arg(long, default_value = DEFAULT_DB_PATH, env = "QUICNPROTOCHAT_DB_PATH")] #[arg(long, default_value = DEFAULT_DB_PATH, env = "QPQ_DB_PATH")]
db_path: PathBuf, db_path: PathBuf,
/// SQLCipher encryption key. Empty string disables encryption. /// SQLCipher encryption key. Empty string disables encryption.
#[arg(long, default_value = "", env = "QUICNPROTOCHAT_DB_KEY")] #[arg(long, default_value = "", env = "QPQ_DB_KEY")]
db_key: String, db_key: String,
/// Metrics HTTP listen address (e.g. 0.0.0.0:9090). If set and metrics enabled, /metrics is served. /// Metrics HTTP listen address (e.g. 0.0.0.0:9090). If set and metrics enabled, /metrics is served.
#[arg(long, env = "QUICNPROTOCHAT_METRICS_LISTEN")] #[arg(long, env = "QPQ_METRICS_LISTEN")]
metrics_listen: Option<String>, metrics_listen: Option<String>,
/// Enable metrics server when metrics_listen is set. /// Enable metrics server when metrics_listen is set.
#[arg(long, env = "QUICNPROTOCHAT_METRICS_ENABLED")] #[arg(long, env = "QPQ_METRICS_ENABLED")]
metrics_enabled: Option<bool>, metrics_enabled: Option<bool>,
/// Enable federation (server-to-server message relay).
#[arg(long, env = "QPQ_FEDERATION_ENABLED", default_value_t = false)]
federation_enabled: bool,
/// This server's domain for federation addressing (e.g. "chat.example.com").
#[arg(long, env = "QPQ_FEDERATION_DOMAIN")]
federation_domain: Option<String>,
/// Federation QUIC listen address (default: 0.0.0.0:7001).
#[arg(long, env = "QPQ_FEDERATION_LISTEN")]
federation_listen: Option<String>,
} }
// ── Entry point ─────────────────────────────────────────────────────────────── // ── Entry point ───────────────────────────────────────────────────────────────
@@ -112,7 +125,7 @@ async fn main() -> anyhow::Result<()> {
let file_cfg = load_config(args.config.as_deref())?; let file_cfg = load_config(args.config.as_deref())?;
let effective = merge_config(&args, &file_cfg); let effective = merge_config(&args, &file_cfg);
let production = std::env::var("QUICNPROTOCHAT_PRODUCTION") let production = std::env::var("QPQ_PRODUCTION")
.map(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes")) .map(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "yes"))
.unwrap_or(false); .unwrap_or(false);
if production { if production {
@@ -143,7 +156,7 @@ async fn main() -> anyhow::Result<()> {
&& !effective.allow_insecure_auth && !effective.allow_insecure_auth
{ {
anyhow::bail!( anyhow::bail!(
"missing QUICNPROTOCHAT_AUTH_TOKEN; set one or pass --allow-insecure-auth for development" "missing QPQ_AUTH_TOKEN; set one or pass --allow-insecure-auth for development"
); );
} }
@@ -154,7 +167,7 @@ async fn main() -> anyhow::Result<()> {
.map(|s| s.is_empty()) .map(|s| s.is_empty())
.unwrap_or(true) .unwrap_or(true)
{ {
tracing::warn!("running without QUICNPROTOCHAT_AUTH_TOKEN (allow-insecure-auth enabled); development only"); tracing::warn!("running without QPQ_AUTH_TOKEN (allow-insecure-auth enabled); development only");
} }
let listen: SocketAddr = effective let listen: SocketAddr = effective
@@ -246,10 +259,174 @@ async fn main() -> anyhow::Result<()> {
"accepting QUIC connections" "accepting QUIC connections"
); );
// ── Federation setup ─────────────────────────────────────────────────────
let federation_client: Option<Arc<federation::FederationClient>> =
if let Some(fed_cfg) = &effective.federation {
tracing::info!(
domain = %fed_cfg.domain,
listen = %fed_cfg.listen,
peers = fed_cfg.peers.len(),
"federation enabled"
);
// Build a client endpoint for outbound federation connections.
// For now we create a simple endpoint; full mTLS is used when certs are provided.
let client_config = if fed_cfg.federation_cert.exists()
&& fed_cfg.federation_key.exists()
&& fed_cfg.federation_ca.exists()
{
let tls_cfg = federation::tls::build_federation_client_config(
&fed_cfg.federation_cert,
&fed_cfg.federation_key,
&fed_cfg.federation_ca,
)
.context("build federation client TLS config")?;
let crypto = quinn::crypto::rustls::QuicClientConfig::try_from(tls_cfg)
.map_err(|e| anyhow::anyhow!("invalid federation client QUIC config: {e}"))?;
let mut qc = quinn::ClientConfig::new(Arc::new(crypto));
let mut transport = quinn::TransportConfig::default();
transport.max_idle_timeout(Some(
std::time::Duration::from_secs(120)
.try_into()
.expect("120s is valid"),
));
qc.transport_config(Arc::new(transport));
Some(qc)
} else {
tracing::warn!("federation cert/key/CA not found; outbound federation connections will fail");
None
};
let fed_bind: SocketAddr = "0.0.0.0:0".parse().unwrap();
let mut fed_endpoint = Endpoint::client(fed_bind)
.context("create federation client endpoint")?;
if let Some(cc) = client_config {
fed_endpoint.set_default_client_config(cc);
}
let client = federation::FederationClient::new(fed_cfg, fed_endpoint)
.context("create federation client")?;
// Register configured peers in storage.
for peer in &fed_cfg.peers {
if let Err(e) = store.upsert_federation_peer(&peer.domain, true) {
tracing::warn!(domain = %peer.domain, error = %e, "failed to register federation peer");
}
}
Some(Arc::new(client))
} else {
None
};
let local_domain: Option<String> = effective.federation.as_ref().map(|f| f.domain.clone());
// ── Federation listener ──────────────────────────────────────────────────
let federation_endpoint: Option<Endpoint> =
if let Some(fed_cfg) = &effective.federation {
if fed_cfg.federation_cert.exists()
&& fed_cfg.federation_key.exists()
&& fed_cfg.federation_ca.exists()
{
let fed_server_config = federation::tls::build_federation_server_config(
&fed_cfg.federation_cert,
&fed_cfg.federation_key,
&fed_cfg.federation_ca,
)
.context("build federation server TLS config")?;
let fed_listen: SocketAddr = fed_cfg
.listen
.parse()
.context("federation listen must be host:port")?;
let ep = Endpoint::server(fed_server_config, fed_listen)
.context("bind federation QUIC endpoint")?;
tracing::info!(addr = %fed_cfg.listen, "federation endpoint listening");
Some(ep)
} else {
tracing::warn!("federation certs not found; federation listener not started");
None
}
} else {
None
};
// capnp-rpc is !Send (Rc internals), so all RPC tasks must stay on a LocalSet. // capnp-rpc is !Send (Rc internals), so all RPC tasks must stay on a LocalSet.
let local = LocalSet::new(); let local = LocalSet::new();
local local
.run_until(async move { .run_until(async move {
// Spawn federation acceptor if enabled.
if let Some(fed_ep) = federation_endpoint {
let fed_store = Arc::clone(&store);
let fed_waiters = Arc::clone(&waiters);
let fed_domain = local_domain.clone().unwrap_or_default();
tokio::task::spawn_local(async move {
loop {
let incoming = match fed_ep.accept().await {
Some(i) => i,
None => break,
};
let connecting = match incoming.accept() {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "federation: accept error");
continue;
}
};
let store = Arc::clone(&fed_store);
let waiters = Arc::clone(&fed_waiters);
let domain = fed_domain.clone();
tokio::task::spawn_local(async move {
match connecting.await {
Ok(conn) => {
tracing::info!(
peer = %conn.remote_address(),
"federation: peer connected"
);
let (send, recv) = match conn.accept_bi().await {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "federation: accept bi error");
return;
}
};
let reader = tokio_util::compat::TokioAsyncReadCompatExt::compat(recv);
let writer = tokio_util::compat::TokioAsyncWriteCompatExt::compat_write(send);
let network = capnp_rpc::twoparty::VatNetwork::new(
reader,
writer,
capnp_rpc::rpc_twoparty_capnp::Side::Server,
Default::default(),
);
let service_impl = federation::service::FederationServiceImpl {
store,
waiters,
local_domain: domain,
};
let client: quicproquo_proto::federation_capnp::federation_service::Client =
capnp_rpc::new_client(service_impl);
if let Err(e) = capnp_rpc::RpcSystem::new(
Box::new(network),
Some(client.client),
).await {
tracing::warn!(error = %e, "federation: RPC error");
}
}
Err(e) => {
tracing::warn!(error = %e, "federation: connection error");
}
}
});
}
});
}
loop { loop {
tokio::select! { tokio::select! {
biased; biased;
@@ -284,6 +461,8 @@ async fn main() -> anyhow::Result<()> {
let sessions = Arc::clone(&sessions); let sessions = Arc::clone(&sessions);
let rate_limits = Arc::clone(&rate_limits); let rate_limits = Arc::clone(&rate_limits);
let sealed_sender = effective.sealed_sender; let sealed_sender = effective.sealed_sender;
let fed_client = federation_client.clone();
let local_dom = local_domain.clone();
tokio::task::spawn_local(async move { tokio::task::spawn_local(async move {
if let Err(e) = handle_node_connection( if let Err(e) = handle_node_connection(
@@ -296,6 +475,8 @@ async fn main() -> anyhow::Result<()> {
sessions, sessions,
rate_limits, rate_limits,
sealed_sender, sealed_sender,
fed_client,
local_dom,
) )
.await .await
{ {

View File

@@ -3,8 +3,8 @@ use opaque_ke::{
CredentialFinalization, CredentialRequest, RegistrationRequest, RegistrationUpload, CredentialFinalization, CredentialRequest, RegistrationRequest, RegistrationUpload,
ServerLogin, ServerRegistration, ServerLogin, ServerRegistration,
}; };
use quicnprotochat_core::opaque_auth::OpaqueSuite; use quicproquo_core::opaque_auth::OpaqueSuite;
use quicnprotochat_proto::node_capnp::node_service; use quicproquo_proto::node_capnp::node_service;
use crate::auth::{coded_error, current_timestamp, PendingLogin, SESSION_TTL_SECS}; use crate::auth::{coded_error, current_timestamp, PendingLogin, SESSION_TTL_SECS};
use crate::error_codes::*; use crate::error_codes::*;

View File

@@ -1,7 +1,7 @@
//! createChannel RPC: create or look up a 1:1 DM channel. //! createChannel RPC: create or look up a 1:1 DM channel.
use capnp::capability::Promise; use capnp::capability::Promise;
use quicnprotochat_proto::node_capnp::node_service; use quicproquo_proto::node_capnp::node_service;
use crate::auth::{coded_error, require_identity, validate_auth_context}; use crate::auth::{coded_error, require_identity, validate_auth_context};
use crate::error_codes::*; use crate::error_codes::*;

View File

@@ -3,7 +3,7 @@ use std::time::Duration;
use capnp::capability::Promise; use capnp::capability::Promise;
use dashmap::DashMap; use dashmap::DashMap;
use quicnprotochat_proto::node_capnp::node_service; use quicproquo_proto::node_capnp::node_service;
use tokio::sync::Notify; use tokio::sync::Notify;
use tokio::time::timeout; use tokio::time::timeout;

View File

@@ -1,5 +1,5 @@
use capnp::capability::Promise; use capnp::capability::Promise;
use quicnprotochat_proto::node_capnp::node_service; use quicproquo_proto::node_capnp::node_service;
use crate::auth::{coded_error, fmt_hex, require_identity_or_request, validate_auth_context}; use crate::auth::{coded_error, fmt_hex, require_identity_or_request, validate_auth_context};
use crate::error_codes::*; use crate::error_codes::*;
@@ -63,7 +63,7 @@ impl NodeServiceImpl {
return Promise::err(e); return Promise::err(e);
} }
if let Err(e) = quicnprotochat_core::validate_keypackage_ciphersuite(&package) { if let Err(e) = quicproquo_core::validate_keypackage_ciphersuite(&package) {
return Promise::err(coded_error( return Promise::err(coded_error(
E021_CIPHERSUITE_NOT_ALLOWED, E021_CIPHERSUITE_NOT_ALLOWED,
format!("KeyPackage ciphersuite not allowed: {e}"), format!("KeyPackage ciphersuite not allowed: {e}"),

View File

@@ -4,8 +4,8 @@ use std::time::Duration;
use capnp_rpc::RpcSystem; use capnp_rpc::RpcSystem;
use dashmap::DashMap; use dashmap::DashMap;
use opaque_ke::ServerSetup; use opaque_ke::ServerSetup;
use quicnprotochat_core::opaque_auth::OpaqueSuite; use quicproquo_core::opaque_auth::OpaqueSuite;
use quicnprotochat_proto::node_capnp::node_service; use quicproquo_proto::node_capnp::node_service;
use tokio::sync::Notify; use tokio::sync::Notify;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
@@ -207,6 +207,10 @@ pub struct NodeServiceImpl {
pub rate_limits: Arc<DashMap<Vec<u8>, RateEntry>>, pub rate_limits: Arc<DashMap<Vec<u8>, RateEntry>>,
/// When true, enqueue does not require identity-bound session (Sealed Sender). /// When true, enqueue does not require identity-bound session (Sealed Sender).
pub sealed_sender: bool, pub sealed_sender: bool,
/// Outbound federation client for relaying to remote servers (None if federation disabled).
pub federation_client: Option<Arc<crate::federation::FederationClient>>,
/// This server's federation domain (empty if federation disabled).
pub local_domain: Option<String>,
} }
impl NodeServiceImpl { impl NodeServiceImpl {
@@ -219,6 +223,8 @@ impl NodeServiceImpl {
sessions: Arc<DashMap<Vec<u8>, SessionInfo>>, sessions: Arc<DashMap<Vec<u8>, SessionInfo>>,
rate_limits: Arc<DashMap<Vec<u8>, RateEntry>>, rate_limits: Arc<DashMap<Vec<u8>, RateEntry>>,
sealed_sender: bool, sealed_sender: bool,
federation_client: Option<Arc<crate::federation::FederationClient>>,
local_domain: Option<String>,
) -> Self { ) -> Self {
Self { Self {
store, store,
@@ -229,6 +235,8 @@ impl NodeServiceImpl {
sessions, sessions,
rate_limits, rate_limits,
sealed_sender, sealed_sender,
federation_client,
local_domain,
} }
} }
} }
@@ -243,6 +251,8 @@ pub async fn handle_node_connection(
sessions: Arc<DashMap<Vec<u8>, SessionInfo>>, sessions: Arc<DashMap<Vec<u8>, SessionInfo>>,
rate_limits: Arc<DashMap<Vec<u8>, RateEntry>>, rate_limits: Arc<DashMap<Vec<u8>, RateEntry>>,
sealed_sender: bool, sealed_sender: bool,
federation_client: Option<Arc<crate::federation::FederationClient>>,
local_domain: Option<String>,
) -> Result<(), anyhow::Error> { ) -> Result<(), anyhow::Error> {
let connection = connecting.await?; let connection = connecting.await?;
@@ -272,6 +282,8 @@ pub async fn handle_node_connection(
sessions, sessions,
rate_limits, rate_limits,
sealed_sender, sealed_sender,
federation_client,
local_domain,
)); ));
RpcSystem::new(Box::new(network), Some(service.client)) RpcSystem::new(Box::new(network), Some(service.client))

View File

@@ -1,5 +1,5 @@
use capnp::capability::Promise; use capnp::capability::Promise;
use quicnprotochat_proto::node_capnp::node_service; use quicproquo_proto::node_capnp::node_service;
use crate::auth::{ use crate::auth::{
coded_error, fmt_hex, require_identity_or_request, validate_auth, validate_auth_context, coded_error, fmt_hex, require_identity_or_request, validate_auth, validate_auth_context,

View File

@@ -1,7 +1,7 @@
//! resolveUser / resolveIdentity RPCs: bidirectional username ↔ identity key lookup. //! resolveUser / resolveIdentity RPCs: bidirectional username ↔ identity key lookup.
use capnp::capability::Promise; use capnp::capability::Promise;
use quicnprotochat_proto::node_capnp::node_service; use quicproquo_proto::node_capnp::node_service;
use crate::auth::{coded_error, validate_auth_context}; use crate::auth::{coded_error, validate_auth_context};
use crate::error_codes::*; use crate::error_codes::*;
@@ -41,7 +41,44 @@ impl NodeServiceImpl {
return Promise::err(coded_error(E020_BAD_PARAMS, "username must not be empty")); return Promise::err(coded_error(E020_BAD_PARAMS, "username must not be empty"));
} }
match self.store.get_user_identity_key(username_str) { // Federation: parse user@domain format.
let addr = crate::federation::address::FederatedAddress::parse(username_str);
let is_remote = match (&addr.domain, &self.local_domain) {
(Some(d), Some(ld)) => d != ld,
(Some(_), None) => true,
_ => false,
};
if is_remote {
// Proxy to remote server via federation.
if let (Some(ref fed_client), Some(ref domain)) = (&self.federation_client, &addr.domain) {
if fed_client.has_peer(domain) {
let fed = fed_client.clone();
let user = addr.username.clone();
let dom = domain.clone();
return Promise::from_future(async move {
match fed.proxy_resolve_user(&dom, &user).await {
Ok(Some(key)) => {
results.get().set_identity_key(&key);
}
Ok(None) => {
// Not found on remote — return empty.
}
Err(e) => {
tracing::warn!(error = %e, "federation proxy_resolve_user failed");
// Fall through — return empty (not found).
}
}
Ok(())
});
}
}
// No federation client or unknown peer — return empty (not found).
return Promise::ok(());
}
// Local resolution.
match self.store.get_user_identity_key(&addr.username) {
Ok(Some(key)) => { Ok(Some(key)) => {
results.get().set_identity_key(&key); results.get().set_identity_key(&key);
} }

View File

@@ -9,13 +9,14 @@ use rusqlite::{params, Connection};
use crate::storage::{StorageError, Store}; use crate::storage::{StorageError, Store};
/// Schema version after introducing the migration runner (existing DBs had 1). /// Schema version after introducing the migration runner (existing DBs had 1).
const SCHEMA_VERSION: i32 = 4; const SCHEMA_VERSION: i32 = 5;
/// Migrations: (migration_number, SQL). Files named NNN_name.sql, applied in order when N > user_version. /// Migrations: (migration_number, SQL). Files named NNN_name.sql, applied in order when N > user_version.
const MIGRATIONS: &[(i32, &str)] = &[ const MIGRATIONS: &[(i32, &str)] = &[
(1, include_str!("../migrations/001_initial.sql")), (1, include_str!("../migrations/001_initial.sql")),
(3, include_str!("../migrations/002_add_seq.sql")), (3, include_str!("../migrations/002_add_seq.sql")),
(4, include_str!("../migrations/003_channels.sql")), (4, include_str!("../migrations/003_channels.sql")),
(5, include_str!("../migrations/004_federation.sql")),
]; ];
/// Runs pending migrations on an open connection: applies any migration whose number is greater /// Runs pending migrations on an open connection: applies any migration whose number is greater
@@ -494,6 +495,71 @@ impl Store for SqlStore {
.optional() .optional()
.map_err(|e| StorageError::Db(e.to_string())) .map_err(|e| StorageError::Db(e.to_string()))
} }
fn store_identity_home_server(
&self,
identity_key: &[u8],
home_server: &str,
) -> Result<(), StorageError> {
let conn = self.lock_conn()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
conn.execute(
"INSERT OR REPLACE INTO identity_home_servers (identity_key, home_server, updated_at) VALUES (?1, ?2, ?3)",
params![identity_key, home_server, now],
)
.map_err(|e| StorageError::Db(e.to_string()))?;
Ok(())
}
fn get_identity_home_server(
&self,
identity_key: &[u8],
) -> Result<Option<String>, StorageError> {
let conn = self.lock_conn()?;
let mut stmt = conn
.prepare("SELECT home_server FROM identity_home_servers WHERE identity_key = ?1")
.map_err(|e| StorageError::Db(e.to_string()))?;
stmt.query_row(params![identity_key], |row| row.get(0))
.optional()
.map_err(|e| StorageError::Db(e.to_string()))
}
fn upsert_federation_peer(
&self,
domain: &str,
is_active: bool,
) -> Result<(), StorageError> {
let conn = self.lock_conn()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
conn.execute(
"INSERT INTO federation_peers (domain, last_seen, is_active) VALUES (?1, ?2, ?3)
ON CONFLICT(domain) DO UPDATE SET last_seen = ?2, is_active = ?3",
params![domain, now, is_active as i32],
)
.map_err(|e| StorageError::Db(e.to_string()))?;
Ok(())
}
fn list_federation_peers(&self) -> Result<Vec<(String, bool)>, StorageError> {
let conn = self.lock_conn()?;
let mut stmt = conn
.prepare("SELECT domain, is_active FROM federation_peers WHERE is_active = 1")
.map_err(|e| StorageError::Db(e.to_string()))?;
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)? != 0))
})
.map_err(|e| StorageError::Db(e.to_string()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| StorageError::Db(e.to_string()))?;
Ok(rows)
}
} }
/// Convenience extension for `rusqlite::OptionalExtension`. /// Convenience extension for `rusqlite::OptionalExtension`.

View File

@@ -133,6 +133,31 @@ pub trait Store: Send + Sync {
/// Get the two members of a channel by channel_id (16 bytes). Returns (member_a, member_b) in sorted order. /// Get the two members of a channel by channel_id (16 bytes). Returns (member_a, member_b) in sorted order.
fn get_channel_members(&self, channel_id: &[u8]) -> Result<Option<(Vec<u8>, Vec<u8>)>, StorageError>; fn get_channel_members(&self, channel_id: &[u8]) -> Result<Option<(Vec<u8>, Vec<u8>)>, StorageError>;
// ── Federation ──────────────────────────────────────────────────────────
/// Store the home server domain for an identity key.
fn store_identity_home_server(
&self,
identity_key: &[u8],
home_server: &str,
) -> Result<(), StorageError>;
/// Get the home server domain for an identity key.
fn get_identity_home_server(
&self,
identity_key: &[u8],
) -> Result<Option<String>, StorageError>;
/// Insert or update a federation peer.
fn upsert_federation_peer(
&self,
domain: &str,
is_active: bool,
) -> Result<(), StorageError>;
/// List all active federation peers.
fn list_federation_peers(&self) -> Result<Vec<(String, bool)>, StorageError>;
} }
// ── ChannelKey ─────────────────────────────────────────────────────────────── // ── ChannelKey ───────────────────────────────────────────────────────────────
@@ -644,6 +669,34 @@ impl Store for FileBackedStore {
let map = lock(&self.channels)?; let map = lock(&self.channels)?;
Ok(map.get(channel_id).cloned()) Ok(map.get(channel_id).cloned())
} }
fn store_identity_home_server(
&self,
_identity_key: &[u8],
_home_server: &str,
) -> Result<(), StorageError> {
// File-backed store: federation mappings are ephemeral (in-memory only).
Ok(())
}
fn get_identity_home_server(
&self,
_identity_key: &[u8],
) -> Result<Option<String>, StorageError> {
Ok(None)
}
fn upsert_federation_peer(
&self,
_domain: &str,
_is_active: bool,
) -> Result<(), StorageError> {
Ok(())
}
fn list_federation_peers(&self) -> Result<Vec<(String, bool)>, StorageError> {
Ok(vec![])
}
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -7,7 +7,7 @@ services:
- "7000:7000" - "7000:7000"
environment: environment:
RUST_LOG: "info" RUST_LOG: "info"
QUICNPROTOCHAT_LISTEN: "0.0.0.0:7000" QPQ_LISTEN: "0.0.0.0:7000"
# Healthcheck: attempt a TCP connection to port 7000. # Healthcheck: attempt a TCP connection to port 7000.
# Uses bash /dev/tcp — available in debian:bookworm-slim without extra packages. # Uses bash /dev/tcp — available in debian:bookworm-slim without extra packages.
healthcheck: healthcheck:

View File

@@ -12,44 +12,44 @@ WORKDIR /build
# Copy manifests first so dependency layers are cached independently of source. # Copy manifests first so dependency layers are cached independently of source.
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY crates/quicnprotochat-core/Cargo.toml crates/quicnprotochat-core/Cargo.toml COPY crates/quicproquo-core/Cargo.toml crates/quicproquo-core/Cargo.toml
COPY crates/quicnprotochat-proto/Cargo.toml crates/quicnprotochat-proto/Cargo.toml COPY crates/quicproquo-proto/Cargo.toml crates/quicproquo-proto/Cargo.toml
COPY crates/quicnprotochat-server/Cargo.toml crates/quicnprotochat-server/Cargo.toml COPY crates/qpq-server/Cargo.toml crates/qpq-server/Cargo.toml
COPY crates/quicnprotochat-client/Cargo.toml crates/quicnprotochat-client/Cargo.toml COPY crates/quicproquo-client/Cargo.toml crates/quicproquo-client/Cargo.toml
COPY crates/quicnprotochat-p2p/Cargo.toml crates/quicnprotochat-p2p/Cargo.toml COPY crates/quicproquo-p2p/Cargo.toml crates/quicproquo-p2p/Cargo.toml
# Create dummy source files so `cargo build` can resolve the dependency graph # Create dummy source files so `cargo build` can resolve the dependency graph
# and cache the compiled dependencies before copying real source. # and cache the compiled dependencies before copying real source.
RUN mkdir -p \ RUN mkdir -p \
crates/quicnprotochat-core/src \ crates/quicproquo-core/src \
crates/quicnprotochat-proto/src \ crates/quicproquo-proto/src \
crates/quicnprotochat-server/src \ crates/qpq-server/src \
crates/quicnprotochat-client/src \ crates/quicproquo-client/src \
crates/quicnprotochat-p2p/src \ crates/quicproquo-p2p/src \
&& echo 'fn main() {}' > crates/quicnprotochat-server/src/main.rs \ && echo 'fn main() {}' > crates/qpq-server/src/main.rs \
&& echo 'fn main() {}' > crates/quicnprotochat-client/src/main.rs \ && echo 'fn main() {}' > crates/quicproquo-client/src/main.rs \
&& touch crates/quicnprotochat-core/src/lib.rs \ && touch crates/quicproquo-core/src/lib.rs \
&& touch crates/quicnprotochat-proto/src/lib.rs \ && touch crates/quicproquo-proto/src/lib.rs \
&& touch crates/quicnprotochat-p2p/src/lib.rs && touch crates/quicproquo-p2p/src/lib.rs
# Schemas must exist before the proto crate's build.rs runs. # Schemas must exist before the proto crate's build.rs runs.
COPY schemas/ schemas/ COPY schemas/ schemas/
# Build dependencies only (source stubs mean this layer is cache-friendly). # Build dependencies only (source stubs mean this layer is cache-friendly).
RUN cargo build --release --bin quicnprotochat-server 2>/dev/null || true RUN cargo build --release --bin qpq-server 2>/dev/null || true
# Copy real source and build for real. # Copy real source and build for real.
COPY crates/ crates/ COPY crates/ crates/
# Touch source to force re-compilation after copying real crates. # Touch source to force re-compilation after copying real crates.
RUN touch \ RUN touch \
crates/quicnprotochat-core/src/lib.rs \ crates/quicproquo-core/src/lib.rs \
crates/quicnprotochat-proto/src/lib.rs \ crates/quicproquo-proto/src/lib.rs \
crates/quicnprotochat-p2p/src/lib.rs \ crates/quicproquo-p2p/src/lib.rs \
crates/quicnprotochat-server/src/main.rs \ crates/qpq-server/src/main.rs \
crates/quicnprotochat-client/src/main.rs crates/quicproquo-client/src/main.rs
RUN cargo build --release --bin quicnprotochat-server RUN cargo build --release --bin qpq-server
# ── Stage 2: Runtime ────────────────────────────────────────────────────────── # ── Stage 2: Runtime ──────────────────────────────────────────────────────────
# #
@@ -62,14 +62,14 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \ && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/quicnprotochat-server /usr/local/bin/quicnprotochat-server COPY --from=builder /build/target/release/qpq-server /usr/local/bin/qpq-server
EXPOSE 7000 EXPOSE 7000
ENV RUST_LOG=info \ ENV RUST_LOG=info \
QUICNPROTOCHAT_LISTEN=0.0.0.0:7000 QPQ_LISTEN=0.0.0.0:7000
# Run as a non-root user. # Run as a non-root user.
USER nobody USER nobody
CMD ["quicnprotochat-server"] CMD ["qpq-server"]

View File

@@ -12,45 +12,45 @@ WORKDIR /build
# Copy manifests first so dependency layers are cached independently of source. # Copy manifests first so dependency layers are cached independently of source.
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY crates/quicnprotochat-core/Cargo.toml crates/quicnprotochat-core/Cargo.toml COPY crates/quicproquo-core/Cargo.toml crates/quicproquo-core/Cargo.toml
COPY crates/quicnprotochat-proto/Cargo.toml crates/quicnprotochat-proto/Cargo.toml COPY crates/quicproquo-proto/Cargo.toml crates/quicproquo-proto/Cargo.toml
COPY crates/quicnprotochat-server/Cargo.toml crates/quicnprotochat-server/Cargo.toml COPY crates/qpq-server/Cargo.toml crates/qpq-server/Cargo.toml
COPY crates/quicnprotochat-client/Cargo.toml crates/quicnprotochat-client/Cargo.toml COPY crates/quicproquo-client/Cargo.toml crates/quicproquo-client/Cargo.toml
COPY crates/quicnprotochat-p2p/Cargo.toml crates/quicnprotochat-p2p/Cargo.toml COPY crates/quicproquo-p2p/Cargo.toml crates/quicproquo-p2p/Cargo.toml
# Create dummy source files so `cargo build` can resolve the dependency graph # Create dummy source files so `cargo build` can resolve the dependency graph
# and cache the compiled dependencies before copying real source. # and cache the compiled dependencies before copying real source.
RUN mkdir -p \ RUN mkdir -p \
crates/quicnprotochat-core/src \ crates/quicproquo-core/src \
crates/quicnprotochat-proto/src \ crates/quicproquo-proto/src \
crates/quicnprotochat-server/src \ crates/qpq-server/src \
crates/quicnprotochat-client/src \ crates/quicproquo-client/src \
crates/quicnprotochat-p2p/src \ crates/quicproquo-p2p/src \
&& echo 'fn main() {}' > crates/quicnprotochat-server/src/main.rs \ && echo 'fn main() {}' > crates/qpq-server/src/main.rs \
&& echo 'fn main() {}' > crates/quicnprotochat-client/src/main.rs \ && echo 'fn main() {}' > crates/quicproquo-client/src/main.rs \
&& touch crates/quicnprotochat-core/src/lib.rs \ && touch crates/quicproquo-core/src/lib.rs \
&& touch crates/quicnprotochat-proto/src/lib.rs \ && touch crates/quicproquo-proto/src/lib.rs \
&& touch crates/quicnprotochat-p2p/src/lib.rs && touch crates/quicproquo-p2p/src/lib.rs
# Schemas must exist before the proto crate's build.rs runs. # Schemas must exist before the proto crate's build.rs runs.
COPY schemas/ schemas/ COPY schemas/ schemas/
# Build dependencies only (source stubs mean this layer is cache-friendly). # Build dependencies only (source stubs mean this layer is cache-friendly).
# The GUI crate is not included, so workspace resolution may fail — || true handles it. # The GUI crate is not included, so workspace resolution may fail — || true handles it.
RUN cargo build --release --bin quicnprotochat-server --bin quicnprotochat 2>/dev/null || true RUN cargo build --release --bin qpq-server --bin qpq 2>/dev/null || true
# Copy real source and build for real. # Copy real source and build for real.
COPY crates/ crates/ COPY crates/ crates/
# Touch source to force re-compilation after copying real crates. # Touch source to force re-compilation after copying real crates.
RUN touch \ RUN touch \
crates/quicnprotochat-core/src/lib.rs \ crates/quicproquo-core/src/lib.rs \
crates/quicnprotochat-proto/src/lib.rs \ crates/quicproquo-proto/src/lib.rs \
crates/quicnprotochat-p2p/src/lib.rs \ crates/quicproquo-p2p/src/lib.rs \
crates/quicnprotochat-server/src/main.rs \ crates/qpq-server/src/main.rs \
crates/quicnprotochat-client/src/main.rs crates/quicproquo-client/src/main.rs
RUN cargo build --release --bin quicnprotochat-server --bin quicnprotochat RUN cargo build --release --bin qpq-server --bin qpq
# ── Stage 2: Runtime ────────────────────────────────────────────────────────── # ── Stage 2: Runtime ──────────────────────────────────────────────────────────
# #
@@ -61,8 +61,8 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \ && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/quicnprotochat-server /usr/local/bin/quicnprotochat-server COPY --from=builder /build/target/release/qpq-server /usr/local/bin/qpq-server
COPY --from=builder /build/target/release/quicnprotochat /usr/local/bin/quicnprotochat COPY --from=builder /build/target/release/qpq /usr/local/bin/qpq
RUN mkdir -p /chat RUN mkdir -p /chat

View File

@@ -14,7 +14,7 @@ services:
context: .. context: ..
dockerfile: docker/Dockerfile.chat-test dockerfile: docker/Dockerfile.chat-test
command: >- command: >-
quicnprotochat-server qpq-server
--listen 0.0.0.0:7000 --listen 0.0.0.0:7000
--data-dir /data --data-dir /data
--tls-cert /data/server-cert.der --tls-cert /data/server-cert.der
@@ -43,10 +43,10 @@ services:
entrypoint: ["sleep", "infinity"] entrypoint: ["sleep", "infinity"]
environment: environment:
RUST_LOG: warn RUST_LOG: warn
QUICNPROTOCHAT_ACCESS_TOKEN: devtoken QPQ_ACCESS_TOKEN: devtoken
QUICNPROTOCHAT_CA_CERT: /data/server-cert.der QPQ_CA_CERT: /data/server-cert.der
QUICNPROTOCHAT_SERVER_NAME: localhost QPQ_SERVER_NAME: localhost
QUICNPROTOCHAT_SERVER: "server:7000" QPQ_SERVER: "server:7000"
volumes: volumes:
- server-data:/data:ro - server-data:/data:ro
working_dir: /chat working_dir: /chat
@@ -65,10 +65,10 @@ services:
entrypoint: ["sleep", "infinity"] entrypoint: ["sleep", "infinity"]
environment: environment:
RUST_LOG: warn RUST_LOG: warn
QUICNPROTOCHAT_ACCESS_TOKEN: devtoken QPQ_ACCESS_TOKEN: devtoken
QUICNPROTOCHAT_CA_CERT: /data/server-cert.der QPQ_CA_CERT: /data/server-cert.der
QUICNPROTOCHAT_SERVER_NAME: localhost QPQ_SERVER_NAME: localhost
QUICNPROTOCHAT_SERVER: "server:7000" QPQ_SERVER: "server:7000"
volumes: volumes:
- server-data:/data:ro - server-data:/data:ro
working_dir: /chat working_dir: /chat

View File

@@ -1,6 +1,6 @@
# Future Improvements # Future Improvements
This document consolidates suggested improvements for quicnprotochat, drawn from the [roadmap](src/roadmap/milestones.md), [production readiness WBS](src/roadmap/production-readiness.md), [security audit](SECURITY-AUDIT.md), [production readiness audit](PRODUCTION-READINESS-AUDIT.md), and [future research](src/roadmap/future-research.md). Items are grouped by theme and ordered by impact and dependency. This document consolidates suggested improvements for quicproquo, drawn from the [roadmap](src/roadmap/milestones.md), [production readiness WBS](src/roadmap/production-readiness.md), [security audit](SECURITY-AUDIT.md), [production readiness audit](PRODUCTION-READINESS-AUDIT.md), and [future research](src/roadmap/future-research.md). Items are grouped by theme and ordered by impact and dependency.
--- ---
@@ -98,7 +98,7 @@ This document consolidates suggested improvements for quicnprotochat, drawn from
### 4.5 Docker user and writable paths ### 4.5 Docker user and writable paths
- **Current:** Image runs as `nobody`; data dir may not be writable. - **Current:** Image runs as `nobody`; data dir may not be writable.
- **Improve:** Create a dedicated user/group in the image and set `QUICNPROTOCHAT_DATA_DIR` (and cert paths) to a directory writable by that user; document in deployment docs. - **Improve:** Create a dedicated user/group in the image and set `QPQ_DATA_DIR` (and cert paths) to a directory writable by that user; document in deployment docs.
- **Ref:** [Production readiness audit § 15](PRODUCTION-READINESS-AUDIT.md). - **Ref:** [Production readiness audit § 15](PRODUCTION-READINESS-AUDIT.md).
--- ---
@@ -133,7 +133,7 @@ This document consolidates suggested improvements for quicnprotochat, drawn from
### 6.1 P2P / NAT traversal (iroh, LibP2P) ### 6.1 P2P / NAT traversal (iroh, LibP2P)
- **Goal:** Direct peer-to-peer when possible; server as optional relay/rendezvous. Reduces single-point-of-failure and can improve latency. - **Goal:** Direct peer-to-peer when possible; server as optional relay/rendezvous. Reduces single-point-of-failure and can improve latency.
- **Ref:** [Future research § LibP2P / iroh](src/roadmap/future-research.md). The `quicnprotochat-p2p` crate is a starting point. - **Ref:** [Future research § LibP2P / iroh](src/roadmap/future-research.md). The `quicproquo-p2p` crate is a starting point.
### 6.2 WebTransport (browser client) ### 6.2 WebTransport (browser client)
@@ -175,8 +175,10 @@ This document consolidates suggested improvements for quicnprotochat, drawn from
## Related documents ## Related documents
- **[ROADMAP.md](../ROADMAP.md)** — phased execution plan (Phases 18) incorporating all items below
- [Milestones](src/roadmap/milestones.md) — M7 and beyond - [Milestones](src/roadmap/milestones.md) — M7 and beyond
- [Production readiness WBS](src/roadmap/production-readiness.md) — phased hardening - [Production readiness WBS](src/roadmap/production-readiness.md) — phased hardening
- [Future research](src/roadmap/future-research.md) — technologies and options - [Future research](src/roadmap/future-research.md) — technologies and options
- [Security audit](SECURITY-AUDIT.md) — recommendations and status - [Security audit](SECURITY-AUDIT.md) — recommendations and status
- [Production readiness audit](PRODUCTION-READINESS-AUDIT.md) — checklist and fixes - [Production readiness audit](PRODUCTION-READINESS-AUDIT.md) — checklist and fixes
- [ADR-006: SDK-First Adoption](src/design-rationale/adr-006-rest-gateway.md) — no REST gateway, native QUIC SDKs

View File

@@ -9,21 +9,21 @@ This document splits work for **Future Improvements §1 (Security and hardening)
**Owns:** Server auth/OPAQUE, TLS config, core crypto (identity, keypackage, hybrid_kem), docs under `docs/src/cryptography/` and TLS/cert docs. **Owns:** Server auth/OPAQUE, TLS config, core crypto (identity, keypackage, hybrid_kem), docs under `docs/src/cryptography/` and TLS/cert docs.
### A1. 1.2 CA-signed TLS / certificate lifecycle ### A1. 1.2 CA-signed TLS / certificate lifecycle
- **Files:** `docs/src/getting-started/` (new or existing), `crates/quicnprotochat-server/src/tls.rs` (optional env), `README.md`. - **Files:** `docs/src/getting-started/` (new or existing), `crates/quicproquo-server/src/tls.rs` (optional env), `README.md`.
- **Tasks:** - **Tasks:**
1. Add **Certificate lifecycle** doc: using CA-issued certs (e.g. Let's Encrypt), cert rotation, OCSP/CRL optional. Recommend pinning for single-server. 1. Add **Certificate lifecycle** doc: using CA-issued certs (e.g. Let's Encrypt), cert rotation, OCSP/CRL optional. Recommend pinning for single-server.
2. Optional: server config or env to prefer CA-signed cert path (e.g. `QUICNPROTOCHAT_USE_CA_CERT=1` and read from a different path). Low priority if docs suffice. 2. Optional: server config or env to prefer CA-signed cert path (e.g. `QPQ_USE_CA_CERT=1` and read from a different path). Low priority if docs suffice.
- **Deliverable:** `docs/src/getting-started/certificate-lifecycle.md` (or section in running-the-server) + README link. - **Deliverable:** `docs/src/getting-started/certificate-lifecycle.md` (or section in running-the-server) + README link.
### A2. 1.4 Username enumeration (OPAQUE) ### A2. 1.4 Username enumeration (OPAQUE)
- **Files:** `crates/quicnprotochat-server/src/node_service/auth_ops.rs`, `docs/SECURITY-AUDIT.md`. - **Files:** `crates/quicproquo-server/src/node_service/auth_ops.rs`, `docs/SECURITY-AUDIT.md`.
- **Tasks:** - **Tasks:**
1. Document the risk in SECURITY-AUDIT (already mentioned). 1. Document the risk in SECURITY-AUDIT (already mentioned).
2. Optional mitigation: ensure `get_user_record` is always called before `ServerLogin::start` (already true). If desired, add a constant-time delay or dummy work when user not found so response timing does not leak existence. Keep OPAQUE security unchanged. 2. Optional mitigation: ensure `get_user_record` is always called before `ServerLogin::start` (already true). If desired, add a constant-time delay or dummy work when user not found so response timing does not leak existence. Keep OPAQUE security unchanged.
- **Deliverable:** Doc update; optional small code change in `handle_opaque_login_start`. - **Deliverable:** Doc update; optional small code change in `handle_opaque_login_start`.
### A3. 1.1 M7 — Post-quantum MLS ### A3. 1.1 M7 — Post-quantum MLS
- **Files:** `crates/quicnprotochat-core/src/` (new or modified crypto provider), `crates/quicnprotochat-core/src/group.rs`, `crates/quicnprotochat-core/src/hybrid_kem.rs`, `crates/quicnprotochat-core/src/hybrid_crypto.rs`. - **Files:** `crates/quicproquo-core/src/` (new or modified crypto provider), `crates/quicproquo-core/src/group.rs`, `crates/quicproquo-core/src/hybrid_kem.rs`, `crates/quicproquo-core/src/hybrid_crypto.rs`.
- **Tasks:** - **Tasks:**
1. Implement a custom `OpenMlsCryptoProvider` (or adapter) that uses hybrid X25519 + ML-KEM-768 for MLS KEM (HPKE layer). 1. Implement a custom `OpenMlsCryptoProvider` (or adapter) that uses hybrid X25519 + ML-KEM-768 for MLS KEM (HPKE layer).
2. Wire hybrid shared secret derivation (see milestones M7) into the provider. 2. Wire hybrid shared secret derivation (see milestones M7) into the provider.
@@ -42,7 +42,7 @@ This document splits work for **Future Improvements §1 (Security and hardening)
**Owns:** Cap'n Proto schema (node.capnp delivery/channel methods), server storage (Store trait, FileBackedStore, SqlStore), `node_service/delivery.rs`, `node_service/key_ops.rs` (if createChannel lives there), client commands for channels. **Owns:** Cap'n Proto schema (node.capnp delivery/channel methods), server storage (Store trait, FileBackedStore, SqlStore), `node_service/delivery.rs`, `node_service/key_ops.rs` (if createChannel lives there), client commands for channels.
### B1. 5.1 Private 1:1 channels (DM) ### B1. 5.1 Private 1:1 channels (DM)
- **Files:** `schemas/node.capnp`, `crates/quicnprotochat-server/src/storage.rs`, `crates/quicnprotochat-server/src/sql_store.rs`, `crates/quicnprotochat-server/src/node_service/delivery.rs`, new `crates/quicnprotochat-server/src/node_service/channel_ops.rs` (or add to delivery), migrations for channels table. - **Files:** `schemas/node.capnp`, `crates/quicproquo-server/src/storage.rs`, `crates/quicproquo-server/src/sql_store.rs`, `crates/quicproquo-server/src/node_service/delivery.rs`, new `crates/quicproquo-server/src/node_service/channel_ops.rs` (or add to delivery), migrations for channels table.
- **Tasks:** - **Tasks:**
1. **Schema:** Add `createChannel @N (auth :Auth, peerKey :Data) -> (channelId :Data);` to `node.capnp`. Rebuild proto. 1. **Schema:** Add `createChannel @N (auth :Auth, peerKey :Data) -> (channelId :Data);` to `node.capnp`. Rebuild proto.
2. **Store trait:** Add `create_channel(&self, member_a: &[u8], member_b: &[u8]) -> Result<Vec<u8>, StorageError>`, `get_channel_members(&self, channel_id: &[u8]) -> Result<Option<(Vec<u8>, Vec<u8>)>, StorageError>`. Implement in FileBackedStore (in-memory map channel_id -> (a, b)) and SqlStore (channels table, unique on sorted (a,b)). 2. **Store trait:** Add `create_channel(&self, member_a: &[u8], member_b: &[u8]) -> Result<Vec<u8>, StorageError>`, `get_channel_members(&self, channel_id: &[u8]) -> Result<Option<(Vec<u8>, Vec<u8>)>, StorageError>`. Implement in FileBackedStore (in-memory map channel_id -> (a, b)) and SqlStore (channels table, unique on sorted (a,b)).
@@ -53,7 +53,7 @@ This document splits work for **Future Improvements §1 (Security and hardening)
- **Ref:** [DM channels design](src/roadmap/dm-channels.md). - **Ref:** [DM channels design](src/roadmap/dm-channels.md).
### B2. 5.2 MLS lifecycle (remove, update, proposals) ### B2. 5.2 MLS lifecycle (remove, update, proposals)
- **Files:** `crates/quicnprotochat-core/src/group.rs`, client commands that use GroupMember. - **Files:** `crates/quicproquo-core/src/group.rs`, client commands that use GroupMember.
- **Tasks:** - **Tasks:**
1. Add `remove_member` (by index or identity) and `update_credential` / rekey using openmls APIs. 1. Add `remove_member` (by index or identity) and `update_credential` / rekey using openmls APIs.
2. Handle incoming MLS proposals (Remove, Update) in `receive_message` path and apply to group state. 2. Handle incoming MLS proposals (Remove, Update) in `receive_message` path and apply to group state.
@@ -62,7 +62,7 @@ This document splits work for **Future Improvements §1 (Security and hardening)
- **Ref:** OpenMLS API for `MlsGroup::remove_member`, `MlsGroup::process_pending_proposals`, etc. - **Ref:** OpenMLS API for `MlsGroup::remove_member`, `MlsGroup::process_pending_proposals`, etc.
### B3. 5.3 Sealed Sender and 5.4 Traffic analysis ### B3. 5.3 Sealed Sender and 5.4 Traffic analysis
- **Files:** Docs; optionally `crates/quicnprotochat-server`, `crates/quicnprotochat-client` for padding. - **Files:** Docs; optionally `crates/quicproquo-server`, `crates/quicproquo-client` for padding.
- **Tasks:** - **Tasks:**
1. Document current `sealed_sender` behaviour (enqueue without identity binding) and that full “sender in ciphertext” is a future protocol change. 1. Document current `sealed_sender` behaviour (enqueue without identity binding) and that full “sender in ciphertext” is a future protocol change.
2. Optional: add optional payload padding (e.g. pad to next 256 bytes) or random delay in client send path for 5.4. 2. Optional: add optional payload padding (e.g. pad to next 256 bytes) or random delay in client send path for 5.4.
@@ -75,12 +75,12 @@ This document splits work for **Future Improvements §1 (Security and hardening)
| Area | Agent A | Agent B | | Area | Agent A | Agent B |
|------|---------|---------| |------|---------|---------|
| `schemas/node.capnp` | — | Add createChannel | | `schemas/node.capnp` | — | Add createChannel |
| `crates/quicnprotochat-server/src/node_service/auth_ops.rs` | 1.4 username enum | — | | `crates/quicproquo-server/src/node_service/auth_ops.rs` | 1.4 username enum | — |
| `crates/quicnprotochat-server/src/node_service/delivery.rs` | — | 5.1 channel authz | | `crates/quicproquo-server/src/node_service/delivery.rs` | — | 5.1 channel authz |
| `crates/quicnprotochat-server/src/storage.rs` | — | 5.1 Store channel methods | | `crates/quicproquo-server/src/storage.rs` | — | 5.1 Store channel methods |
| `crates/quicnprotochat-server/src/sql_store.rs` | — | 5.1 channels table + impl | | `crates/quicproquo-server/src/sql_store.rs` | — | 5.1 channels table + impl |
| `crates/quicnprotochat-server/src/tls.rs` | 1.2 optional | — | | `crates/quicproquo-server/src/tls.rs` | 1.2 optional | — |
| `crates/quicnprotochat-core/` | 1.1 M7, 1.3 doc | 5.2 group.rs | | `crates/quicproquo-core/` | 1.1 M7, 1.3 doc | 5.2 group.rs |
| `docs/` | 1.2, 1.3, 1.4, 5.3/5.4 | — (or shared) | | `docs/` | 1.2, 1.3, 1.4, 5.3/5.4 | — (or shared) |
**Shared:** `docs/`, `README.md`. Prefer non-overlapping files (e.g. A adds `certificate-lifecycle.md`, B does not edit it). **Shared:** `docs/`, `README.md`. Prefer non-overlapping files (e.g. A adds `certificate-lifecycle.md`, B does not edit it).

View File

@@ -1,6 +1,6 @@
# Production Readiness Audit # Production Readiness Audit
This document summarizes issues and fixes needed to get quicnprotochat production-ready, based on a codebase review. It aligns with the existing [Production Readiness WBS](src/roadmap/production-readiness.md) and [Coding Standards](src/contributing/coding-standards.md). This document summarizes issues and fixes needed to get quicproquo production-ready, based on a codebase review. It aligns with the existing [Production Readiness WBS](src/roadmap/production-readiness.md) and [Coding Standards](src/contributing/coding-standards.md).
--- ---
@@ -10,7 +10,7 @@ This document summarizes issues and fixes needed to get quicnprotochat productio
- **README and example config** use `auth_token = "devtoken"` and `db_key = ""`. - **README and example config** use `auth_token = "devtoken"` and `db_key = ""`.
- **Risk:** Deploying with default/example config allows weak or no auth and unencrypted DB. - **Risk:** Deploying with default/example config allows weak or no auth and unencrypted DB.
- **Fix:** Require explicit `QUICNPROTOCHAT_AUTH_TOKEN` (or config) in production; reject empty or `"devtoken"` when a production mode/env is set. Document that `db_key` empty disables SQLCipher and is not acceptable for production. - **Fix:** Require explicit `QPQ_AUTH_TOKEN` (or config) in production; reject empty or `"devtoken"` when a production mode/env is set. Document that `db_key` empty disables SQLCipher and is not acceptable for production.
### 2. **Database encryption optional** ### 2. **Database encryption optional**
@@ -19,15 +19,15 @@ This document summarizes issues and fixes needed to get quicnprotochat productio
### 3. **Secrets and generated files not ignored** ### 3. **Secrets and generated files not ignored**
- **`.gitignore`** does not include `data/`, so `data/server-cert.der`, `data/server-key.der`, and `data/quicnprotochat.db` could be committed. - **`.gitignore`** does not include `data/`, so `data/server-cert.der`, `data/server-key.der`, and `data/qpq.db` could be committed.
- **Fix:** Add `data/` (and any other dirs that hold certs, keys, or DBs) to `.gitignore`. Consider adding `*.der` and `*.db` if used only for local/dev. - **Fix:** Add `data/` (and any other dirs that hold certs, keys, or DBs) to `.gitignore`. Consider adding `*.der` and `*.db` if used only for local/dev.
### 4. **Dockerfile out of sync with workspace** ### 4. **Dockerfile out of sync with workspace**
- **Workspace** has 5 members including `crates/quicnprotochat-p2p`. - **Workspace** has 5 members including `crates/quicproquo-p2p`.
- **Dockerfile** only copies 4 crate manifests and creates stub dirs for those 4; it never copies `quicnprotochat-p2p`. - **Dockerfile** only copies 4 crate manifests and creates stub dirs for those 4; it never copies `quicproquo-p2p`.
- **Result:** `cargo build --release --bin quicnprotochat-server` can fail (missing workspace member) or behave inconsistently. - **Result:** `cargo build --release --bin quicproquo-server` can fail (missing workspace member) or behave inconsistently.
- **Fix:** Add `COPY crates/quicnprotochat-p2p/Cargo.toml` and a stub `crates/quicnprotochat-p2p/src` (or equivalent) in the dependency-cache layer so the workspace resolves. Ensure the final `COPY crates/ crates/` still brings in real p2p source. - **Fix:** Add `COPY crates/quicproquo-p2p/Cargo.toml` and a stub `crates/quicproquo-p2p/src` (or equivalent) in the dependency-cache layer so the workspace resolves. Ensure the final `COPY crates/ crates/` still brings in real p2p source.
### 5. **E2E test failing (rustls CryptoProvider)** ### 5. **E2E test failing (rustls CryptoProvider)**
@@ -41,7 +41,7 @@ This document summarizes issues and fixes needed to get quicnprotochat productio
### 6. **Panic risk in client RPC path** ### 6. **Panic risk in client RPC path**
- **`quicnprotochat-client/src/lib.rs`:** `set_auth()` uses `.expect("init_auth must be called with a non-empty token before RPCs")`. If RPC is called without `init_auth`, the process panics. - **`quicproquo-client/src/lib.rs`:** `set_auth()` uses `.expect("init_auth must be called with a non-empty token before RPCs")`. If RPC is called without `init_auth`, the process panics.
- **Fix:** Replace with a `Result` or an error return (e.g. a dedicated error type) so callers get a recoverable error instead of a panic. Document that `init_auth` must be called before RPCs. - **Fix:** Replace with a `Result` or an error return (e.g. a dedicated error type) so callers get a recoverable error instead of a panic. Document that `init_auth` must be called before RPCs.
### 7. **Mutex `.unwrap()` in production paths** ### 7. **Mutex `.unwrap()` in production paths**
@@ -95,7 +95,7 @@ This document summarizes issues and fixes needed to get quicnprotochat productio
### 15. **Docker image runs as `nobody`** ### 15. **Docker image runs as `nobody`**
- **Dockerfile** uses `USER nobody`. Good for not running as root, but `nobody` may not have a writable home or data dir. - **Dockerfile** uses `USER nobody`. Good for not running as root, but `nobody` may not have a writable home or data dir.
- **Fix:** Ensure `QUICNPROTOCHAT_DATA_DIR` (and cert paths) point to a directory writable by `nobody`, or create a dedicated user/group with a known UID and use that in the Dockerfile and docs. - **Fix:** Ensure `QPQ_DATA_DIR` (and cert paths) point to a directory writable by `nobody`, or create a dedicated user/group with a known UID and use that in the Dockerfile and docs.
--- ---

View File

@@ -1,6 +1,6 @@
# Security Audit # Security Audit
This document is a security audit of the quicnprotochat codebase as of the audit date. It aligns with the [Threat Model](src/cryptography/threat-model.md) and [Production Readiness Audit](PRODUCTION-READINESS-AUDIT.md). The project has **not** undergone a formal third-party audit; this is an internal review. This document is a security audit of the quicproquo codebase as of the audit date. It aligns with the [Threat Model](src/cryptography/threat-model.md) and [Production Readiness Audit](PRODUCTION-READINESS-AUDIT.md). The project has **not** undergone a formal third-party audit; this is an internal review.
--- ---
@@ -24,19 +24,19 @@ This document is a security audit of the quicnprotochat codebase as of the audit
### 1.1 Token comparison ### 1.1 Token comparison
- **Location:** `crates/quicnprotochat-server/src/auth.rs` - **Location:** `crates/quicproquo-server/src/auth.rs`
- **Finding:** Bearer token and identity key comparisons use `subtle::ConstantTimeEq` (`ct_eq`). Length is checked before comparison where applicable. - **Finding:** Bearer token and identity key comparisons use `subtle::ConstantTimeEq` (`ct_eq`). Length is checked before comparison where applicable.
- **Status:** ✅ No timing leakage from token or identity comparison. - **Status:** ✅ No timing leakage from token or identity comparison.
### 1.2 Session token generation ### 1.2 Session token generation
- **Location:** `crates/quicnprotochat-server/src/node_service/auth_ops.rs` (login finish) - **Location:** `crates/quicproquo-server/src/node_service/auth_ops.rs` (login finish)
- **Finding:** Session tokens are 32 bytes from `rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token)`. Stored in `sessions` with TTL (24h). Expired sessions are removed on next use. - **Finding:** Session tokens are 32 bytes from `rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token)`. Stored in `sessions` with TTL (24h). Expired sessions are removed on next use.
- **Status:** ✅ Cryptographically strong, single-use style (opaque 32-byte token). - **Status:** ✅ Cryptographically strong, single-use style (opaque 32-byte token).
### 1.3 OPAQUE (RFC 9497) ### 1.3 OPAQUE (RFC 9497)
- **Location:** `crates/quicnprotochat-core/src/opaque_auth.rs`, server `auth_ops.rs` - **Location:** `crates/quicproquo-core/src/opaque_auth.rs`, server `auth_ops.rs`
- **Finding:** Shared `OpaqueSuite` (Ristretto255, Triple-DH, Argon2id). Server never sees password. Registration and login flows use `ServerRegistration`/`ServerLogin` correctly. Pending login state is stored server-side and removed on consume. Identity key is bound at login finish; mismatch returns E016 and is not logged with secrets. - **Finding:** Shared `OpaqueSuite` (Ristretto255, Triple-DH, Argon2id). Server never sees password. Registration and login flows use `ServerRegistration`/`ServerLogin` correctly. Pending login state is stored server-side and removed on consume. Identity key is bound at login finish; mismatch returns E016 and is not logged with secrets.
- **DoS:** Pending-login check runs **before** expensive OPAQUE work (login start); repeated attempts for the same username within 60s are rejected early. - **DoS:** Pending-login check runs **before** expensive OPAQUE work (login start); repeated attempts for the same username within 60s are rejected early.
- **Status:** ✅ Correct usage; DoS mitigation in place. - **Status:** ✅ Correct usage; DoS mitigation in place.
@@ -52,19 +52,19 @@ This document is a security audit of the quicnprotochat codebase as of the audit
### 2.1 MLS and identity ### 2.1 MLS and identity
- **Location:** `quicnprotochat-core` (group, identity, keypackage) - **Location:** `quicproquo-core` (group, identity, keypackage)
- **Finding:** MLS ciphersuite `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` (RFC 9420). Ed25519 identity seed stored in `Zeroizing<[u8; 32]>`; zeroize-on-drop. KeyPackages validated for ciphersuite before server stores. Single-use KeyPackage semantics enforced (consume-on-fetch). - **Finding:** MLS ciphersuite `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` (RFC 9420). Ed25519 identity seed stored in `Zeroizing<[u8; 32]>`; zeroize-on-drop. KeyPackages validated for ciphersuite before server stores. Single-use KeyPackage semantics enforced (consume-on-fetch).
- **Status:** ✅ Aligns with key lifecycle and zeroization goals. - **Status:** ✅ Aligns with key lifecycle and zeroization goals.
### 2.2 Hybrid KEM (X25519 + ML-KEM-768) ### 2.2 Hybrid KEM (X25519 + ML-KEM-768)
- **Location:** `crates/quicnprotochat-core/src/hybrid_kem.rs` - **Location:** `crates/quicproquo-core/src/hybrid_kem.rs`
- **Finding:** Hybrid keypair and shared secrets use `Zeroizing` where appropriate. HKDF domain separation (`quicnprotochat-hybrid-v1`). ChaCha20-Poly1305 for AEAD. Versioned envelope. - **Finding:** Hybrid keypair and shared secrets use `Zeroizing` where appropriate. HKDF domain separation (`quicproquo-hybrid-v1`). ChaCha20-Poly1305 for AEAD. Versioned envelope.
- **Status:** ✅ PQ-ready envelope layer; secret handling is careful. - **Status:** ✅ PQ-ready envelope layer; secret handling is careful.
### 2.3 Client state encryption (QPCE) ### 2.3 Client state encryption (QPCE)
- **Location:** `crates/quicnprotochat-client/src/client/state.rs` - **Location:** `crates/quicproquo-client/src/client/state.rs`
- **Finding:** Optional password protection: Argon2id (default params) for key derivation, ChaCha20-Poly1305, random salt and nonce. Derived key held in `Zeroizing` during use. Unencrypted state is a documented option (e.g. dev). - **Finding:** Optional password protection: Argon2id (default params) for key derivation, ChaCha20-Poly1305, random salt and nonce. Derived key held in `Zeroizing` during use. Unencrypted state is a documented option (e.g. dev).
- **Recommendation:** Document Argon2 params (memory, iterations) for auditability; consider explicit `Argon2::new()` with named params in a future revision. - **Recommendation:** Document Argon2 params (memory, iterations) for auditability; consider explicit `Argon2::new()` with named params in a future revision.
- **Status:** ✅ Appropriate for optional at-rest protection. - **Status:** ✅ Appropriate for optional at-rest protection.
@@ -75,13 +75,13 @@ This document is a security audit of the quicnprotochat codebase as of the audit
### 3.1 Server TLS ### 3.1 Server TLS
- **Location:** `crates/quicnprotochat-server/src/tls.rs` - **Location:** `crates/quicproquo-server/src/tls.rs`
- **Finding:** TLS 1.3 only. No client cert. ALPN `capnp`. When not in production, missing cert/key triggers self-signed generation; key file permissions set to `0o600` on Unix. Production mode requires existing cert/key (no auto-generation). - **Finding:** TLS 1.3 only. No client cert. ALPN `capnp`. When not in production, missing cert/key triggers self-signed generation; key file permissions set to `0o600` on Unix. Production mode requires existing cert/key (no auto-generation).
- **Status:** ✅ Matches documented design; self-signed limitation is documented in threat model. - **Status:** ✅ Matches documented design; self-signed limitation is documented in threat model.
### 3.2 Client TLS ### 3.2 Client TLS
- **Location:** `crates/quicnprotochat-client/src/client/rpc.rs` - **Location:** `crates/quicproquo-client/src/client/rpc.rs`
- **Finding:** Client loads CA cert from file, builds `RootCertStore` with that single cert, uses it for server verification. Server name from CLI/env is used for connection (SNI and cert verification). No custom bypass. - **Finding:** Client loads CA cert from file, builds `RootCertStore` with that single cert, uses it for server verification. Server name from CLI/env is used for connection (SNI and cert verification). No custom bypass.
- **Status:** ✅ Proper verification against provided CA; trust-on-first-use / self-signed caveat is documented. - **Status:** ✅ Proper verification against provided CA; trust-on-first-use / self-signed caveat is documented.
@@ -106,7 +106,7 @@ This document is a security audit of the quicnprotochat codebase as of the audit
### 4.2 Rate limiting ### 4.2 Rate limiting
- **Location:** `crates/quicnprotochat-server/src/auth.rs`, `delivery.rs` - **Location:** `crates/quicproquo-server/src/auth.rs`, `delivery.rs`
- **Finding:** Per-token rate limit (e.g. 100 enqueues per 60s). Enqueue path checks before storage. Queue depth and payload size caps (1000 messages, 5 MB) enforced. - **Finding:** Per-token rate limit (e.g. 100 enqueues per 60s). Enqueue path checks before storage. Queue depth and payload size caps (1000 messages, 5 MB) enforced.
- **Status:** ✅ Limits in place to curb abuse and DoS. - **Status:** ✅ Limits in place to curb abuse and DoS.
@@ -156,11 +156,11 @@ These remain as documented, not new findings:
### 8.1 High value ### 8.1 High value
- **Dependency audit:** Run `cargo install cargo-audit` then `cargo audit` locally (and in CI if available) to check for known-vulnerable dependencies. Fix or document any findings. See [Checking dependencies](#checking-dependencies) below. - **Dependency audit:** Run `cargo install cargo-audit` then `cargo audit` locally (and in CI if available) to check for known-vulnerable dependencies. Fix or document any findings. See [Checking dependencies](#checking-dependencies) below.
- **Argon2 params:** Implemented: client state KDF now uses explicit Argon2id parameters (19 MiB memory, 2 iterations, 1 lane) in `quicnprotochat-client` so they are auditable. - **Argon2 params:** Implemented: client state KDF now uses explicit Argon2id parameters (19 MiB memory, 2 iterations, 1 lane) in `quicproquo-client` so they are auditable.
### 8.2 Medium value ### 8.2 Medium value
- **Certificate pinning:** To pin the server, use the server's certificate as the client's `ca_cert` (e.g. copy `server-cert.der` from the server and pass it via `--ca-cert` or `QUICNPROTOCHAT_CA_CERT`). Do not use a general CA unless you intend to trust that CA for all servers. See [Certificate pinning](#certificate-pinning) below. - **Certificate pinning:** To pin the server, use the server's certificate as the client's `ca_cert` (e.g. copy `server-cert.der` from the server and pass it via `--ca-cert` or `QPQ_CA_CERT`). Do not use a general CA unless you intend to trust that CA for all servers. See [Certificate pinning](#certificate-pinning) below.
- **Health endpoint:** The `health` RPC is unauthenticated by design for liveness probes and load balancers; this is documented in code and in this audit. - **Health endpoint:** The `health` RPC is unauthenticated by design for liveness probes and load balancers; this is documented in code and in this audit.
### 8.3 Lower priority ### 8.3 Lower priority
@@ -208,7 +208,7 @@ Fix or document any reported issues. Running `cargo audit` in CI (e.g. GitHub Ac
## Certificate pinning ## Certificate pinning
The client trusts the server certificate(s) in the file given by `--ca-cert` (or `QUICNPROTOCHAT_CA_CERT`). To **pin** a specific server: The client trusts the server certificate(s) in the file given by `--ca-cert` (or `QPQ_CA_CERT`). To **pin** a specific server:
1. Obtain the server's certificate (e.g. copy `data/server-cert.der` from the server, or export from your deployment). 1. Obtain the server's certificate (e.g. copy `data/server-cert.der` from the server, or export from your deployment).
2. Use that file as the client's `ca_cert`. The client will only connect to a server that presents that exact certificate (or chain). 2. Use that file as the client's `ca_cert`. The client will only connect to a server that presents that exact certificate (or chain).

View File

@@ -1,7 +1,7 @@
[book] [book]
title = "quicnprotochat" title = "quicproquo"
description = "End-to-end encrypted group messaging over QUIC + TLS 1.3 + MLS (RFC 9420)" description = "End-to-end encrypted group messaging over QUIC + TLS 1.3 + MLS (RFC 9420)"
authors = ["quicnprotochat contributors"] authors = ["quicproquo contributors"]
language = "en" language = "en"
src = "src" src = "src"

View File

@@ -4,7 +4,7 @@
--- ---
# Why quicnprotochat? # Why quicproquo?
- [Comparison with Classical Chat Protocols](design-rationale/protocol-comparison.md) - [Comparison with Classical Chat Protocols](design-rationale/protocol-comparison.md)
- [Why This Design, Not Signal/Matrix/...](design-rationale/why-not-signal.md) - [Why This Design, Not Signal/Matrix/...](design-rationale/why-not-signal.md)
@@ -71,6 +71,7 @@
- [ADR-002: Cap'n Proto over MessagePack](design-rationale/adr-002-capnproto.md) - [ADR-002: Cap'n Proto over MessagePack](design-rationale/adr-002-capnproto.md)
- [ADR-004: MLS-Unaware Delivery Service](design-rationale/adr-004-mls-unaware-ds.md) - [ADR-004: MLS-Unaware Delivery Service](design-rationale/adr-004-mls-unaware-ds.md)
- [ADR-005: Single-Use KeyPackages](design-rationale/adr-005-single-use-keypackages.md) - [ADR-005: Single-Use KeyPackages](design-rationale/adr-005-single-use-keypackages.md)
- [ADR-006: SDK-First Adoption (No REST Gateway)](design-rationale/adr-006-rest-gateway.md)
--- ---
@@ -92,6 +93,7 @@
- [Auth, Devices, and Tokens](roadmap/authz-plan.md) - [Auth, Devices, and Tokens](roadmap/authz-plan.md)
- [1:1 Channel Design](roadmap/dm-channels.md) - [1:1 Channel Design](roadmap/dm-channels.md)
- [Future Research Directions](roadmap/future-research.md) - [Future Research Directions](roadmap/future-research.md)
- [Full Roadmap (Phases 18)](../../ROADMAP.md)
--- ---

View File

@@ -1,18 +1,18 @@
# Glossary # Glossary
Alphabetical glossary of terms used throughout the quicnprotochat documentation. Alphabetical glossary of terms used throughout the quicproquo documentation.
Each entry includes a brief definition and, where applicable, a reference to the Each entry includes a brief definition and, where applicable, a reference to the
relevant specification or documentation page. relevant specification or documentation page.
--- ---
**AEAD** -- Authenticated Encryption with Associated Data. A symmetric encryption **AEAD** -- Authenticated Encryption with Associated Data. A symmetric encryption
scheme that provides both confidentiality and integrity. quicnprotochat uses scheme that provides both confidentiality and integrity. quicproquo uses
AES-128-GCM (in the MLS ciphersuite). See [Cryptography Overview](../cryptography/overview.md). AES-128-GCM (in the MLS ciphersuite). See [Cryptography Overview](../cryptography/overview.md).
**ALPN** -- Application-Layer Protocol Negotiation. A TLS extension that allows **ALPN** -- Application-Layer Protocol Negotiation. A TLS extension that allows
the client and server to agree on an application protocol during the TLS the client and server to agree on an application protocol during the TLS
handshake. quicnprotochat uses the ALPN token `b"capnp"` to identify Cap'n Proto handshake. quicproquo uses the ALPN token `b"capnp"` to identify Cap'n Proto
RPC connections. See [QUIC + TLS 1.3](../protocol-layers/quic-tls.md). RPC connections. See [QUIC + TLS 1.3](../protocol-layers/quic-tls.md).
**AS** -- Authentication Service. The server component that stores and **AS** -- Authentication Service. The server component that stores and
@@ -21,7 +21,7 @@ generation; peers fetch them to add new members to a group.
See [Architecture Overview](../architecture/overview.md). See [Architecture Overview](../architecture/overview.md).
**Cap'n Proto** -- A zero-copy serialisation format with a built-in RPC system. **Cap'n Proto** -- A zero-copy serialisation format with a built-in RPC system.
quicnprotochat uses Cap'n Proto for all wire messages and service RPCs. Schemas quicproquo uses Cap'n Proto for all wire messages and service RPCs. Schemas
live in `schemas/*.capnp` and are compiled to Rust at build time. live in `schemas/*.capnp` and are compiled to Rust at build time.
See [Cap'n Proto Serialisation and RPC](../protocol-layers/capn-proto.md). See [Cap'n Proto Serialisation and RPC](../protocol-layers/capn-proto.md).
@@ -32,13 +32,13 @@ forward secrecy and post-compromise security.
See [MLS (RFC 9420)](../protocol-layers/mls.md). See [MLS (RFC 9420)](../protocol-layers/mls.md).
**Credential** -- An MLS identity binding that associates a member's signing key **Credential** -- An MLS identity binding that associates a member's signing key
with their identity. quicnprotochat uses `BasicCredential`, which contains the with their identity. quicproquo uses `BasicCredential`, which contains the
raw Ed25519 public key bytes. See raw Ed25519 public key bytes. See
[Ed25519 Identity Keys](../cryptography/identity-keys.md). [Ed25519 Identity Keys](../cryptography/identity-keys.md).
**DER** -- Distinguished Encoding Rules. A binary encoding format for ASN.1 **DER** -- Distinguished Encoding Rules. A binary encoding format for ASN.1
structures, used for X.509 certificates and TLS certificate chains. The structures, used for X.509 certificates and TLS certificate chains. The
self-signed TLS certificate generated by quicnprotochat is DER-encoded. self-signed TLS certificate generated by quicproquo is DER-encoded.
**DS** -- Delivery Service. The server component that provides store-and-forward **DS** -- Delivery Service. The server component that provides store-and-forward
relay for opaque MLS payloads. The DS never inspects ciphertext -- it routes relay for opaque MLS payloads. The DS never inspects ciphertext -- it routes
@@ -47,7 +47,7 @@ See [Architecture Overview](../architecture/overview.md).
**Ed25519** -- Edwards-curve Digital Signature Algorithm on Curve25519. Used for **Ed25519** -- Edwards-curve Digital Signature Algorithm on Curve25519. Used for
MLS identity credentials and signing (KeyPackages, Commits, group operations). MLS identity credentials and signing (KeyPackages, Commits, group operations).
quicnprotochat uses the `ed25519-dalek` crate. quicproquo uses the `ed25519-dalek` crate.
See [Ed25519 Identity Keys](../cryptography/identity-keys.md). See [Ed25519 Identity Keys](../cryptography/identity-keys.md).
**Epoch** -- The version number of an MLS group's key state. Each Commit **Epoch** -- The version number of an MLS group's key state. Each Commit
@@ -61,11 +61,11 @@ the epoch ratchet: key material from earlier epochs is deleted when the epoch
advances. See [Forward Secrecy](../cryptography/forward-secrecy.md). advances. See [Forward Secrecy](../cryptography/forward-secrecy.md).
**HKDF** -- HMAC-based Key Derivation Function. Used in MLS to derive symmetric **HKDF** -- HMAC-based Key Derivation Function. Used in MLS to derive symmetric
keys from shared secrets. quicnprotochat uses HKDF-SHA256. keys from shared secrets. quicproquo uses HKDF-SHA256.
**HPKE** -- Hybrid Public Key Encryption. The public-key encryption scheme used **HPKE** -- Hybrid Public Key Encryption. The public-key encryption scheme used
in MLS for key exchange (encrypting to a KeyPackage's init key). Defined in in MLS for key exchange (encrypting to a KeyPackage's init key). Defined in
RFC 9180. In quicnprotochat, HPKE uses DHKEM(X25519, HKDF-SHA256). RFC 9180. In quicproquo, HPKE uses DHKEM(X25519, HKDF-SHA256).
See [Hybrid KEM](../protocol-layers/hybrid-kem.md). See [Hybrid KEM](../protocol-layers/hybrid-kem.md).
**KEM** -- Key Encapsulation Mechanism. A cryptographic primitive that generates **KEM** -- Key Encapsulation Mechanism. A cryptographic primitive that generates
@@ -80,7 +80,7 @@ is consumed on fetch. See
**ML-KEM-768** -- Module-Lattice-based Key Encapsulation Mechanism, security **ML-KEM-768** -- Module-Lattice-based Key Encapsulation Mechanism, security
level 3 (NIST FIPS 203). A post-quantum KEM based on the hardness of the level 3 (NIST FIPS 203). A post-quantum KEM based on the hardness of the
module learning-with-errors (MLWE) problem. quicnprotochat plans to use ML-KEM-768 module learning-with-errors (MLWE) problem. quicproquo plans to use ML-KEM-768
in a hybrid construction with X25519 at milestone M7. in a hybrid construction with X25519 at milestone M7.
See [Post-Quantum Readiness](../cryptography/post-quantum-readiness.md). See [Post-Quantum Readiness](../cryptography/post-quantum-readiness.md).
@@ -104,7 +104,7 @@ See [Future Research](../roadmap/future-research.md).
**QUIC** -- A UDP-based, multiplexed, encrypted transport protocol defined in **QUIC** -- A UDP-based, multiplexed, encrypted transport protocol defined in
RFC 9000. QUIC integrates TLS 1.3 for authentication and confidentiality and RFC 9000. QUIC integrates TLS 1.3 for authentication and confidentiality and
provides 0-RTT connection establishment, stream multiplexing, and built-in provides 0-RTT connection establishment, stream multiplexing, and built-in
congestion control. quicnprotochat uses the `quinn` crate. congestion control. quicproquo uses the `quinn` crate.
See [QUIC + TLS 1.3](../protocol-layers/quic-tls.md). See [QUIC + TLS 1.3](../protocol-layers/quic-tls.md).
**Ratchet Tree** -- The binary tree data structure used in MLS for efficient **Ratchet Tree** -- The binary tree data structure used in MLS for efficient
@@ -113,7 +113,7 @@ hold derived key material. Updates propagate along the path from a leaf to the
root, giving O(log N) cost for key updates in a group of N members. root, giving O(log N) cost for key updates in a group of N members.
**TLS 1.3** -- Transport Layer Security version 1.3, defined in RFC 8446. The **TLS 1.3** -- Transport Layer Security version 1.3, defined in RFC 8446. The
standard for authenticated, encrypted transport. quicnprotochat uses TLS 1.3 standard for authenticated, encrypted transport. quicproquo uses TLS 1.3
exclusively (via `rustls` with `TLS13` cipher suites only) as part of the QUIC exclusively (via `rustls` with `TLS13` cipher suites only) as part of the QUIC
transport. See [QUIC + TLS 1.3](../protocol-layers/quic-tls.md). transport. See [QUIC + TLS 1.3](../protocol-layers/quic-tls.md).
@@ -125,11 +125,11 @@ KeyPackage. See [MLS (RFC 9420)](../protocol-layers/mls.md).
**X25519** -- Elliptic curve Diffie-Hellman key exchange on Curve25519 (using **X25519** -- Elliptic curve Diffie-Hellman key exchange on Curve25519 (using
the Montgomery form). Used as the classical component of DHKEM in MLS HPKE the Montgomery form). Used as the classical component of DHKEM in MLS HPKE
and in the hybrid KEM (X25519 + ML-KEM-768). and in the hybrid KEM (X25519 + ML-KEM-768).
quicnprotochat uses the `x25519-dalek` crate. quicproquo uses the `x25519-dalek` crate.
See [Cryptography Overview](../cryptography/overview.md). See [Cryptography Overview](../cryptography/overview.md).
**Zeroize** -- The practice of securely clearing sensitive data (private keys, **Zeroize** -- The practice of securely clearing sensitive data (private keys,
shared secrets) from memory when it is no longer needed. quicnprotochat uses the shared secrets) from memory when it is no longer needed. quicproquo uses the
`zeroize` crate with the `ZeroizeOnDrop` derive macro to ensure that key material `zeroize` crate with the `ZeroizeOnDrop` derive macro to ensure that key material
is overwritten on drop. is overwritten on drop.
See [Key Lifecycle and Zeroization](../cryptography/key-lifecycle.md). See [Key Lifecycle and Zeroization](../cryptography/key-lifecycle.md).

View File

@@ -1,7 +1,7 @@
# References and Further Reading # References and Further Reading
This page collects the standards, crate documentation, and research papers This page collects the standards, crate documentation, and research papers
referenced throughout the quicnprotochat documentation. Entries are organised by referenced throughout the quicproquo documentation. Entries are organised by
category. category.
--- ---
@@ -10,21 +10,21 @@ category.
| Reference | Description | | Reference | Description |
|-----------|-------------| |-----------|-------------|
| [RFC 9420 -- The Messaging Layer Security (MLS) Protocol](https://datatracker.ietf.org/doc/rfc9420/) | The group key agreement protocol used by quicnprotochat. Defines KeyPackages, Welcome messages, Commits, the ratchet tree, epoch advancement, and the security properties (forward secrecy, post-compromise security). See [MLS (RFC 9420)](../protocol-layers/mls.md). | | [RFC 9420 -- The Messaging Layer Security (MLS) Protocol](https://datatracker.ietf.org/doc/rfc9420/) | The group key agreement protocol used by quicproquo. Defines KeyPackages, Welcome messages, Commits, the ratchet tree, epoch advancement, and the security properties (forward secrecy, post-compromise security). See [MLS (RFC 9420)](../protocol-layers/mls.md). |
| [RFC 9000 -- QUIC: A UDP-Based Multiplexed and Secure Transport](https://datatracker.ietf.org/doc/rfc9000/) | The transport protocol underlying quicnprotochat's primary connection layer. Provides multiplexed streams, 0-RTT connection establishment, and built-in congestion control. See [QUIC + TLS 1.3](../protocol-layers/quic-tls.md). | | [RFC 9000 -- QUIC: A UDP-Based Multiplexed and Secure Transport](https://datatracker.ietf.org/doc/rfc9000/) | The transport protocol underlying quicproquo's primary connection layer. Provides multiplexed streams, 0-RTT connection establishment, and built-in congestion control. See [QUIC + TLS 1.3](../protocol-layers/quic-tls.md). |
| [RFC 9001 -- Using TLS to Secure QUIC](https://datatracker.ietf.org/doc/rfc9001/) | Defines how TLS 1.3 is integrated into QUIC for authentication and key exchange. quicnprotochat uses this via the `quinn` + `rustls` stack. | | [RFC 9001 -- Using TLS to Secure QUIC](https://datatracker.ietf.org/doc/rfc9001/) | Defines how TLS 1.3 is integrated into QUIC for authentication and key exchange. quicproquo uses this via the `quinn` + `rustls` stack. |
| [RFC 8446 -- The Transport Layer Security (TLS) Protocol Version 1.3](https://datatracker.ietf.org/doc/rfc8446/) | The TLS version used exclusively by quicnprotochat (no TLS 1.2 fallback). Provides the handshake, key schedule, and record layer for QUIC transport security. | | [RFC 8446 -- The Transport Layer Security (TLS) Protocol Version 1.3](https://datatracker.ietf.org/doc/rfc8446/) | The TLS version used exclusively by quicproquo (no TLS 1.2 fallback). Provides the handshake, key schedule, and record layer for QUIC transport security. |
| [RFC 9180 -- Hybrid Public Key Encryption (HPKE)](https://datatracker.ietf.org/doc/rfc9180/) | The public-key encryption scheme used internally by MLS for encrypting to KeyPackage init keys. quicnprotochat's MLS ciphersuite uses DHKEM(X25519, HKDF-SHA256) with AES-128-GCM. | | [RFC 9180 -- Hybrid Public Key Encryption (HPKE)](https://datatracker.ietf.org/doc/rfc9180/) | The public-key encryption scheme used internally by MLS for encrypting to KeyPackage init keys. quicproquo's MLS ciphersuite uses DHKEM(X25519, HKDF-SHA256) with AES-128-GCM. |
| [NIST FIPS 203 -- Module-Lattice-Based Key-Encapsulation Mechanism Standard (ML-KEM)](https://csrc.nist.gov/pubs/fips/203/final) | The post-quantum KEM standard. quicnprotochat plans to use ML-KEM-768 in a hybrid construction with X25519 at milestone M7. See [Post-Quantum Readiness](../cryptography/post-quantum-readiness.md). | | [NIST FIPS 203 -- Module-Lattice-Based Key-Encapsulation Mechanism Standard (ML-KEM)](https://csrc.nist.gov/pubs/fips/203/final) | The post-quantum KEM standard. quicproquo plans to use ML-KEM-768 in a hybrid construction with X25519 at milestone M7. See [Post-Quantum Readiness](../cryptography/post-quantum-readiness.md). |
| [Cap'n Proto specification](https://capnproto.org/) | The zero-copy serialisation format and RPC system used for all quicnprotochat wire messages and service interfaces. See [Cap'n Proto Serialisation and RPC](../protocol-layers/capn-proto.md). | | [Cap'n Proto specification](https://capnproto.org/) | The zero-copy serialisation format and RPC system used for all quicproquo wire messages and service interfaces. See [Cap'n Proto Serialisation and RPC](../protocol-layers/capn-proto.md). |
| [draft-ietf-tls-hybrid-design -- Hybrid Key Exchange in TLS 1.3](https://datatracker.ietf.org/doc/draft-ietf-tls-hybrid-design/) | The combiner approach used by quicnprotochat's hybrid KEM construction (X25519 shared secret concatenated with ML-KEM-768 shared secret, fed through HKDF). See [Hybrid KEM](../protocol-layers/hybrid-kem.md). | | [draft-ietf-tls-hybrid-design -- Hybrid Key Exchange in TLS 1.3](https://datatracker.ietf.org/doc/draft-ietf-tls-hybrid-design/) | The combiner approach used by quicproquo's hybrid KEM construction (X25519 shared secret concatenated with ML-KEM-768 shared secret, fed through HKDF). See [Hybrid KEM](../protocol-layers/hybrid-kem.md). |
| [RFC 9497 -- OPAQUE](https://datatracker.ietf.org/doc/rfc9497/) | Asymmetric password-authenticated key exchange. Considered for future authentication (see [Future Research](../roadmap/future-research.md)). | | [RFC 9497 -- OPAQUE](https://datatracker.ietf.org/doc/rfc9497/) | Asymmetric password-authenticated key exchange. Considered for future authentication (see [Future Research](../roadmap/future-research.md)). |
--- ---
## Rust Crate Documentation ## Rust Crate Documentation
| Crate | docs.rs | Role in quicnprotochat | | Crate | docs.rs | Role in quicproquo |
|-------|---------|----------------------| |-------|---------|----------------------|
| `openmls` | [docs.rs/openmls](https://docs.rs/openmls/) | MLS protocol implementation: group creation, member addition, Welcome processing, application message encryption/decryption. See [MLS (RFC 9420)](../protocol-layers/mls.md). | | `openmls` | [docs.rs/openmls](https://docs.rs/openmls/) | MLS protocol implementation: group creation, member addition, Welcome processing, application message encryption/decryption. See [MLS (RFC 9420)](../protocol-layers/mls.md). |
| `openmls_rust_crypto` | [docs.rs/openmls_rust_crypto](https://docs.rs/openmls_rust_crypto/) | Pure-Rust cryptographic backend for openmls. Provides the `OpenMlsRustCrypto` provider used by `GroupMember`. | | `openmls_rust_crypto` | [docs.rs/openmls_rust_crypto](https://docs.rs/openmls_rust_crypto/) | Pure-Rust cryptographic backend for openmls. Provides the `OpenMlsRustCrypto` provider used by `GroupMember`. |
@@ -57,7 +57,7 @@ Katriel Cohn-Gordon, Cas Cremers, Luke Garratt, Jon Millican, and Kevin Milner.
This paper analyses the security properties of group messaging protocols and This paper analyses the security properties of group messaging protocols and
motivates the design of MLS. It defines the security goals (forward secrecy, motivates the design of MLS. It defines the security goals (forward secrecy,
post-compromise security, asynchronous operation) that MLS formalises into a post-compromise security, asynchronous operation) that MLS formalises into a
protocol. Essential background for understanding why quicnprotochat uses MLS protocol. Essential background for understanding why quicproquo uses MLS
rather than extending the Signal protocol to groups. rather than extending the Signal protocol to groups.
### Signal Protocol ### Signal Protocol
@@ -67,7 +67,7 @@ Trevor Perrin and Moxie Marlinspike.
[signal.org/docs/specifications/doubleratchet](https://signal.org/docs/specifications/doubleratchet/) [signal.org/docs/specifications/doubleratchet](https://signal.org/docs/specifications/doubleratchet/)
Defines the double ratchet used in Signal's 1:1 messaging. Relevant as a Defines the double ratchet used in Signal's 1:1 messaging. Relevant as a
potential optimisation for quicnprotochat's 1:1 channels (see potential optimisation for quicproquo's 1:1 channels (see
[Future Research: Double-Ratchet DM Layer](../roadmap/future-research.md#double-ratchet-dm-layer)) [Future Research: Double-Ratchet DM Layer](../roadmap/future-research.md#double-ratchet-dm-layer))
and as background for understanding how MLS generalises ratcheting to groups. and as background for understanding how MLS generalises ratcheting to groups.
@@ -86,7 +86,7 @@ Roberto Avanzi et al.
[NIST PQC Round 3 submission](https://pq-crystals.org/kyber/) [NIST PQC Round 3 submission](https://pq-crystals.org/kyber/)
The predecessor to ML-KEM (NIST FIPS 203). CRYSTALS-Kyber was selected by NIST The predecessor to ML-KEM (NIST FIPS 203). CRYSTALS-Kyber was selected by NIST
and standardised as ML-KEM. quicnprotochat uses the `ml-kem` crate which and standardised as ML-KEM. quicproquo uses the `ml-kem` crate which
implements the final FIPS 203 standard. implements the final FIPS 203 standard.
### Metadata Resistance ### Metadata Resistance
@@ -96,7 +96,7 @@ Signal Blog.
[signal.org/blog/sealed-sender](https://signal.org/blog/sealed-sender/) [signal.org/blog/sealed-sender](https://signal.org/blog/sealed-sender/)
Describes Signal's approach to hiding sender identity from the server. Relevant Describes Signal's approach to hiding sender identity from the server. Relevant
to quicnprotochat's future research on metadata resistance (see to quicproquo's future research on metadata resistance (see
[Future Research](../roadmap/future-research.md)). [Future Research](../roadmap/future-research.md)).
--- ---
@@ -104,7 +104,7 @@ to quicnprotochat's future research on metadata resistance (see
## Cross-references ## Cross-references
- [Glossary](glossary.md) -- definitions of terms used in these references - [Glossary](glossary.md) -- definitions of terms used in these references
- [Protocol Layers Overview](../protocol-layers/overview.md) -- how the protocols layer in quicnprotochat - [Protocol Layers Overview](../protocol-layers/overview.md) -- how the protocols layer in quicproquo
- [Cryptography Overview](../cryptography/overview.md) -- cryptographic properties and threat model - [Cryptography Overview](../cryptography/overview.md) -- cryptographic properties and threat model
- [Future Research](../roadmap/future-research.md) -- technologies under consideration - [Future Research](../roadmap/future-research.md) -- technologies under consideration
- [Milestones](../roadmap/milestones.md) -- current project status - [Milestones](../roadmap/milestones.md) -- current project status

View File

@@ -1,9 +1,9 @@
# Crate Responsibilities # Crate Responsibilities
The quicnprotochat workspace contains six crates. The main four (proto, core, The quicproquo workspace contains six crates. The main four (proto, core,
server, client) follow strict layering rules; each owns one concern and depends server, client) follow strict layering rules; each owns one concern and depends
only on the crates below it. The workspace also includes **quicnprotochat-gui** only on the crates below it. The workspace also includes **quicproquo-gui**
(Tauri desktop app) and **quicnprotochat-p2p** (P2P endpoint resolution). This (Tauri desktop app) and **quicproquo-p2p** (P2P endpoint resolution). This
page documents what each crate provides, what it explicitly avoids, and how the page documents what each crate provides, what it explicitly avoids, and how the
crates relate to one another. crates relate to one another.
@@ -13,7 +13,7 @@ crates relate to one another.
```text ```text
┌──────────────────────────┐ ┌──────────────────────────┐
│ quicnprotochat-client │ │ quicproquo-client │
│ (CLI, QUIC client, │ │ (CLI, QUIC client, │
│ GroupMember orchestr.) │ │ GroupMember orchestr.) │
└─────────┬───────┬────────┘ └─────────┬───────┬────────┘
@@ -21,7 +21,7 @@ crates relate to one another.
┌───────┘ └────────┐ ┌───────┘ └────────┐
▼ ▼ ▼ ▼
┌────────────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐
│ quicnprotochat-core │ │ quicnprotochat-server │ │ quicproquo-core │ │ quicproquo-server │
│ (crypto, MLS, │ │ (QUIC listener, │ │ (crypto, MLS, │ │ (QUIC listener, │
│ hybrid KEM) │ │ NodeService RPC, │ │ hybrid KEM) │ │ NodeService RPC, │
│ │ │ storage) │ │ │ │ storage) │
@@ -30,7 +30,7 @@ crates relate to one another.
│ ┌───────────────────┘ │ ┌───────────────────┘
▼ ▼ ▼ ▼
┌────────────────────────┐ ┌────────────────────────┐
│ quicnprotochat-proto │ │ quicproquo-proto │
│ (Cap'n Proto schemas, │ │ (Cap'n Proto schemas, │
│ codegen, helpers) │ │ codegen, helpers) │
└────────────────────────┘ └────────────────────────┘
@@ -42,7 +42,7 @@ serialisation. The server and client crates both depend on core and proto.
--- ---
## quicnprotochat-core ## quicproquo-core
**Role:** Pure cryptographic logic. No network I/O. No async runtime **Role:** Pure cryptographic logic. No network I/O. No async runtime
dependency. dependency.
@@ -70,12 +70,12 @@ dependency.
`ed25519-dalek`, `openmls`, `openmls_rust_crypto`, `ed25519-dalek`, `openmls`, `openmls_rust_crypto`,
`openmls_traits`, `tls_codec`, `ml-kem`, `x25519-dalek`, `chacha20poly1305`, `openmls_traits`, `tls_codec`, `ml-kem`, `x25519-dalek`, `chacha20poly1305`,
`hkdf`, `sha2`, `zeroize`, `capnp`, `quicnprotochat-proto`, `tokio`, `hkdf`, `sha2`, `zeroize`, `capnp`, `quicproquo-proto`, `tokio`,
`serde`, `bincode`, `serde_json`, `thiserror`. `serde`, `bincode`, `serde_json`, `thiserror`.
--- ---
## quicnprotochat-proto ## quicproquo-proto
**Role:** Cap'n Proto schema definitions, compile-time code generation, and **Role:** Cap'n Proto schema definitions, compile-time code generation, and
pure-synchronous serialisation helpers. This crate is the single source of truth pure-synchronous serialisation helpers. This crate is the single source of truth
@@ -110,7 +110,7 @@ for the wire format.
--- ---
## quicnprotochat-server ## quicproquo-server
**Role:** Network-facing server binary. Accepts QUIC + TLS 1.3 connections, **Role:** Network-facing server binary. Accepts QUIC + TLS 1.3 connections,
dispatches Cap'n Proto RPC calls to `NodeServiceImpl`, and persists state to dispatches Cap'n Proto RPC calls to `NodeServiceImpl`, and persists state to
@@ -143,19 +143,19 @@ is handled by `spawn_local`.
### What this crate does NOT do ### What this crate does NOT do
- No direct crypto operations (it delegates to `quicnprotochat-core` types - No direct crypto operations (it delegates to `quicproquo-core` types
for fingerprinting and storage only). for fingerprinting and storage only).
- No MLS processing -- all payloads are opaque byte strings. - No MLS processing -- all payloads are opaque byte strings.
### Key dependencies ### Key dependencies
`quicnprotochat-core`, `quicnprotochat-proto`, `quinn`, `quinn-proto`, `quicproquo-core`, `quicproquo-proto`, `quinn`, `quinn-proto`,
`rustls`, `rcgen`, `capnp`, `capnp-rpc`, `tokio`, `tokio-util`, `dashmap`, `rustls`, `rcgen`, `capnp`, `capnp-rpc`, `tokio`, `tokio-util`, `dashmap`,
`sha2`, `clap`, `tracing`, `anyhow`, `thiserror`, `bincode`, `serde`. `sha2`, `clap`, `tracing`, `anyhow`, `thiserror`, `bincode`, `serde`.
--- ---
## quicnprotochat-client ## quicproquo-client
**Role:** CLI client binary. Connects to the server over QUIC + TLS 1.3, **Role:** CLI client binary. Connects to the server over QUIC + TLS 1.3,
orchestrates MLS group operations via `GroupMember`, and persists identity and orchestrates MLS group operations via `GroupMember`, and persists identity and
@@ -167,7 +167,7 @@ group state to disk.
|-------------------------|-------------| |-------------------------|-------------|
| `connect_node` | Establishes a QUIC/TLS connection, opens a bidirectional stream, and bootstraps a `capnp-rpc` `RpcSystem` to obtain a `node_service::Client`. | | `connect_node` | Establishes a QUIC/TLS connection, opens a bidirectional stream, and bootstraps a `capnp-rpc` `RpcSystem` to obtain a `node_service::Client`. |
| CLI subcommands (`clap`)| `ping`, `register`, `fetch-key`, `demo-group`, `register-state`, `create-group`, `invite`, `join`, `send`, `recv`. | | CLI subcommands (`clap`)| `ping`, `register`, `fetch-key`, `demo-group`, `register-state`, `create-group`, `invite`, `join`, `send`, `recv`. |
| `GroupMember` usage | The client creates a `GroupMember` (from `quicnprotochat-core`), calls `generate_key_package` / `create_group` / `add_member` / `join_group` / `send_message` / `receive_message`. | | `GroupMember` usage | The client creates a `GroupMember` (from `quicproquo-core`), calls `generate_key_package` / `create_group` / `add_member` / `join_group` / `send_message` / `receive_message`. |
| State persistence | `StoredState` holds `identity_seed` (32 bytes) and optional serialised `MlsGroup`. A companion `.ks` file stores the `DiskKeyStore` with HPKE init private keys. | | State persistence | `StoredState` holds `identity_seed` (32 bytes) and optional serialised `MlsGroup`. A companion `.ks` file stores the `DiskKeyStore` with HPKE init private keys. |
| Auth context | `ClientAuth` bundles an optional bearer token and device ID. Passed to every RPC via the `Auth` struct in `node.capnp`. | | Auth context | `ClientAuth` bundles an optional bearer token and device ID. Passed to every RPC via the `Auth` struct in `node.capnp`. |
@@ -194,7 +194,7 @@ group state to disk.
### Key dependencies ### Key dependencies
`quicnprotochat-core`, `quicnprotochat-proto`, `quinn`, `quinn-proto`, `quicproquo-core`, `quicproquo-proto`, `quinn`, `quinn-proto`,
`rustls`, `capnp`, `capnp-rpc`, `tokio`, `tokio-util`, `clap`, `sha2`, `rustls`, `capnp`, `capnp-rpc`, `tokio`, `tokio-util`, `clap`, `sha2`,
`serde`, `bincode`, `anyhow`, `thiserror`, `tracing`. `serde`, `bincode`, `anyhow`, `thiserror`, `tracing`.
@@ -204,8 +204,8 @@ group state to disk.
| Crate | Role | | Crate | Role |
|-------------------------|------| |-------------------------|------|
| **quicnprotochat-gui** | Tauri 2 desktop application; provides a GUI on top of the client/core stack. | | **quicproquo-gui** | Tauri 2 desktop application; provides a GUI on top of the client/core stack. |
| **quicnprotochat-p2p** | P2P endpoint publish/resolve; used by the server and clients for direct peer discovery. | | **quicproquo-p2p** | P2P endpoint publish/resolve; used by the server and clients for direct peer discovery. |
These crates are optional for building and running the server and CLI client. These crates are optional for building and running the server and CLI client.
@@ -220,12 +220,12 @@ These crates are optional for building and running the server and CLI client.
4. **client** depends on **core** and **proto**. It does not depend on server. 4. **client** depends on **core** and **proto**. It does not depend on server.
5. **server** and **client** never depend on each other. They communicate 5. **server** and **client** never depend on each other. They communicate
exclusively via the Cap'n Proto RPC wire protocol. exclusively via the Cap'n Proto RPC wire protocol.
6. **quicnprotochat-gui** and **quicnprotochat-p2p** are optional; they depend 6. **quicproquo-gui** and **quicproquo-p2p** are optional; they depend
on client/core/proto as needed and do not change the core layering. on client/core/proto as needed and do not change the core layering.
This layering ensures that: This layering ensures that:
- Crypto code can be tested in isolation (`cargo test -p quicnprotochat-core`). - Crypto code can be tested in isolation (`cargo test -p quicproquo-core`).
- Schema changes propagate automatically through codegen. - Schema changes propagate automatically through codegen.
- The server binary contains no client-side MLS orchestration logic. - The server binary contains no client-side MLS orchestration logic.
- The client binary contains no server-side storage or listener logic. - The client binary contains no server-side storage or listener logic.

Some files were not shown because too many files have changed in this diff Show More