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:
12
.github/CODEOWNERS
vendored
12
.github/CODEOWNERS
vendored
@@ -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
2
.gitignore
vendored
@@ -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
301
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
16
Cargo.toml
16
Cargo.toml
@@ -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" }
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -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
376
ROADMAP.md
Normal 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 | 1–2 days |
|
||||||
|
| **2** | Test & CI Maturity | 2–3 days |
|
||||||
|
| **3** | Client SDKs (Go, Python, WASM, FFI, WebTransport) | 5–8 days |
|
||||||
|
| **4** | Trust & Security Infrastructure | 2–4 days (excl. audit) |
|
||||||
|
| **5** | Features & UX | 5–7 days |
|
||||||
|
| **6** | Scale & Operations | 3–5 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) — M1–M7 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
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
//! Desktop entry point for quicnprotochat-gui.
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
quicnprotochat_gui::run()
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
@@ -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)?;
|
||||||
@@ -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,
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -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;
|
||||||
@@ -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)]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
152
crates/quicproquo-core/benches/hybrid_kem_bench.rs
Normal file
152
crates/quicproquo-core/benches/hybrid_kem_bench.rs
Normal 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);
|
||||||
132
crates/quicproquo-core/benches/mls_operations.rs
Normal file
132
crates/quicproquo-core/benches/mls_operations.rs
Normal 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);
|
||||||
170
crates/quicproquo-core/benches/serialization.rs
Normal file
170
crates/quicproquo-core/benches/serialization.rs
Normal 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);
|
||||||
21
crates/quicproquo-core/proto/chat_message.proto
Normal file
21
crates/quicproquo-core/proto/chat_message.proto
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Error types for `quicnprotochat-core`.
|
//! Error types for `quicproquo-core`.
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
@@ -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() {
|
||||||
@@ -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() {
|
||||||
@@ -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 {
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -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
|
||||||
144
crates/quicproquo-core/src/padding.rs
Normal file
144
crates/quicproquo-core/src/padding.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
154
crates/quicproquo-core/src/sealed_sender.rs
Normal file
154
crates/quicproquo-core/src/sealed_sender.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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.:
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -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 {
|
||||||
@@ -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
|
||||||
5
crates/quicproquo-gui/src/main.rs
Normal file
5
crates/quicproquo-gui/src/main.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//! Desktop entry point for quicproquo-gui.
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
quicproquo_gui::run()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
23
crates/quicproquo-mobile/Cargo.toml
Normal file
23
crates/quicproquo-mobile/Cargo.toml
Normal 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 }
|
||||||
331
crates/quicproquo-mobile/src/lib.rs
Normal file
331
crates/quicproquo-mobile/src/lib.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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. \
|
||||||
@@ -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> {
|
||||||
@@ -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 }
|
||||||
16
crates/quicproquo-server/migrations/004_federation.sql
Normal file
16
crates/quicproquo-server/migrations/004_federation.sql
Normal 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
|
||||||
|
);
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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(())
|
||||||
78
crates/quicproquo-server/src/federation/address.rs
Normal file
78
crates/quicproquo-server/src/federation/address.rs
Normal 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
287
crates/quicproquo-server/src/federation/client.rs
Normal file
287
crates/quicproquo-server/src/federation/client.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
crates/quicproquo-server/src/federation/mod.rs
Normal file
16
crates/quicproquo-server/src/federation/mod.rs
Normal 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;
|
||||||
44
crates/quicproquo-server/src/federation/routing.rs
Normal file
44
crates/quicproquo-server/src/federation/routing.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
201
crates/quicproquo-server/src/federation/service.rs
Normal file
201
crates/quicproquo-server/src/federation/service.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
85
crates/quicproquo-server/src/federation/tls.rs
Normal file
85
crates/quicproquo-server/src/federation/tls.rs
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -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::*;
|
||||||
@@ -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::*;
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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}"),
|
||||||
@@ -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))
|
||||||
@@ -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,
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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`.
|
||||||
@@ -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)]
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 1–8) 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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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 1–8)](../../ROADMAP.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user