Compare commits

...
Author SHA1 Message Date
Alex Emmet
f6a8b464e0
Merge remote-tracking branch 'refs/remotes/origin/master'
All checks were successful
CI / checks (push) Successful in 5m48s
2026-08-27 22:43:40 +02:00
Alex Emmet
9cea795d1c
ChaCha20 2026-08-27 22:42:32 +02:00
4493ed32cf
chore(deps): update chacha20 from 0.10.1 to 0.10.2
All checks were successful
CI / checks (push) Successful in 5m53s
2026-08-27 22:33:20 +02:00
c7c7afe578
fix(example): web-client type
Some checks failed
CI / checks (push) Failing after 3m16s
2026-08-27 22:16:16 +02:00
8d94bc5498
feat(qol): add direnv
Some checks failed
CI / checks (push) Failing after 3m14s
2026-08-27 21:55:36 +02:00
22d13742ae
feat(qol): remove dup
Some checks failed
CI / checks (push) Failing after 4m44s
2026-08-27 21:10:37 +02:00
df75fd2830
feat(qol): remove dup
Some checks failed
CI / checks (push) Failing after 4m19s
2026-08-27 20:28:26 +02:00
6348d7884f
Fix thing
Some checks failed
CI / checks (push) Failing after 3m59s
2026-08-27 20:17:11 +02:00
7ef6ec9e88
Merge remote-tracking branch 'refs/remotes/origin/master'
Some checks failed
CI / checks (push) Failing after 2s
2026-08-27 19:30:03 +02:00
bd5547ae6f
feat(ts-sdk): add schemas 2026-08-27 19:29:53 +02:00
Alex Emmet
c30315af94
Clean
Some checks failed
CI / checks (push) Failing after 2s
2026-08-27 16:55:52 +02:00
e83cd132a2
feat(wasm, native, h3): make wasm, native and h3 use unified interface
Some checks failed
CI / checks (push) Failing after 2s
2026-08-27 15:31:55 +02:00
Alex Emmet
101b8322a1
[Fix] Policy overwrites
Some checks failed
CI / checks (push) Failing after 2s
2026-08-20 21:59:57 +02:00
Alex Emmet
24167c4aa0
Merge remote-tracking branch 'refs/remotes/origin/master'
Some checks failed
CI / checks (push) Failing after 2s
2026-08-20 21:42:43 +02:00
Alex Emmet
420831cd09
[Add] Docs & patches 2026-08-20 21:42:23 +02:00
687d56f9f1
Merge remote-tracking branch 'refs/remotes/origin/master'
Some checks failed
CI / checks (push) Failing after 2s
2026-08-20 20:55:52 +02:00
4c10b56a6c
Update workflows 2026-08-20 20:55:43 +02:00
Alex Emmet
bd660b2afb
[Debug]
Some checks failed
CI / checks (push) Failing after 2m17s
2026-08-20 20:37:25 +02:00
Alex Emmet
2b0bdc3257
[Fix] Connections
Some checks failed
CI / checks (push) Failing after 3m14s
2026-08-20 20:11:08 +02:00
a5c8d4f0c8
Update ci.yml
Some checks failed
CI / checks (push) Failing after 2m36s
2026-08-19 13:17:05 +02:00
a6c4e56835
[Upd] Docs
Some checks failed
CI / checks (push) Failing after 2m33s
2026-08-19 12:37:22 +02:00
d11eb04d12
[Fix] Clean
Some checks failed
CI / checks (push) Failing after 2m37s
2026-08-19 11:46:40 +02:00
Alex Emmet
b331b9f6a3
[Fix] Clean
Some checks failed
CI / checks (push) Failing after 16m34s
2026-08-18 21:53:02 +02:00
Alex Emmet
3395b91ad1
Merge remote-tracking branch 'refs/remotes/origin/master'
Some checks failed
CI / checks (push) Failing after 2m20s
2026-08-18 20:59:06 +02:00
Alex Emmet
a7e804c603
[Fix] Harden MTP codec, transport, and SDK security 2026-08-18 20:58:01 +02:00
113a54048f Merge pull request 'Update Rust crate tokio-stream to v0.1.19' (#21) from renovate/tokio-stream-0.x-lockfile into master
All checks were successful
CI / checks (push) Successful in 7m47s
2026-08-14 17:00:34 +03:00
bd187106a1 Update Rust crate tokio-stream to v0.1.19
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
CI / checks (pull_request) Successful in 11m23s
2026-08-14 16:00:47 +03:00
Alex Emmet
188caf56cc [Fix] Clean
All checks were successful
CI / checks (push) Successful in 4m36s
2026-08-14 14:39:09 +02:00
Alex Emmet
e939714557 [Fix] Version Bump, Checks, tests CQ
Some checks failed
CI / checks (push) Failing after 2m54s
2026-08-13 21:18:55 +02:00
3c09493b00
Merge branch 'master' of ssh://git.methanium.net/methanium/mtp
Some checks failed
CI / checks (push) Failing after 2m18s
2026-08-12 22:47:48 +02:00
7f0231e3f1
[WIP] Security work While on holiday 2026-08-12 22:45:28 +02:00
b067614a68 Merge pull request 'Update Rust crate thiserror to v2' (#19) from renovate/thiserror-2.x into master
Some checks failed
CI / checks (push) Failing after 7m8s
2026-08-06 20:03:04 +02:00
0c08678d30 Merge pull request 'Update Rust crate ed25519-dalek to v3' (#18) from renovate/ed25519-dalek-3.x into master
Some checks failed
CI / checks (push) Has been cancelled
2026-08-06 20:03:03 +02:00
7adc13adb1 Merge pull request 'Update https://data.forgejo.org/actions/checkout action to v7' (#17) from renovate/https-data.forgejo.org-actions-checkout-7.x into master
Some checks failed
CI / checks (push) Has been cancelled
2026-08-06 20:03:00 +02:00
7f95b2356f Merge pull request 'Update dependency typescript to v7' (#16) from renovate/typescript-7.x into master
Some checks failed
CI / checks (push) Has been cancelled
2026-08-06 20:02:59 +02:00
cb509142eb Update Rust crate thiserror to v2
Some checks failed
renovate/artifacts Artifact file update failure
CI / checks (pull_request) Failing after 2m14s
renovate/stability-days Updates have met minimum release age requirement
2026-08-06 05:02:35 +02:00
b176f8d813 Update Rust crate ed25519-dalek to v3
Some checks failed
renovate/artifacts Artifact file update failure
CI / checks (pull_request) Failing after 5m10s
renovate/stability-days Updates have met minimum release age requirement
2026-08-06 04:02:37 +02:00
4cc18ef1dd Update https://data.forgejo.org/actions/checkout action to v7
Some checks failed
CI / checks (pull_request) Failing after 5m14s
renovate/stability-days Updates have met minimum release age requirement
2026-08-06 04:02:32 +02:00
103f5f9773 Update dependency typescript to v7
Some checks failed
renovate/artifacts Artifact file update failure
CI / checks (pull_request) Failing after 3m1s
renovate/stability-days Updates have met minimum release age requirement
2026-08-06 03:02:50 +02:00
7182272e3e Merge pull request 'Update Rust crate base64 to 0.23' (#10) from renovate/base64-0.x into master
Some checks failed
CI / checks (push) Failing after 2m26s
2026-08-06 02:10:40 +02:00
190a5e2f12 Merge pull request 'Update Rust crate getrandom to 0.4.0' (#12) from renovate/getrandom-0.x into master
Some checks failed
CI / checks (push) Has been cancelled
2026-08-06 02:10:39 +02:00
9fc6bf9e24 Merge pull request 'Update Rust crate http to v1.5.0' (#13) from renovate/http-1.x-lockfile into master
Some checks failed
CI / checks (push) Has been cancelled
2026-08-06 02:10:38 +02:00
1d010bc7a3 Merge pull request 'Update Rust crate rand to 0.10' (#14) from renovate/rand-0.x into master
Some checks failed
CI / checks (push) Has been cancelled
2026-08-06 02:10:37 +02:00
5effb763f0 Update Rust crate rand to 0.10
All checks were successful
CI / checks (pull_request) Successful in 7m57s
2026-08-06 02:02:36 +02:00
b4cd71411b Update Rust crate http to v1.5.0
All checks were successful
CI / checks (pull_request) Successful in 8m24s
2026-08-06 02:02:34 +02:00
5a030073a8 Update Rust crate getrandom to 0.4.0
Some checks failed
renovate/artifacts Artifact file update failure
CI / checks (pull_request) Failing after 6m57s
2026-08-06 01:04:05 +02:00
2809435304 Update Rust crate base64 to 0.23
Some checks failed
CI / checks (pull_request) Failing after 5m7s
2026-08-06 00:02:57 +02:00
e8b7c433e6 Merge pull request 'Update Rust crate rustls to v0.23.43' (#5) from renovate/rustls-0.x-lockfile into master
Some checks failed
CI / checks (push) Failing after 3m39s
2026-08-05 23:09:41 +02:00
f6db53925e Merge pull request 'Update Rust crate serde to v1.0.229' (#6) from renovate/serde-1.x-lockfile into master
Some checks failed
CI / checks (push) Has been cancelled
2026-08-05 23:09:39 +02:00
602a98a818 Merge pull request 'Update Rust crate serde_json to v1.0.151' (#7) from renovate/serde_json-1.x-lockfile into master
Some checks failed
CI / checks (push) Has been cancelled
2026-08-05 23:09:38 +02:00
a856ee1717 Merge pull request 'Update Rust crate tokio to v1.53.1' (#8) from renovate/tokio-1.x-lockfile into master
Some checks failed
CI / checks (push) Has been cancelled
2026-08-05 23:09:35 +02:00
07c9837a69 Update Rust crate tokio to v1.53.1
All checks were successful
CI / checks (pull_request) Successful in 7m19s
2026-08-05 23:02:14 +02:00
50c35dc710 Update Rust crate serde_json to v1.0.151
Some checks failed
CI / checks (pull_request) Failing after 5m1s
2026-08-05 23:02:11 +02:00
02df350b55 Update Rust crate serde to v1.0.229
Some checks failed
CI / checks (pull_request) Failing after 5m13s
2026-08-05 22:01:56 +02:00
d75eb49b70 Update Rust crate rustls to v0.23.43
Some checks failed
CI / checks (pull_request) Failing after 5m14s
2026-08-05 22:01:53 +02:00
a81ac4efca Merge pull request 'Configure Renovate' (#2) from renovate/configure into master
Some checks failed
CI / checks (push) Failing after 9m54s
Reviewed-on: #2
2026-08-05 20:17:08 +02:00
a46583d1fd Add renovate.json
All checks were successful
CI / checks (pull_request) Successful in 13m55s
2026-08-05 20:16:23 +02:00
d10266198d
Revert "(fix): disable QUIC GSO on Android"
All checks were successful
CI / checks (push) Successful in 9m47s
This reverts commit 8c0c39fe92.
2026-08-05 16:25:01 +02:00
8e22962f57
Revert "(fix): prefer IPv4 DNS on Android"
This reverts commit 88e5851ba2.
2026-08-05 16:24:50 +02:00
88e5851ba2
(fix): prefer IPv4 DNS on Android
Some checks failed
CI / checks (push) Failing after 3m39s
2026-08-05 14:35:27 +02:00
8c0c39fe92
(fix): disable QUIC GSO on Android
Some checks failed
CI / checks (push) Failing after 3m41s
2026-08-05 14:28:36 +02:00
a692bed326
Apply disabled server idle timeout
All checks were successful
CI / checks (push) Successful in 5m53s
2026-07-29 01:22:49 +02:00
c3fe269dc6
Keep idle streams from closing connections
Some checks failed
CI / checks (push) Failing after 7m16s
2026-07-29 01:01:53 +02:00
8da4bc57ba
Wake receivers when connections close
All checks were successful
CI / checks (push) Successful in 6m4s
2026-07-29 00:26:25 +02:00
6f673ba7f2
Fix ping tracker Clippy warning
All checks were successful
CI / checks (push) Successful in 7m14s
2026-07-28 22:57:08 +02:00
b6483b7f6d
Fix native ping connection lifecycle
Some checks failed
CI / checks (push) Failing after 2m49s
2026-07-28 22:26:45 +02:00
590810ce59
Merge branch 'master' of ssh://git.methanium.net/methanium/mtp
All checks were successful
CI / checks (push) Successful in 5m56s
2026-07-28 19:57:27 +02:00
cab2cd7a52
[Fix] Syncronized Webserver & Host behaviour, Fixed the 10 sec default wait on auth 2026-07-28 18:49:40 +02:00
fa271e62be
Expose native ping RTT in WASM SDK
All checks were successful
CI / checks (push) Successful in 6m41s
2026-07-28 02:46:41 +02:00
bcf8aee371
(fix) vers
All checks were successful
CI / checks (push) Successful in 6m12s
2026-07-27 22:24:03 +02:00
465a0f338a
(fix): tests
All checks were successful
CI / checks (push) Successful in 6m56s
2026-07-27 21:48:54 +02:00
10c862de59
(fix): broken connections
Some checks failed
CI / checks (push) Failing after 5m37s
2026-07-27 21:37:41 +02:00
Alex-Emmet
88ae866b91 [Fix] Allowing for a following / on paths
Some checks failed
CI / checks (push) Failing after 3m17s
2026-07-23 23:54:11 +02:00
Alex Emmet
00f0aaeeff [Add] TCP server to core MTP (HTTP/1.1 & HTTP/2) compatibility
All checks were successful
CI / checks (push) Successful in 5m29s
2026-07-21 00:43:00 +02:00
Alex Emmet
04760fd88d [Add] Ip tracking
All checks were successful
CI / checks (push) Successful in 5m27s
2026-07-20 01:39:27 +02:00
Alex Emmet
cf3cccd3ca [Add] Dynamic routes through path parameters
All checks were successful
CI / checks (push) Successful in 5m22s
2026-07-19 23:48:32 +02:00
Alex Emmet
3919eb46fd [Fix] Wasm & Webserver
All checks were successful
CI / checks (push) Successful in 5m28s
2026-07-19 20:16:17 +02:00
Alex Emmet
1b796d0ce7 Brought Example up to spec
Some checks failed
CI / checks (push) Failing after 3m29s
2026-07-19 02:01:58 +02:00
Alex Emmet
6e5c985719 General Upgrade, NEW: WebServers, Better Docs
Some checks failed
CI / checks (push) Failing after 5m18s
2026-07-18 14:48:21 +02:00
Alex Emmet
5f11d476b6 [Clean] safer unwrap & except handling
Some checks failed
CI / checks (push) Failing after 1m51s
2026-07-15 19:11:01 +02:00
Alex Emmet
6a65e43ca9 [Fix] Pipes...
Some checks failed
CI / checks (push) Failing after 1m52s
2026-07-15 03:41:54 +02:00
Alex Emmet
3d757e00f2 Formating
Some checks failed
CI / checks (push) Failing after 2m22s
2026-07-15 01:59:11 +02:00
Alex Emmet
203ef1adcc format
Some checks failed
CI / checks (push) Failing after 2m23s
2026-07-15 01:53:48 +02:00
Alex Emmet
089def45d1 [Add] Pipes (experimental)
Some checks failed
CI / checks (push) Failing after 1m51s
2026-07-15 01:42:54 +02:00
Alex Emmet
69be9f7aca [Fix] export Sendmode in Host (not a stream feat)
Some checks failed
CI / checks (push) Failing after 1m53s
2026-07-14 01:59:41 +02:00
Alex Emmet
a6dd73a41f [add] ping jitter, receiver backpressure, stream frame limits
Some checks failed
CI / checks (push) Failing after 1m50s
2026-07-14 01:55:31 +02:00
Alex Emmet
c148314742 [WIP] Pings, Pongs & Streams
Some checks failed
CI / checks (push) Failing after 1m38s
2026-07-14 00:14:53 +02:00
Alex Emmet
2126a142f4 insecure clients
Some checks failed
CI / checks (push) Failing after 1m51s
2026-07-06 21:13:34 +02:00
40e942337f (fix): thing
All checks were successful
CI / checks (push) Successful in 4m50s
2026-07-06 15:58:33 +02:00
515244ce66 (fix): build stuff
All checks were successful
CI / checks (push) Successful in 5m38s
2026-07-05 22:08:32 +02:00
e1fcb90e19 (feat): crypto migrations
Some checks failed
CI / checks (push) Failing after 6m9s
2026-07-05 21:45:43 +02:00
13432c1ac2 (feat): add function to easily split keyring
Some checks failed
CI / checks (push) Failing after 3m23s
2026-07-05 11:59:35 +02:00
fdc694bdcd (fix): formatting issue
All checks were successful
CI / checks (push) Successful in 6m27s
2026-07-05 01:16:45 +02:00
3e12257cf3 (feat): add crypto stuff to ts-sdk
Some checks failed
CI / checks (push) Failing after 1m58s
2026-07-05 01:11:21 +02:00
20cbb45743 (fix): some ts-sdk stuff
All checks were successful
CI / checks (push) Successful in 4m45s
2026-07-05 00:23:27 +02:00
2e7c0b4893 (fix): obscure rust wasm error
All checks were successful
CI / checks (push) Successful in 4m58s
2026-07-04 15:36:50 +02:00
c7a57a851e (fix): rust errors
All checks were successful
CI / checks (push) Successful in 6m2s
2026-07-04 14:22:05 +02:00
c1761aae2b (feat): improve wasm/ts-sdk logger
All checks were successful
CI / checks (push) Successful in 6m9s
(fix): wasm rust runtime error
(qol): update comments
2026-07-04 14:06:03 +02:00
92f1190b11 (fix): STOP_SENDING in wasm
All checks were successful
CI / checks (push) Successful in 6m7s
2026-07-04 13:35:45 +02:00
0bb3f607c7 (fix): STOP_SENDING
Some checks failed
CI / checks (push) Has been cancelled
2026-07-04 13:33:13 +02:00
a4447ebce8 (fix): builds
All checks were successful
CI / checks (push) Successful in 4m46s
2026-07-04 12:47:46 +02:00
4d75f23a17 (fix): builds
Some checks failed
CI / checks (push) Failing after 3m15s
2026-07-04 12:42:18 +02:00
870b8002d2 (fix): formatting
Some checks failed
CI / checks (push) Failing after 2m20s
2026-07-04 03:04:16 +02:00
391f92c9c5 (fix): STOP_SENDING && git push
Some checks failed
CI / checks (push) Failing after 1m50s
2026-07-04 02:52:30 +02:00
Alex Emmet
b96c072a0f keynames
Some checks failed
CI / checks (push) Failing after 2m21s
2026-07-03 19:37:06 +02:00
Alex Emmet
0679a241c2 Client Policy
Some checks failed
CI / checks (push) Failing after 2m19s
2026-07-03 19:00:23 +02:00
Alex Emmet
75f4139dea mk & mpkb files
Some checks failed
CI / checks (push) Failing after 2m24s
2026-07-03 18:56:54 +02:00
Alex Emmet
44ff1d8781 [Fix] Correct exports 2
Some checks failed
CI / checks (push) Failing after 2m21s
2026-07-03 17:47:44 +02:00
Alex Emmet
0967120e3b [Fix] Correct exports
Some checks failed
CI / checks (push) Failing after 2m20s
2026-07-03 17:45:22 +02:00
Alex Emmet
b78cf3f82b [Add] Policy derives
Some checks failed
CI / checks (push) Failing after 2m23s
2026-07-03 17:42:47 +02:00
Alex Emmet
7ffc1ab3fe [Add] host with policy
Some checks failed
CI / checks (push) Has been cancelled
2026-07-03 17:40:46 +02:00
Alex Emmet
bed73a82f1 [Add] Sender & Receiver Exports
Some checks failed
CI / checks (push) Has been cancelled
2026-07-03 17:37:53 +02:00
Alex Emmet
54a3ef4f11 [Fix] host crypto
Some checks failed
CI / checks (push) Failing after 1m51s
2026-07-03 17:33:33 +02:00
Alex Emmet
099073dc27 [Fix] Swapped from User to client
Some checks failed
CI / checks (push) Failing after 2m18s
2026-07-03 17:00:13 +02:00
Alex Emmet
b85cda505f exports
Some checks failed
CI / checks (push) Failing after 2m17s
2026-07-03 16:56:34 +02:00
Alex Emmet
efe5cca9bb getFunctions
Some checks failed
CI / checks (push) Failing after 2m20s
2026-07-03 16:51:27 +02:00
Alex Emmet
eff96ed72f Merge remote-tracking branch 'refs/remotes/origin/master'
Some checks failed
CI / checks (push) Failing after 3m19s
2026-07-03 16:43:47 +02:00
Alex Emmet
912aa9491c CryptoUtil 2026-07-03 16:43:29 +02:00
8dc8b54b26 (feat): add codec helpers to ts-sdk
All checks were successful
CI / checks (push) Successful in 8m38s
2026-07-03 14:45:55 +02:00
c3fbeb2e78 (feat): add descriptor to wasm & ts-sdk
All checks were successful
CI / checks (push) Successful in 7m16s
2026-07-02 23:58:40 +02:00
8ae8377662 (feat): add auth() function
All checks were successful
CI / checks (push) Successful in 4m52s
(fix): wasm error
2026-07-02 23:38:38 +02:00
Alex Emmet
1efcb8837c Format
All checks were successful
CI / checks (push) Successful in 4m44s
2026-07-02 23:26:44 +02:00
Alex Emmet
bbb9a2d6c2 Merge remote-tracking branch 'refs/remotes/origin/master'
Some checks failed
CI / checks (push) Failing after 1m49s
2026-07-02 23:22:32 +02:00
Alex Emmet
30f2a82145 Crypto Readme 2026-07-02 23:22:26 +02:00
1b60ccfe43 Merge remote-tracking branch 'origin/master'
Some checks failed
CI / checks (push) Failing after 1m51s
2026-07-02 23:12:37 +02:00
f354fe8fd6 (feat): improve ts-sdk types 2026-07-02 23:11:42 +02:00
Alex Emmet
5bfcccc056 Desctiption-Docs
Some checks failed
CI / checks (push) Failing after 1m51s
2026-07-02 20:21:12 +02:00
Alex Emmet
56903049b6 [Add] Description
Some checks failed
CI / checks (push) Failing after 1m55s
[Add] AuthenticationPolicy on host
2026-07-02 19:39:40 +02:00
c4a52c61b6 (fix): add jq
All checks were successful
CI / checks (push) Successful in 5m1s
2026-06-28 20:41:23 +02:00
6e993a58fb (fix): install sed
All checks were successful
CI / checks (push) Successful in 8m45s
2026-06-28 20:30:00 +02:00
38f8123264 (fix): workflow
All checks were successful
CI / checks (push) Successful in 5m59s
2026-06-28 20:18:42 +02:00
645e328e74 (fix): remove nix install step
All checks were successful
CI / checks (push) Successful in 7m6s
2026-06-28 20:06:37 +02:00
4857c36053 (fix): release workflow
Some checks failed
CI / checks (push) Has been cancelled
2026-06-28 20:05:38 +02:00
f4c5429828 (fix): workflow
All checks were successful
CI / checks (push) Successful in 7m9s
2026-06-28 19:50:43 +02:00
c77a4e0e2d (fix): workflows
Some checks failed
CI / checks (push) Has been cancelled
2026-06-28 19:50:12 +02:00
22137d77c9 (fix): ci workflow
Some checks failed
CI / checks (push) Has been cancelled
2026-06-28 19:45:55 +02:00
181 changed files with 52425 additions and 9247 deletions

View file

@ -1,5 +1,5 @@
[env] [env]
MTP_TYPE_MAPS = { value = "example-type-maps.yaml", relative = true } MTP_TYPE_MAPS = { value = "example/type-maps.yaml", relative = true }
# web-sys's WebTransport* bindings are behind unstable APIs, gated by this cfg. # web-sys's WebTransport* bindings are behind unstable APIs, gated by this cfg.
# Scoped to the wasm32 target so it applies to the wasm crate however cargo is # Scoped to the wasm32 target so it applies to the wasm crate however cargo is

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

View file

@ -7,7 +7,6 @@ on:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
NIX_CONFIG: experimental-features = nix-command flakes
jobs: jobs:
checks: checks:
@ -15,7 +14,7 @@ jobs:
runs-on: nixos runs-on: nixos
steps: steps:
- name: Checkout - name: Checkout
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v7
- name: Run checks - name: Run checks
run: | run: |
@ -30,12 +29,17 @@ jobs:
cargo machete cargo machete
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
pnpm run dup
RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack test --node wasm
RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack build wasm --target web RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack build wasm --target web
RUSTFLAGS="--cfg web_sys_unstable_apis" pnpm run build RUSTFLAGS="--cfg web_sys_unstable_apis" pnpm run build
pnpm --filter mtp-web-client run build pnpm --filter mtp-web-client run build
node test/e2ee.mjs
pnpm run test:secrets
pnpm run test:types
pnpm run test:boundary
( (
cd example cd example
export MTP_TYPE_MAPS="$PWD/type-maps.yaml" export MTP_TYPE_MAPS="$PWD/type-maps.yaml"

View file

@ -16,22 +16,13 @@ on:
jobs: jobs:
release: release:
runs-on: docker runs-on: nixos
steps: steps:
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install Packages
run: apt-get update && apt-get install -y sudo curl jq npm
- name: Install Nix
uses: https://github.com/cachix/install-nix-action@v30
- name: Install Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install

2
.gitignore vendored
View file

@ -5,3 +5,5 @@ node_modules/
dist/ dist/
*.tgz *.tgz
wasm/pkg/ wasm/pkg/
web_client/
.direnv

1392
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,8 @@ members = [
"host", "host",
"client", "client",
"wasm", "wasm",
"files",
"mtp-webserver",
] ]
# `wasm` is a wasm32-only crate: it relies on web-sys unstable APIs # `wasm` is a wasm32-only crate: it relies on web-sys unstable APIs
# (`--cfg=web_sys_unstable_apis`, set in wasm/.cargo/config.toml) and the # (`--cfg=web_sys_unstable_apis`, set in wasm/.cargo/config.toml) and the
@ -26,6 +28,8 @@ default-members = [
"transport", "transport",
"host", "host",
"client", "client",
"files",
"mtp-webserver",
] ]
resolver = "3" resolver = "3"
@ -41,27 +45,26 @@ resolver = "3"
# ============================================================================= # =============================================================================
[package] [package]
name = "mtp" name = "mtp"
version = "0.1.0" version = "0.3.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
# --- always-on core --- # --- always-on core ---
mtp-common = { version = "0.1.0", path = "common" } mtp-common = { version = "0.3.0", path = "common" }
mtp-type-map = { version = "0.1.0", path = "type-map" } mtp-type-map = { version = "0.3.0", path = "type-map" }
mtp-codec = { version = "0.1.0", path = "codec" } mtp-codec = { version = "0.3.0", path = "codec" }
mtp-transport = { version = "0.1.0", path = "transport" }
# --- optional, behind features --- # --- optional, behind features ---
mtp-crypto = { version = "0.1.0", path = "crypto", optional = true, features = [ mtp-crypto = { version = "0.3.0", path = "crypto", optional = true, features = [
"serde", "serde",
"mlkem-tls", "mlkem-tls",
] } ] }
mtp-host = { version = "0.1.0", path = "host", optional = true } mtp-host = { version = "0.3.0", path = "host", optional = true }
mtp-client = { version = "0.1.0", path = "client", optional = true } mtp-client = { version = "0.3.0", path = "client", optional = true }
mtp-files = { version = "0.3.0", path = "files", optional = true }
mtp-webserver = { version = "0.3.0", path = "mtp-webserver", optional = true }
mtp-transport = { version = "0.3.0", path = "transport", optional = true }
[features] [features]
default = []
# Serialization # Serialization
serde = ["mtp-crypto/serde"] serde = ["mtp-crypto/serde"]
@ -72,15 +75,43 @@ crypto = [
"mtp-codec/crypto", "mtp-codec/crypto",
"mtp-host?/crypto", "mtp-host?/crypto",
"mtp-client?/crypto", "mtp-client?/crypto",
"mtp-webserver?/crypto",
] ]
# MTP server host - version negotiation, Registry, incoming QUIC connections. # MTP server host - version negotiation, Registry, incoming QUIC connections.
host = ["dep:mtp-host", "mtp-codec/registry", "mtp-transport/host"] host = ["dep:mtp-host", "mtp-codec/registry", "transport"]
# MTP client - outgoing QUIC connections to a host. # MTP client - outgoing QUIC connections to a host.
client = ["dep:mtp-client"] client = ["dep:mtp-client", "transport"]
[dev-dependencies] # Direct access to the framed QUIC transport. Host/client features enable it
tokio = { version = "1", features = ["full"] } # automatically; this feature is useful for low-level integrations.
rcgen = "0.14" transport = ["dep:mtp-transport"]
rand = "0.8"
# Direct access to the pipes. Pipes can be used to send raw binary
# without after creation overhead.
pipes = ["mtp-common/pipes", "mtp-codec/pipes", "mtp-transport?/pipes", "mtp-host?/pipes", "mtp-client?/pipes", "mtp-webserver?/pipes"]
# On-disk storage for keyrings (`.mk`) and public key bundles (`.mpkb`).
# Pulls in `crypto` so the `Keyring` / `PublicKeyBundle` types are in scope.
files = ["dep:mtp-files", "crypto"]
# Development/migration-only access to the legacy plaintext keyring format.
# Production users should use the Argon2id-protected `.mk` APIs instead.
raw = ["mtp-files?/raw"]
# HTTP/3 routing and WebTransport-based MTP hosting on one QUIC endpoint.
web-server = ["dep:mtp-webserver", "dep:mtp-host", "mtp-codec/registry", "transport"]
# Complete native server surface.
full-server = ["host", "web-server", "crypto", "pipes"]
# Self-signed certificate generation helper (requires crypto).
tls = ["crypto", "mtp-crypto?/tls"]
# Insecure TLS certificate verification (development only).
# Requires MTP_INSECURE_TLS=1 at runtime.
insecure-tls = ["dep:mtp-transport", "mtp-transport?/insecure-tls"]
[package.metadata.cargo-machete]
ignored = ["mtp-transport"]

180
README.md
View file

@ -1,160 +1,108 @@
# Methanium Transport Protocol # Methanium Transport Protocol
MTP is a modular transport protocol built on QUIC. It provides version-negotiable type maps, a binary codec, cryptographic primitives (classical and post-quantum), and host/client connection management with mutual authentication. MTP is a modular transport protocol built on QUIC. It provides versioned type maps, a binary codec, native and browser clients, host and WebTransport server implementations, and optional cryptographic authentication and end-to-end encryption.
See the area-specific docs for [Native Client](./docs/NATIVE-CLIENT.md), [WASM Client](./docs/WASM-CLIENT.md), [Host](./docs/NATIVE-HOST.md), and [Type Maps](./docs/TYPE-MAP.md). Security assumptions, certificate handling, authentication, cryptographic primitives, key storage, and known limitations: [Security](./docs/SECURITY.md).
Area-specific guides:
- [Architecture](./docs/ARCHITECTURE.md)
- [Connection lifecycle](./docs/CONNECTIONS.md)
- [Native client](./docs/NATIVE-CLIENT.md)
- [WASM client](./docs/WASM-CLIENT.md)
- [Native host](./docs/NATIVE-HOST.md)
- [Web server](./docs/NATIVE-HOST-WEB-SERVER.md)
- [Connector and version negotiation](./docs/CONNECTOR.md)
- [Protocol reference](./docs/PROTOCOL-REFERENCE.md)
- [Type maps](./docs/TYPE-MAP.md)
- [Error reference](./docs/ERRORS.md)
- [Troubleshooting](./docs/TROUBLESHOOTING.md)
- [Operations](./docs/OPERATIONS.md)
`MTPWebServer` owns TCP TLS (HTTP/1.1 and HTTP/2) plus UDP QUIC (HTTP/3 and WebTransport) on one numeric port. It must not bind its UDP address and port as `MTPHost`: their QUIC ALPN protocols remain incompatible (`h3` for the web server, native MTP for the host).
## Browser SDK ## Browser SDK
The JavaScript package is `mtp`: The JavaScript package is `mtp`.
```typescript - `mtp` exports the SDK-first `MTPClient` API and codec helpers.
import { MTPClient } from "mtp"; - `mtp/raw` exports generated WASM bindings for lower-level integrations.
import { mtp } from "mtp/vite"; - `mtp/vite` exports the Vite integration that builds app-specific bindings.
``` - `mtp/type-map` exports generated TypeScript type-name unions.
Use `mtp` for the SDK-first API, `mtp/raw` for generated WASM bindings, and `mtp/vite` for the Vite integration. Use the [WASM client guide](./docs/WASM-CLIENT.md) for installation, type-map configuration, credentials, certificate pins, requests, subscriptions, pipes, and protocol pings. Use the SDK methods before raw bindings because the raw API does not provide the wrapper's validation, persistence, timeout, logging, or lifecycle handling.
```typescript The SDK exposes crypto helpers and frame codec helpers from the main `mtp` export. The security guide describes the available algorithms and their limitations.
// vite.config.ts
import { defineConfig } from "vite";
import { mtp } from "mtp/vite";
export default defineConfig({
plugins: [mtp({ typeMaps: "./type-maps.yaml" })],
});
```
```typescript
import { MTPClient, type MTPCredentials } from "mtp";
const credentialsStorageKey = "mtpCredentialsForMyApp";
function loadCredentials(): MTPCredentials {
const saved = localStorage.getItem(credentialsStorageKey);
if (saved) {
return JSON.parse(saved) as MTPCredentials;
}
return {
clientId: null,
keyring: MTPClient.crypto.generateKeyring(),
};
}
await MTPClient.init();
// Example-looking host public key bundle bytes. Replace this with the public
// key bundle published by your MTP host before connecting to a real service.
const hostPublicKey = Uint8Array.from({ length: 128 }, (_, index) => (
[0xb6, 0x4f, 0x7d, 0x9a, 0x3c, 0x21, 0xe8, 0x05][index % 8] ^ index
));
const client = await MTPClient.create({
url: "https://localhost:4433",
hostPublicKey,
credentials: loadCredentials(),
storage: window.localStorage, // Same API as localStorage for convenience
credentialsStorageKey,
pings: true,
logger: (event) => console.log("[MTP]: " + event),
});
client.subscribe("SomeType", (message) => console.log(message));
const clientId = client.credentials?.clientId == null
? await client.register()
: (await client.connect(), client.credentials.clientId);
await client.send("SomeType", { value: "hello" });
console.log("Connected MTP client", clientId, client.state);
```
`client.raw` exposes the lower-level WASM client and generated binding module for advanced integrations. Prefer the SDK methods unless you specifically need an API the wrapper does not expose; raw calls bypass SDK validation, credential persistence, logging, timeout handling, frame helpers, and lifecycle safeguards.
Use `MTPClient.crypto` for SDK-level crypto helpers such as `generateKeyring()`, `generateEd25519()`, `keyringFromEd25519()`, `verifyEd25519()`, `sha256()`, `sha256Double()`, `hkdfExpand()`, and `deriveEncryptionKey()`.
## Getting Started ## Getting Started
Add the `mtp` crate with your desired features: Add the umbrella crate with the features required by the application:
```toml ```toml
[dependencies] [dependencies]
mtp = { path = "..", features = ["client", "crypto"] } mtp = { path = "..", features = ["client", "crypto"] }
``` ```
The `mtp` umbrella crate re-exports all sub-crates behind feature flags: Feature summary:
| Feature | Pulls in | Enables | | Feature | Pulls in | Enables |
| -------- | --------------------------- | ----------------------------------------- | | --- | --- | --- |
| `crypto` | `mtp::crypto` | AEAD, signatures, KEM, KDF, hashing | | `serde` | Crypto serialization support | Serde implementations for crypto key types |
| `host` | `mtp::host`, `mtp::codec::registry` | QUIC server, version negotiation | | `crypto` | `mtp::crypto` | AEAD, signatures, KEM, KDF, hashing, and connection authentication support |
| `client` | `mtp::client` | QUIC client connections | | `host` | `mtp::host` | Native QUIC host and version negotiation |
| `client` | `mtp::client` | Native QUIC client connections |
| `transport` | `mtp-transport` dependency | Low-level transport support; enabled automatically by `host` and `client` |
| `pipes` | Pipe support in transport, host, client, and web server | Raw and encrypted byte streams |
| `files` | `mtp::files` | `.mk` keyrings and `.mpkb` public bundles; also enables `crypto` |
| `raw` | Raw file APIs | Legacy plaintext keyring migration APIs |
| `web-server` | `mtp::webserver` | HTTPS server with HTTP/1.1, HTTP/2, HTTP/3, and WebTransport MTP sessions |
| `full-server` | Native host and web-server surface | `host`, `web-server`, `crypto`, and `pipes` together |
| `tls` | `mtp::crypto::tls` | Development self-signed certificate generation |
| `insecure-tls` | Lower-level transport | Development-only certificate verification bypass, gated by `MTP_INSECURE_TLS=1` |
Core crates (`codec`, `transport`, `common`, `type_map`) are always available. The core modules always available from the facade are `codec`, `common`, and
`type_map`. Native `client` and `host` modules re-export the transport policy
```rust types; the low-level transport crate is not exposed as `mtp::transport`. See the [native client](./docs/NATIVE-CLIENT.md) and [native host](./docs/NATIVE-HOST.md)
use mtp::codec::{CommunicationValue, DataValue}; guides for configuration and usage. See [Security](./docs/SECURITY.md) for security boundaries.
use mtp::type_map::{CommunicationType, DataType, TypeMap};
use mtp::transport::{Sender, Receiver};
#[cfg(feature = "crypto")]
use mtp::crypto::ChaCha20Poly1305;
```
## Sub-crates ## Sub-crates
All sub-crates are re-exported through the `mtp` facade and can be referenced as `mtp::codec`, `mtp::transport`, `mtp::common`, `mtp::type_map`, `mtp::crypto`, `mtp::host`, `mtp::client`. The `mtp` facade re-exports the following modules:
`mtp::codec`, `mtp::common`, `mtp::type_map`, `mtp::crypto`, `mtp::host`,
`mtp::client`, `mtp::files`, and `mtp::webserver` when their features are enabled.
### Codec ### Codec
The codec crate handles binary encoding and decoding of MTP packets using Communication Types and Data Types resolved through the type-map registry. The codec encodes and decodes MTP frames using Communication Types and Data Types resolved through a version-specific type map. It supports self-delimiting containers, integers, booleans, floats, strings, arrays, bytes, null values, and composable `Signed<Value>` and `Encrypted<Value>` protection wrappers. Wrap in either order to choose whether signer metadata is public or encrypted. See [Type Map](./docs/TYPE-MAP.md) for mapping configuration and [Connector](./docs/CONNECTOR.md) for negotiated codecs.
**Data Value types:**
- Container (key-value map of typed entries)
- Encrypted Container (requires `crypto`)
- Signed Container (requires `crypto`)
- SignedEncrypted Container (requires `crypto`)
- Signed Integer (i128)
- Unsigned Integer (u128)
- Boolean
- Float (exponent + mantissa)
- String
- Array
- Bytes
- Null
Encoding and decoding use a `TypeMap` to resolve type names to wire IDs. The `CommunicationValue` struct provides the frame format (type, flags, optional id/sender/receiver, data payload, optional signature).
### Transport ### Transport
The transport crate wraps QUIC using `wtransport`. It provides `Sender`/`Receiver` for bidirectional message passing over uni-directional QUIC streams. Supports two send modes: persistent stream and single-stream-per-message. The transport crate wraps QUIC with `wtransport`. `Sender` and `Receiver` exchange framed messages over unidirectional streams. The transport supports a persistent stream and a single-stream-per-message mode, with configurable message limits, timeouts, queues, and stream concurrency.
Certificate verification is controlled by client configuration. Use system roots or explicit certificate and SPKI pinning for production. Development self-signed and insecure modes: [Security](./docs/SECURITY.md).
### Host ### Host
The host crate provides `MTPHost` with built-in version negotiation and optional authenticated login/registration (requires `crypto`). Accepts connections, negotiates protocol version, and returns `MTPConnection` handles. The host crate provides `MTPHost`, registry-backed version negotiation, optional authentication, and `MTPConnection` handles. Authentication policies and the challenge-response protocol: [Native Host](./docs/NATIVE-HOST.md) and [Security](./docs/SECURITY.md).
### Client ### Client
The client crate provides `MTPClient` that connects to an MTP host. Supports `connect` (unauthenticated), `auth_connect` (login), and `auth_register` (registration) when built with `crypto`. The native client provides unauthenticated connections, authenticated login, and registration when the `crypto` feature is enabled. See [Native Client](./docs/NATIVE-CLIENT.md).
The browser client uses the `mtp` SDK over WebTransport. See [WASM Client](./docs/WASM-CLIENT.md).
### Common ### Common
Common defines shared error types (`CodecError`, `CommunicationError`) used across all crates. Common defines shared errors such as `CodecError` and `CommunicationError`, as well as protocol-level types used by the other crates.
### Type Map ### Type Map
The type-map build script reads a YAML configuration to generate `CommunicationType` and `DataType` enums at compile time. The runtime crate provides `TypeMap`, `Version`, `CommunicationTypeId`, `DataTypeId`, and the multi-version `Registry` (requires `registry` feature). The type-map build script reads YAML and generates `CommunicationType` and `DataType` enums at compile time. The runtime crate provides `TypeMap`, `Version`, ID types, and the multi-version `Registry` when the registry feature is enabled. See [Type Map](./docs/TYPE-MAP.md).
### Crypto Stack ### Crypto
| Crate | Audited? | Notes | `mtp-crypto` provides AEAD encryption, Ed25519 and ML-DSA-65 signatures, X25519 plus ML-KEM-768 hybrid KEM support, HKDF, SHA-256, keyrings, composable protection envelopes, and certificate generation for development. Feature flags and security boundaries: [Security](./docs/SECURITY.md).
| ---------------- | -------- | ------------------------------------------------ |
| ml-dsa | No | NIST vectors pass; regression bug fixed Jan 2026 | ## Examples
| ed25519-dalek | Yes | Used by Signal, Diem |
| chacha20poly1305 | Yes | NCC Group audit, Dec 2019 | The [`example/`](./example/) workspace contains native client, native server, key-generation, WebTransport server, and browser client examples. The example server stores its generated development certificate and host keys locally; use the certificate pin it prints when connecting the example client.
| aes-gcm | Yes | NCC Group audit, Dec 2019 |
| hkdf | No | Simple construction; well-reviewed |
| sha2 | No | Standard construction; widely reviewed |
| zeroize | No | Simple; widely used |
| mlkem-tls | No | mlkem-rs backend unaudited |

2
client/Cargo.lock generated
View file

@ -4,4 +4,4 @@ version = 4
[[package]] [[package]]
name = "client" name = "client"
version = "0.1.0" version = "0.2.0"

View file

@ -1,15 +1,21 @@
[package] [package]
name = "mtp-client" name = "mtp-client"
version = "0.1.0" version = "0.3.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
mtp-common = { version = "0.1.0", path = "../common" } mtp-common = { version = "0.3.0", path = "../common" }
mtp-codec = { version = "0.1.0", path = "../codec" } mtp-codec = { version = "0.3.0", path = "../codec", features = ["registry"] }
mtp-transport = { version = "0.1.0", path = "../transport" } mtp-transport = { version = "0.3.0", path = "../transport" }
mtp-crypto = { version = "0.1.0", path = "../crypto", optional = true } mtp-crypto = { version = "0.3.0", path = "../crypto", optional = true }
rand = "0.8" rand = "0.10.1"
tokio = { version = "1", features = ["time"] } tokio = { version = "1", features = ["rt", "sync", "time"] }
[dev-dependencies]
mtp-host = { version = "0.3.0", path = "../host" }
mtp-transport = { version = "0.3.0", path = "../transport", features = ["host"] }
rcgen = "0.14"
[features] [features]
crypto = ["dep:mtp-crypto", "mtp-codec/crypto"] crypto = ["dep:mtp-crypto", "mtp-codec/crypto"]
pipes = ["mtp-common/pipes", "mtp-transport/pipes"]

115
client/src/config.rs Normal file
View file

@ -0,0 +1,115 @@
use tokio::time::Duration;
pub use mtp_transport::Policy;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientTlsConfig {
SystemRoots,
PinnedPem(Vec<u8>),
}
pub struct ClientConfig {
pub url: String,
pub tls: ClientTlsConfig,
pub client_id: u64,
pub description: Option<String>,
pub policy: Policy,
pub ping_interval: Duration,
pub ping_jitter: Option<Duration>,
pub max_missed_pings: usize,
pub ping_timestamp: bool,
pub request_timeout: Duration,
#[cfg(feature = "crypto")]
pub auth_timeout: Duration,
#[cfg(feature = "crypto")]
pub require_pq: bool,
}
impl ClientConfig {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
tls: ClientTlsConfig::SystemRoots,
client_id: 0,
description: None,
policy: Policy::default(),
ping_interval: Duration::ZERO,
ping_jitter: None,
max_missed_pings: 3,
ping_timestamp: true,
request_timeout: Duration::from_secs(30),
#[cfg(feature = "crypto")]
auth_timeout: Duration::from_secs(30),
#[cfg(feature = "crypto")]
require_pq: true,
}
}
pub fn with_tls(mut self, tls: ClientTlsConfig) -> Self {
self.tls = tls;
self
}
pub fn with_pinned_pem(self, cert_pem: Vec<u8>) -> Self {
self.with_tls(ClientTlsConfig::PinnedPem(cert_pem))
}
pub fn with_client_id(mut self, client_id: u64) -> Self {
self.client_id = client_id;
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_policy(mut self, policy: Policy) -> Self {
self.policy = policy;
self
}
pub fn with_ping_interval(mut self, interval: Duration) -> Self {
self.ping_interval = interval;
self
}
pub fn with_ping_jitter(mut self, jitter: Option<Duration>) -> Self {
self.ping_jitter = jitter;
self
}
pub fn with_max_missed_pings(mut self, max_missed_pings: usize) -> Self {
self.max_missed_pings = max_missed_pings;
self
}
pub fn with_ping_timestamp(mut self, ping_timestamp: bool) -> Self {
self.ping_timestamp = ping_timestamp;
self
}
pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
self.request_timeout = timeout;
self
}
#[cfg(feature = "crypto")]
pub fn with_auth_timeout(mut self, timeout: Duration) -> Self {
self.auth_timeout = timeout;
self
}
#[cfg(feature = "crypto")]
pub fn with_require_pq(mut self, require_pq: bool) -> Self {
self.require_pq = require_pq;
self
}
pub(crate) fn server_cert(&self) -> Option<Vec<u8>> {
match &self.tls {
ClientTlsConfig::SystemRoots => None,
ClientTlsConfig::PinnedPem(cert) => Some(cert.clone()),
}
}
}

307
client/src/connection.rs Normal file
View file

@ -0,0 +1,307 @@
use mtp_codec::{CommunicationValue, Version, registry::VersionedCodec};
#[cfg(feature = "pipes")]
use mtp_codec::{DataType, DataValue};
use mtp_common::CommunicationError;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
use tokio::time::Duration;
use crate::config::ClientConfig;
#[cfg(feature = "crypto")]
use crate::error::AuthState;
use crate::ping::{PingSession, start_ping_session};
#[cfg(feature = "pipes")]
use crate::pipe::PipeRequest;
#[cfg(feature = "pipes")]
use crate::pipe::is_expired_creation;
#[cfg(feature = "pipes")]
use crate::pipe::{PendingCreation, PendingCreationGuard};
use crate::pipe::{PendingRequest, PipeDispatcher, run_dispatcher};
pub struct MTPConnection {
pub version: Version,
pub codec: VersionedCodec,
pub sender: mtp_transport::Sender,
pub receiver: mtp_transport::Receiver,
pub description: Option<String>,
/// The peer address observed by the underlying QUIC connection.
pub remote_addr: Option<SocketAddr>,
pub(crate) ping: Option<PingSession>,
pub(crate) app_rx: Mutex<mpsc::Receiver<Result<CommunicationValue, CommunicationError>>>,
#[cfg(feature = "pipes")]
pub(crate) pipe_req_rx: Mutex<mpsc::Receiver<PipeRequest>>,
pub(crate) pipe_dispatcher: Arc<PipeDispatcher>,
pub(crate) request_timeout: Duration,
pub(crate) _dispatcher_task: tokio::task::JoinHandle<()>,
#[cfg(feature = "crypto")]
pub auth_state: AuthState,
#[cfg(feature = "crypto")]
pub client_id: u64,
}
impl MTPConnection {
pub fn get_ping(&self) -> Option<Duration> {
self.ping.as_ref().and_then(PingSession::get_ping)
}
pub async fn request(
&self,
request: &CommunicationValue,
expected_response: Option<mtp_codec::CommunicationType>,
) -> Result<CommunicationValue, CommunicationError> {
let request_id = request
.id()
.ok_or_else(|| CommunicationError::Other("request frame must contain an id".into()))?;
if request_id == 0 {
return Err(CommunicationError::Other(
"request frame must have a non-zero id".into(),
));
}
if crate::pipe::is_expired_request(&self.pipe_dispatcher, request_id).await {
return Err(CommunicationError::Other(format!(
"request id {request_id} recently timed out; use a new request id"
)));
}
let (sender, receiver) = tokio::sync::oneshot::channel();
let token = Arc::new(());
{
let mut pending = self.pipe_dispatcher.pending_requests.lock().await;
if pending.contains_key(&request_id) {
return Err(CommunicationError::Other(format!(
"request id {request_id} is already pending"
)));
}
pending.insert(
request_id,
PendingRequest {
token: token.clone(),
sender,
},
);
}
let response = match tokio::time::timeout(self.request_timeout, async {
self.sender.send(request).await?;
receiver
.await
.map_err(|_| CommunicationError::StreamClosed)?
})
.await
{
Ok(result) => {
if result.is_err() {
crate::pipe::remove_pending_request(&self.pipe_dispatcher, request_id, &token)
.await;
}
result?
}
Err(_) => {
crate::pipe::expire_pending_request(&self.pipe_dispatcher, request_id, &token)
.await;
return Err(CommunicationError::Other(format!(
"request {request_id} timed out after {:?}",
self.request_timeout
)));
}
};
if let Some(expected) = expected_response {
let expected_type = expected.try_to_id(self.codec.type_map());
if Some(response.get_type()) != expected_type {
return Err(CommunicationError::Other(format!(
"unexpected response type: expected {:?}, got {:?}; parsed {}",
expected_type,
response.get_type(),
response
)));
}
}
Ok(response)
}
pub async fn receive(&self) -> Result<CommunicationValue, CommunicationError> {
let mut rx = self.app_rx.lock().await;
match rx.recv().await {
Some(result) => result,
None => Err(CommunicationError::StreamClosed),
}
}
}
#[cfg(feature = "pipes")]
impl MTPConnection {
pub async fn create_pipe(
&self,
description: &str,
) -> Result<crate::pipe::PipeHandle, mtp_common::PipeError> {
let (tx, rx) = tokio::sync::oneshot::channel();
let token = Arc::new(());
let pipe_id = {
let mut pending = self
.pipe_dispatcher
.pending_creations
.lock()
.map_err(|_| mtp_common::PipeError::ConnectionClosed)?;
let pipe_id = loop {
let candidate = rand::random::<u32>();
if candidate != 0
&& !pending.contains_key(&candidate)
&& !is_expired_creation(&self.pipe_dispatcher, candidate)
{
break candidate;
}
};
pending.insert(
pipe_id,
PendingCreation {
token: token.clone(),
sender: tx,
},
);
pipe_id
};
let mut creation_guard =
PendingCreationGuard::new(self.pipe_dispatcher.clone(), pipe_id, token.clone());
let request = CommunicationValue::new_with_type_map(
mtp_codec::CommunicationType::PipeRequest,
self.codec.type_map(),
)
.with_id(pipe_id)
.add_typed_default(DataType::Description, DataValue::Str(description.into()));
if let Err(error) = self.sender.send(&request).await {
return Err(mtp_common::PipeError::from(error));
}
creation_guard.disarm();
Ok(crate::pipe::PipeHandle {
pipe_id,
description: description.to_string(),
sender: self.sender.clone(),
response_rx: rx,
dispatcher: self.pipe_dispatcher.clone(),
token,
})
}
pub async fn receive_pipe(&self) -> Result<PipeRequest, CommunicationError> {
let mut rx = self.pipe_req_rx.lock().await;
match rx.recv().await {
Some(req) => Ok(req),
None => Err(CommunicationError::StreamClosed),
}
}
}
pub(crate) async fn connection_from_parts(
config: ClientConfig,
sender: mtp_transport::Sender,
receiver: mtp_transport::Receiver,
version: Version,
codec: VersionedCodec,
#[cfg(feature = "crypto")] auth_state: AuthState,
#[cfg(feature = "crypto")] client_id: u64,
) -> MTPConnection {
#[cfg(feature = "pipes")]
let type_map = codec.type_map().clone();
receiver.set_type_map(codec.type_map()).await;
let remote_addr = sender.handle().remote_addr();
#[cfg(feature = "crypto")]
let ping_client_id = client_id;
#[cfg(not(feature = "crypto"))]
let ping_client_id = config.client_id;
let ping = start_ping_session(
&config,
sender.clone(),
&receiver,
codec.type_map(),
ping_client_id,
)
.await;
#[cfg(feature = "pipes")]
{
let receiver_queue_capacity = config.policy.receiver_queue_capacity.max(1);
let (app_tx, app_rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(
receiver_queue_capacity,
);
let (pipe_req_tx, pipe_req_rx) = mpsc::channel::<PipeRequest>(receiver_queue_capacity);
let dispatcher = Arc::new(PipeDispatcher {
pending_requests: Mutex::new(std::collections::HashMap::new()),
expired_requests: Mutex::new(std::collections::HashMap::new()),
#[cfg(feature = "pipes")]
type_map: type_map.clone(),
pending_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
expired_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
pending_pipes: Mutex::new(std::collections::HashMap::new()),
policy: Arc::new(config.policy),
});
let dispatcher_clone = dispatcher.clone();
let sender_clone = sender.clone();
let dispatcher_task = tokio::spawn(run_dispatcher(
receiver.clone(),
sender_clone,
app_tx,
pipe_req_tx,
dispatcher_clone,
));
MTPConnection {
version,
codec,
sender,
receiver,
app_rx: Mutex::new(app_rx),
pipe_req_rx: Mutex::new(pipe_req_rx),
pipe_dispatcher: dispatcher,
request_timeout: config.request_timeout,
description: config.description,
remote_addr,
ping,
_dispatcher_task: dispatcher_task,
#[cfg(feature = "crypto")]
auth_state,
#[cfg(feature = "crypto")]
client_id,
}
}
#[cfg(not(feature = "pipes"))]
{
let receiver_queue_capacity = config.policy.receiver_queue_capacity.max(1);
let (app_tx, app_rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(
receiver_queue_capacity,
);
let dispatcher = Arc::new(PipeDispatcher {
pending_requests: Mutex::new(std::collections::HashMap::new()),
expired_requests: Mutex::new(std::collections::HashMap::new()),
#[cfg(feature = "pipes")]
type_map,
});
let task = tokio::spawn(run_dispatcher(receiver.clone(), app_tx, dispatcher.clone()));
MTPConnection {
version,
codec,
sender,
receiver,
app_rx: Mutex::new(app_rx),
pipe_dispatcher: dispatcher,
request_timeout: config.request_timeout,
description: config.description,
remote_addr,
ping,
_dispatcher_task: task,
#[cfg(feature = "crypto")]
auth_state,
#[cfg(feature = "crypto")]
client_id,
}
}
}

241
client/src/crypto.rs Normal file
View file

@ -0,0 +1,241 @@
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, Version};
use mtp_common::CommunicationError;
pub(crate) fn unexpected_response_type_error(
context: &str,
expected_type: mtp_codec::CommunicationTypeId,
response: &CommunicationValue,
) -> CommunicationError {
CommunicationError::AuthenticationFailed(format!(
"unexpected response type during {context}: expected {:?}, got {:?}; parsed {}",
expected_type,
response.get_type(),
response
))
}
pub(crate) async fn verify_host_challenge(
challenge: &CommunicationValue,
host_pk: &mtp_crypto::PublicKeyBundle,
id: u64,
server_challenge: u128,
require_pq: bool,
) -> Result<(), CommunicationError> {
use mtp_crypto::{auth, verify_ed25519};
let sig = match challenge.get_data(DataType::Signature) {
Some(DataValue::Bytes(b)) => b.clone(),
_ => {
return Err(CommunicationError::AuthenticationFailed(
"Missing host challenge signature".into(),
));
}
};
let pq_sig = match challenge.get_data(DataType::PqSignature) {
Some(DataValue::Bytes(b)) => b.clone(),
_ => vec![],
};
let host_requires_pq = challenge.get_data(DataType::RequirePq) == Some(&DataValue::BoolTrue);
if host_requires_pq && host_pk.sig_pq_public_key.as_bytes().is_empty() {
return Err(CommunicationError::AuthenticationFailed(
"Host requires post-quantum authentication but its PQ public key is absent".into(),
));
}
if require_pq && pq_sig.is_empty() {
return Err(CommunicationError::AuthenticationFailed(
"Host challenge is missing the required PQ signature".into(),
));
}
let payload = auth::challenge_payload(id, server_challenge);
if pq_sig.is_empty() {
verify_ed25519(&host_pk.sig_cl_public_key, &payload, &sig).map_err(|_| {
CommunicationError::AuthenticationFailed("Host challenge signature invalid".into())
})?;
} else {
mtp_crypto::sign_parallel::verify_dual_parallel(
host_pk.sig_cl_public_key.clone(),
host_pk.sig_pq_public_key.clone(),
payload,
sig,
pq_sig,
)
.await
.map_err(|_| {
CommunicationError::AuthenticationFailed("Host challenge signature invalid".into())
})?;
}
Ok(())
}
pub(crate) async fn verify_host_final(
response: &CommunicationValue,
host_pk: &mtp_crypto::PublicKeyBundle,
id: u64,
client_nonce: u128,
server_challenge: u128,
require_pq: bool,
) -> Result<(), CommunicationError> {
use mtp_crypto::{auth, verify_ed25519};
match response.get_data(DataType::ClientNonce) {
Some(DataValue::UnsignedNumber(n)) if *n == client_nonce => {}
_ => {
return Err(CommunicationError::AuthenticationFailed(
"Nonce mismatch".into(),
));
}
}
let sig = match response.get_data(DataType::Signature) {
Some(DataValue::Bytes(b)) => b.clone(),
_ => {
return Err(CommunicationError::AuthenticationFailed(
"Missing signature".into(),
));
}
};
let pq_sig = match response.get_data(DataType::PqSignature) {
Some(DataValue::Bytes(b)) => b.clone(),
_ => vec![],
};
if require_pq && pq_sig.is_empty() {
return Err(CommunicationError::AuthenticationFailed(
"Host confirmation is missing the required PQ signature".into(),
));
}
let payload = auth::host_final_payload(id, client_nonce, server_challenge);
if pq_sig.is_empty() {
verify_ed25519(&host_pk.sig_cl_public_key, &payload, &sig).map_err(|_| {
CommunicationError::AuthenticationFailed("Host signature invalid".into())
})?;
} else {
mtp_crypto::sign_parallel::verify_dual_parallel(
host_pk.sig_cl_public_key.clone(),
host_pk.sig_pq_public_key.clone(),
payload,
sig,
pq_sig,
)
.await
.map_err(|_| CommunicationError::AuthenticationFailed("Host signature invalid".into()))?;
}
Ok(())
}
pub(crate) fn check_connected(
response: &CommunicationValue,
reject_msg: &str,
) -> Result<(), CommunicationError> {
match response.get_data(DataType::Connected) {
Some(DataValue::BoolTrue) => Ok(()),
Some(DataValue::BoolFalse) => Err(CommunicationError::AuthenticationFailed(
response
.get_str(DataType::ErrorMessage)
.unwrap_or(reject_msg)
.into(),
)),
_ => Err(CommunicationError::AuthenticationFailed(
"Invalid response".into(),
)),
}
}
pub(crate) fn negotiated_version(
response: &CommunicationValue,
) -> Result<Version, CommunicationError> {
match response.get_data(DataType::Version) {
Some(DataValue::Str(version)) => Version::parse(version).ok_or_else(|| {
CommunicationError::AuthenticationFailed(
"Host returned an invalid negotiated protocol version".into(),
)
}),
_ => Err(CommunicationError::AuthenticationFailed(
"Host omitted the negotiated protocol version".into(),
)),
}
}
pub(crate) async fn signed_challenge_response(
keys: &mtp_crypto::Keyring,
proof_payload: Vec<u8>,
client_nonce: u128,
type_map: &TypeMap,
) -> Result<CommunicationValue, CommunicationError> {
use mtp_crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme};
let signer = Ed25519Signer::new(&keys.sig_cl_secret_key)
.map_err(|e| CommunicationError::Other(e.to_string()))?;
let mut proof =
CommunicationValue::new_with_type_map(CommunicationType::ChallengeResponse, type_map)
.add_typed_default(
DataType::ClientNonce,
DataValue::UnsignedNumber(client_nonce),
);
if keys.sig_pq_secret_key.as_bytes().is_empty() {
let signature = signer
.sign(&proof_payload)
.map_err(|e| CommunicationError::Other(e.to_string()))?;
proof = proof.add_typed_default(DataType::Signature, DataValue::Bytes(signature));
} else {
let pq_signer = MlDsaSigner::new(&keys.sig_pq_secret_key, &keys.sig_pq_public_key)
.map_err(|e| CommunicationError::Other(e.to_string()))?;
let (signature, pq_signature) =
mtp_crypto::sign_parallel::sign_dual_parallel(signer, pq_signer, proof_payload)
.await
.map_err(|e| CommunicationError::Other(e.to_string()))?;
proof = proof
.add_typed_default(DataType::Signature, DataValue::Bytes(signature))
.add_typed_default(DataType::PqSignature, DataValue::Bytes(pq_signature));
}
Ok(proof)
}
pub(crate) async fn receive_verified_challenge(
receiver: &mtp_transport::Receiver,
tm: &mtp_codec::TypeMap,
host_public_key_bundle: &mtp_crypto::PublicKeyBundle,
bound_id: u64,
context: &str,
require_pq: bool,
client_has_pq_key: bool,
) -> Result<u128, CommunicationError> {
let challenge = receiver.receive().await?;
let expected = CommunicationType::Challenge
.try_to_id(tm)
.ok_or_else(|| CommunicationError::Other("Challenge is absent from the type map".into()))?;
if challenge.get_type() != expected {
return Err(unexpected_response_type_error(
context, expected, &challenge,
));
}
let server_challenge = match challenge.get_data(DataType::ServerNonce) {
Some(DataValue::UnsignedNumber(n)) => *n,
_ => {
return Err(CommunicationError::AuthenticationFailed(
"Missing server challenge".into(),
));
}
};
if challenge.get_data(DataType::RequirePq) == Some(&DataValue::BoolTrue) && !client_has_pq_key {
return Err(CommunicationError::AuthenticationFailed(
"Host requires post-quantum authentication but the client PQ key is absent".into(),
));
}
verify_host_challenge(
&challenge,
host_public_key_bundle,
bound_id,
server_challenge,
require_pq,
)
.await?;
Ok(server_challenge)
}

File diff suppressed because it is too large Load diff

179
client/src/ping.rs Normal file
View file

@ -0,0 +1,179 @@
use rand::RngExt;
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
use tokio::time::{Duration, Instant};
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap};
use mtp_transport::{Receiver, Sender};
pub(crate) struct PingSession {
pub(crate) last_ping: Arc<Mutex<Option<Duration>>>,
pub(crate) task: tokio::task::JoinHandle<()>,
}
#[derive(Default)]
struct PingTracker {
pending: Option<(u32, Instant)>,
missed_pings: usize,
}
impl PingTracker {
fn begin_round(&mut self) -> usize {
if self.pending.take().is_some() {
self.missed_pings += 1;
}
self.missed_pings
}
fn sent(&mut self, id: u32) {
self.pending = Some((id, Instant::now()));
}
fn received(&mut self, id: u32) -> Option<Duration> {
if self
.pending
.as_ref()
.is_none_or(|(pending, _)| *pending != id)
{
return None;
}
let (_, sent_at) = self.pending.take()?;
self.missed_pings = 0;
Some(sent_at.elapsed())
}
}
impl PingSession {
pub(crate) fn get_ping(&self) -> Option<Duration> {
self.last_ping.try_lock().ok().and_then(|ping| *ping)
}
}
impl Drop for PingSession {
fn drop(&mut self) {
self.task.abort();
}
}
pub(crate) async fn start_ping_session(
config: &crate::config::ClientConfig,
sender: Sender,
receiver: &Receiver,
type_map: &TypeMap,
client_id: u64,
) -> Option<PingSession> {
if config.ping_interval.is_zero() {
return None;
}
let (pong_tx, mut pong_rx) = mpsc::channel(1);
receiver.observe_pongs_bounded(pong_tx).await;
let last_ping = Arc::new(Mutex::new(None));
let ping_state = last_ping.clone();
let interval = config.ping_interval;
let ping_jitter = config.ping_jitter;
let max_missed_pings = config.max_missed_pings;
let ping_timestamp = config.ping_timestamp;
let type_map = type_map.clone();
let ping_receiver = receiver.clone();
let mut close_rx = receiver.handle().subscribe_close();
let task = tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
ticker.tick().await;
let mut tracker = PingTracker::default();
loop {
tokio::select! {
_ = close_rx.changed() => {
if close_rx.borrow().is_some() {
break;
}
}
_ = ticker.tick() => {
let missed_pings = tracker.begin_round();
ping_receiver.set_expected_pong_id(None).await;
if max_missed_pings > 0 && missed_pings >= max_missed_pings {
sender.close().await;
break;
}
if let Some(jitter) = ping_jitter && !jitter.is_zero() {
let max_ms = jitter.as_millis() as u64;
let extra = rand::rng().random_range(0..=max_ms);
tokio::time::sleep(Duration::from_millis(extra)).await;
}
let mut ping = CommunicationValue::new_with_type_map(
CommunicationType::Ping,
&type_map,
)
.with_sender(client_id);
if ping_timestamp {
let sent_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
ping = ping.add_typed_default(
DataType::Timestamp,
DataValue::UnsignedNumber(sent_at),
);
}
let Some(id) = ping.id() else {
sender.close().await;
break;
};
ping_receiver.set_expected_pong_id(Some(id)).await;
if sender.send(&ping).await.is_err() {
ping_receiver.set_expected_pong_id(None).await;
sender.close().await;
break;
}
tracker.sent(id);
}
pong = pong_rx.recv() => match pong {
Some(pong) => {
if let Some(id) = pong.id()
&& let Some(ping) = tracker.received(id)
{
let mut last_ping = ping_state.lock().await;
*last_ping = Some(ping);
}
}
None => break,
},
}
}
});
Some(PingSession { last_ping, task })
}
#[cfg(test)]
mod tests {
use super::PingTracker;
#[test]
fn successful_pong_resets_consecutive_misses() {
let mut tracker = PingTracker::default();
tracker.sent(1);
assert_eq!(tracker.begin_round(), 1);
tracker.sent(2);
assert!(tracker.received(2).is_some());
tracker.sent(3);
assert_eq!(tracker.begin_round(), 1);
}
#[test]
fn stale_pong_does_not_acknowledge_current_round() {
let mut tracker = PingTracker::default();
tracker.sent(1);
assert_eq!(tracker.begin_round(), 1);
tracker.sent(2);
assert!(tracker.received(1).is_none());
assert_eq!(tracker.begin_round(), 2);
}
}

534
client/src/pipe.rs Normal file
View file

@ -0,0 +1,534 @@
use mtp_codec::CommunicationValue;
#[cfg(feature = "pipes")]
use mtp_codec::TypeMap;
use mtp_common::CommunicationError;
use mtp_transport::Receiver;
use std::collections::HashMap;
use std::sync::Arc;
#[cfg(feature = "pipes")]
use std::sync::Mutex as StdMutex;
use tokio::sync::{Mutex, mpsc};
use tokio::time::{Duration, Instant};
#[cfg(feature = "pipes")]
use mtp_codec::{CommunicationType, DataType, DataValue};
#[cfg(feature = "pipes")]
use mtp_common::PipeError;
#[cfg(feature = "pipes")]
use mtp_transport::{Policy, Sender};
#[cfg(feature = "pipes")]
pub struct PipeHandle {
pub(crate) pipe_id: u32,
pub(crate) description: String,
pub(crate) sender: Sender,
pub(crate) response_rx: tokio::sync::oneshot::Receiver<Result<bool, PipeError>>,
pub(crate) dispatcher: Arc<PipeDispatcher>,
pub(crate) token: Arc<()>,
}
#[cfg(feature = "pipes")]
impl PipeHandle {
pub fn pipe_id(&self) -> u32 {
self.pipe_id
}
pub fn description(&self) -> &str {
&self.description
}
pub async fn wait(mut self) -> Result<Option<mtp_transport::PipeWriter>, PipeError> {
let response =
tokio::time::timeout(self.dispatcher.policy.read_timeout, &mut self.response_rx).await;
match response {
Ok(Ok(Ok(true))) => {
let writer = self
.sender
.open_pipe(self.pipe_id, &self.description)
.await
.map_err(PipeError::from)?;
Ok(Some(writer))
}
Ok(Ok(Ok(false))) => Ok(None),
Ok(Ok(Err(error))) => {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
Err(error)
}
Ok(Err(_)) => {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
Err(PipeError::StreamClosed)
}
Err(_) => {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
Err(PipeError::HandshakeTimeout)
}
}
}
}
#[cfg(feature = "pipes")]
impl Drop for PipeHandle {
fn drop(&mut self) {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
}
}
#[cfg(feature = "pipes")]
pub struct PipeRequest {
pub(crate) pipe_id: u32,
pub(crate) description: String,
pub(crate) sender: Sender,
pub(crate) receiver: Receiver,
pub(crate) dispatcher: Arc<PipeDispatcher>,
}
#[cfg(feature = "pipes")]
struct ExpectedPipeGuard {
receiver: Receiver,
pipe_id: u32,
armed: bool,
}
#[cfg(feature = "pipes")]
impl ExpectedPipeGuard {
fn new(receiver: Receiver, pipe_id: u32) -> Self {
Self {
receiver,
pipe_id,
armed: true,
}
}
fn disarm(&mut self) {
self.armed = false;
}
}
#[cfg(feature = "pipes")]
impl Drop for ExpectedPipeGuard {
fn drop(&mut self) {
if self.armed {
self.receiver.cancel_expected_pipe(self.pipe_id);
}
}
}
#[cfg(feature = "pipes")]
impl PipeRequest {
pub fn id(&self) -> u32 {
self.pipe_id
}
pub fn description(&self) -> &str {
&self.description
}
pub async fn accept(self) -> Result<mtp_transport::PipeReader, PipeError> {
self.receiver
.expect_pipe(self.pipe_id)
.map_err(PipeError::from)?;
let mut expected_pipe = ExpectedPipeGuard::new(self.receiver.clone(), self.pipe_id);
let (pipe_tx, pipe_rx) = tokio::sync::oneshot::channel();
{
let mut pending = self.dispatcher.pending_pipes.lock().await;
pending.insert(self.pipe_id, pipe_tx);
}
let resp = CommunicationValue::new_with_type_map(
CommunicationType::PipeResponse,
&self.dispatcher.type_map,
)
.with_id(self.pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolTrue);
if let Err(error) = self.sender.send(&resp).await {
self.dispatcher
.pending_pipes
.lock()
.await
.remove(&self.pipe_id);
return Err(PipeError::from(error));
}
let timeout = self.dispatcher.policy.read_timeout;
match tokio::time::timeout(timeout, pipe_rx).await {
Ok(Ok(reader)) => {
expected_pipe.disarm();
Ok(reader)
}
Ok(Err(_)) => {
self.dispatcher
.pending_pipes
.lock()
.await
.remove(&self.pipe_id);
Err(PipeError::StreamClosed)
}
Err(_) => {
self.dispatcher
.pending_pipes
.lock()
.await
.remove(&self.pipe_id);
Err(PipeError::HandshakeTimeout)
}
}
}
pub async fn deny(self) -> Result<(), PipeError> {
let resp = CommunicationValue::new_with_type_map(
CommunicationType::PipeResponse,
&self.dispatcher.type_map,
)
.with_id(self.pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
self.sender.send(&resp).await.map_err(PipeError::from)?;
Ok(())
}
}
pub(crate) struct PendingRequest {
pub(crate) token: Arc<()>,
pub(crate) sender: tokio::sync::oneshot::Sender<Result<CommunicationValue, CommunicationError>>,
}
#[cfg(feature = "pipes")]
pub(crate) struct PendingCreation {
pub(crate) token: Arc<()>,
pub(crate) sender: tokio::sync::oneshot::Sender<Result<bool, PipeError>>,
}
#[cfg(feature = "pipes")]
pub(crate) struct PendingCreationGuard {
dispatcher: Arc<PipeDispatcher>,
pipe_id: u32,
token: Arc<()>,
armed: bool,
}
#[cfg(feature = "pipes")]
impl PendingCreationGuard {
pub(crate) fn new(dispatcher: Arc<PipeDispatcher>, pipe_id: u32, token: Arc<()>) -> Self {
Self {
dispatcher,
pipe_id,
token,
armed: true,
}
}
pub(crate) fn disarm(&mut self) {
self.armed = false;
}
}
#[cfg(feature = "pipes")]
impl Drop for PendingCreationGuard {
fn drop(&mut self) {
if self.armed {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
}
}
}
pub(crate) struct PipeDispatcher {
pub(crate) pending_requests: Mutex<HashMap<u32, PendingRequest>>,
pub(crate) expired_requests: Mutex<HashMap<u32, Instant>>,
#[cfg(feature = "pipes")]
pub(crate) type_map: TypeMap,
#[cfg(feature = "pipes")]
pub(crate) pending_creations: StdMutex<HashMap<u32, PendingCreation>>,
#[cfg(feature = "pipes")]
pub(crate) expired_creations: StdMutex<HashMap<u32, Instant>>,
#[cfg(feature = "pipes")]
pub(crate) pending_pipes:
Mutex<HashMap<u32, tokio::sync::oneshot::Sender<mtp_transport::PipeReader>>>,
#[cfg(feature = "pipes")]
pub(crate) policy: Arc<Policy>,
}
#[cfg(feature = "pipes")]
const EXPIRED_CREATION_TOMBSTONE_TTL: Duration = Duration::from_secs(60);
#[cfg(feature = "pipes")]
const MAX_EXPIRED_CREATION_TOMBSTONES: usize = 1024;
#[cfg(feature = "pipes")]
pub(crate) fn expire_pending_creation(dispatcher: &PipeDispatcher, pipe_id: u32, token: &Arc<()>) {
let removed = dispatcher
.pending_creations
.lock()
.ok()
.and_then(|mut pending| {
if pending
.get(&pipe_id)
.is_some_and(|entry| Arc::ptr_eq(&entry.token, token))
{
pending.remove(&pipe_id);
Some(())
} else {
None
}
});
if removed.is_none() {
return;
}
let Ok(mut expired) = dispatcher.expired_creations.lock() else {
return;
};
let now = Instant::now();
expired.retain(|_, expires_at| *expires_at > now);
if expired.len() >= MAX_EXPIRED_CREATION_TOMBSTONES
&& let Some(oldest) = expired
.iter()
.min_by_key(|(_, expires_at)| **expires_at)
.map(|(id, _)| *id)
{
expired.remove(&oldest);
}
expired.insert(pipe_id, now + EXPIRED_CREATION_TOMBSTONE_TTL);
}
#[cfg(feature = "pipes")]
fn consume_expired_creation(dispatcher: &PipeDispatcher, pipe_id: u32) -> bool {
let Ok(mut expired) = dispatcher.expired_creations.lock() else {
return false;
};
let now = Instant::now();
expired.retain(|_, expires_at| *expires_at > now);
expired.remove(&pipe_id).is_some()
}
#[cfg(feature = "pipes")]
pub(crate) fn is_expired_creation(dispatcher: &PipeDispatcher, pipe_id: u32) -> bool {
let Ok(mut expired) = dispatcher.expired_creations.lock() else {
return true;
};
let now = Instant::now();
expired.retain(|_, expires_at| *expires_at > now);
expired.contains_key(&pipe_id)
}
#[cfg(feature = "pipes")]
pub(crate) fn fail_pending_creations(dispatcher: &PipeDispatcher, error: &CommunicationError) {
let pending = dispatcher
.pending_creations
.lock()
.ok()
.map(|mut pending| std::mem::take(&mut *pending));
if let Some(pending) = pending {
let error = PipeError::from(error.clone());
for (_, pending) in pending {
let _ = pending.sender.send(Err(error.clone()));
}
}
if let Ok(mut expired) = dispatcher.expired_creations.lock() {
expired.clear();
}
}
#[cfg(feature = "pipes")]
pub(crate) async fn fail_pending_pipes(dispatcher: &PipeDispatcher) {
dispatcher.pending_pipes.lock().await.clear();
}
pub(crate) async fn route_message(
msg: CommunicationValue,
app_tx: &mpsc::Sender<Result<CommunicationValue, CommunicationError>>,
dispatcher: &PipeDispatcher,
) -> bool {
if !matches!(msg.id(), Some(id) if id != 0)
&& msg
.get_type_name()
.is_some_and(|name| name.ends_with("Response"))
{
return app_tx
.send(Err(CommunicationError::Other(
"response frame must contain a non-zero id".into(),
)))
.await
.is_ok();
}
if let Some(id) = msg.id() {
let pending = dispatcher.pending_requests.lock().await.remove(&id);
if let Some(tx) = pending {
let _ = tx.sender.send(Ok(msg));
return true;
}
if consume_expired_request(dispatcher, id).await {
return true;
}
}
app_tx.send(Ok(msg)).await.is_ok()
}
pub(crate) async fn fail_pending_requests(dispatcher: &PipeDispatcher, error: CommunicationError) {
let pending = std::mem::take(&mut *dispatcher.pending_requests.lock().await);
for (_, pending) in pending {
let _ = pending.sender.send(Err(error.clone()));
}
}
const EXPIRED_REQUEST_TOMBSTONE_TTL: Duration = Duration::from_secs(60);
const MAX_EXPIRED_REQUEST_TOMBSTONES: usize = 1024;
pub(crate) async fn expire_pending_request(
dispatcher: &PipeDispatcher,
request_id: u32,
token: &Arc<()>,
) {
let mut pending = dispatcher.pending_requests.lock().await;
if pending
.get(&request_id)
.is_some_and(|entry| Arc::ptr_eq(&entry.token, token))
{
pending.remove(&request_id);
drop(pending);
let mut expired = dispatcher.expired_requests.lock().await;
let now = Instant::now();
expired.retain(|_, expires_at| *expires_at > now);
if expired.len() >= MAX_EXPIRED_REQUEST_TOMBSTONES
&& let Some(oldest) = expired
.iter()
.min_by_key(|(_, expires_at)| **expires_at)
.map(|(id, _)| *id)
{
expired.remove(&oldest);
}
expired.insert(request_id, now + EXPIRED_REQUEST_TOMBSTONE_TTL);
}
}
pub(crate) async fn is_expired_request(dispatcher: &PipeDispatcher, request_id: u32) -> bool {
let mut expired = dispatcher.expired_requests.lock().await;
let now = Instant::now();
expired.retain(|_, expires_at| *expires_at > now);
expired.contains_key(&request_id)
}
pub(crate) async fn remove_pending_request(
dispatcher: &PipeDispatcher,
request_id: u32,
token: &Arc<()>,
) {
let mut pending = dispatcher.pending_requests.lock().await;
if pending
.get(&request_id)
.is_some_and(|entry| Arc::ptr_eq(&entry.token, token))
{
pending.remove(&request_id);
}
}
async fn consume_expired_request(dispatcher: &PipeDispatcher, request_id: u32) -> bool {
let mut expired = dispatcher.expired_requests.lock().await;
let now = Instant::now();
expired.retain(|_, expires_at| *expires_at > now);
expired.remove(&request_id).is_some()
}
#[cfg(feature = "pipes")]
pub(crate) async fn run_dispatcher(
receiver: Receiver,
sender: Sender,
app_tx: mpsc::Sender<Result<CommunicationValue, CommunicationError>>,
pipe_req_tx: mpsc::Sender<PipeRequest>,
dispatcher: Arc<PipeDispatcher>,
) {
loop {
match receiver.receive_event().await {
Ok(mtp_transport::TransportEvent::Message(msg)) => {
if msg.is_type(CommunicationType::PipeRequest) {
let Some(pipe_id) = msg.id().filter(|id| *id != 0) else {
let error = CommunicationError::Other(
"PipeRequest frame must contain a non-zero id".into(),
);
if app_tx.send(Err(error)).await.is_err() {
break;
}
continue;
};
let description = msg.get_str(DataType::Description).unwrap_or("").to_string();
let req = PipeRequest {
pipe_id,
description,
sender: sender.clone(),
receiver: receiver.clone(),
dispatcher: dispatcher.clone(),
};
let _ = pipe_req_tx.send(req).await;
continue;
}
if msg.is_type(CommunicationType::PipeResponse) {
let Some(pipe_id) = msg.id().filter(|id| *id != 0) else {
let error = CommunicationError::Other(
"PipeResponse frame must contain a non-zero id".into(),
);
if app_tx.send(Err(error)).await.is_err() {
break;
}
continue;
};
let accepted = msg.get_bool(DataType::Accepted).unwrap_or(false);
let pending = dispatcher
.pending_creations
.lock()
.ok()
.and_then(|mut pending| pending.remove(&pipe_id));
if let Some(entry) = pending {
let _ = entry.sender.send(Ok(accepted));
} else {
let _ = consume_expired_creation(&dispatcher, pipe_id);
}
continue;
}
if !route_message(msg, &app_tx, &dispatcher).await {
break;
}
}
Ok(mtp_transport::TransportEvent::Pipe(reader)) => {
let pipe_id = reader.pipe_id();
let mut pending = dispatcher.pending_pipes.lock().await;
if let Some(tx) = pending.remove(&pipe_id) {
let _ = tx.send(reader);
}
}
Err(e) => {
fail_pending_requests(&dispatcher, e.clone()).await;
#[cfg(feature = "pipes")]
fail_pending_creations(&dispatcher, &e);
#[cfg(feature = "pipes")]
fail_pending_pipes(&dispatcher).await;
let _ = app_tx.send(Err(e)).await;
break;
}
}
}
}
#[cfg(not(feature = "pipes"))]
pub(crate) async fn run_dispatcher(
receiver: Receiver,
app_tx: mpsc::Sender<Result<CommunicationValue, CommunicationError>>,
dispatcher: Arc<PipeDispatcher>,
) {
loop {
match receiver.receive().await {
Ok(msg) => {
if !route_message(msg, &app_tx, &dispatcher).await {
break;
}
}
Err(e) => {
fail_pending_requests(&dispatcher, e.clone()).await;
#[cfg(feature = "pipes")]
fail_pending_creations(&dispatcher, &e);
#[cfg(feature = "pipes")]
fail_pending_pipes(&dispatcher).await;
let _ = app_tx.send(Err(e)).await;
break;
}
}
}
}

81
client/tests/ping.rs Normal file
View file

@ -0,0 +1,81 @@
use std::net::{IpAddr, Ipv4Addr};
use mtp_client::{ClientConfig, MTPClient};
use mtp_host::{HostConfig, MTPHost};
async fn generate_self_signed_cert() -> Result<(Vec<u8>, Vec<u8>), Box<dyn std::error::Error>> {
let key_pair = rcgen::KeyPair::generate()?;
let params = rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()])?;
let cert = params.self_signed(&key_pair)?;
let cert_pem = cert.pem();
let key_pem = key_pair.serialize_pem();
Ok((cert_pem.into_bytes(), key_pem.into_bytes()))
}
async fn start_host(send_pongs: bool) -> Result<(MTPHost, Vec<u8>), Box<dyn std::error::Error>> {
let (cert_pem, key_pem) = generate_self_signed_cert().await?;
let host = MTPHost::new(
HostConfig::new(
IpAddr::V4(Ipv4Addr::LOCALHOST),
0,
cert_pem.clone(),
key_pem,
)
.with_pongs(send_pongs),
)
.await?;
Ok((host, cert_pem))
}
#[tokio::test]
async fn test_ping_rtt_and_missed_ping_teardown() -> Result<(), Box<dyn std::error::Error>> {
let (mut host, cert_pem) = start_host(true).await?;
let url = format!("https://127.0.0.1:{}", host.local_addr().port());
let client_connect = MTPClient::connect(
ClientConfig::new(url)
.with_pinned_pem(cert_pem)
.with_ping_interval(std::time::Duration::from_millis(25))
.with_max_missed_pings(3),
);
let (client, accepted) = tokio::join!(client_connect, host.accept());
let client = client?;
let _accepted = accepted?;
let ping = tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
if let Some(ping) = client.get_ping() {
return ping;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
})
.await?;
assert!(ping > std::time::Duration::ZERO);
let (mut silent_host, silent_cert_pem) = start_host(false).await?;
let silent_url = format!("https://127.0.0.1:{}", silent_host.local_addr().port());
let silent_connect = MTPClient::connect(
ClientConfig::new(silent_url)
.with_pinned_pem(silent_cert_pem)
.with_ping_interval(std::time::Duration::from_millis(25))
.with_max_missed_pings(2),
);
let (silent_client, accepted) = tokio::join!(silent_connect, silent_host.accept());
let silent_client = silent_client?;
let _accepted = accepted?;
let closed = tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
if silent_client.sender.is_closed() {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
})
.await;
assert!(closed.is_ok(), "client should close after missed pings");
Ok(())
}

1991
codec/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,18 @@
[package] [package]
name = "mtp-codec" name = "mtp-codec"
version = "0.1.0" version = "0.3.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
mtp-type-map = { version = "0.1.0", path = "../type-map" } mtp-type-map = { version = "0.3.0", path = "../type-map" }
mtp-common = { version = "0.1.0", path = "../common" } mtp-common = { version = "0.3.0", path = "../common" }
mtp-crypto = { version = "0.1.0", path = "../crypto", optional = true } mtp-crypto = { version = "0.3.0", path = "../crypto", optional = true }
base64 = "0.22" base64 = "0.23"
byteorder = "1.5" byteorder = "1.5"
rand = { version = "0.8", features = ["std", "std_rng"] } rand = { version = "0.10.1", features = ["std", "std_rng"] }
thiserror = "2.0.18"
[features] [features]
default = []
registry = ["mtp-type-map/registry"] registry = ["mtp-type-map/registry"]
crypto = ["dep:mtp-crypto", "mtp-crypto/mlkem-tls"] crypto = ["dep:mtp-crypto", "mtp-crypto/mlkem-tls"]
pipes = ["mtp-type-map/pipes"]

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,57 @@
pub mod communication_value; pub mod communication_value;
pub mod data_value; pub mod data_value;
#[cfg(feature = "crypto")]
pub mod protected;
#[cfg(feature = "crypto")]
pub mod relay;
pub use communication_value::CommunicationValue; pub use communication_value::CommunicationValue;
pub use data_value::{DataKind, DataValue}; #[cfg(feature = "crypto")]
pub use mtp_common::CodecError; pub use data_value::{
ApplicationProtectionPurpose, EncryptedValue, MtpProtectionPurpose, ProtectionError,
ProtectionPolicy, ProtectionPurpose, ProtectionPurposeError, SignaturePolicy, SignedValue,
};
pub use data_value::{
DEFAULT_TRANSPORT_ALLOCATION_FACTOR, DataKind, DataValue, DecodeError, DecodeLimits,
EncodeLimits,
};
pub use mtp_common::{CodecError, TimeError, unix_time_millis};
#[cfg(feature = "crypto")]
#[allow(deprecated)]
pub use protected::{
CURRENT_PROTECTED_VERSION, InMemoryReplayGuard, ProtectedError, ProtectedLimits,
ProtectedMessageBuilder, ProtectedOpenOptions, ReplayError, ReplayGuard,
VerifiedProtectedMessage, open_protected_checked, open_protected_with_checked,
open_protected_with_keys_checked, open_protected_with_keys_without_replay,
open_protected_with_without_replay, open_protected_without_replay, protected_claimed_signer_id,
protected_claimed_signer_id_with_limits, protected_claimed_signer_id_with_options,
};
#[cfg(feature = "crypto")]
#[allow(deprecated)]
pub use relay::{
CURRENT_RELAY_VERSION, RelayError, RelayOpenOptions, SealedRelayBuilder, VerifiedRelayContent,
VerifiedRelayMetadata, forward_relay_frame, open_relay_content,
open_relay_content_with_keyrings, open_relay_content_with_keyrings_and_limits,
open_relay_content_with_keys, open_relay_content_with_limits,
open_relay_content_with_limits_without_replay, open_relay_metadata_checked,
open_relay_metadata_with_checked, open_relay_metadata_with_limits_checked,
open_relay_metadata_with_limits_without_replay, open_relay_metadata_with_without_replay,
open_relay_metadata_without_replay, relay_metadata_claimed_signer_id,
relay_metadata_claimed_signer_id_with_limits, relay_metadata_claimed_signer_id_with_options,
};
pub use mtp_type_map::{ pub use mtp_type_map::{
CommunicationType, CommunicationTypeId, DataType, DataTypeId, PROTOCOL_VERSION, TypeMap, CommunicationType, CommunicationTypeId, DataType, DataTypeId, PROTOCOL_VERSION, TypeMap,
Version, communication_type_name, data_type_name, Version,
}; };
pub(crate) fn rand_u32() -> u32 { pub(crate) fn rand_u32() -> u32 {
rand::random() loop {
let value = rand::random();
if value != 0 {
return value;
}
}
} }
#[cfg(feature = "registry")] #[cfg(feature = "registry")]

1613
codec/src/protected.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,8 @@
use mtp_type_map::Version; use mtp_common::CodecError;
use mtp_type_map::{PROTOCOL_VERSION, TypeMap, Version};
use crate::CommunicationValue;
use crate::EncodeLimits;
pub use mtp_type_map::Registry; pub use mtp_type_map::Registry;
@ -9,11 +13,76 @@ pub use mtp_type_map::Registry;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct VersionedCodec { pub struct VersionedCodec {
registry: Registry, registry: Registry,
type_map: TypeMap,
} }
impl VersionedCodec { impl VersionedCodec {
pub fn new(registry: Registry) -> Self { pub fn new(registry: Registry) -> Self {
Self { registry } let type_map = registry
.latest()
.cloned()
.unwrap_or_else(|| TypeMap::new(PROTOCOL_VERSION));
Self { registry, type_map }
}
/// Create a codec bound to a negotiated protocol version.
pub fn for_version(registry: Registry, version: Version) -> Option<Self> {
let type_map = registry.get(&version)?.clone();
Some(Self { registry, type_map })
}
/// Return the type map used by this codec.
pub fn type_map(&self) -> &TypeMap {
&self.type_map
}
/// Return the protocol version used by this codec.
pub fn version(&self) -> &Version {
&self.type_map.version
}
/// Encode a value using the codec's negotiated framing rules.
pub fn encode(&self, value: &CommunicationValue) -> Result<Vec<u8>, CodecError> {
self.encode_with_limits(value, EncodeLimits::default())
}
/// Encode using an explicit output/resource limit after verifying the
/// value belongs to this codec's negotiated type map.
pub fn encode_with_limits(
&self,
value: &CommunicationValue,
limits: EncodeLimits,
) -> Result<Vec<u8>, CodecError> {
let value_map = value.type_map().ok_or(CodecError::MissingTypeMap)?;
if value_map.version != self.type_map.version {
return Err(CodecError::TypeMapMismatch {
expected: self.type_map.version.to_string(),
actual: value_map.version.to_string(),
});
}
value.to_bytes_with_limits(limits)
}
/// Explicitly migrate a clear frame to this codec's negotiated type map
/// before encoding it.
pub fn encode_migrating(&self, value: &CommunicationValue) -> Result<Vec<u8>, CodecError> {
self.encode_migrating_with_limits(value, EncodeLimits::default())
}
/// Explicitly migrate and encode with bounded traversal/output.
pub fn encode_migrating_with_limits(
&self,
value: &CommunicationValue,
limits: EncodeLimits,
) -> Result<Vec<u8>, CodecError> {
value
.migrate_with_limits(&self.type_map, limits)?
.to_bytes_with_limits(limits)
}
/// Decode a frame and retain the negotiated type map for typed access.
pub fn decode(&self, bytes: &[u8]) -> Result<CommunicationValue, CodecError> {
CommunicationValue::from_bytes_with(bytes, &self.type_map)
} }
pub fn negotiate(&self, client_versions: &[Version]) -> Option<Version> { pub fn negotiate(&self, client_versions: &[Version]) -> Option<Version> {
@ -24,3 +93,34 @@ impl VersionedCodec {
&self.registry &self.registry
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::DataValue;
use mtp_type_map::{CommunicationType, Version};
#[test]
fn encode_rejects_a_value_from_another_negotiated_map() {
let mut registry = Registry::new();
let version_a = Version::new(3, 0);
let version_b = Version::new(4, 0);
registry.register(TypeMap::new(version_a.clone()));
registry.register(TypeMap::new(version_b.clone()));
let codec = VersionedCodec::for_version(registry, version_b).expect("codec version");
let value = CommunicationValue::new_with_type_map(
CommunicationType::Ping,
&TypeMap::new(version_a.clone()),
)
.with_payload(DataValue::Null);
assert_eq!(
codec.encode(&value),
Err(CodecError::TypeMapMismatch {
expected: "4.0".into(),
actual: "3.0".into(),
})
);
}
}

1753
codec/src/relay.rs Normal file

File diff suppressed because it is too large Load diff

1471
common/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,18 +1,20 @@
[package] [package]
name = "mtp-common" name = "mtp-common"
version = "0.1.0" version = "0.3.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
thiserror = "2.0.18" thiserror = "2.0.18"
[features]
pipes = []
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
wtransport = { version = "0.7.1", default-features = false, features = [ wtransport = { version = "0.7.1", default-features = false, features = [
"aws-lc-rs", "aws-lc-rs",
"quinn", "quinn",
"self-signed", "self-signed",
] } ] }
rustls = { version = "0.23.41" }
quinn = { version = "0.11.11", default-features = false, features = [ quinn = { version = "0.11.11", default-features = false, features = [
"rustls-aws-lc-rs", "rustls-aws-lc-rs",
"rustls", "rustls",

View file

@ -1,5 +1,32 @@
use thiserror::Error; use thiserror::Error;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// Errors returned when the system clock cannot be represented as MTP time.
#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
pub enum TimeError {
#[error("system clock is before the Unix epoch")]
BeforeUnixEpoch,
#[error("Unix epoch milliseconds exceed the u64 range")]
OutOfRange,
}
fn duration_to_unix_time_millis(duration: Duration) -> Result<u64, TimeError> {
u64::try_from(duration.as_millis()).map_err(|_| TimeError::OutOfRange)
}
/// Return the current Unix time in milliseconds.
///
/// MTP protocol fields that use `CreatedAt` store this value as an unsigned
/// integer. The conversion is centralized here so native writers do not
/// accidentally use seconds.
pub fn unix_time_millis() -> Result<u64, TimeError> {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| TimeError::BeforeUnixEpoch)?;
duration_to_unix_time_millis(duration)
}
#[derive(Clone, Debug, Error, PartialEq, Eq)] #[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum CodecError { pub enum CodecError {
#[error("Unknown version")] #[error("Unknown version")]
@ -14,6 +41,10 @@ pub enum CodecError {
InvalidEncoding, InvalidEncoding,
#[error("Too many entries to encode")] #[error("Too many entries to encode")]
TooManyEntries, TooManyEntries,
#[error("Missing negotiated type map")]
MissingTypeMap,
#[error("Type-map mismatch: expected {expected}, actual {actual}")]
TypeMapMismatch { expected: String, actual: String },
#[error("Crypto failed: {0}")] #[error("Crypto failed: {0}")]
CryptoFailed(String), CryptoFailed(String),
#[error("Missing required field: {0}")] #[error("Missing required field: {0}")]
@ -25,6 +56,24 @@ pub enum CodecError {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn unix_time_millis_preserves_subsecond_precision() {
let duration = Duration::new(1_786_449_600, 123_000_000);
assert_eq!(
duration_to_unix_time_millis(duration),
Ok(1_786_449_600_123)
);
}
#[test]
fn unix_time_millis_rejects_values_outside_u64() {
let duration = Duration::new(u64::MAX, 0);
assert_eq!(
duration_to_unix_time_millis(duration),
Err(TimeError::OutOfRange)
);
}
#[test] #[test]
fn test_codec_error_display() { fn test_codec_error_display() {
let e = CodecError::InvalidEncoding; let e = CodecError::InvalidEncoding;
@ -50,7 +99,6 @@ mod tests {
* wrappers) is available. On WASM only the transport-independent subset is * wrappers) is available. On WASM only the transport-independent subset is
* compiled. */ * compiled. */
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Error, Clone)] #[derive(Debug, Error, Clone)]
pub enum CommunicationError { pub enum CommunicationError {
#[error("Use after Closed")] #[error("Use after Closed")]
@ -66,6 +114,7 @@ pub enum CommunicationError {
ConnectionLost, ConnectionLost,
#[error("QUIC error: {0}")] #[error("QUIC error: {0}")]
#[cfg(not(target_arch = "wasm32"))]
Quinn(#[from] quinn::ConnectionError), Quinn(#[from] quinn::ConnectionError),
#[error("ParseCommunicationValue error")] #[error("ParseCommunicationValue error")]
@ -84,24 +133,29 @@ pub enum CommunicationError {
ParseError(String), ParseError(String),
#[error("Connection error: {0}")] #[error("Connection error: {0}")]
#[cfg(not(target_arch = "wasm32"))]
ConnectionError(#[from] wtransport::error::ConnectionError), ConnectionError(#[from] wtransport::error::ConnectionError),
#[error("Connecting error: {0}")] #[error("Connecting error: {0}")]
ConnectingError(String), ConnectingError(String),
#[error("ReadToEnd error: {0}")] #[error("ReadToEnd error: {0}")]
#[cfg(not(target_arch = "wasm32"))]
ReadToEndError(#[from] quinn::ReadToEndError), ReadToEndError(#[from] quinn::ReadToEndError),
#[error("Write error: {0}")] #[error("Write error: {0}")]
#[cfg(not(target_arch = "wasm32"))]
WriteError(#[from] quinn::WriteError), WriteError(#[from] quinn::WriteError),
#[error("Closed error: {0}")] #[error("Closed error: {0}")]
#[cfg(not(target_arch = "wasm32"))]
ClosedError(#[from] quinn::ClosedStream), ClosedError(#[from] quinn::ClosedStream),
#[error("Message too large")] #[error("Message too large")]
MessageTooLarge, MessageTooLarge,
#[error("ReadExactError: {0}")] #[error("ReadExactError: {0}")]
#[cfg(not(target_arch = "wasm32"))]
ReadExactError(#[from] quinn::ReadExactError), ReadExactError(#[from] quinn::ReadExactError),
#[error("Stream Closed")] #[error("Stream Closed")]
@ -110,10 +164,15 @@ pub enum CommunicationError {
#[error("Stream Error")] #[error("Stream Error")]
StreamError, StreamError,
#[error("Stream failed after delivery may have started")]
DeliveryUnknown,
#[error("Stream Error: {0}")] #[error("Stream Error: {0}")]
#[cfg(not(target_arch = "wasm32"))]
StreamWriteError(#[from] wtransport::error::StreamWriteError), StreamWriteError(#[from] wtransport::error::StreamWriteError),
#[error("Read Exact Error: {0}")] #[error("Read Exact Error: {0}")]
#[cfg(not(target_arch = "wasm32"))]
StreamReadExactError(#[from] wtransport::error::StreamReadExactError), StreamReadExactError(#[from] wtransport::error::StreamReadExactError),
#[error("Crypto Provider Install Error")] #[error("Crypto Provider Install Error")]
@ -126,61 +185,40 @@ pub enum CommunicationError {
Other(String), Other(String),
} }
#[cfg(target_arch = "wasm32")] /// How the protocol layer should handle the first frame on a receive stream.
#[derive(Debug, Error, Clone)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CommunicationError { pub enum FirstFrameDisposition {
#[error("Use after Closed")] Message,
UseAfterClosed, Pipe(u32),
}
#[error("Connection closed by local shutdown")] /// Classify a first frame without tying the decision to a WebTransport backend.
ClosedLocally, ///
/// `PipeRequest` is used both as a control message and as the header of the raw
/// stream opened after that request is accepted. Only the protocol layer knows
/// which raw stream IDs are currently expected.
pub fn classify_first_frame(
is_pipe_request: bool,
pipe_id: Option<u32>,
pipe_is_expected: bool,
) -> Result<FirstFrameDisposition, CommunicationError> {
if !is_pipe_request {
return Ok(FirstFrameDisposition::Message);
}
#[error("Connection closed by peer")] let pipe_id = pipe_id.filter(|id| *id != 0).ok_or_else(|| {
ClosedByPeer, CommunicationError::Other("PipeRequest frame must contain a non-zero id".into())
})?;
#[error("Connection terminated unexpectedly")] if pipe_is_expected {
ConnectionLost, Ok(FirstFrameDisposition::Pipe(pipe_id))
} else {
#[error("ParseCommunicationValue error")] Ok(FirstFrameDisposition::Message)
ParseCommunicationValue, }
#[error("Encode error")]
Encode,
#[error("Parse Certificate error")]
CertificateParseFailed,
#[error("Loading Certificate error")]
CertificateLoadFailed,
#[error("Parse error: {0}")]
ParseError(String),
#[error("Connecting error: {0}")]
ConnectingError(String),
#[error("Message too large")]
MessageTooLarge,
#[error("Stream Closed")]
StreamClosed,
#[error("Stream Error")]
StreamError,
#[error("Crypto Provider Install Error")]
CryptoProviderInstallFailed,
#[error("Authentication failed: {0}")]
AuthenticationFailed(String),
#[error("Other: {0}")]
Other(String),
} }
// ---- manual PartialEq (quinn / wtransport types don't impl PartialEq) ---- // ---- manual PartialEq (quinn / wtransport types don't impl PartialEq) ----
#[cfg(not(target_arch = "wasm32"))]
impl PartialEq for CommunicationError { impl PartialEq for CommunicationError {
fn eq(&self, other: &Self) -> bool { fn eq(&self, other: &Self) -> bool {
match (self, other) { match (self, other) {
@ -188,22 +226,31 @@ impl PartialEq for CommunicationError {
(Self::ClosedLocally, Self::ClosedLocally) => true, (Self::ClosedLocally, Self::ClosedLocally) => true,
(Self::ClosedByPeer, Self::ClosedByPeer) => true, (Self::ClosedByPeer, Self::ClosedByPeer) => true,
(Self::ConnectionLost, Self::ConnectionLost) => true, (Self::ConnectionLost, Self::ConnectionLost) => true,
#[cfg(not(target_arch = "wasm32"))]
(Self::Quinn(_), Self::Quinn(_)) => true, (Self::Quinn(_), Self::Quinn(_)) => true,
(Self::ParseCommunicationValue, Self::ParseCommunicationValue) => true, (Self::ParseCommunicationValue, Self::ParseCommunicationValue) => true,
(Self::Encode, Self::Encode) => true, (Self::Encode, Self::Encode) => true,
(Self::CertificateParseFailed, Self::CertificateParseFailed) => true, (Self::CertificateParseFailed, Self::CertificateParseFailed) => true,
(Self::CertificateLoadFailed, Self::CertificateLoadFailed) => true, (Self::CertificateLoadFailed, Self::CertificateLoadFailed) => true,
(Self::ParseError(a), Self::ParseError(b)) => a == b, (Self::ParseError(a), Self::ParseError(b)) => a == b,
#[cfg(not(target_arch = "wasm32"))]
(Self::ConnectionError(_), Self::ConnectionError(_)) => true, (Self::ConnectionError(_), Self::ConnectionError(_)) => true,
(Self::ConnectingError(a), Self::ConnectingError(b)) => a == b, (Self::ConnectingError(a), Self::ConnectingError(b)) => a == b,
#[cfg(not(target_arch = "wasm32"))]
(Self::ReadToEndError(_), Self::ReadToEndError(_)) => true, (Self::ReadToEndError(_), Self::ReadToEndError(_)) => true,
#[cfg(not(target_arch = "wasm32"))]
(Self::WriteError(_), Self::WriteError(_)) => true, (Self::WriteError(_), Self::WriteError(_)) => true,
#[cfg(not(target_arch = "wasm32"))]
(Self::ClosedError(_), Self::ClosedError(_)) => true, (Self::ClosedError(_), Self::ClosedError(_)) => true,
(Self::MessageTooLarge, Self::MessageTooLarge) => true, (Self::MessageTooLarge, Self::MessageTooLarge) => true,
#[cfg(not(target_arch = "wasm32"))]
(Self::ReadExactError(_), Self::ReadExactError(_)) => true, (Self::ReadExactError(_), Self::ReadExactError(_)) => true,
(Self::StreamClosed, Self::StreamClosed) => true, (Self::StreamClosed, Self::StreamClosed) => true,
(Self::StreamError, Self::StreamError) => true, (Self::StreamError, Self::StreamError) => true,
(Self::DeliveryUnknown, Self::DeliveryUnknown) => true,
#[cfg(not(target_arch = "wasm32"))]
(Self::StreamWriteError(_), Self::StreamWriteError(_)) => true, (Self::StreamWriteError(_), Self::StreamWriteError(_)) => true,
#[cfg(not(target_arch = "wasm32"))]
(Self::StreamReadExactError(_), Self::StreamReadExactError(_)) => true, (Self::StreamReadExactError(_), Self::StreamReadExactError(_)) => true,
(Self::CryptoProviderInstallFailed, Self::CryptoProviderInstallFailed) => true, (Self::CryptoProviderInstallFailed, Self::CryptoProviderInstallFailed) => true,
(Self::AuthenticationFailed(a), Self::AuthenticationFailed(b)) => a == b, (Self::AuthenticationFailed(a), Self::AuthenticationFailed(b)) => a == b,
@ -213,36 +260,93 @@ impl PartialEq for CommunicationError {
} }
} }
#[cfg(target_arch = "wasm32")] impl Eq for CommunicationError {}
impl PartialEq for CommunicationError {
fn eq(&self, other: &Self) -> bool { /* ================================ PipeError ================================ */
match (self, other) {
(Self::UseAfterClosed, Self::UseAfterClosed) => true, #[cfg(feature = "pipes")]
(Self::ClosedLocally, Self::ClosedLocally) => true, #[derive(Debug, Clone, PartialEq, Eq)]
(Self::ClosedByPeer, Self::ClosedByPeer) => true, pub enum PipeError {
(Self::ConnectionLost, Self::ConnectionLost) => true, Rejected,
(Self::ParseCommunicationValue, Self::ParseCommunicationValue) => true, HandshakeTimeout,
(Self::Encode, Self::Encode) => true, StreamClosed,
(Self::CertificateParseFailed, Self::CertificateParseFailed) => true, IoError(String),
(Self::CertificateLoadFailed, Self::CertificateLoadFailed) => true, ConnectionClosed,
(Self::ParseError(a), Self::ParseError(b)) => a == b, }
(Self::ConnectingError(a), Self::ConnectingError(b)) => a == b,
(Self::MessageTooLarge, Self::MessageTooLarge) => true, #[cfg(feature = "pipes")]
(Self::StreamClosed, Self::StreamClosed) => true, impl std::fmt::Display for PipeError {
(Self::StreamError, Self::StreamError) => true, fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
(Self::CryptoProviderInstallFailed, Self::CryptoProviderInstallFailed) => true, match self {
(Self::AuthenticationFailed(a), Self::AuthenticationFailed(b)) => a == b, PipeError::Rejected => write!(f, "pipe request was rejected"),
(Self::Other(a), Self::Other(b)) => a == b, PipeError::HandshakeTimeout => write!(f, "pipe handshake timed out"),
_ => false, PipeError::StreamClosed => write!(f, "pipe stream closed unexpectedly"),
PipeError::IoError(s) => write!(f, "pipe I/O error: {s}"),
PipeError::ConnectionClosed => write!(f, "connection closed"),
} }
} }
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(feature = "pipes")]
impl Eq for CommunicationError {} impl std::error::Error for PipeError {}
#[cfg(target_arch = "wasm32")] #[cfg(feature = "pipes")]
impl Eq for CommunicationError {} impl From<CommunicationError> for PipeError {
fn from(e: CommunicationError) -> Self {
match e {
CommunicationError::StreamClosed => PipeError::StreamClosed,
CommunicationError::ConnectionError(_) => PipeError::ConnectionClosed,
other => PipeError::IoError(other.to_string()),
}
}
}
/* ===================== Handshake Outcome Types ===================== */
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RejectionReason {
BadVersion { supported_versions: Vec<String> },
AuthenticationFailed { detail: String },
RateLimited,
}
impl std::fmt::Display for RejectionReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RejectionReason::BadVersion { supported_versions } => {
write!(
f,
"unsupported protocol version; supported: {}",
supported_versions.join(", ")
)
}
RejectionReason::AuthenticationFailed { detail } => {
write!(f, "authentication failed: {detail}")
}
RejectionReason::RateLimited => write!(f, "rate limited"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HandshakeOutcome {
Accepted { version: String, assigned_id: u64 },
Rejected { reason: RejectionReason },
}
impl std::fmt::Display for HandshakeOutcome {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HandshakeOutcome::Accepted {
version,
assigned_id,
} => {
write!(f, "accepted (version={version}, id={assigned_id})")
}
HandshakeOutcome::Rejected { reason } => write!(f, "rejected: {reason}"),
}
}
}
/* ================================ TESTS ================================ */ /* ================================ TESTS ================================ */
#[cfg(test)] #[cfg(test)]
@ -291,3 +395,132 @@ mod communication_error_tests {
assert!(format!("{}", e).contains("refused")); assert!(format!("{}", e).contains("refused"));
} }
} }
/* ================================ PipeError TESTS ================================ */
#[cfg(feature = "pipes")]
#[cfg(test)]
mod pipe_error_tests {
use super::*;
#[test]
fn test_pipe_error_display() {
assert_eq!(
format!("{}", PipeError::Rejected),
"pipe request was rejected"
);
assert_eq!(
format!("{}", PipeError::HandshakeTimeout),
"pipe handshake timed out"
);
assert_eq!(
format!("{}", PipeError::StreamClosed),
"pipe stream closed unexpectedly"
);
assert_eq!(
format!("{}", PipeError::ConnectionClosed),
"connection closed"
);
assert_eq!(
format!("{}", PipeError::IoError("boom".into())),
"pipe I/O error: boom"
);
}
#[test]
fn test_pipe_error_from_stream_closed() {
let pe: PipeError = CommunicationError::StreamClosed.into();
assert_eq!(pe, PipeError::StreamClosed);
}
#[test]
fn test_pipe_error_from_connection_error() {
let pe: PipeError =
CommunicationError::ConnectionError(wtransport::error::ConnectionError::TimedOut)
.into();
assert_eq!(pe, PipeError::ConnectionClosed);
}
#[test]
fn test_pipe_error_from_other() {
let pe: PipeError = CommunicationError::StreamError.into();
assert_eq!(pe, PipeError::IoError("Stream Error".into()));
}
}
/* ==================== HandshakeOutcome TESTS ==================== */
#[cfg(test)]
mod handshake_outcome_tests {
use super::*;
#[test]
fn test_accepted_display() {
let outcome = HandshakeOutcome::Accepted {
version: "1.0".into(),
assigned_id: 42,
};
assert_eq!(format!("{outcome}"), "accepted (version=1.0, id=42)");
}
#[test]
fn test_rejected_bad_version_display() {
let outcome = HandshakeOutcome::Rejected {
reason: RejectionReason::BadVersion {
supported_versions: vec!["1.0".into(), "2.0".into()],
},
};
let msg = format!("{outcome}");
assert!(msg.contains("1.0"));
assert!(msg.contains("2.0"));
}
#[test]
fn test_rejected_auth_failed_display() {
let outcome = HandshakeOutcome::Rejected {
reason: RejectionReason::AuthenticationFailed {
detail: "invalid signature".into(),
},
};
assert!(format!("{outcome}").contains("invalid signature"));
}
#[test]
fn test_rejected_rate_limited_display() {
let outcome = HandshakeOutcome::Rejected {
reason: RejectionReason::RateLimited,
};
assert_eq!(format!("{outcome}"), "rejected: rate limited");
}
#[test]
fn test_rejection_reason_display() {
assert!(
format!(
"{}",
RejectionReason::BadVersion {
supported_versions: vec!["1.0".into()]
}
)
.contains("1.0")
);
assert!(
format!(
"{}",
RejectionReason::AuthenticationFailed {
detail: "bad".into()
}
)
.contains("bad")
);
assert_eq!(format!("{}", RejectionReason::RateLimited), "rate limited");
}
#[test]
fn test_handshake_outcome_clone_eq() {
let a = HandshakeOutcome::Accepted {
version: "1.0".into(),
assigned_id: 1,
};
let b = a.clone();
assert_eq!(a, b);
}
}

243
create-web-release.mjs Normal file
View file

@ -0,0 +1,243 @@
#!/usr/bin/env node
import { execFile, spawn } from "node:child_process";
import { access, cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
const execFileAsync = promisify(execFile);
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".");
const packageJsonPath = path.join(repositoryRoot, "package.json");
function usage() {
return `Usage: node create-web-release.mjs [options]
Build and pack the browser package using the version of the root Cargo package.
Options:
--skip-build Pack the existing dist/ and wasm/pkg/ artifacts
--output-dir <path> Write the archive to this directory (default: repository root)
--help Show this help
`;
}
function parseArguments(arguments_) {
const options = {
outputDir: repositoryRoot,
skipBuild: false,
};
for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index];
if (argument === "--help") {
options.help = true;
} else if (argument === "--skip-build") {
options.skipBuild = true;
} else if (argument === "--output-dir") {
const outputDir = arguments_[index + 1];
if (!outputDir || outputDir.startsWith("--")) {
throw new Error("--output-dir requires a directory path");
}
options.outputDir = path.resolve(repositoryRoot, outputDir);
index += 1;
} else if (argument.startsWith("--output-dir=")) {
const outputDir = argument.slice("--output-dir=".length);
if (!outputDir) {
throw new Error("--output-dir requires a directory path");
}
options.outputDir = path.resolve(repositoryRoot, outputDir);
} else {
throw new Error(`Unknown option: ${argument}`);
}
}
return options;
}
async function readJson(filePath) {
const source = await readFile(filePath, "utf8");
try {
return JSON.parse(source);
} catch (error) {
throw new Error(`Invalid JSON in ${path.relative(repositoryRoot, filePath)}`, {
cause: error,
});
}
}
async function run(command, arguments_, options = {}) {
const renderedArguments = arguments_.map((argument) => JSON.stringify(argument)).join(" ");
console.log(`\n> ${command}${renderedArguments ? ` ${renderedArguments}` : ""}`);
await new Promise((resolve, reject) => {
const child = spawn(command, arguments_, {
cwd: options.cwd ?? repositoryRoot,
env: options.env ?? process.env,
stdio: "inherit",
});
child.once("error", (error) => {
reject(new Error(`Failed to run ${command}: ${error.message}`, { cause: error }));
});
child.once("exit", (code, signal) => {
if (code === 0) {
resolve();
return;
}
const reason = signal ? `signal ${signal}` : `exit code ${code}`;
reject(new Error(`${command} failed with ${reason}`));
});
});
}
async function readCargoVersion() {
let stdout;
try {
({ stdout } = await execFileAsync(
"cargo",
[
"metadata",
"--no-deps",
"--format-version",
"1",
"--manifest-path",
path.join(repositoryRoot, "Cargo.toml"),
],
{ cwd: repositoryRoot, maxBuffer: 1024 * 1024 },
));
} catch (error) {
throw new Error(`Unable to read the root Cargo package version: ${error.message}`, {
cause: error,
});
}
let metadata;
try {
metadata = JSON.parse(stdout);
} catch (error) {
throw new Error("cargo metadata returned invalid JSON", { cause: error });
}
const rootPackage = metadata.packages?.find((packageMetadata) => packageMetadata.name === "mtp");
if (!rootPackage || typeof rootPackage.version !== "string") {
throw new Error("The root Cargo package named 'mtp' was not found");
}
return rootPackage.version;
}
function packageRelativePath(entry) {
if (typeof entry !== "string" || entry.length === 0) {
throw new Error("package.json files entries must be non-empty strings");
}
const relativePath = entry.replace(/\/$/, "");
if (
!relativePath ||
path.isAbsolute(relativePath) ||
relativePath.split(/[\\/]/u).includes("..") ||
relativePath.includes("*")
) {
throw new Error(`Unsupported package file entry: ${entry}`);
}
return relativePath;
}
async function copyPackageFiles(stageRoot, packageJson) {
if (!Array.isArray(packageJson.files)) {
throw new Error("package.json must declare a files array for Web releases");
}
for (const entry of packageJson.files) {
const relativePath = packageRelativePath(entry);
const sourcePath = path.join(repositoryRoot, relativePath);
const destinationPath = path.join(stageRoot, relativePath);
try {
await access(sourcePath);
} catch (error) {
throw new Error(`Release file is missing: ${relativePath}`, { cause: error });
}
await mkdir(path.dirname(destinationPath), { recursive: true });
await cp(sourcePath, destinationPath, { recursive: true });
}
}
async function createRelease({ outputDir, packageJson, version }) {
const stageRoot = await mkdtemp(path.join(os.tmpdir(), "mtp-web-release-"));
const stagedPackageJson = {
...packageJson,
version,
};
try {
await writeFile(
path.join(stageRoot, "package.json"),
`${JSON.stringify(stagedPackageJson, null, 2)}\n`,
);
await copyPackageFiles(stageRoot, packageJson);
const stagedWasmPackagePath = path.join(stageRoot, "wasm", "pkg", "package.json");
const stagedWasmPackageJson = await readJson(stagedWasmPackagePath);
stagedWasmPackageJson.version = version;
await writeFile(
stagedWasmPackagePath,
`${JSON.stringify(stagedWasmPackageJson, null, 2)}\n`,
);
await mkdir(outputDir, { recursive: true });
const archiveName = `${packageJson.name}-${version}.tgz`;
const archivePath = path.join(outputDir, archiveName);
await rm(archivePath, { force: true });
await run("npm", ["pack", "--pack-destination", outputDir], { cwd: stageRoot });
try {
await access(archivePath);
} catch (error) {
throw new Error(`npm pack did not create ${archiveName}`, { cause: error });
}
return archivePath;
} finally {
await rm(stageRoot, { recursive: true, force: true });
}
}
async function main() {
const options = parseArguments(process.argv.slice(2));
if (options.help) {
console.log(usage());
return;
}
const packageJson = await readJson(packageJsonPath);
if (packageJson.name !== "mtp") {
throw new Error("package.json must describe the 'mtp' Web package");
}
const version = await readCargoVersion();
console.log(`Using Cargo package version ${version}`);
if (!options.skipBuild) {
await run("pnpm", ["run", "clean"]);
await run("pnpm", ["run", "build"]);
}
const archivePath = await createRelease({
outputDir: options.outputDir,
packageJson,
version,
});
console.log(`\nCreated ${path.relative(repositoryRoot, archivePath) || archivePath}`);
}
main().catch((error) => {
console.error(`\n${error.message}`);
process.exitCode = 1;
});

1405
crypto/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,15 @@
[package] [package]
name = "mtp-crypto" name = "mtp-crypto"
version = "0.1.0" version = "0.3.0"
edition = "2024" edition = "2024"
[package.metadata.cargo-machete] [package.metadata.cargo-machete]
ignored = ["getrandom"] ignored = ["rand_core"]
[dependencies] [dependencies]
chacha20poly1305 = { version = "0.10", optional = true } chacha20poly1305 = { version = "0.10", optional = true }
aes-gcm = { version = "0.10", optional = true } aes-gcm = { version = "0.10", optional = true }
ed25519-dalek = { version = "2.2", optional = true, features = [ ed25519-dalek = { version = "3.0", optional = true, features = [
"pkcs8", "pkcs8",
"pem", "pem",
] } ] }
@ -17,17 +17,31 @@ hkdf = { version = "0.13", optional = true }
sha2 = { version = "0.11", optional = true } sha2 = { version = "0.11", optional = true }
zeroize = { version = "1.9", features = ["derive"] } zeroize = { version = "1.9", features = ["derive"] }
thiserror = "1" thiserror = "1"
base64 = "0.22"
rand_core = { version = "0.6", features = ["getrandom"] } rand_core = { version = "0.6", features = ["getrandom"] }
rand = "0.10.2"
getrandom = "0.4.3" getrandom = "0.4.3"
mlkem-tls = { version = "0.2", optional = true } mlkem-tls = { version = "0.2", optional = true }
ml-dsa = { version = "0.1.1", optional = true } ml-dsa = { version = "0.1.1", optional = true }
argon2 = { version = "0.5", optional = true }
serde = { version = "1", optional = true, features = ["derive"] } serde = { version = "1", optional = true, features = ["derive"] }
rcgen = { version = "0.14", optional = true }
time = { version = "0.3", optional = true }
tokio = { version = "1", features = ["macros", "rt"], optional = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rustls = "0.23.41"
[features] [features]
default = ["chacha20poly1305", "ed25519-dalek", "hkdf", "sha2", "ml-dsa"] default = ["chacha20poly1305", "ed25519-dalek", "hkdf", "sha2", "ml-dsa", "parallel"]
# Enabling ml-dsa by default ensures dual-signature support in the handshake # Enabling ml-dsa by default ensures dual-signature support in the handshake
# without requiring a separate PQC feature flag in host/client crates. # without requiring a separate PQC feature flag in host/client crates.
full = ["default", "aes-gcm"] full = ["default", "aes-gcm"]
pqc = ["mlkem-tls", "ml-dsa"] pqc = ["mlkem-tls", "ml-dsa"]
serde = ["dep:serde"] serde = ["dep:serde"]
wasm = ["getrandom/wasm_js"] wasm = ["getrandom/wasm_js"]
hkdf = ["dep:hkdf", "dep:sha2"]
sha2 = ["dep:sha2"]
tls = ["dep:rcgen", "dep:time"]
parallel = ["dep:tokio"]
password-kdf = ["dep:argon2"]

View file

@ -1,113 +0,0 @@
# mtp-crypto
Cryptographic primitives for the MTP protocol. Classical and post-quantum.
## Features
| Feature | Primitives | Status |
|---------|-----------|--------|
| `default` | XChaCha20-Poly1305, Ed25519, HKDF-SHA-256, SHA-256 | Classical |
| `full` | default + AES-256-GCM | Classical |
| `pqc` | ML-KEM-768+X25519 hybrid KEM, ML-DSA-65 | Post-quantum |
## AEAD
XChaCha20-Poly1305 (default) and AES-256-GCM (`full` feature). Nonce is prepended to ciphertext.
```rust
use mtp_crypto::{ChaCha20Poly1305, AeadEncrypt, AeadDecrypt};
let cipher = ChaCha20Poly1305::new([0u8; 32]);
let ct = cipher.encrypt(b"hello", b"aad")?;
let pt = cipher.decrypt(&ct, b"aad")?;
```
## Signatures
### Ed25519
```rust
use mtp_crypto::{Ed25519Signer, SignatureScheme};
let (signer, sk, pk) = Ed25519Signer::generate();
let sig = signer.sign(b"message")?;
signer.verify(b"message", &sig)?;
```
### ML-DSA-65
```rust
use mtp_crypto::{MlDsaSigner, SignatureScheme};
let (signer, sk, pk) = MlDsaSigner::generate();
let sig = signer.sign(b"message")?;
signer.verify(b"message", &sig)?;
// Load from stored bytes
let signer = MlDsaSigner::new(&sk, &pk)?;
```
### Dual signatures
```rust
use mtp_crypto::{sign_dual, DualSignature, Ed25519Signer, MlDsaSigner};
let (ed_signer, _, _) = Ed25519Signer::generate();
let (ml_signer, _, _) = MlDsaSigner::generate();
let dual = sign_dual(ed_signer.signing_key(), ml_signer.signing_key(), b"msg");
dual.verify(ed_signer.verifying_key(), ml_signer.verifying_key(), b"msg")?;
```
## Hybrid KEM
X25519 + ML-KEM-768. 64-byte shared secret. Feed into HKDF before use.
```rust
use mtp_crypto::HybridKem;
let (sk, pk) = HybridKem::generate_keypair();
let enc = HybridKem::encapsulate(&pk)?;
let ss = HybridKem::decapsulate(&sk, &enc.ciphertext)?;
assert_eq!(enc.shared_secret, ss);
```
## KDF
```rust
use mtp_crypto::{hkdf_expand, derive_encryption_key};
let key = derive_encryption_key(b"ikm", b"salt", b"context")?;
```
## Hashing
```rust
use mtp_crypto::{sha256, sha256_double};
let h = sha256(b"data");
let h2 = sha256_double(b"data");
```
## Key types
| Type | Secret | Zeroized |
|------|--------|----------|
| `EncryptionPrivateKey` | KEM/ECDH secret | Yes |
| `EncryptionPublicKey` | KEM/ECDH public | No |
| `SignaturePrivateKey` | Classical signing key | Yes |
| `SignaturePublicKey` | Classical verifying key | No |
| `KemPrivateKey` | Hybrid KEM secret | Yes |
| `KemPublicKey` | Hybrid KEM public | No |
| `SignaturePqPrivateKey` | PQC signing key | Yes |
| `SignaturePqPublicKey` | PQC verifying key | No |
`KeyGroup` holds classical keys; `Keyring` holds all six (hybrid KEM + PQ sig + classical sig).
## Feature flags
```toml
[dependencies]
mtp-crypto = { path = "../crypto" } # classical
mtp-crypto = { path = "../crypto", features = ["pqc"] } # post-quantum
mtp-crypto = { path = "../crypto", features = ["full", "pqc"] } # all
```

View file

@ -1,10 +1,19 @@
use crate::error::CryptoError; use crate::error::CryptoError;
#[cfg(any(feature = "chacha20poly1305", feature = "aes-gcm"))] #[cfg(any(feature = "chacha20poly1305", feature = "aes-gcm"))]
use rand_core::OsRng; use zeroize::Zeroizing;
#[cfg(any(feature = "chacha20poly1305", feature = "aes-gcm"))] #[cfg(any(feature = "chacha20poly1305", feature = "aes-gcm"))]
use rand_core::RngCore; use getrandom::fill;
/// Authentication-tag length shared by the supported AEAD constructions.
pub const AUTH_TAG_LEN: usize = 16;
/// Nonce length stored at the front of an XChaCha20-Poly1305 output.
pub const XCHACHA20POLY1305_NONCE_LEN: usize = 24;
/// Nonce length stored at the front of an AES-256-GCM output.
pub const AES256GCM_NONCE_LEN: usize = 12;
pub trait AeadEncrypt { pub trait AeadEncrypt {
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError>; fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError>;
@ -27,29 +36,31 @@ fn prepend_nonce(nonce: &[u8], ciphertext: &mut Vec<u8>) -> Vec<u8> {
} }
#[cfg(feature = "chacha20poly1305")] #[cfg(feature = "chacha20poly1305")]
pub struct ChaCha20Poly1305 { pub struct XChaCha20Poly1305 {
key: [u8; 32], key: Zeroizing<[u8; 32]>,
} }
#[cfg(feature = "chacha20poly1305")] #[cfg(feature = "chacha20poly1305")]
impl ChaCha20Poly1305 { impl XChaCha20Poly1305 {
pub fn new(key: [u8; 32]) -> Self { pub fn new(key: [u8; 32]) -> Self {
Self { key } Self {
key: Zeroizing::new(key),
}
} }
} }
#[cfg(feature = "chacha20poly1305")] #[cfg(feature = "chacha20poly1305")]
impl AeadEncrypt for ChaCha20Poly1305 { impl AeadEncrypt for XChaCha20Poly1305 {
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> { fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
use chacha20poly1305::XChaCha20Poly1305; use chacha20poly1305::XChaCha20Poly1305;
use chacha20poly1305::XNonce; use chacha20poly1305::XNonce;
use chacha20poly1305::aead::{Aead, KeyInit, Payload}; use chacha20poly1305::aead::{Aead, KeyInit, Payload};
let key = chacha20poly1305::Key::from_slice(&self.key); let key = chacha20poly1305::Key::from_slice(self.key.as_ref());
let cipher = XChaCha20Poly1305::new(key); let cipher = XChaCha20Poly1305::new(key);
let mut nonce = [0u8; 24]; let mut nonce = [0u8; XCHACHA20POLY1305_NONCE_LEN];
OsRng.fill_bytes(&mut nonce); fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?;
let nonce_ref = XNonce::from_slice(&nonce); let nonce_ref = XNonce::from_slice(&nonce);
let payload = Payload { let payload = Payload {
@ -66,18 +77,18 @@ impl AeadEncrypt for ChaCha20Poly1305 {
} }
#[cfg(feature = "chacha20poly1305")] #[cfg(feature = "chacha20poly1305")]
impl AeadDecrypt for ChaCha20Poly1305 { impl AeadDecrypt for XChaCha20Poly1305 {
fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> { fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
use chacha20poly1305::XChaCha20Poly1305; use chacha20poly1305::XChaCha20Poly1305;
use chacha20poly1305::XNonce; use chacha20poly1305::XNonce;
use chacha20poly1305::aead::{Aead, KeyInit, Payload}; use chacha20poly1305::aead::{Aead, KeyInit, Payload};
if ciphertext.len() < 24 { if ciphertext.len() < XCHACHA20POLY1305_NONCE_LEN + AUTH_TAG_LEN {
return Err(CryptoError::InvalidNonceLength); return Err(CryptoError::InvalidNonceLength);
} }
let (nonce, ct) = ciphertext.split_at(24); let (nonce, ct) = ciphertext.split_at(XCHACHA20POLY1305_NONCE_LEN);
let key = chacha20poly1305::Key::from_slice(&self.key); let key = chacha20poly1305::Key::from_slice(self.key.as_ref());
let cipher = XChaCha20Poly1305::new(key); let cipher = XChaCha20Poly1305::new(key);
let nonce_ref = XNonce::from_slice(nonce); let nonce_ref = XNonce::from_slice(nonce);
@ -90,21 +101,28 @@ impl AeadDecrypt for ChaCha20Poly1305 {
} }
#[cfg(feature = "chacha20poly1305")] #[cfg(feature = "chacha20poly1305")]
impl AeadCipher for ChaCha20Poly1305 { impl AeadCipher for XChaCha20Poly1305 {
fn key_size() -> usize { fn key_size() -> usize {
32 32
} }
} }
/// Compatibility alias for the original public name. The implementation is
/// XChaCha20-Poly1305, including its 24-byte nonce format.
#[cfg(feature = "chacha20poly1305")]
pub type ChaCha20Poly1305 = XChaCha20Poly1305;
#[cfg(feature = "aes-gcm")] #[cfg(feature = "aes-gcm")]
pub struct Aes256Gcm { pub struct Aes256Gcm {
key: [u8; 32], key: Zeroizing<[u8; 32]>,
} }
#[cfg(feature = "aes-gcm")] #[cfg(feature = "aes-gcm")]
impl Aes256Gcm { impl Aes256Gcm {
pub fn new(key: [u8; 32]) -> Self { pub fn new(key: [u8; 32]) -> Self {
Self { key } Self {
key: Zeroizing::new(key),
}
} }
} }
@ -115,11 +133,11 @@ impl AeadEncrypt for Aes256Gcm {
use aes_gcm::Nonce; use aes_gcm::Nonce;
use aes_gcm::aead::{Aead, KeyInit, Payload}; use aes_gcm::aead::{Aead, KeyInit, Payload};
let key = aes_gcm::Key::<AesGcmInner>::from_slice(&self.key); let key = aes_gcm::Key::<AesGcmInner>::from_slice(self.key.as_ref());
let cipher = AesGcmInner::new(key); let cipher = AesGcmInner::new(key);
let mut nonce = [0u8; 12]; let mut nonce = [0u8; AES256GCM_NONCE_LEN];
OsRng.fill_bytes(&mut nonce); fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?;
let nonce_ref = Nonce::from_slice(&nonce); let nonce_ref = Nonce::from_slice(&nonce);
let payload = Payload { let payload = Payload {
@ -142,12 +160,12 @@ impl AeadDecrypt for Aes256Gcm {
use aes_gcm::Nonce; use aes_gcm::Nonce;
use aes_gcm::aead::{Aead, KeyInit, Payload}; use aes_gcm::aead::{Aead, KeyInit, Payload};
if ciphertext.len() < 12 { if ciphertext.len() < AES256GCM_NONCE_LEN + AUTH_TAG_LEN {
return Err(CryptoError::InvalidNonceLength); return Err(CryptoError::InvalidNonceLength);
} }
let (nonce, ct) = ciphertext.split_at(12); let (nonce, ct) = ciphertext.split_at(AES256GCM_NONCE_LEN);
let key = aes_gcm::Key::<AesGcmInner>::from_slice(&self.key); let key = aes_gcm::Key::<AesGcmInner>::from_slice(self.key.as_ref());
let cipher = AesGcmInner::new(key); let cipher = AesGcmInner::new(key);
let nonce_ref = Nonce::from_slice(nonce); let nonce_ref = Nonce::from_slice(nonce);

View file

@ -11,7 +11,7 @@
* Step 4. Host -> Client : IdentificationResponse { connected, id, host_sig } host_sig over host_final_payload * Step 4. Host -> Client : IdentificationResponse { connected, id, host_sig } host_sig over host_final_payload
*/ */
/// Domain-separation tags — a distinct leading byte per signed context. /// Domain-separation tags
pub mod domain { pub mod domain {
/// Host's signature over the challenge it issues (step 2). /// Host's signature over the challenge it issues (step 2).
pub const CHALLENGE: u8 = 0x10; pub const CHALLENGE: u8 = 0x10;

View file

@ -1,19 +1,15 @@
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] #[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::error::CryptoError; use crate::error::CryptoError;
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] #[cfg(feature = "mlkem-tls")]
use crate::kdf::derive_encryption_key;
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::kem::HybridKem; use crate::kem::HybridKem;
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::keypair::{Keyring, PublicKeyBundle};
/* /*
* Algorithm selector for encrypted containers. * Algorithm selector for encrypted values.
* *
* Mirrors `SigAlgorithm` for signatures: a single marking byte identifies the * Mirrors `SigAlgorithm` for signatures: a single marking byte identifies the
* key-encapsulation mechanism and the AEAD used to seal a container. The byte * key-encapsulation mechanism and the AEAD used to seal a container. The byte
* is stored as the first byte of every encrypted blob so the decryptor can pick * is stored as the first byte of every encrypted envelope so the decryptor can pick
* the matching algorithm (and the matching keypair from a `Keyring`) without * the matching algorithm (and the matching keypair from a `Keyring`) without
* any out-of-band agreement. * any out-of-band agreement.
* *
@ -34,7 +30,7 @@ impl EncryptionType {
pub const ML_KEM_CHACHA20POLY1305: u8 = 0x01; pub const ML_KEM_CHACHA20POLY1305: u8 = 0x01;
pub const ML_KEM_AES256_GCM: u8 = 0x02; pub const ML_KEM_AES256_GCM: u8 = 0x02;
/// The marking byte written at the front of an encrypted blob. /// The marking byte written at the front of an encrypted envelope.
pub const fn to_byte(self) -> u8 { pub const fn to_byte(self) -> u8 {
match self { match self {
Self::MlKemChaCha20Poly1305 => Self::ML_KEM_CHACHA20POLY1305, Self::MlKemChaCha20Poly1305 => Self::ML_KEM_CHACHA20POLY1305,
@ -50,6 +46,50 @@ impl EncryptionType {
_ => None, _ => None,
} }
} }
/// Size of the content-encryption key wrapped for each recipient.
pub const CONTENT_ENCRYPTION_KEY_LEN: usize = 32;
/// The fixed-size ciphertext emitted by the KEM selected by this suite.
pub const fn kem_ciphertext_len(self) -> usize {
match self {
Self::MlKemChaCha20Poly1305 | Self::MlKemAes256Gcm => {
#[cfg(feature = "mlkem-tls")]
{
HybridKem::ciphertext_len()
}
#[cfg(not(feature = "mlkem-tls"))]
{
0
}
}
}
}
/// Bytes the selected AEAD prepends/appends to an encrypted payload.
pub const fn aead_overhead(self) -> usize {
match self {
Self::MlKemChaCha20Poly1305 => {
crate::aead::XCHACHA20POLY1305_NONCE_LEN + crate::aead::AUTH_TAG_LEN
}
Self::MlKemAes256Gcm => crate::aead::AES256GCM_NONCE_LEN + crate::aead::AUTH_TAG_LEN,
}
}
/// Total output length for an encrypted plaintext of `plaintext_len` bytes.
pub const fn encrypted_len(self, plaintext_len: usize) -> usize {
plaintext_len.saturating_add(self.aead_overhead())
}
/// Minimum valid AEAD output length for this suite.
pub const fn minimum_ciphertext_len(self) -> usize {
self.encrypted_len(0)
}
/// The size of a wrapped 32-byte content key for this suite.
pub const fn wrapped_key_len(self) -> usize {
self.encrypted_len(Self::CONTENT_ENCRYPTION_KEY_LEN)
}
} }
/* /*
@ -59,7 +99,7 @@ impl EncryptionType {
*/ */
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] #[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
#[allow(unused_variables)] #[allow(unused_variables)]
fn aead_seal( pub fn seal_with_key(
enc_type: EncryptionType, enc_type: EncryptionType,
key: [u8; 32], key: [u8; 32],
plaintext: &[u8], plaintext: &[u8],
@ -70,7 +110,7 @@ fn aead_seal(
match enc_type { match enc_type {
#[cfg(feature = "chacha20poly1305")] #[cfg(feature = "chacha20poly1305")]
EncryptionType::MlKemChaCha20Poly1305 => { EncryptionType::MlKemChaCha20Poly1305 => {
crate::aead::ChaCha20Poly1305::new(key).encrypt(plaintext, aad) crate::aead::XChaCha20Poly1305::new(key).encrypt(plaintext, aad)
} }
#[cfg(feature = "aes-gcm")] #[cfg(feature = "aes-gcm")]
EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).encrypt(plaintext, aad), EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).encrypt(plaintext, aad),
@ -86,7 +126,7 @@ fn aead_seal(
*/ */
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] #[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
#[allow(unused_variables)] #[allow(unused_variables)]
fn aead_open( pub fn open_with_key(
enc_type: EncryptionType, enc_type: EncryptionType,
key: [u8; 32], key: [u8; 32],
ciphertext: &[u8], ciphertext: &[u8],
@ -97,7 +137,7 @@ fn aead_open(
match enc_type { match enc_type {
#[cfg(feature = "chacha20poly1305")] #[cfg(feature = "chacha20poly1305")]
EncryptionType::MlKemChaCha20Poly1305 => { EncryptionType::MlKemChaCha20Poly1305 => {
crate::aead::ChaCha20Poly1305::new(key).decrypt(ciphertext, aad) crate::aead::XChaCha20Poly1305::new(key).decrypt(ciphertext, aad)
} }
#[cfg(feature = "aes-gcm")] #[cfg(feature = "aes-gcm")]
EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).decrypt(ciphertext, aad), EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).decrypt(ciphertext, aad),
@ -106,76 +146,51 @@ fn aead_open(
} }
} }
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
const ENC_KDF_SALT: &[u8] = b"mtp-container-enc";
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
const ENC_KDF_CONTEXT: &[u8] = b"single-recipient";
/*
* Encrypt `plaintext` for a single recipient, selecting the algorithm with
* `enc_type` and the recipient's KEM public key from `recipient`.
*
* The returned, self-describing blob is laid out as:
* [1 byte EncryptionType] [2 bytes u16 kem_ct_len] [kem_ciphertext] [aead_payload]
* where `aead_payload` is the AEAD output (nonce + ciphertext + tag). The AEAD
* key is derived from the KEM shared secret via HKDF, so no separate content key
* is transmitted.
*
* Requires the `mlkem-tls` and `hkdf` features, plus the AEAD feature backing
* `enc_type`.
*/
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub fn encrypt_for(
enc_type: EncryptionType,
recipient: &PublicKeyBundle,
plaintext: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, CryptoError> {
let enc = HybridKem::encapsulate(&recipient.kem_public_key)?;
let key = derive_encryption_key(&enc.shared_secret, ENC_KDF_SALT, ENC_KDF_CONTEXT)?;
let aead_payload = aead_seal(enc_type, key, plaintext, aad)?;
let kem_ct = enc.ciphertext;
let mut out = Vec::with_capacity(1 + 2 + kem_ct.len() + aead_payload.len());
out.push(enc_type.to_byte());
out.extend_from_slice(&(kem_ct.len() as u16).to_be_bytes());
out.extend_from_slice(&kem_ct);
out.extend_from_slice(&aead_payload);
Ok(out)
}
/*
* Decrypt a blob produced by [`encrypt_for`] using `keyring`.
*
* The leading byte selects the `EncryptionType` (and thus which keypair to use
* from the keyring); for the current ML-KEM variants that is `kem_secret_key`.
* Returns `DecryptionFailed` on any malformed input or authentication failure.
*
* Requires the `mlkem-tls` and `hkdf` features, plus the AEAD feature backing
* the blob's algorithm.
*/
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub fn decrypt_with(blob: &[u8], keyring: &Keyring, aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
if blob.len() < 3 {
return Err(CryptoError::DecryptionFailed);
}
let enc_type = EncryptionType::from_byte(blob[0]).ok_or(CryptoError::DecryptionFailed)?;
let kem_ct_len = u16::from_be_bytes([blob[1], blob[2]]) as usize;
let kem_end = 3usize
.checked_add(kem_ct_len)
.ok_or(CryptoError::DecryptionFailed)?;
let kem_ct = blob.get(3..kem_end).ok_or(CryptoError::DecryptionFailed)?;
let aead_payload = blob.get(kem_end..).ok_or(CryptoError::DecryptionFailed)?;
let shared_secret = HybridKem::decapsulate(&keyring.kem_secret_key, kem_ct)?;
let key = derive_encryption_key(&shared_secret, ENC_KDF_SALT, ENC_KDF_CONTEXT)?;
aead_open(enc_type, key, aead_payload, aad)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[cfg(feature = "mlkem-tls")]
#[test]
fn envelope_parser_uses_suite_dependent_fixed_widths() {
use crate::helper::{MultiEncryptedMessage, RecipientEntry};
let suites = [
EncryptionType::MlKemChaCha20Poly1305,
EncryptionType::MlKemAes256Gcm,
];
assert_ne!(suites[0].wrapped_key_len(), suites[1].wrapped_key_len());
for (index, suite) in suites.into_iter().enumerate() {
let marker = u8::try_from(index).unwrap();
let message = MultiEncryptedMessage {
encryption_type: suite,
purpose: 0xA5,
recipients: vec![RecipientEntry {
kem_ciphertext: vec![0x10 + marker; suite.kem_ciphertext_len()],
encrypted_key: vec![0x20 + marker; suite.wrapped_key_len()],
}],
ciphertext: vec![0x30 + marker; suite.minimum_ciphertext_len() + 3],
};
let encoded = message.to_bytes().expect("synthetic envelope is valid");
let kem_end = 4 + suite.kem_ciphertext_len();
let wrapped_end = kem_end + suite.wrapped_key_len();
assert_eq!(&encoded[..4], &[suite.to_byte(), 0xA5, 0, 1]);
assert_eq!(&encoded[4..kem_end], message.recipients[0].kem_ciphertext);
assert_eq!(
&encoded[kem_end..wrapped_end],
message.recipients[0].encrypted_key
);
assert_eq!(&encoded[wrapped_end..], message.ciphertext);
assert_eq!(
MultiEncryptedMessage::from_bytes(&encoded)
.expect("suite-specific envelope should parse"),
message
);
}
}
#[test] #[test]
fn encryption_type_byte_roundtrip() { fn encryption_type_byte_roundtrip() {
for t in [ for t in [
@ -188,59 +203,19 @@ mod tests {
assert_eq!(EncryptionType::from_byte(0xFF), None); assert_eq!(EncryptionType::from_byte(0xFF), None);
} }
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
#[test] #[test]
fn encrypt_for_roundtrip() { fn suite_lengths_are_derived_from_the_selected_primitives() {
let kr = Keyring::generate(); assert_eq!(
let blob = encrypt_for( EncryptionType::MlKemChaCha20Poly1305.wrapped_key_len(),
EncryptionType::MlKemChaCha20Poly1305, EncryptionType::CONTENT_ENCRYPTION_KEY_LEN
&kr.public_key_bundle(), + crate::aead::XCHACHA20POLY1305_NONCE_LEN
b"secret payload", + crate::aead::AUTH_TAG_LEN
b"aad", );
) assert_eq!(
.unwrap(); EncryptionType::MlKemAes256Gcm.wrapped_key_len(),
assert_eq!(blob[0], EncryptionType::ML_KEM_CHACHA20POLY1305); EncryptionType::CONTENT_ENCRYPTION_KEY_LEN
+ crate::aead::AES256GCM_NONCE_LEN
let pt = decrypt_with(&blob, &kr, b"aad").unwrap(); + crate::aead::AUTH_TAG_LEN
assert_eq!(pt, b"secret payload"); );
}
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
#[test]
fn decrypt_with_wrong_keyring_fails() {
let kr = Keyring::generate();
let other = Keyring::generate();
let blob = encrypt_for(
EncryptionType::MlKemChaCha20Poly1305,
&kr.public_key_bundle(),
b"secret",
b"aad",
)
.unwrap();
assert!(decrypt_with(&blob, &other, b"aad").is_err());
}
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
#[test]
fn decrypt_with_wrong_aad_fails() {
let kr = Keyring::generate();
let blob = encrypt_for(
EncryptionType::MlKemChaCha20Poly1305,
&kr.public_key_bundle(),
b"secret",
b"right",
)
.unwrap();
assert!(decrypt_with(&blob, &kr, b"wrong").is_err());
}
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
#[test]
fn decrypt_with_malformed_fails() {
let kr = Keyring::generate();
assert!(decrypt_with(b"", &kr, b"").is_err());
assert!(decrypt_with(&[0x01, 0x00], &kr, b"").is_err());
// Unknown algorithm byte.
assert!(decrypt_with(&[0x7F, 0x00, 0x00], &kr, b"").is_err());
} }
} }

View file

@ -6,8 +6,18 @@ pub enum CryptoError {
EncryptionFailed, EncryptionFailed,
#[error("decryption failed")] #[error("decryption failed")]
DecryptionFailed, DecryptionFailed,
#[error("decryption output exceeds the caller's allocation limit")]
AllocationLimit,
#[error("malformed encryption envelope")]
MalformedEnvelope,
#[error("no encryption recipients")]
NoRecipients,
#[error("no matching encryption recipient")]
NoMatchingRecipient,
#[error("invalid key length")] #[error("invalid key length")]
InvalidKeyLength, InvalidKeyLength,
#[error("public and private key material do not match")]
InvalidKeyMaterial,
#[error("invalid nonce length")] #[error("invalid nonce length")]
InvalidNonceLength, InvalidNonceLength,
#[error("invalid signature")] #[error("invalid signature")]
@ -28,4 +38,8 @@ pub enum CryptoError {
UnknownAlgorithm, UnknownAlgorithm,
#[error("invalid hex encoding")] #[error("invalid hex encoding")]
InvalidHex, InvalidHex,
#[error("invalid base64 encoding")]
InvalidBase64,
#[error("TLS error: {0}")]
Tls(String),
} }

View file

@ -1,198 +1,471 @@
// Canonical multi-recipient encryption envelopes.
use crate::enc::EncryptionType;
use crate::error::CryptoError; use crate::error::CryptoError;
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] #[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305}; use crate::enc::{open_with_key, seal_with_key};
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] #[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::kdf::derive_encryption_key; use crate::kdf::derive_encryption_key;
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] #[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::kem::HybridKem; use crate::kem::HybridKem;
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] #[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::keypair::{Keyring, PublicKeyBundle}; use crate::keypair::{Keyring, PublicKeyBundle};
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] #[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use rand_core::RngCore; use rand::Rng;
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use zeroize::Zeroizing;
pub const ENCRYPT_DOMAIN: &[u8] = b"MTP-DATA-ENC-1";
pub const KEY_WRAP_DOMAIN: &[u8] = b"MTP-DATA-WRAP-1";
/// Operational cap for recipient entries accepted in one envelope.
///
/// The wire count remains a `u16` for format stability, but decapsulation is
/// intentionally bounded because each entry can require a KEM operation.
pub const MAX_RECIPIENTS: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecipientEntry { pub struct RecipientEntry {
pub kem_ciphertext: Vec<u8>, pub kem_ciphertext: Vec<u8>,
pub encrypted_key: Vec<u8>, pub encrypted_key: Vec<u8>,
} }
/* /// The envelope body used by `DataValue::Encrypted`.
* A payload encrypted for multiple recipients. #[derive(Debug, Clone, PartialEq, Eq)]
*
* Any recipient who possesses the corresponding `KemPrivateKey` can decrypt the message.
*/
pub struct MultiEncryptedMessage { pub struct MultiEncryptedMessage {
pub encryption_type: EncryptionType,
pub purpose: u8,
pub recipients: Vec<RecipientEntry>, pub recipients: Vec<RecipientEntry>,
pub nonce: [u8; 24], /// The AEAD output, including its nonce as defined by the selected suite.
pub ciphertext: Vec<u8>, pub ciphertext: Vec<u8>,
} }
impl MultiEncryptedMessage { /// Borrowed view of a canonical encrypted envelope.
/* ///
* Serialize into a compact byte vector. /// The codec uses this view while validating an attacker-controlled envelope
* /// so parsing it does not first create a complete temporary copy of every
* Format: /// recipient entry and the ciphertext.
* - `num_recipients: u16` #[derive(Debug, Clone, Copy)]
* - for each recipient: pub struct MultiEncryptedMessageRef<'a> {
* - `kem_ct_len: u16` | `kem_ciphertext` encryption_type: EncryptionType,
* - `ek_len: u16` | `encrypted_key` purpose: u8,
* - `nonce: 24 bytes` bytes: &'a [u8],
* - `ciphertext` (remaining) entries_start: usize,
*/ entry_len: usize,
pub fn to_bytes(&self) -> Vec<u8> { count: usize,
let mut out = Vec::new(); ciphertext_start: usize,
out.extend_from_slice(&(self.recipients.len() as u16).to_be_bytes()); }
for r in &self.recipients {
out.extend_from_slice(&(r.kem_ciphertext.len() as u16).to_be_bytes()); impl<'a> MultiEncryptedMessageRef<'a> {
out.extend_from_slice(&r.kem_ciphertext); pub fn from_bytes(bytes: &'a [u8]) -> Result<Self, CryptoError> {
out.extend_from_slice(&(r.encrypted_key.len() as u16).to_be_bytes()); if bytes.len() < 4 {
out.extend_from_slice(&r.encrypted_key); return Err(CryptoError::MalformedEnvelope);
} }
out.extend_from_slice(&self.nonce); let encryption_type =
out.extend_from_slice(&self.ciphertext); EncryptionType::from_byte(bytes[0]).ok_or(CryptoError::UnknownAlgorithm)?;
out let purpose = bytes[1];
let count = u16::from_be_bytes([bytes[2], bytes[3]]) as usize;
if count == 0 || count > MAX_RECIPIENTS {
return Err(CryptoError::MalformedEnvelope);
} }
let entry_len = encryption_type
/// Deserialize from bytes produced by `to_bytes`. .kem_ciphertext_len()
pub fn from_bytes(bytes: &[u8]) -> Result<Self, CryptoError> { .checked_add(encryption_type.wrapped_key_len())
let mut offset = 0; .ok_or(CryptoError::MalformedEnvelope)?;
let read_u16 = |off: &mut usize| -> Result<u16, CryptoError> { let entries_len = count
let slice = bytes .checked_mul(entry_len)
.get(*off..*off + 2) .ok_or(CryptoError::MalformedEnvelope)?;
.ok_or(CryptoError::DecryptionFailed)?; let entries_start = 4usize;
let arr: [u8; 2] = slice let ciphertext_start = entries_start
.try_into() .checked_add(entries_len)
.map_err(|_| CryptoError::DecryptionFailed)?; .ok_or(CryptoError::MalformedEnvelope)?;
*off += 2; let ciphertext_len = bytes
Ok(u16::from_be_bytes(arr)) .len()
}; .checked_sub(ciphertext_start)
.ok_or(CryptoError::MalformedEnvelope)?;
let num = read_u16(&mut offset)? as usize; if ciphertext_len < encryption_type.minimum_ciphertext_len() {
let mut recipients = Vec::with_capacity(num); return Err(CryptoError::MalformedEnvelope);
for _ in 0..num {
let klen = read_u16(&mut offset)? as usize;
let kem_ct = bytes
.get(offset..offset + klen)
.ok_or(CryptoError::DecryptionFailed)?
.to_vec();
offset += klen;
let elen = read_u16(&mut offset)? as usize;
let enc_key = bytes
.get(offset..offset + elen)
.ok_or(CryptoError::DecryptionFailed)?
.to_vec();
offset += elen;
recipients.push(RecipientEntry {
kem_ciphertext: kem_ct,
encrypted_key: enc_key,
});
} }
let nonce: [u8; 24] = bytes
.get(offset..offset + 24)
.ok_or(CryptoError::DecryptionFailed)?
.try_into()
.map_err(|_| CryptoError::DecryptionFailed)?;
offset += 24;
let ciphertext = bytes
.get(offset..)
.ok_or(CryptoError::DecryptionFailed)?
.to_vec();
Ok(Self { Ok(Self {
recipients, encryption_type,
nonce, purpose,
ciphertext, bytes,
entries_start,
entry_len,
count,
ciphertext_start,
}) })
} }
pub const fn encryption_type(&self) -> EncryptionType {
self.encryption_type
}
pub const fn purpose(&self) -> u8 {
self.purpose
}
pub const fn recipient_count(&self) -> usize {
self.count
}
pub fn recipient(&self, index: usize) -> Option<(&'a [u8], &'a [u8])> {
if index >= self.count {
return None;
}
let offset = self
.entries_start
.checked_add(index.checked_mul(self.entry_len)?)?;
let kem_len = self.encryption_type.kem_ciphertext_len();
let kem_end = offset.checked_add(kem_len)?;
let end = offset.checked_add(self.entry_len)?;
Some((
self.bytes.get(offset..kem_end)?,
self.bytes.get(kem_end..end)?,
))
}
pub fn ciphertext(&self) -> &'a [u8] {
&self.bytes[self.ciphertext_start..]
}
pub fn to_owned(self) -> MultiEncryptedMessage {
let recipients = (0..self.count)
.filter_map(|index| {
let (kem_ciphertext, encrypted_key) = self.recipient(index)?;
Some(RecipientEntry {
kem_ciphertext: kem_ciphertext.to_vec(),
encrypted_key: encrypted_key.to_vec(),
})
})
.collect();
MultiEncryptedMessage {
encryption_type: self.encryption_type,
purpose: self.purpose,
recipients,
ciphertext: self.ciphertext().to_vec(),
}
}
} }
/* impl MultiEncryptedMessage {
* Encrypt `plaintext` for every recipient in `entities`. /// Serialize the envelope body without redundant per-recipient lengths.
* pub fn to_bytes(&self) -> Result<Vec<u8>, CryptoError> {
* Internally generates a fresh content-encryption key, encrypts the payload let kem_len = self.encryption_type.kem_ciphertext_len();
* with ChaCha20-Poly1305, then KEM-encapsulates and wraps the key for each let wrapped_len = self.encryption_type.wrapped_key_len();
* recipient. The returned `MultiEncryptedMessage` can be decrypted by any let count =
* entity whose keyring contains the corresponding private KEM key. u16::try_from(self.recipients.len()).map_err(|_| CryptoError::EncryptionFailed)?;
* if self.recipients.is_empty()
* Requires the `pqc` and `chacha20poly1305` features. || self.recipients.len() > MAX_RECIPIENTS
*/ || self.ciphertext.len() < self.encryption_type.minimum_ciphertext_len()
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] || self
pub fn encrypt_multi( .recipients
.iter()
.any(|r| r.kem_ciphertext.len() != kem_len || r.encrypted_key.len() != wrapped_len)
{
return Err(CryptoError::MalformedEnvelope);
}
let mut out = Vec::new();
out.push(self.encryption_type.to_byte());
out.push(self.purpose);
out.extend_from_slice(&count.to_be_bytes());
for recipient in &self.recipients {
out.extend_from_slice(&recipient.kem_ciphertext);
out.extend_from_slice(&recipient.encrypted_key);
}
out.extend_from_slice(&self.ciphertext);
Ok(out)
}
/// Parse the canonical envelope body.
pub fn from_bytes(bytes: &[u8]) -> Result<Self, CryptoError> {
Ok(MultiEncryptedMessageRef::from_bytes(bytes)?.to_owned())
}
}
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
fn wrap_aad(encryption_type: EncryptionType, purpose: u8, kem_ciphertext: &[u8]) -> Vec<u8> {
let mut aad = Vec::with_capacity(KEY_WRAP_DOMAIN.len() + 2 + kem_ciphertext.len());
aad.extend_from_slice(KEY_WRAP_DOMAIN);
aad.push(encryption_type.to_byte());
aad.push(purpose);
aad.extend_from_slice(kem_ciphertext);
aad
}
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
fn payload_aad(message: &MultiEncryptedMessage) -> Result<Vec<u8>, CryptoError> {
let count =
u16::try_from(message.recipients.len()).map_err(|_| CryptoError::MalformedEnvelope)?;
let mut aad = Vec::new();
aad.extend_from_slice(ENCRYPT_DOMAIN);
aad.push(message.encryption_type.to_byte());
aad.push(message.purpose);
aad.extend_from_slice(&count.to_be_bytes());
for recipient in &message.recipients {
aad.extend_from_slice(&recipient.kem_ciphertext);
aad.extend_from_slice(&recipient.encrypted_key);
}
Ok(aad)
}
/// Encrypt a value for one or more recipients using the canonical envelope.
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub fn encrypt_multi_for(
encryption_type: EncryptionType,
purpose: u8,
plaintext: &[u8], plaintext: &[u8],
aad: &[u8],
entities: &[PublicKeyBundle], entities: &[PublicKeyBundle],
) -> Result<MultiEncryptedMessage, CryptoError> { ) -> Result<MultiEncryptedMessage, CryptoError> {
let mut cek = [0u8; 32]; if entities.is_empty() {
rand_core::OsRng.fill_bytes(&mut cek); return Err(CryptoError::NoRecipients);
}
if entities.len() > MAX_RECIPIENTS {
return Err(CryptoError::EncryptionFailed);
}
let cipher = ChaCha20Poly1305::new(cek); let mut cek = Zeroizing::new([0u8; 32]);
let encrypted_payload = cipher.encrypt(plaintext, aad)?; rand::rng().fill_bytes(cek.as_mut());
let nonce: [u8; 24] = encrypted_payload[..24]
.try_into()
.map_err(|_| CryptoError::EncryptionFailed)?;
let ciphertext = encrypted_payload[24..].to_vec();
let mut recipients = Vec::with_capacity(entities.len()); let mut recipients = Vec::with_capacity(entities.len());
for entity in entities { for entity in entities {
let enc = HybridKem::encapsulate(&entity.kem_public_key)?; let enc = HybridKem::encapsulate(&entity.kem_public_key)?;
let wrap_key = derive_encryption_key( let wrap_key = Zeroizing::new(derive_encryption_key(
&enc.shared_secret, &enc.shared_secret,
b"mtp-multi-key-wrap", KEY_WRAP_DOMAIN,
b"multi-recipient", &[encryption_type.to_byte(), purpose],
)?; )?);
let aad = wrap_aad(encryption_type, purpose, &enc.ciphertext);
let wrap_cipher = ChaCha20Poly1305::new(wrap_key); let encrypted_key = seal_with_key(encryption_type, *wrap_key, cek.as_ref(), &aad)?;
let encrypted_key = wrap_cipher.encrypt(&cek, b"")?;
recipients.push(RecipientEntry { recipients.push(RecipientEntry {
kem_ciphertext: enc.ciphertext, kem_ciphertext: enc.ciphertext,
encrypted_key, encrypted_key,
}); });
} }
Ok(MultiEncryptedMessage { let mut message = MultiEncryptedMessage {
encryption_type,
purpose,
recipients, recipients,
nonce, ciphertext: Vec::new(),
ciphertext, };
}) let aad = payload_aad(&message)?;
message.ciphertext = seal_with_key(encryption_type, *cek, plaintext, &aad)?;
Ok(message)
} }
/* /// Decrypt a canonical envelope for a recipient in `keyring`.
* Decrypt a `MultiEncryptedMessage` using the recipient's `Keyring`. #[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
* pub fn decrypt_multi_for(
* Tries each `RecipientEntry` until one succeeds with the given keyring's message: &MultiEncryptedMessage,
* KEM secret key. Returns the original plaintext. purpose: u8,
*/
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
pub fn decrypt_multi(
msg: &MultiEncryptedMessage,
aad: &[u8],
keyring: &Keyring, keyring: &Keyring,
) -> Result<Vec<u8>, CryptoError> { ) -> Result<Vec<u8>, CryptoError> {
for entry in &msg.recipients { decrypt_multi_for_parts(
let ss = match HybridKem::decapsulate(&keyring.kem_secret_key, &entry.kem_ciphertext) { message.encryption_type,
Ok(s) => s, message.purpose,
&message.recipients,
&message.ciphertext,
purpose,
keyring,
)
}
/// Decrypt an envelope represented by borrowed recipient and ciphertext
/// slices. This keeps protected-value opening from cloning an already-owned
/// envelope solely to call the cryptographic primitive.
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub fn decrypt_multi_for_parts(
encryption_type: EncryptionType,
envelope_purpose: u8,
recipients: &[RecipientEntry],
ciphertext: &[u8],
purpose: u8,
keyring: &Keyring,
) -> Result<Vec<u8>, CryptoError> {
if recipients.is_empty()
|| recipients.len() > MAX_RECIPIENTS
|| envelope_purpose != purpose
|| ciphertext.len() < encryption_type.minimum_ciphertext_len()
|| recipients.iter().any(|recipient| {
recipient.kem_ciphertext.len() != encryption_type.kem_ciphertext_len()
|| recipient.encrypted_key.len() != encryption_type.wrapped_key_len()
})
{
return Err(CryptoError::MalformedEnvelope);
}
let count = u16::try_from(recipients.len()).map_err(|_| CryptoError::MalformedEnvelope)?;
let mut payload_aad = Vec::new();
payload_aad.extend_from_slice(ENCRYPT_DOMAIN);
payload_aad.push(encryption_type.to_byte());
payload_aad.push(envelope_purpose);
payload_aad.extend_from_slice(&count.to_be_bytes());
for entry in recipients {
payload_aad.extend_from_slice(&entry.kem_ciphertext);
payload_aad.extend_from_slice(&entry.encrypted_key);
}
for entry in recipients {
let shared_secret =
match HybridKem::decapsulate(&keyring.kem_secret_key, &entry.kem_ciphertext) {
Ok(secret) => secret,
Err(_) => continue, Err(_) => continue,
}; };
let wrap_key = derive_encryption_key(&ss, b"mtp-multi-key-wrap", b"multi-recipient")?; let wrap_key = Zeroizing::new(derive_encryption_key(
let wrap_cipher = ChaCha20Poly1305::new(wrap_key); &shared_secret,
let cek = match wrap_cipher.decrypt(&entry.encrypted_key, b"") { KEY_WRAP_DOMAIN,
Ok(k) => k, &[encryption_type.to_byte(), purpose],
)?);
let aad = wrap_aad(encryption_type, purpose, &entry.kem_ciphertext);
let cek = match open_with_key(encryption_type, *wrap_key, &entry.encrypted_key, &aad) {
Ok(key) => key,
Err(_) => continue, Err(_) => continue,
}; };
let cek_arr: [u8; 32] = cek.try_into().map_err(|_| CryptoError::DecryptionFailed)?; let cek: [u8; 32] = cek.try_into().map_err(|_| CryptoError::DecryptionFailed)?;
return open_with_key(encryption_type, cek, ciphertext, &payload_aad);
}
Err(CryptoError::NoMatchingRecipient)
}
/// Decrypt a canonical envelope only when its plaintext can fit inside the
/// caller's allocation budget.
///
/// The AEAD implementation allocates its output buffer internally. Checking
/// the ciphertext upper bound before entering that implementation makes the
/// codec's reservation meaningful instead of merely checking the result
/// after the allocation has already happened.
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub fn decrypt_multi_for_parts_with_limit(
encryption_type: EncryptionType,
envelope_purpose: u8,
recipients: &[RecipientEntry],
ciphertext: &[u8],
purpose: u8,
keyring: &Keyring,
max_plaintext_len: usize,
) -> Result<Vec<u8>, CryptoError> {
if ciphertext.len() > max_plaintext_len {
return Err(CryptoError::AllocationLimit);
}
decrypt_multi_for_parts(
encryption_type,
envelope_purpose,
recipients,
ciphertext,
purpose,
keyring,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipient_count_is_operationally_bounded() {
assert!(matches!(
MultiEncryptedMessage::from_bytes(&[EncryptionType::ML_KEM_CHACHA20POLY1305, 0, 0, 65]),
Err(CryptoError::MalformedEnvelope)
));
}
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
#[test]
fn authenticated_envelope_fields_reject_tampering() -> Result<(), CryptoError> {
let recipient_a = Keyring::generate();
let recipient_b = Keyring::generate();
let message = encrypt_multi_for(
EncryptionType::MlKemChaCha20Poly1305,
7,
b"authenticated payload",
&[
recipient_a.public_key_bundle(),
recipient_b.public_key_bundle(),
],
)?;
assert_eq!(
decrypt_multi_for(&message, message.purpose, &recipient_a)?,
b"authenticated payload"
);
let mut wrong_purpose = message.clone();
wrong_purpose.purpose ^= 1;
assert!(
decrypt_multi_for(&wrong_purpose, wrong_purpose.purpose, &recipient_a).is_err(),
"mutating the encryption purpose must invalidate the envelope"
);
let mut wrong_recipient_table = message.clone();
wrong_recipient_table.recipients[1].encrypted_key[0] ^= 1;
assert!(
decrypt_multi_for(&wrong_recipient_table, message.purpose, &recipient_a).is_err(),
"mutating another recipient's table entry must invalidate the payload"
);
let mut wrong_ciphertext = message;
let last = wrong_ciphertext.ciphertext.len() - 1;
wrong_ciphertext.ciphertext[last] ^= 1;
assert!(
decrypt_multi_for(&wrong_ciphertext, wrong_ciphertext.purpose, &recipient_a).is_err(),
"mutating the ciphertext must invalidate the envelope"
);
Ok(())
}
#[cfg(feature = "mlkem-tls")]
#[test]
fn rejects_envelopes_without_a_complete_aead_payload() {
let encryption_type = EncryptionType::MlKemChaCha20Poly1305;
let message = MultiEncryptedMessage {
encryption_type,
purpose: 1,
recipients: vec![RecipientEntry {
kem_ciphertext: vec![0; encryption_type.kem_ciphertext_len()],
encrypted_key: vec![0; encryption_type.wrapped_key_len()],
}],
ciphertext: vec![0; encryption_type.minimum_ciphertext_len() - 1],
};
assert!(matches!(
message.to_bytes(),
Err(CryptoError::MalformedEnvelope)
));
let mut encoded = vec![encryption_type.to_byte(), 1, 0, 1];
encoded.extend_from_slice(&vec![0; encryption_type.kem_ciphertext_len()]);
encoded.extend_from_slice(&vec![0; encryption_type.wrapped_key_len()]);
encoded.extend_from_slice(&vec![0; encryption_type.minimum_ciphertext_len() - 1]);
assert!(matches!(
MultiEncryptedMessage::from_bytes(&encoded),
Err(CryptoError::MalformedEnvelope)
));
}
let mut full_ct = Vec::with_capacity(24 + msg.ciphertext.len()); #[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
full_ct.extend_from_slice(&msg.nonce); #[test]
full_ct.extend_from_slice(&msg.ciphertext); fn bounded_decryption_rejects_before_plaintext_allocation() -> Result<(), CryptoError> {
let recipient = Keyring::generate();
let message = encrypt_multi_for(
EncryptionType::MlKemChaCha20Poly1305,
1,
b"bounded plaintext",
&[recipient.public_key_bundle()],
)?;
let data_cipher = ChaCha20Poly1305::new(cek_arr); assert!(matches!(
return data_cipher.decrypt(&full_ct, aad); decrypt_multi_for_parts_with_limit(
message.encryption_type,
message.purpose,
&message.recipients,
&message.ciphertext,
message.purpose,
&recipient,
message.ciphertext.len() - 1,
),
Err(CryptoError::AllocationLimit)
));
Ok(())
} }
Err(CryptoError::DecryptionFailed)
} }

View file

@ -36,3 +36,29 @@ pub fn derive_encryption_key(
out.copy_from_slice(&key); out.copy_from_slice(&key);
Ok(out) Ok(out)
} }
#[cfg(feature = "password-kdf")]
pub fn derive_password_key(
passphrase: &[u8],
salt: &[u8],
memory_kib: u32,
iterations: u32,
lanes: u32,
) -> Result<[u8; 32], CryptoError> {
if passphrase.is_empty()
|| salt.len() < 16
|| !(8 * 1024..=256 * 1024).contains(&memory_kib)
|| !(1..=10).contains(&iterations)
|| !(1..=8).contains(&lanes)
{
return Err(CryptoError::KdfError);
}
let params = argon2::Params::new(memory_kib, iterations, lanes, Some(32))
.map_err(|_| CryptoError::KdfError)?;
let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = [0u8; 32];
argon
.hash_password_into(passphrase, salt, &mut key)
.map_err(|_| CryptoError::KdfError)?;
Ok(key)
}

View file

@ -1,9 +1,10 @@
use crate::error::CryptoError; use crate::error::CryptoError;
use crate::keypair::{KemPrivateKey, KemPublicKey}; use crate::keypair::{KemPrivateKey, KemPublicKey};
use zeroize::Zeroizing;
pub struct Encapsulated { pub struct Encapsulated {
pub ciphertext: Vec<u8>, pub ciphertext: Vec<u8>,
pub shared_secret: Vec<u8>, pub shared_secret: Zeroizing<Vec<u8>>,
} }
#[cfg(feature = "mlkem-tls")] #[cfg(feature = "mlkem-tls")]
@ -11,6 +12,11 @@ pub struct HybridKem;
#[cfg(feature = "mlkem-tls")] #[cfg(feature = "mlkem-tls")]
impl HybridKem { impl HybridKem {
/// Fixed wire size of the KEM ciphertext used by MTP envelopes.
pub const fn ciphertext_len() -> usize {
mlkem_tls::X25519MlKem768::CIPHERTEXT_SIZE
}
pub fn generate_keypair() -> (KemPrivateKey, KemPublicKey) { pub fn generate_keypair() -> (KemPrivateKey, KemPublicKey) {
let (ek, dk) = mlkem_tls::X25519MlKem768::keygen(&mut rand_core::OsRng); let (ek, dk) = mlkem_tls::X25519MlKem768::keygen(&mut rand_core::OsRng);
( (
@ -25,19 +31,19 @@ impl HybridKem {
let (ct, ss) = mlkem_tls::X25519MlKem768::encapsulate(&ek, &mut rand_core::OsRng); let (ct, ss) = mlkem_tls::X25519MlKem768::encapsulate(&ek, &mut rand_core::OsRng);
Ok(Encapsulated { Ok(Encapsulated {
ciphertext: ct.as_bytes().to_vec(), ciphertext: ct.as_bytes().to_vec(),
shared_secret: ss.as_bytes().to_vec(), shared_secret: Zeroizing::new(ss.as_bytes().to_vec()),
}) })
} }
pub fn decapsulate( pub fn decapsulate(
recipient_sk: &KemPrivateKey, recipient_sk: &KemPrivateKey,
ciphertext: &[u8], ciphertext: &[u8],
) -> Result<Vec<u8>, CryptoError> { ) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
let dk = mlkem_tls::DecapsKey768::try_from(recipient_sk.as_bytes()) let dk = mlkem_tls::DecapsKey768::try_from(recipient_sk.as_bytes())
.map_err(|_| CryptoError::KemDecapsulationFailed)?; .map_err(|_| CryptoError::KemDecapsulationFailed)?;
let ct = mlkem_tls::Ciphertext768Hybrid::try_from(ciphertext) let ct = mlkem_tls::Ciphertext768Hybrid::try_from(ciphertext)
.map_err(|_| CryptoError::KemDecapsulationFailed)?; .map_err(|_| CryptoError::KemDecapsulationFailed)?;
let ss = mlkem_tls::X25519MlKem768::decapsulate(&dk, &ct); let ss = mlkem_tls::X25519MlKem768::decapsulate(&dk, &ct);
Ok(ss.as_bytes().to_vec()) Ok(Zeroizing::new(ss.as_bytes().to_vec()))
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,12 @@ pub mod auth;
pub mod error; pub mod error;
pub mod keypair; pub mod keypair;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Once;
#[cfg(not(target_arch = "wasm32"))]
static CRYPTO_INIT: Once = Once::new();
#[cfg(feature = "sha2")] #[cfg(feature = "sha2")]
pub mod hash; pub mod hash;
@ -12,6 +18,9 @@ pub mod kdf;
#[cfg(any(feature = "ed25519-dalek", feature = "ml-dsa"))] #[cfg(any(feature = "ed25519-dalek", feature = "ml-dsa"))]
pub mod sign; pub mod sign;
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa", feature = "parallel"))]
pub mod sign_parallel;
#[cfg(any(feature = "ed25519-dalek", feature = "ml-dsa"))] #[cfg(any(feature = "ed25519-dalek", feature = "ml-dsa"))]
pub use sign::SigAlgorithm; pub use sign::SigAlgorithm;
@ -22,6 +31,9 @@ pub mod enc;
pub mod helper; pub mod helper;
#[cfg(feature = "tls")]
pub mod tls;
pub use aead::{AeadCipher, AeadDecrypt, AeadEncrypt}; pub use aead::{AeadCipher, AeadDecrypt, AeadEncrypt};
pub use error::CryptoError; pub use error::CryptoError;
pub use keypair::{ pub use keypair::{
@ -31,7 +43,7 @@ pub use keypair::{
}; };
#[cfg(feature = "chacha20poly1305")] #[cfg(feature = "chacha20poly1305")]
pub use aead::ChaCha20Poly1305; pub use aead::{ChaCha20Poly1305, XChaCha20Poly1305};
#[cfg(feature = "aes-gcm")] #[cfg(feature = "aes-gcm")]
pub use aead::Aes256Gcm; pub use aead::Aes256Gcm;
@ -43,11 +55,13 @@ pub use sign::{Ed25519Signer, SignatureScheme, verify_ed25519};
pub use sign::{MlDsaSigner, verify_ml_dsa}; pub use sign::{MlDsaSigner, verify_ml_dsa};
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))] #[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
pub use sign::{DualSignature, sign_dual}; pub use sign::{DualSignature, DualSigner, sign_dual};
#[cfg(feature = "sha2")] #[cfg(feature = "sha2")]
pub use hash::{Sha256Hasher, sha256, sha256_double}; pub use hash::{Sha256Hasher, sha256, sha256_double};
#[cfg(feature = "password-kdf")]
pub use kdf::derive_password_key;
#[cfg(feature = "hkdf")] #[cfg(feature = "hkdf")]
pub use kdf::{derive_encryption_key, hkdf_expand, hkdf_extract}; pub use kdf::{derive_encryption_key, hkdf_expand, hkdf_extract};
@ -56,11 +70,25 @@ pub use kem::{Encapsulated, HybridKem};
pub use enc::EncryptionType; pub use enc::EncryptionType;
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] /// Install Rustls' AWS-LC provider once for the entire process.
pub use enc::{decrypt_with, encrypt_for}; ///
/// Rustls only accepts one process-wide default provider. Calling this helper
/// from every TLS entry point makes that initialization idempotent.
#[cfg(not(target_arch = "wasm32"))]
pub fn ensure_crypto_provider() {
CRYPTO_INIT.call_once(|| {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
});
}
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] pub use helper::{ENCRYPT_DOMAIN, KEY_WRAP_DOMAIN};
pub use helper::{MultiEncryptedMessage, RecipientEntry, decrypt_multi, encrypt_multi};
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub use helper::{
MAX_RECIPIENTS, MultiEncryptedMessage, MultiEncryptedMessageRef, RecipientEntry,
decrypt_multi_for, decrypt_multi_for_parts, decrypt_multi_for_parts_with_limit,
encrypt_multi_for,
};
/* ================================ TESTS ================================ */ /* ================================ TESTS ================================ */
#[cfg(test)] #[cfg(test)]
@ -69,13 +97,14 @@ mod tests {
#[cfg(feature = "chacha20poly1305")] #[cfg(feature = "chacha20poly1305")]
#[test] #[test]
fn aead_encrypt_decrypt() { fn aead_encrypt_decrypt() -> Result<(), CryptoError> {
use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305}; use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305};
let key = [0xAB; 32]; let key = [0xAB; 32];
let cipher = ChaCha20Poly1305::new(key); let cipher = ChaCha20Poly1305::new(key);
let ct = cipher.encrypt(b"hello world", b"aad").unwrap(); let ct = cipher.encrypt(b"hello world", b"aad")?;
let pt = cipher.decrypt(&ct, b"aad").unwrap(); let pt = cipher.decrypt(&ct, b"aad")?;
assert_eq!(pt, b"hello world"); assert_eq!(pt, b"hello world");
Ok(())
} }
#[cfg(feature = "chacha20poly1305")] #[cfg(feature = "chacha20poly1305")]
@ -84,7 +113,9 @@ mod tests {
use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305}; use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305};
let cipher_a = ChaCha20Poly1305::new([0xAB; 32]); let cipher_a = ChaCha20Poly1305::new([0xAB; 32]);
let cipher_b = ChaCha20Poly1305::new([0xCD; 32]); let cipher_b = ChaCha20Poly1305::new([0xCD; 32]);
let ct = cipher_a.encrypt(b"hello", b"").unwrap(); let ct = cipher_a
.encrypt(b"hello", b"")
.expect("encryption should succeed");
assert!(cipher_b.decrypt(&ct, b"").is_err()); assert!(cipher_b.decrypt(&ct, b"").is_err());
} }
@ -93,7 +124,9 @@ mod tests {
fn aead_wrong_aad_fails() { fn aead_wrong_aad_fails() {
use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305}; use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305};
let cipher = ChaCha20Poly1305::new([0xAB; 32]); let cipher = ChaCha20Poly1305::new([0xAB; 32]);
let ct = cipher.encrypt(b"hello", b"correct-aad").unwrap(); let ct = cipher
.encrypt(b"hello", b"correct-aad")
.expect("encryption should succeed");
assert!(cipher.decrypt(&ct, b"wrong-aad").is_err()); assert!(cipher.decrypt(&ct, b"wrong-aad").is_err());
} }
@ -102,12 +135,16 @@ mod tests {
fn ed25519_sign_verify() { fn ed25519_sign_verify() {
let (signer, sk, pk) = Ed25519Signer::generate(); let (signer, sk, pk) = Ed25519Signer::generate();
let msg = b"test message"; let msg = b"test message";
let sig = signer.sign(msg).unwrap(); let sig = signer.sign(msg).expect("signing should succeed");
signer.verify(msg, &sig).unwrap(); signer
verify_ed25519(&pk, msg, &sig).unwrap(); .verify(msg, &sig)
.expect("verification should succeed");
verify_ed25519(&pk, msg, &sig).expect("verification should succeed");
let loaded = Ed25519Signer::new(&sk).unwrap(); let loaded = Ed25519Signer::new(&sk).expect("signer loading should succeed");
loaded.verify(msg, &sig).unwrap(); loaded
.verify(msg, &sig)
.expect("verification should succeed");
} }
#[cfg(feature = "ed25519-dalek")] #[cfg(feature = "ed25519-dalek")]
@ -115,7 +152,7 @@ mod tests {
fn ed25519_wrong_sig_fails() { fn ed25519_wrong_sig_fails() {
let (signer, _, pk) = Ed25519Signer::generate(); let (signer, _, pk) = Ed25519Signer::generate();
let msg = b"test message"; let msg = b"test message";
let sig = signer.sign(msg).unwrap(); let sig = signer.sign(msg).expect("signing should succeed");
assert!(verify_ed25519(&pk, b"wrong message", &sig).is_err()); assert!(verify_ed25519(&pk, b"wrong message", &sig).is_err());
} }
@ -124,12 +161,16 @@ mod tests {
fn mldsa_sign_verify() { fn mldsa_sign_verify() {
let (signer, sk, pk) = MlDsaSigner::generate(); let (signer, sk, pk) = MlDsaSigner::generate();
let msg = b"test message"; let msg = b"test message";
let sig = signer.sign(msg).unwrap(); let sig = signer.sign(msg).expect("signing should succeed");
signer.verify(msg, &sig).unwrap(); signer
verify_ml_dsa(&pk, msg, &sig).unwrap(); .verify(msg, &sig)
.expect("verification should succeed");
verify_ml_dsa(&pk, msg, &sig).expect("verification should succeed");
let loaded = MlDsaSigner::new(&sk, &pk).unwrap(); let loaded = MlDsaSigner::new(&sk, &pk).expect("signer loading should succeed");
loaded.verify(msg, &sig).unwrap(); loaded
.verify(msg, &sig)
.expect("verification should succeed");
} }
#[cfg(feature = "ml-dsa")] #[cfg(feature = "ml-dsa")]
@ -137,7 +178,7 @@ mod tests {
fn mldsa_wrong_sig_fails() { fn mldsa_wrong_sig_fails() {
let (signer, _, pk) = MlDsaSigner::generate(); let (signer, _, pk) = MlDsaSigner::generate();
let msg = b"test message"; let msg = b"test message";
let sig = signer.sign(msg).unwrap(); let sig = signer.sign(msg).expect("signing should succeed");
assert!(verify_ml_dsa(&pk, b"wrong message", &sig).is_err()); assert!(verify_ml_dsa(&pk, b"wrong message", &sig).is_err());
} }
@ -148,9 +189,10 @@ mod tests {
let (ed_signer, _, _) = Ed25519Signer::generate(); let (ed_signer, _, _) = Ed25519Signer::generate();
let (ml_signer, _, _) = MlDsaSigner::generate(); let (ml_signer, _, _) = MlDsaSigner::generate();
let dual = sign_dual(ed_signer.signing_key(), ml_signer.signing_key(), b"msg").unwrap(); let dual = sign_dual(ed_signer.signing_key(), ml_signer.signing_key(), b"msg")
.expect("dual signing should succeed");
dual.verify(ed_signer.verifying_key(), ml_signer.verifying_key(), b"msg") dual.verify(ed_signer.verifying_key(), ml_signer.verifying_key(), b"msg")
.unwrap(); .expect("dual verification should succeed");
} }
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))] #[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
@ -160,7 +202,8 @@ mod tests {
let (ed_signer, _, _) = Ed25519Signer::generate(); let (ed_signer, _, _) = Ed25519Signer::generate();
let (ml_signer, _, _) = MlDsaSigner::generate(); let (ml_signer, _, _) = MlDsaSigner::generate();
let dual = sign_dual(ed_signer.signing_key(), ml_signer.signing_key(), b"msg").unwrap(); let dual = sign_dual(ed_signer.signing_key(), ml_signer.signing_key(), b"msg")
.expect("dual signing should succeed");
assert!( assert!(
dual.verify( dual.verify(
ed_signer.verifying_key(), ed_signer.verifying_key(),
@ -171,21 +214,39 @@ mod tests {
); );
} }
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
#[test]
fn dual_scheme_implements_signature_trait() {
use crate::sign::SignatureScheme;
let (signer, _, _, _, _) = DualSigner::generate();
let signature = signer.sign(b"msg").expect("dual signing should succeed");
assert_eq!(signer.algorithm(), SigAlgorithm::DUAL);
signer
.verify(b"msg", &signature)
.expect("dual verification should succeed");
assert!(signer.verify(b"wrong", &signature).is_err());
}
#[cfg(feature = "hkdf")] #[cfg(feature = "hkdf")]
#[test] #[test]
fn hkdf_expand_produces_key() { fn hkdf_expand_produces_key() {
let key = derive_encryption_key(b"ikm", b"salt", b"context").unwrap(); let key = derive_encryption_key(b"ikm", b"salt", b"context")
.expect("key derivation should succeed");
assert_eq!(key.len(), 32); assert_eq!(key.len(), 32);
let expanded = hkdf_expand(b"ikm", b"salt", b"info", 64).unwrap(); let expanded =
hkdf_expand(b"ikm", b"salt", b"info", 64).expect("HKDF expansion should succeed");
assert_eq!(expanded.len(), 64); assert_eq!(expanded.len(), 64);
} }
#[cfg(feature = "hkdf")] #[cfg(feature = "hkdf")]
#[test] #[test]
fn hkdf_different_info_different_key() { fn hkdf_different_info_different_key() {
let a = derive_encryption_key(b"ikm", b"salt", b"info-a").unwrap(); let a = derive_encryption_key(b"ikm", b"salt", b"info-a")
let b = derive_encryption_key(b"ikm", b"salt", b"info-b").unwrap(); .expect("key derivation should succeed");
let b = derive_encryption_key(b"ikm", b"salt", b"info-b")
.expect("key derivation should succeed");
assert_ne!(a, b); assert_ne!(a, b);
} }
@ -254,8 +315,10 @@ mod tests {
#[test] #[test]
fn keyring_serialize_roundtrip() { fn keyring_serialize_roundtrip() {
let kr = Keyring::generate(); let kr = Keyring::generate();
let bytes = kr.to_bytes(); let bytes = kr
let loaded = Keyring::from_bytes(&bytes).unwrap(); .try_to_bytes()
.expect("keyring serialization should succeed");
let loaded = Keyring::from_bytes(&bytes).expect("keyring roundtrip should succeed");
assert_eq!( assert_eq!(
kr.kem_public_key.as_bytes(), kr.kem_public_key.as_bytes(),
loaded.kem_public_key.as_bytes() loaded.kem_public_key.as_bytes()
@ -275,8 +338,10 @@ mod tests {
fn public_key_bundle_serialize_roundtrip() { fn public_key_bundle_serialize_roundtrip() {
let kr = Keyring::generate(); let kr = Keyring::generate();
let bundle = kr.public_key_bundle(); let bundle = kr.public_key_bundle();
let bytes = bundle.as_bytes(); let bytes = bundle
let loaded = PublicKeyBundle::from_bytes(&bytes).unwrap(); .try_as_bytes()
.expect("bundle serialization should succeed");
let loaded = PublicKeyBundle::from_bytes(&bytes).expect("bundle roundtrip should succeed");
assert_eq!( assert_eq!(
bundle.kem_public_key.as_bytes(), bundle.kem_public_key.as_bytes(),
loaded.kem_public_key.as_bytes() loaded.kem_public_key.as_bytes()
@ -295,22 +360,52 @@ mod tests {
#[test] #[test]
fn hybrid_kem_roundtrip() { fn hybrid_kem_roundtrip() {
let (sk, pk) = HybridKem::generate_keypair(); let (sk, pk) = HybridKem::generate_keypair();
let enc = HybridKem::encapsulate(&pk).unwrap(); let enc = HybridKem::encapsulate(&pk).expect("encapsulation should succeed");
let ss = HybridKem::decapsulate(&sk, &enc.ciphertext).unwrap(); let ss =
HybridKem::decapsulate(&sk, &enc.ciphertext).expect("decapsulation should succeed");
assert_eq!(enc.shared_secret, ss); assert_eq!(enc.shared_secret, ss);
} }
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] #[cfg(all(
#[test] feature = "mlkem-tls",
fn encrypt_multi_roundtrip() { feature = "hkdf",
use crate::helper::{decrypt_multi, encrypt_multi}; feature = "ml-dsa",
feature = "ed25519-dalek"
))]
fn multi_envelope_roundtrip(encryption_type: EncryptionType) {
use crate::helper::{decrypt_multi_for, encrypt_multi_for};
use crate::keypair::Keyring; use crate::keypair::Keyring;
let kr = Keyring::generate(); let kr = Keyring::generate();
let entities = vec![kr.public_key_bundle()]; let entities = vec![kr.public_key_bundle()];
let msg = b"secret data"; let msg = b"secret data";
let ct = encrypt_multi(msg, b"aad", &entities).unwrap(); let ct = encrypt_multi_for(encryption_type, 7, msg, &entities)
let pt = decrypt_multi(&ct, b"aad", &kr).unwrap(); .expect("multi encrypt should succeed");
let pt = decrypt_multi_for(&ct, 7, &kr).expect("multi decrypt should succeed");
assert_eq!(pt, msg); assert_eq!(pt, msg);
} }
#[cfg(all(
feature = "mlkem-tls",
feature = "hkdf",
feature = "ml-dsa",
feature = "ed25519-dalek",
feature = "chacha20poly1305"
))]
#[test]
fn chacha20_multi_envelope_roundtrip() {
multi_envelope_roundtrip(EncryptionType::MlKemChaCha20Poly1305);
}
#[cfg(all(
feature = "mlkem-tls",
feature = "hkdf",
feature = "ml-dsa",
feature = "ed25519-dalek",
feature = "aes-gcm"
))]
#[test]
fn aes_gcm_multi_envelope_roundtrip() {
multi_envelope_roundtrip(EncryptionType::MlKemAes256Gcm);
}
} }

View file

@ -21,13 +21,12 @@ impl SigAlgorithm {
} }
} }
#[cfg(feature = "ed25519-dalek")]
use rand_core::RngCore;
#[cfg(feature = "ml-dsa")] #[cfg(feature = "ml-dsa")]
use crate::keypair::{SignaturePqPrivateKey, SignaturePqPublicKey}; use crate::keypair::{SignaturePqPrivateKey, SignaturePqPublicKey};
pub trait SignatureScheme { pub trait SignatureScheme {
/// The wire algorithm identifier produced by this signer.
fn algorithm(&self) -> u8;
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError>; fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError>;
fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError>; fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError>;
} }
@ -50,9 +49,12 @@ impl Ed25519Signer {
Ok(Self { secret, public }) Ok(Self { secret, public })
} }
#[cfg(feature = "ed25519-dalek")]
pub fn generate() -> (Self, SignaturePrivateKey, SignaturePublicKey) { pub fn generate() -> (Self, SignaturePrivateKey, SignaturePublicKey) {
use rand::RngExt;
let mut bytes = [0u8; 32]; let mut bytes = [0u8; 32];
rand_core::OsRng.fill_bytes(&mut bytes); rand::rng().fill(&mut bytes);
let secret = ed25519_dalek::SigningKey::from_bytes(&bytes); let secret = ed25519_dalek::SigningKey::from_bytes(&bytes);
let public = secret.verifying_key(); let public = secret.verifying_key();
let priv_key = SignaturePrivateKey::new(secret.to_bytes().to_vec()); let priv_key = SignaturePrivateKey::new(secret.to_bytes().to_vec());
@ -76,6 +78,10 @@ impl Ed25519Signer {
#[cfg(feature = "ed25519-dalek")] #[cfg(feature = "ed25519-dalek")]
impl SignatureScheme for Ed25519Signer { impl SignatureScheme for Ed25519Signer {
fn algorithm(&self) -> u8 {
SigAlgorithm::ED25519
}
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> { fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
use ed25519_dalek::Signer; use ed25519_dalek::Signer;
let signature = self.secret.sign(msg).to_bytes().to_vec(); let signature = self.secret.sign(msg).to_bytes().to_vec();
@ -170,6 +176,10 @@ impl MlDsaSigner {
#[cfg(feature = "ml-dsa")] #[cfg(feature = "ml-dsa")]
impl SignatureScheme for MlDsaSigner { impl SignatureScheme for MlDsaSigner {
fn algorithm(&self) -> u8 {
SigAlgorithm::ML_DSA_65
}
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> { fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
use ml_dsa::Signer; use ml_dsa::Signer;
let signature = self let signature = self
@ -237,6 +247,75 @@ pub fn sign_dual(
Ok(DualSignature { ed25519, mldsa }) Ok(DualSignature { ed25519, mldsa })
} }
/// A signer that produces the canonical concatenated Ed25519 + ML-DSA-65
/// signature represented by [`SigAlgorithm::DUAL`].
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
pub struct DualSigner {
ed25519: Ed25519Signer,
mldsa: MlDsaSigner,
}
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
impl DualSigner {
pub fn new(
ed25519_secret: &SignaturePrivateKey,
mldsa_secret: &SignaturePqPrivateKey,
mldsa_public: &SignaturePqPublicKey,
) -> Result<Self, CryptoError> {
Ok(Self {
ed25519: Ed25519Signer::new(ed25519_secret)?,
mldsa: MlDsaSigner::new(mldsa_secret, mldsa_public)?,
})
}
pub fn generate() -> (
Self,
SignaturePrivateKey,
SignaturePqPrivateKey,
SignaturePublicKey,
SignaturePqPublicKey,
) {
let (ed25519, ed25519_secret, ed25519_public) = Ed25519Signer::generate();
let (mldsa, mldsa_secret, mldsa_public) = MlDsaSigner::generate();
(
Self { ed25519, mldsa },
ed25519_secret,
mldsa_secret,
ed25519_public,
mldsa_public,
)
}
}
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
impl SignatureScheme for DualSigner {
fn algorithm(&self) -> u8 {
SigAlgorithm::DUAL
}
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
let dual = sign_dual(self.ed25519.signing_key(), self.mldsa.signing_key(), msg)?;
let mut signature = Vec::with_capacity(
SigAlgorithm::length(SigAlgorithm::DUAL).expect("known signature algorithm length"),
);
signature.extend_from_slice(&dual.ed25519);
signature.extend_from_slice(&dual.mldsa);
Ok(signature)
}
fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError> {
let ed_len =
SigAlgorithm::length(SigAlgorithm::ED25519).expect("known signature algorithm length");
let mldsa_len = SigAlgorithm::length(SigAlgorithm::ML_DSA_65)
.expect("known signature algorithm length");
if signature.len() != ed_len + mldsa_len {
return Err(CryptoError::InvalidSignature);
}
verify_ed25519(&self.ed25519.public_key(), msg, &signature[..ed_len])?;
verify_ml_dsa(&self.mldsa.public_key(), msg, &signature[ed_len..])
}
}
impl DualSignature { impl DualSignature {
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))] #[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
pub fn verify( pub fn verify(

View file

@ -0,0 +1,73 @@
//! Parallel helpers for dual (classical + post-quantum) signatures.
use std::sync::Arc;
use tokio::task;
use crate::{
CryptoError, SignaturePqPublicKey, SignaturePublicKey, SignatureScheme, verify_ed25519,
verify_ml_dsa,
};
/// Signs a message with the classical and PQ schemes concurrently on Tokio's blocking pool.
pub async fn sign_dual_parallel<S1, S2>(
ed_signer: S1,
pq_signer: S2,
message: Vec<u8>,
) -> Result<(Vec<u8>, Vec<u8>), CryptoError>
where
S1: SignatureScheme + Send + 'static,
S2: SignatureScheme + Send + 'static,
{
let ed_message = message.clone();
let ed_handle = task::spawn_blocking(move || ed_signer.sign(&ed_message));
let pq_handle = task::spawn_blocking(move || pq_signer.sign(&message));
let (ed_result, pq_result) = tokio::join!(ed_handle, pq_handle);
let ed_signature = ed_result.map_err(|_| CryptoError::SigningFailed)??;
let pq_signature = pq_result.map_err(|_| CryptoError::SigningFailed)??;
Ok((ed_signature, pq_signature))
}
/// Signs with an owned classical signer and a shared PQ signer.
///
/// This avoids reconstructing the ML-DSA signing key when a host signs both
/// the challenge and the final response in one authentication handshake.
pub async fn sign_dual_parallel_shared_pq<S1, S2>(
ed_signer: S1,
pq_signer: Arc<S2>,
message: Vec<u8>,
) -> Result<(Vec<u8>, Vec<u8>), CryptoError>
where
S1: SignatureScheme + Send + 'static,
S2: SignatureScheme + Send + Sync + 'static,
{
let ed_message = message.clone();
let ed_handle = task::spawn_blocking(move || ed_signer.sign(&ed_message));
let pq_handle = task::spawn_blocking(move || pq_signer.sign(&message));
let (ed_result, pq_result) = tokio::join!(ed_handle, pq_handle);
let ed_signature = ed_result.map_err(|_| CryptoError::SigningFailed)??;
let pq_signature = pq_result.map_err(|_| CryptoError::SigningFailed)??;
Ok((ed_signature, pq_signature))
}
/// Verifies the classical and PQ signatures concurrently on Tokio's blocking pool.
pub async fn verify_dual_parallel(
ed_public_key: SignaturePublicKey,
pq_public_key: SignaturePqPublicKey,
message: Vec<u8>,
ed_signature: Vec<u8>,
pq_signature: Vec<u8>,
) -> Result<(), CryptoError> {
let ed_message = message.clone();
let ed_handle =
task::spawn_blocking(move || verify_ed25519(&ed_public_key, &ed_message, &ed_signature));
let pq_handle =
task::spawn_blocking(move || verify_ml_dsa(&pq_public_key, &message, &pq_signature));
let (ed_result, pq_result) = tokio::join!(ed_handle, pq_handle);
ed_result.map_err(|_| CryptoError::VerificationFailed)??;
pq_result.map_err(|_| CryptoError::VerificationFailed)??;
Ok(())
}

45
crypto/src/tls.rs Normal file
View file

@ -0,0 +1,45 @@
use rcgen::{CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, SanType};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use time::{Duration, OffsetDateTime};
use crate::CryptoError;
/// Generate a self-signed TLS certificate and private key for development.
///
/// Returns `(cert_pem, key_pem)` as byte vectors. The certificate is valid for
/// the given domain name plus `127.0.0.1` and `::1`, uses ECDSA P-256, and is
/// valid for 13 days from the time of generation.
///
/// Never panics; all errors are returned as [`CryptoError`].
pub fn generate_self_signed_cert(domain: &str) -> Result<(Vec<u8>, Vec<u8>), CryptoError> {
let key_pair = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
.map_err(|e| CryptoError::Tls(format!("key generation failed: {e}")))?;
let mut params = CertificateParams::new(vec![domain.to_string()])
.map_err(|e| CryptoError::Tls(format!("certificate params failed: {e}")))?;
params.not_before = OffsetDateTime::now_utc() - Duration::minutes(5);
params.not_after = OffsetDateTime::now_utc() + Duration::days(13);
params
.subject_alt_names
.push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
params
.subject_alt_names
.push(SanType::IpAddress(IpAddr::V6(Ipv6Addr::new(
0, 0, 0, 0, 0, 0, 0, 1,
))));
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
params.is_ca = IsCa::NoCa;
let cert = params
.self_signed(&key_pair)
.map_err(|e| CryptoError::Tls(format!("certificate signing failed: {e}")))?;
let cert_pem = cert.pem().into_bytes();
let key_pem = key_pair.serialize_pem().into_bytes();
Ok((cert_pem, key_pem))
}

View file

@ -8,9 +8,23 @@ ignore = []
[bans] [bans]
# Flag multiple versions of the same crate so duplicate trees are visible. # Flag multiple versions of the same crate so duplicate trees are visible.
multiple-versions = "warn" multiple-versions = "deny"
wildcards = "deny" wildcards = "deny"
# These versions are required by incompatible upstream dependency lines:
# - pem/rcgen/wtransport still use base64 0.22.
# - ring and wasm-bindgen still use getrandom 0.2.
# - current displaydoc/serde/thiserror/tokio and wasm-bindgen trees span syn 2
# and syn 3.
# - ring still uses windows-sys 0.52 while the Tokio/QUIC tree uses 0.61.
# Keep the duplicate-version policy strict for every other crate/version.
skip = [
{ name = "base64", version = "0.22.1" },
{ name = "getrandom", version = "0.2.17" },
{ name = "syn", version = "2.0.119" },
{ name = "windows-sys", version = "0.52.0" },
]
[licenses] [licenses]
# Allowlist of licenses acceptable for this project's dependencies. # Allowlist of licenses acceptable for this project's dependencies.
allow = [ allow = [

70
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,70 @@
# MTP Architecture
MTP separates wire encoding, QUIC transport, connection policy, protocol negotiation, and application-facing clients.
```text
application
┌────────────────┴────────────────┐
│ │
Native client Browser SDK
mtp-client mtp + WASM
│ │
└──────────────┬──────────────────┘
│ MTP frames
┌─────────▼─────────┐
│ codec + type-map │
│ versions, values │
└─────────┬─────────┘
┌─────────▼─────────┐
│ QUIC transport │
│ framing, policy │
└─────────┬─────────┘
┌─────────────────┴─────────────────┐
│ │
MTPHost MTPWebServer
native QUIC HTTPS + HTTP/3 + WebTransport
│ │
└──────────────┬────────────────────┘
optional mtp-crypto
authentication and E2EE
```
`mtp-codec` owns `CommunicationValue` and `DataValue` serialization. A version-specific `TypeMap` translates generated type names to wire IDs.
`mtp-transport` writes each frame as a four-byte big-endian length followed by the frame bytes and applies message, timeout, queue, and stream limits.
The top row represents application entry points. Native Rust code calls the client or host crates directly. Browser code calls the TypeScript SDK, which uses generated WASM bindings for the same codec and WebTransport session.
Both clients exchange the same MTP frames with a host.
The middle row is shared protocol machinery. The type map determines numeric IDs, the codec serializes self-delimiting `DataValue` payloads, and transport framing places each serialized frame on a QUIC stream. `CommunicationValue` contains only routing metadata and one generic payload. Protection is a composable value property (`Signed<Value>` or `Encrypted<Value>`), not a transport or communication-frame mode, so the frame and transport layers never infer encryption or signature state from header flags. This is why a type-map or codec change must be compiled into both peers before the new message can be exchanged.
The bottom row shows the two server entry points. `MTPHost` is a native QUIC endpoint for native MTP clients. `MTPWebServer` owns TCP HTTPS and UDP HTTP/3/WebTransport listeners on the same numeric port, reuses one `HostConfig` and router, and provides the same `accept()`-based MTP session API. Its QUIC listener still uses only the `h3` ALPN, so it cannot share its UDP address with the native MTP ALPN endpoint. Choose `MTPHost` for native clients and `MTPWebServer` for browser-facing HTTP and WebTransport.
`mtp-crypto` is an optional cross-cutting layer used by authenticated native connections, WebTransport connections, and browser E2EE; TLS remains the transport security layer in both paths.
MTP exposes protection as independent capabilities rather than prescribing an
application topology:
- A stateless protected `DataValue` composes `Signed<Value>` and
`Encrypted<Value>` in the order selected by the application.
- A direct protected frame carries a protected value under its application
communication type and routes it straight to the frame receiver.
- A sealed relay uses the reserved `Relay` communication type, an absent outer
sender, and separately protected metadata and content. Applications choose
the next hop, final recipient, and both recipient sets.
- A stateful encrypted session advances symmetric send and receive chains for
an active exchange.
- An encrypted pipe protects an ordered byte stream with transcript-bound
records and an authenticated final record; forward-secure duplex setup is an
explicit option.
These constructions are peers. Relay is optional and is not the default path
for encrypted application messages. Use direct protected frames when no
intermediate component needs relay metadata; use sealed relay when routing or
store-and-forward topology requires a distinct metadata-access boundary.
`mtp-host` performs version negotiation and native authentication before returning an `MTPConnection`. `mtp-webserver` routes HTTP/1.1, HTTP/2, and HTTP/3 requests through one route table and surfaces WebTransport sessions through `accept()`. WebTransport MTP sessions support the same optional cryptographic authentication as native hosts when the `crypto` feature is enabled.
The [native client](NATIVE-CLIENT.md), [WASM client](WASM-CLIENT.md), [native host](NATIVE-HOST.md), and [web server](NATIVE-HOST-WEB-SERVER.md) guides cover the public APIs for each boundary. The web server guide should be read as the host API for browser-facing deployments; it accepts the same `HostConfig` and authentication callbacks as the native host.

34
docs/CONNECTIONS.md Normal file
View file

@ -0,0 +1,34 @@
# MTP Connections
Native clients and server-side hosts expose parallel connection handles after the
opening handshake. The client creates its handle; the host receives one from
`accept()`.
| Member | Native client | Native host | Web host (`WebMTPConnection`) |
| --- | --- | --- | --- |
| `version` | Compiled client version accepted by the host | Version selected by the registry | Version selected by the registry |
| `sender` | Sends `CommunicationValue` frames | Sends `CommunicationValue` frames | Sends `CommunicationValue` frames |
| `receiver` | Underlying receiver; use `receive()` for application frames | Underlying receiver; use `receive()` for application frames | Underlying receiver; use `receive()` for application frames |
| `description` | Optional label sent during setup | Optional label received from the client | Optional label received from the client |
| `client_id` | Confirmed or assigned ID with `crypto` | Authenticated or guest client ID with `crypto` | Authenticated or guest client ID with `crypto` |
| `auth_state` | Authentication result with `crypto` | Authentication result with `crypto` | Authentication result with `crypto` |
| `path` | — | Native hosts use `/` | WebTransport CONNECT path (e.g. `/mtp`) |
| `remote_addr` | Server `SocketAddr` when available | Peer `SocketAddr` | Peer `SocketAddr` |
`WebMTPConnection`, returned by `MTPWebServer::accept()`, exposes the same
server-side members as the native host connection. Its `path` contains the
HTTP/3 path used for the WebTransport extended CONNECT request.
Server-side MTP connections expose `remote_addr`, the peer address observed by
QUIC. HTTP route handlers receive the peer address as `HttpRequest::remote_addr`.
It is transport metadata and should not be treated as an authenticated identity;
behind a proxy, use the proxy's trusted forwarding mechanism separately.
The host connection also exposes a version-scoped `codec` and, for an authenticated client, its `client_public_key`. The native client connection also exposes these methods:
| Method | Behavior |
| --- | --- |
| `request` | Sends a frame and waits for a response with the same frame ID, subject to `request_timeout`. |
| `get_ping` | Returns the latest matched protocol Ping round-trip duration. |
| Pipe methods | Create, accept, deny, read, write, and close native pipe streams when the `pipes` feature is enabled. |
Connection lifecycle and keepalive behavior are defined in [Protocol Reference](PROTOCOL-REFERENCE.md). Pipe dispatch rules are in [Pipes](PIPES.md). Closing or dropping the connection stops its background tasks and closes the underlying QUIC session.

View file

@ -4,23 +4,28 @@ This file documents the connection and version negotiation logic.
## Registry ## Registry
The `registry` module provides a multi-version `Registry` used by the host for version negotiation. Accessed through the `mtp` facade (requires the `host` feature): The `registry` module provides a multi-version `Registry` used by the host for
version negotiation. Accessed through the `mtp` facade (requires the `host`
feature). In this repository, `Registry::builtin()` is generated from
[`example/type-maps.yaml`](../example/type-maps.yaml), which currently contains
protocol version 3.0 only. Downstream projects can register additional versions
in their own YAML configuration.
```rust ```rust
use mtp::codec::registry::Registry; use mtp::codec::{Version, registry::Registry};
let registry = Registry::builtin(); // loads all TypeMaps from config let registry = Registry::builtin(); // loads all TypeMaps from the build config
// Check if a version is supported // Check if a version is supported
assert!(registry.supports(&Version(1, 0))); assert!(registry.supports(&Version(3, 0)));
// Find highest mutual version for a client // Find highest mutual version for a client
let client_versions = &[Version(0, 0), Version(1, 0)]; let client_versions = &[Version(2, 0), Version(3, 0)];
let negotiated = registry.negotiate(client_versions); let negotiated = registry.negotiate(client_versions);
assert_eq!(negotiated, Some(Version(1, 0))); assert_eq!(negotiated, Some(Version(3, 0)));
// Look up a version's TypeMap // Look up a version's TypeMap
let tm = registry.get(&Version(2, 0)).unwrap(); let tm = registry.get(&Version(3, 0)).unwrap();
``` ```
The `Registry::builtin()` constructor uses the `TypeMap::vX_Y()` methods generated from the config. The `Registry::builtin()` constructor uses the `TypeMap::vX_Y()` methods generated from the config.
@ -54,39 +59,16 @@ let mut host = MTPHost::new(config).await?;
while let Some(conn) = host.accept().await? { while let Some(conn) = host.accept().await? {
// conn.version is the negotiated version // conn.version is the negotiated version
// conn.codec is a VersionedCodec scoped to that version // conn.codec is a VersionedCodec scoped to that version
// conn.sender / conn.receiver for raw CommunicationValue I/O // conn.sender / conn.receive() for application CommunicationValue I/O
let msg = conn.receiver.receive().await?; let msg = conn.receive().await?;
} }
``` ```
The host's `accept()` method: The host reads the reserved opening frame, extracts `DataType::Version`, calls `registry.negotiate`, and returns `AcceptError::UnsupportedVersion` when no registered version matches.
1. Accepts a QUIC connection
2. If authentication is required (crypto feature): performs login/register handshake
3. Reads the first `CommunicationValue` (always encoded with reserved type IDs)
4. Extracts the client's protocol version from `DataType::Version` (reserved data type ID 0)
5. Calls `registry.negotiate(&[client_version])`
6. Returns an `AcceptError` if the version is unsupported
7. Returns `Ok(Some(MTPConnection))` with the negotiated version otherwise
### Login/Register Handshake Authentication follows the version-bearing hello when the host enables it.
The sequence is defined in [Protocol Reference](PROTOCOL-REFERENCE.md).
When `require_authentication` is set, the parties run a mutually-authenticated
**challenge-response**. The client speaks first with an *unsigned* hello:
- **Login** (`CommunicationType::Identification`, reserved ID 0): version, client ID
- **Register** (`CommunicationType::Register`, reserved ID 2): version, public keys
The host then issues a fresh random `server_challenge` in a signed `Challenge`
(`CommunicationType::Challenge`, reserved ID 4, carrying `ServerNonce`). The client signs
that challenge, binding its id (login) or public keys (register), and returns a
`ChallengeResponse` (reserved ID 5). The host verifies the proof against the challenge it
issued and sends a signed final response, which the client verifies.
Because the client's proof covers the host-issued `server_challenge` (a one-time
value held only on the accepting task's stack), a captured proof cannot be
replayed on another connection. All signed payloads are domain-separated; see
`mtp::crypto::auth`.
--- ---
@ -110,34 +92,45 @@ let conn = MTPClient::auth_connect(pinned.with_client_id(8765), &keys, &host_pk)
let conn = MTPClient::auth_register(config, &keys, &host_pk).await?; let conn = MTPClient::auth_register(config, &keys, &host_pk).await?;
``` ```
The client's `PROTOCOL_VERSION` constant is set by `protocol_version` in `type-maps.yaml` and baked in at compile time. The client never imports the `registry` crate; it only uses `mtp::type_map` for enum types and `mtp::codec` for encoding. The client's `PROTOCOL_VERSION` constant is set by `protocol_version` in `type-maps.yaml` and baked in at compile time. The client uses one version and does not import the registry.
--- ---
## Version Negotiation Flow ## Version Negotiation Flow
``` ```
Client (v2.0) Host (v0.0, v1.0, v2.0) Client (v3.0) Host (v3.0)
| | | |
| QUIC connect | | QUIC connect |
|----------------------->| |----------------------->|
| | | |
| CommValue{ Ident. } | | CommValue{ Ident. } |
| Version -> "2.0" | | Version -> "3.0" |
| Id -> 8765 | | Id -> 8765 |
| (unsigned hello; auth | | (unsigned hello; auth |
| challenge follows) | | challenge follows) |
|----------------------->| |----------------------->|
| | registry.negotiate(&[Version(2,0)]) | | registry.negotiate(&[Version(3,0)])
| | -> Some(Version(2,0)) | | -> Some(Version(3,0))
| | | |
| Response | | Response | selected v3.0 TypeMap
|<-----------------------| (uses v2.0 TypeMap for encoding) |<-----------------------|
| Status, Nonces, | | Status, version |
| Signature |
| | | |
| (subsequent messages | | subsequent messages |
| use v2.0 TypeMap) | | use v3.0 TypeMap |
``` ```
If the client sends an unsupported version (e.g. v3.0 when the host only knows up to v2.0), `negotiate` returns `None` and the connection is closed. If the client sends an unsupported version (for example, v2.0 to the current
repository builtin host), `negotiate` returns `None` and the connection is
closed.
## Protocol Ping and Pong
See [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive).
## Protocol Version Changes
Add a protocol version by adding its type-map entry and `protocol_version` to the YAML configuration, then rebuild both peers. The type-map build script generates a version-specific `TypeMap`. Native hosts built with the registry feature keep an enum union across configured versions; a browser client and its generated `mtp/type-map` declarations use only the map selected by that client's `protocol_version`, plus reserved names.
For a backward-compatible change, keep existing communication and data IDs stable and add new types with the new version. For a breaking change, add a new version and register both versions on the host while clients migrate. A client compiles one protocol version; it can connect only when that version is present in the host registry. Remove an old version only after its clients no longer connect, because the host closes connections whose version is unsupported.

48
docs/ERRORS.md Normal file
View file

@ -0,0 +1,48 @@
# Error Reference
MTP reports codec failures separately from connection and transport failures.
## CodecError
| Variant | Meaning |
| --- | --- |
| `UnknownVersion` | A codec was asked to use an unsupported protocol version. |
| `UnknownCommunicationType` | A communication type has no mapping in the selected type map. |
| `UnknownDataType` | A data type has no mapping in the selected type map. |
| `ReservedCommunicationType` | An application attempted to use a reserved communication type ID. |
| `InvalidEncoding` | Bytes do not match the MTP value or frame format. |
| `TooManyEntries` | A serialized value or frame exceeds its representable size. |
| `MissingTypeMap` | A versioned codec was asked to encode a value without a retained negotiated type map. |
| `TypeMapMismatch` | A value was created with a different protocol type map from the codec or peer operation. |
| `CryptoFailed` | Signing, verification, encryption, or decryption failed while encoding or decoding. |
| `MissingField` | A required typed field is absent. |
An application should select a `TypeMap` for the negotiated version and treat `UnknownCommunicationType` and `UnknownDataType` as a type-map compatibility failure. Do not send the unmapped variant again on that connection.
Retry guidance: retry `ConnectionLost`, `ConnectingError`, and transient stream errors after applying backoff. Correct the request before retrying `MessageTooLarge`, `InvalidEncoding`, `MissingField`, and type-map errors.
Retry `AuthenticationFailed` only after changing credentials or host policy;
repeating the same proof does not repair a validation failure.
## CommunicationError
| Variant | Typical cause |
| --- | --- |
| `UseAfterClosed` | A send or receive operation ran after the connection handle was closed. |
| `ClosedLocally` | The local endpoint initiated shutdown. |
| `ClosedByPeer` | The peer closed the connection. |
| `ConnectionLost` | The connection ended without a normal close. |
| `ParseCommunicationValue` or `ParseError` | An incoming frame or certificate could not be parsed. |
| `Encode` | An outgoing value could not be serialized. |
| `MessageTooLarge` | A frame exceeds `Policy::max_message_size` or the handshake limit. |
| `StreamClosed` or `StreamError` | A QUIC stream ended or returned an I/O error. |
| `ConnectingError` or `ConnectionError` | The endpoint could not establish or maintain QUIC. |
| `AuthenticationFailed` | A login, registration, signature, nonce, or host response failed validation. |
| `CertificateParseFailed` or `CertificateLoadFailed` | TLS certificate input is malformed or unavailable. |
| `CryptoProviderInstallFailed` | The native TLS crypto provider could not be installed. |
| `Other` | A component returned an error without a more specific variant. |
Native builds may expose additional variants wrapping QUIC and WebTransport errors. WASM builds expose the transport-independent subset.
## Authentication Rejections
The host reports unsupported or missing protocol versions through `AcceptError`. Authentication failures return `AcceptError::AuthenticationFailed` after the host sends a rejected handshake response; a handshake that exceeds the configured limit returns `AcceptError::AuthenticationTimedOut`. The authentication flow and its signed fields are defined in [Security](SECURITY.md).

View file

@ -2,74 +2,103 @@
The native client is a Rust library (`mtp-client`) for connecting to an MTP host over QUIC. It uses `wtransport` under the hood and provides both unauthenticated and authenticated (crypto handshake) connection modes. The native client is a Rust library (`mtp-client`) for connecting to an MTP host over QUIC. It uses `wtransport` under the hood and provides both unauthenticated and authenticated (crypto handshake) connection modes.
## Cargo Dependency ## Prerequisites
Add the `mtp` umbrella crate with the `client` feature (and optionally `crypto` for authentication): Add the `mtp` umbrella crate with `client`. Add `crypto` for authenticated connections, `pipes` for raw streams, and `tls` for development certificate generation. The `insecure-tls` feature applies only to the lower-level transport API. The feature table is in the [README](../README.md).
```toml ## Quick Start
[dependencies]
mtp = { path = "/path/to/mtp", features = ["client"] }
# Add crypto for auth_connect / auth_register: ```rust
mtp = { path = "/path/to/mtp", features = ["client", "crypto"] } use mtp::client::{ClientConfig, MTPClient};
use mtp::codec::{CommunicationType, CommunicationValue};
let conn = MTPClient::connect(
ClientConfig::new("https://host.example.com:4433").with_client_id(42),
).await?;
let request = CommunicationValue::new(CommunicationType::Ping).with_id(1);
conn.sender.send(&request).await?;
let response = conn.receive().await?;
println!("received {:?}", response.id());
conn.sender.close().await;
``` ```
## ClientConfig ## Configuration
```rust ```rust
use mtp::client::{ClientConfig, ClientTlsConfig}; use mtp::client::{ClientConfig, ClientTlsConfig};
use std::time::Duration;
let config = ClientConfig::new("https://host.example.com:4433") let config = ClientConfig::new("https://host.example.com:4433")
.with_tls(ClientTlsConfig::SystemRoots) .with_tls(ClientTlsConfig::SystemRoots)
.with_client_id(0); .with_client_id(0)
.with_ping_interval(Duration::from_secs(5))
.with_max_missed_pings(3)
.with_ping_timestamp(true);
``` ```
| Field | Type | Description | | Field | Type | Default | Description |
|---------------|--------------------|-----------------------------------------------------| |-------------------------|--------------------|------------------|---------------------------------------------|
| `url` | `String` | `https://host:port` address of the MTP host | | `url` | `String` | required | Host URL (`https://host:port`) |
| `tls` | `ClientTlsConfig` | `SystemRoots` or `PinnedPem(pem_bytes)` | | `tls` | `ClientTlsConfig` | `SystemRoots` | `SystemRoots` or `PinnedPem(Vec<u8>)` |
| `client_id` | `u64` | Client identifier (ignored during `auth_register`) | | `client_id` | `u64` | `0` | Client identifier (for login) |
| `auth_timeout` | `Duration` (crypto) | Authentication handshake timeout (default 30s) | | `description` | `Option<String>` | `None` | Optional label sent to host |
| `policy` | `Policy` | default | Transport policy (timeouts, send mode) |
| `ping_interval` | `Duration` | `Duration::ZERO` | Interval between protocol Ping frames |
| `ping_jitter` | `Option<Duration>` | `None` | Random jitter added to each interval |
| `max_missed_pings` | `usize` | `3` | Disconnect after this many unanswered Pings |
| `ping_timestamp` | `bool` | `true` | Include a `Timestamp` data entry in Ping |
| `request_timeout` | `Duration` | `30s` | Max time for `MTPConnection::request` |
| `auth_timeout` (crypto) | `Duration` | `30s` | Max time for auth handshake |
| `require_pq` (crypto) | `bool` | `true` | Require ML-DSA-65 during authentication |
### TLS Certificate Handling ### TLS Certificate Handling
When `tls` is `ClientTlsConfig::SystemRoots` (the default), the client loads the **system's `ClientTlsConfig::SystemRoots` is the default. Use `ClientTlsConfig::PinnedPem` or `ClientConfig::with_pinned_pem` for a supplied certificate chain. SPKI pinning and development or insecure transport configuration are available through lower-level transport APIs. See [Security](SECURITY.md) for trust models, certificate generation, rotation, and the insecure-mode gates.
native root certificate store** via `rustls_native_certs`. This works with
publicly-trusted CAs out of the box on Linux (using `openssl-probe`), macOS
(Keychain), and Windows (Root Store).
For development or self-signed certificates, provide one or more PEM-encoded ## Connecting
certificates:
```rust
let pem = std::fs::read("my-server-cert.pem")?;
let config = ClientConfig::new("https://host.example.com:4433").with_pinned_pem(pem);
```
When pinned, **only** the given certificate(s) are trusted for the TLS
handshake.
## Connection Methods
All methods return a `Result<MTPConnection, CommunicationError>`. All methods return a `Result<MTPConnection, CommunicationError>`.
### MTPConnection ### MTPConnection
Shared fields and lifecycle: [MTP Connections](CONNECTIONS.md).
Keepalive behavior is defined in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive).
### Requests
`MTPConnection::request` sends a `CommunicationValue` and waits for a response with the same frame ID. It uses `ClientConfig::request_timeout`; timeout and connection errors reject the request.
The request must have a non-zero ID. The response is removed from the pending request table and is not returned by a later `conn.receive()` call. A timeout removes the pending request and returns `CommunicationError`; a response with the wrong expected type also returns an error. Frames with other IDs remain available through `conn.receive()`.
```rust ```rust
pub struct MTPConnection { let response = conn
pub version: Version, .request(&request_value, Some(CommunicationType::Pong))
pub sender: Sender, .await?;
pub receiver: Receiver, ```
#[cfg(feature = "crypto")]
pub auth_state: AuthState, ### Protocol keepalive
#[cfg(feature = "crypto")]
pub client_id: u64, Enable it with `ClientConfig` and inspect the latest matched round-trip time with `get_ping()`. See [Protocol Reference](PROTOCOL-REFERENCE.md).
```rust
use mtp::client::{ClientConfig, MTPClient};
use std::time::Duration;
let config = ClientConfig::new("https://host.example.com:4433")
.with_client_id(42)
.with_ping_interval(Duration::from_secs(5))
.with_max_missed_pings(3)
.with_ping_timestamp(true);
let conn = MTPClient::connect(config).await?;
if let Some(round_trip) = conn.get_ping() {
println!("latest MTP round trip: {round_trip:?}");
} }
``` ```
- `version` -- the negotiated protocol version Pong dispatch and missed-Ping behavior are defined in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive). Set `ping_interval` to `Duration::ZERO` (the default) to disable protocol pings.
- `sender` / `receiver` -- for message I/O
- `client_id` -- the confirmed/assigned client identifier (crypto only)
### Unauthenticated Connect ### Unauthenticated Connect
@ -81,8 +110,7 @@ let config = ClientConfig::new("https://host.example.com:4433").with_client_id(4
let conn = MTPClient::connect(config).await?; let conn = MTPClient::connect(config).await?;
``` ```
Sends an `Identification` frame with the compiled-in protocol version and Sends an `Identification` frame with the compiled-in protocol version and client ID. No cryptographic handshake is performed.
client ID. No cryptographic handshake is performed.
### Authenticated Login ### Authenticated Login
@ -99,21 +127,7 @@ let config = ClientConfig::new("https://host.example.com:4433")
let conn = MTPClient::auth_connect(config, &keys, &host_pk).await?; let conn = MTPClient::auth_connect(config, &keys, &host_pk).await?;
``` ```
Protocol (challenge-response, the host issues the freshness): Authentication uses the signed challenge flow in [Protocol Reference](PROTOCOL-REFERENCE.md#authentication-flow). Cryptographic fields and domain separation are defined in [Security](SECURITY.md).
1. Client sends an unsigned `Identification` hello (version, client ID)
2. Host replies with a `Challenge` carrying a fresh random `server_challenge`
and the host's signature over it; the client verifies that signature
3. Client generates a random `client_nonce` and signs
`version || client_id || server_challenge || client_nonce` with Ed25519
(and optionally ML-DSA-65)
4. Client sends a `ChallengeResponse` frame (nonce + signature(s))
5. Host verifies the proof against `server_challenge` and responds with
`IdentificationResponse` (echoed nonce + host signature)
6. Client verifies the host signature and nonce echo
Because the client's signature covers the host-issued `server_challenge`, a
captured proof cannot be replayed on another connection (each connection gets a
different challenge).
### Registration ### Registration
@ -128,11 +142,10 @@ let conn = MTPClient::auth_register(config, &keyring, &host_pk).await?;
// Save for next session // Save for next session
let id = conn.client_id; let id = conn.client_id;
let keyring_bytes = keyring.to_bytes(); let keyring_bytes = keyring.try_to_bytes()?;
``` ```
When callers already know whether a saved client id exists, the convenience When callers already know whether a saved client ID exists, the convenience helper uses `Some(id)` for login and `None` for registration:
helper chooses login or registration:
```rust ```rust
let conn = MTPClient::auth_connect_or_register( let conn = MTPClient::auth_connect_or_register(
@ -143,17 +156,7 @@ let conn = MTPClient::auth_connect_or_register(
).await?; ).await?;
``` ```
Protocol (challenge-response): Registration uses the authentication flow in [Protocol Reference](PROTOCOL-REFERENCE.md#authentication-flow).
1. Client sends an unsigned `Register` hello (version, public key bundle)
2. Host replies with a `Challenge` carrying a fresh random `server_challenge`
(signed by the host); the client verifies that signature
3. Client generates a random `client_nonce` and signs
`version || server_challenge || client_nonce || public_key_bytes` with
Ed25519 (and optionally ML-DSA-65)
4. Client sends a `ChallengeResponse` frame (nonce + signature(s))
5. Host verifies the proof against `server_challenge`, assigns a new client ID,
and responds with `RegisterResponse` (the ID, echoed nonce, host signature)
6. Client verifies the host signature and nonce echo
## Key Material ## Key Material
@ -172,14 +175,13 @@ pub struct Keyring {
} }
``` ```
- Serialise: `keyring.to_bytes()` -> `Vec<u8>` - Serialise: `keyring.try_to_bytes()` -> `Result<Zeroizing<Vec<u8>>, CryptoError>`
- Deserialise: `Keyring::from_bytes(&bytes)` -> `Result<Keyring, CryptoError>` - Deserialise: `Keyring::from_bytes(&bytes)` -> `Result<Keyring, CryptoError>`
- Get public half: `keyring.public_key_bundle()` -> `PublicKeyBundle` - Get public half: `keyring.public_key_bundle()` -> `PublicKeyBundle`
### PublicKeyBundle ### PublicKeyBundle
The public half of a keyring, used by the host for signature verification and The public half of a keyring, used by the host for signature verification and by the client for host signature verification:
by the client for host signature verification:
```rust ```rust
pub struct PublicKeyBundle { pub struct PublicKeyBundle {
@ -189,10 +191,11 @@ pub struct PublicKeyBundle {
} }
``` ```
Obtain the host's `PublicKeyBundle` out of band (e.g. from files exported by Obtain the host's `PublicKeyBundle` out of band (e.g. from files exported by the host, or from a trusted directory).
the host, or from a trusted directory).
## Sending and Receiving Messages ## Communicate
### Sending and Receiving Messages
### CommunicationValue ### CommunicationValue
@ -209,9 +212,7 @@ let msg = CommunicationValue::new(CommunicationType::Ping)
.to_bytes(); .to_bytes();
``` ```
When the `registry` feature is enabled (via the `host` feature), you can also When the `registry` feature is enabled (via the `host` feature), you can also use `add_typed` with a `TypeMap` to resolve data type names from your project's type-map configuration.
use `add_typed` with a `TypeMap` to resolve data type names from your project's
type-map configuration.
### Send ### Send
@ -219,9 +220,7 @@ type-map configuration.
conn.sender.send(&msg).await?; conn.sender.send(&msg).await?;
``` ```
For request/response flows, `MTPConnection::request` sends one frame and waits For request/response flows, `MTPConnection::request` sends one frame and waits for a response with the same non-zero frame id. An expected response type can be provided for validation:
for a response with the same non-zero frame id. An expected response type can be
provided for validation:
```rust ```rust
let response = conn let response = conn
@ -229,92 +228,97 @@ let response = conn
.await?; .await?;
``` ```
Frames with other ids are consumed by this helper. Applications that need Requests are routed by id through the connection's receive dispatcher. Frames with other ids remain available through `conn.receive()`.
subscriptions or broad routing should use one receive task and correlate there.
Two send modes (configured via `mtp::transport::Policy`): Two send modes (configured via `mtp::client::Policy`):
- `PersistentStream` (default) -- reuses one QUIC uni-directional stream - `PersistentStream` (default): reuses one QUIC unidirectional stream
- `SingleStreamPerMessage` -- opens a new stream per message - `SingleStreamPerMessage`: opens a new stream per message
### Receive ### Receive
```rust ```rust
match conn.receiver.receive().await { match conn.receive().await {
Ok(msg) => { /* handle CommunicationValue */ } Ok(msg) => { /* handle CommunicationValue */ }
Err(e) => { /* connection closed or error */ } Err(e) => { /* connection closed or error */ }
} }
``` ```
Inbound frames are queued internally. The `receive()` method returns the next Inbound frames are queued internally. The `receive()` method returns the next available message. Do not read from `conn.receiver` directly because the connection dispatcher owns the shared transport receive loop.
available message.
### Close ### Close
```rust ```rust
conn.sender.close(); conn.sender.close().await;
// or // or
conn.receiver.close(); conn.receiver.close();
``` ```
Sends a close frame and signals the peer. The `Sender::close()` spawns an async `Sender::close().await` gracefully finishes the active send stream, sends the
task that sends the frame, waits for `force_close_delay` (default 300ms), then MTP close frame, and waits for `force_close_delay` (default 300ms) before
force-closes the QUIC connection if the peer has not already done so. force-closing the QUIC connection if necessary. `Sender::close_immediate()` is
the fire-and-forget variant. `Receiver::close()` closes the local receive
handle without performing the sender's graceful close sequence.
## Crypto Containers ### Pipes
With the `crypto` feature, `DataValue` supports encrypted, signed, and The complete pipe protocol, native API, browser API, lifecycle, and errors are documented in [Pipes](PIPES.md). Use the connection facade described there when the `pipes` feature is enabled.
signed+encrypted containers. Encryption uses ML-KEM to encapsulate to a
recipient's KEM public key (from their `PublicKeyBundle`); only the holder of ## Appendix: Composable Data Protection
the matching `Keyring` can decrypt. Signing uses the sender's Ed25519 key.
With the `crypto` feature, any `DataValue` can be signed or encrypted. The operations return typed errors and compose by operation order. `Encrypted(Signed(Value))` keeps the signer identity inside the encrypted plaintext; `Signed(Encrypted(Value))` leaves it visible. The example uses different keyrings for the signer and recipient to make the ownership explicit.
```rust ```rust
use mtp::crypto::{EncryptionType, Ed25519Signer, SigAlgorithm}; use mtp::codec::{ProtectionPurpose, DataTypeId, DataValue};
use mtp::crypto::{Ed25519Signer, Keyring};
let enc_type = EncryptionType::MlKemChaCha20Poly1305; let sender_keyring = Keyring::generate();
let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)?; let recipient_keyring = Keyring::generate();
let signer = Ed25519Signer::new(&sender_keyring.sig_cl_secret_key)?;
// `recipient` is the PublicKeyBundle of whoever should be able to decrypt let recipient = recipient_keyring.public_key_bundle();
// (e.g. the host's bundle, obtained out of band). let sender_public_keys = sender_keyring.public_key_bundle();
let value = DataValue::Container(vec![
// Encrypted container (DataTypeId(32), DataValue::Str("secret".into())),
let mut enc = DataValue::Container(vec![
(DataTypeId(1), DataValue::Str("secret".into())),
]); ]);
enc.encrypt_container(enc_type, &recipient, b"aad");
// Signed container // The outer encrypted wrapper hides the signer metadata.
let mut sig = DataValue::Container(vec![ let private_signer = value.clone().sign(7, ProtectionPurpose::from(1), &signer)?;
(DataTypeId(1), DataValue::Str("signed".into())), let sealed = private_signer.encrypt_for(
]); std::slice::from_ref(&recipient),
sig.sign_container(SigAlgorithm::ED25519, &signer); ProtectionPurpose::from(2),
)?;
// Signed + encrypted
let mut sec = DataValue::Container(vec![
(DataTypeId(1), DataValue::Str("both".into())),
]);
sec.sign_and_encrypt_container(SigAlgorithm::ED25519, &signer, enc_type, &recipient, b"aad");
``` ```
On the receiving side, the recipient decrypts with its own `Keyring` (each blob Reverse the calls when the signer identity should remain visible to the recipient before opening the encrypted value:
is self-describing: its leading byte selects the algorithm and the matching KEM
key from the keyring):
```rust ```rust
enc.decrypt_into_container(&keyring, b"aad"); // -> Container let encrypted = value.encrypt_for(
sig.verify_into_container(&verifier); // verifier: impl SignatureScheme std::slice::from_ref(&recipient),
sec.decrypt_signed_encrypted_container(&keyring, b"aad"); // -> SignedContainer, then verify_into_container ProtectionPurpose::from(2),
)?;
let public_signer = encrypted.sign(7, ProtectionPurpose::from(1), &signer)?;
``` ```
## Policy Configuration Opening and verification are explicit and return the inner value without mutating the wrapper:
```rust
let signed = sealed.decrypt(&recipient_keyring, ProtectionPurpose::from(2))?;
signed.verify(7, &sender_public_keys, ProtectionPurpose::from(1))?;
let plain = signed.into_verified(7, &sender_public_keys, ProtectionPurpose::from(1))?;
```
For `public_signer`, call `verify` and `into_verified` before calling `decrypt`; its outer signature is available before the encrypted value is opened.
### Policy Configuration
The `Policy` struct controls transport behaviour: The `Policy` struct controls transport behaviour:
```rust ```rust
use mtp::transport::{Policy, SendMode}; use mtp::client::{Policy, SendMode};
let policy = Policy { let policy = Policy {
send_mode: SendMode::PersistentStream, send_mode: SendMode::PersistentStream,
max_message_size: 1_000_000_000, max_message_size: 16 * 1024 * 1024,
handshake_max_message_size: 64 * 1024,
open_stream_timeout: Duration::from_millis(2000), open_stream_timeout: Duration::from_millis(2000),
write_timeout: Duration::from_millis(2000), write_timeout: Duration::from_millis(2000),
read_timeout: Duration::from_millis(30_000), read_timeout: Duration::from_millis(30_000),
@ -324,41 +328,20 @@ let policy = Policy {
}; };
``` ```
To apply a custom policy, call `mtp_transport::connect()` directly instead of Apply a custom policy with `ClientConfig::with_policy`:
using `MTPClient`:
```rust ```rust
use mtp_transport::{connect, Policy}; let config = config.with_policy(policy);
let conn = MTPClient::connect(config).await?;
let server_cert = match &config.tls {
ClientTlsConfig::SystemRoots => None,
ClientTlsConfig::PinnedPem(pem) => Some(pem.clone()),
};
let (sender, receiver) = connect(&config.url, server_cert, policy).await?;
``` ```
Then build and send the initial `Identification` frame manually to complete ### Version
version negotiation.
## Version The client's protocol version is baked in at compile time via the `PROTOCOL_VERSION` constant from `mtp::codec`. The version is set by the `protocol_version` field in your `type-maps.yaml`.
The client's protocol version is baked in at compile time via the The client never imports the `registry` module; it uses a single compiled-in version and expects the host to negotiate a compatible version.
`PROTOCOL_VERSION` constant from `mtp::codec`. The version is set by the
`protocol_version` field in your `type-maps.yaml`.
The client never imports the `registry` module; it uses a single compiled-in ### Error Handling
version and expects the host to negotiate a compatible version.
## Error Handling `CommunicationError` is summarized in the [Error Reference](ERRORS.md).
Native builds can expose additional variants that wrap QUIC and WebTransport errors.
`CommunicationError` covers transport errors:
| Variant | Meaning |
|-------------------------|--------------------------------------------|
| `StreamClosed` | Connection was closed by peer or timed out |
| `StreamError` | Transport-level I/O error |
| `MessageTooLarge` | Frame exceeds `max_message_size` |
| `ParseCommunicationValue` | Failed to deserialize incoming frame |
| `AuthenticationFailed` | Nonce mismatch or invalid host signature |
| `ConnectionError` | QUIC connection failure |
| `UseAfterClosed` | Attempted send/receive after close |

View file

@ -0,0 +1,184 @@
# MTP Web Server
`MTPWebServer` is a complete browser-facing HTTPS server. TCP TLS serves HTTP/1.1 and HTTP/2, while UDP QUIC serves HTTP/3 and WebTransport. Both listeners use the same certificate, router, IP address, and numeric port. Ordinary HTTP requests are handled inside the server; WebTransport MTP sessions are returned by `accept()` for application messages.
`MTPWebServer` and the native `MTPHost` cannot bind the same IP and port. The TCP integration does not add the native MTP QUIC ALPN protocol to `MTPWebServer`.
The repository's server example serves the compiled web client at `/`, exposes status at `/health`, and accepts WebTransport sessions at the same origin. No second TCP server is required.
## WebServerConfig
| Builder | Default | Purpose |
| --- | --- | --- |
| `route(path, handler)` | None | Register an exact-path HTTP handler. |
| `route_method(method, path, handler)` | None | Register a method-specific handler. |
| `route_pattern(pattern, handler)` | None | Register a route with `{name}` single-segment parameters. |
| `route_pattern_method(method, pattern, handler)` | None | Register a method-specific parameterized route. |
| `fallback(handler)` | None | Handle requests that match no route. |
| `mtp_path(path)` | `/` | Path for WebTransport extended CONNECT. |
| `serve_tcp_https(enabled)` | `true` | Enable the TCP TLS listener for HTTP/1.1 and HTTP/2. |
| `max_tcp_connections(count)` | 256 | Maximum concurrent TCP TLS connections. |
| `tls_handshake_timeout(duration)` | 10 seconds | Maximum TCP TLS handshake duration. |
| `max_request_body(bytes)` | 4 MiB | Maximum request body across all HTTP versions. |
| `max_connections(count)` | 256 | Maximum concurrent QUIC/HTTP/3 connections. |
| `request_timeout(duration)` | 30 seconds | Handler timeout across all HTTP versions. |
| `drain_timeout(duration)` | 5 seconds | Graceful shutdown period across both transports. |
| `with_metrics(metrics)` | None | Receive connection, request, and error callbacks. |
The route and fallback builders return `Result` because duplicate routes and duplicate fallback handlers are rejected.
Parameterized routes use braces around a name and pass extracted values to the
handler as `RouteParams`. Each parameter matches exactly one path segment. Exact
routes take precedence over parameterized routes; among parameterized routes,
method-specific and more-specific routes take precedence.
```rust
use http::{Method, StatusCode};
use mtp::webserver::{HttpRequest, HttpResponse, RouteParams, WebServerConfig};
async fn profile(
_request: HttpRequest,
response: HttpResponse,
params: RouteParams,
) -> HttpResponse {
let Some(userid) = params.get("userid") else {
return response.status(StatusCode::BAD_REQUEST);
};
response
.status(StatusCode::OK)
.header("content-type", "application/json")
.body(format!(r#"{{"userid":"{}"}}"#, userid))
}
let web = WebServerConfig::new()
.route_pattern_method(
Method::GET,
"/api/get/{userid}/profile.json",
profile,
)?;
```
`GET /api/get/user-123/profile.json` invokes `profile` with
`params["userid"] == "user-123"`. Percent-encoded parameter values are
UTF-8 decoded before being passed to the handler. Malformed encoded values do
not match the route. Query strings remain available through
`request.uri.query()` and are not part of route matching.
## HTTP Requests and Responses
`HttpRequest` contains `method`, `uri`, `headers`, the connecting `remote_addr`, and an optional buffered `body` represented by `bytes::Bytes`. `HttpResponse::status`, `header`, and `body` build a buffered response. `try_header` returns an error for invalid header names or values. `stream` takes a `tokio::sync::mpsc::Receiver<Bytes>` for incremental response chunks. The deprecated `Http3Request` and `Http3Response` aliases remain available for source compatibility.
```rust
use bytes::Bytes;
use http::{Method, StatusCode};
use tokio::sync::mpsc;
use mtp::webserver::{HttpRequest, HttpResponse, WebServerConfig};
async fn health(_request: HttpRequest, response: HttpResponse) -> HttpResponse {
response.status(StatusCode::OK).body("ok")
}
async fn whoami(request: HttpRequest, response: HttpResponse) -> HttpResponse {
response.body(format!("client: {}", request.remote_addr))
}
async fn stream_numbers(_request: HttpRequest, response: HttpResponse) -> HttpResponse {
let (tx, rx) = mpsc::channel::<Bytes>(10);
tokio::spawn(async move {
for number in 0..10 {
if tx.send(Bytes::from(format!("{number}\n"))).await.is_err() {
break;
}
}
});
response
.status(StatusCode::OK)
.header("content-type", "text/plain")
.stream(rx)
}
let web = WebServerConfig::new()
.route("/health", health)?
.route("/whoami", whoami)?
.route_method(Method::GET, "/numbers", stream_numbers)?
.fallback(|_request, response| async move {
response.status(StatusCode::NOT_FOUND).body("not found")
})?
.mtp_path("/mtp");
```
## Starting and Accepting MTP Sessions
```rust
use mtp::{host::HostConfig, webserver::MTPWebServer};
let host_config = HostConfig::new(
"0.0.0.0".parse()?,
4433,
std::fs::read("cert.pem")?,
std::fs::read("key.pem")?,
);
let mut server = MTPWebServer::new(host_config, web).await?;
while let Some(connection) = server.accept().await? {
// connection: WebMTPConnection
while let Ok(message) = connection.receive().await {
println!("received MTP message {:?}", message.id());
}
}
```
> `MTPWebServer::new` consumes a `HostConfig` (not an `MTPHost` instance). It creates its own QUIC endpoint and does not share a port with a running `MTPHost`.
`server.accept()` returns `Option<WebMTPConnection>` for each WebTransport session. Ordinary HTTP routes do not surface through `accept()` because the server dispatches them internally. `WebMTPConnection` retains the negotiated version, codec, `path`, remote address, description, sender, and receiver used by native MTP connections.
## Deployment
For direct browser access, leave `serve_tcp_https(true)` enabled. The server advertises `h2` and `http/1.1` on TCP TLS and `h3` on UDP QUIC; WebTransport extended CONNECT is available only over HTTP/3. Both transports must present the certificate supplied by the same `HostConfig` and use the same origin port.
When a reverse proxy or another process owns TCP, use `WebServerConfig::new().serve_tcp_https(false)`. This retains the UDP HTTP/3/WebTransport endpoint and its shared router without claiming the TCP port.
With port `0` and TCP enabled, construction binds TCP first and binds UDP to the selected TCP port, so `local_addr()` reports the common address. With TCP disabled, Quinn selects the UDP port as before. `shutdown().await` stops both accept loops, gracefully finishes active HTTP requests until `drain_timeout`, closes Quinn, and then aborts remaining work. `close().await` and dropping the server stop both listeners immediately.
### Authentication
`MTPWebServer` does not impose its own authentication policy. It respects the `AuthenticationPolicy` set on the supplied `HostConfig`:
| Policy | Behavior |
|--------|----------|
| `Unauthenticated` (default) | No authentication handshake is performed. The connection has `AuthState::Unauthenticated` and a random full-width `u64` client ID. `guest_id_generator` is not used by this adapter. |
| `AllowAuthentication` | The server accepts the first message. If it is an `Identification` or `Register` message, a full challenge-response handshake is performed. If it is an ordinary opening message, the connection remains unauthenticated. |
| `ForceAuthentication` | The server requires a valid `Identification` or `Register` message as the first frame and performs the challenge-response handshake. Any other opening message is rejected. |
When authentication is required or allowed and the client presents credentials, the server performs the same Ed25519/ML-DSA challenge-response handshake used by native MTP host connections:
1. The client sends `Identification` (with a client ID) or `Register` (with a public-key bundle).
2. The server looks up or accepts the client's public keys, generates a random 128-bit server nonce, and signs a challenge payload with its host keyring.
3. The client responds with a proof signed by its own keys.
4. The server verifies the proof, assigns the client ID, and sends a final signed response.
On success, the connection has `AuthState::Authenticated`, the assigned `client_id`, and `client_public_key` populated. On failure, `accept()` returns `AcceptError::AuthenticationFailed` (or `AcceptError::AuthenticationTimedOut` if the handshake exceeds `host_config.auth_timeout`).
`MTPWebServer::new` returns `CommunicationError` for certificate parsing, certificate loading, and bind failures. It does **not** reject `HostConfig` based on `AuthenticationPolicy`; any policy is accepted at construction time.
## Errors
`MTPWebServer::new` returns `CommunicationError` for certificate parsing,
certificate loading, and bind failures. Authentication policy is evaluated when
WebTransport sessions are accepted, not rejected during construction.
`accept()` returns `AcceptError` for a missing or unsupported version, a receive failure, or a send failure during the WebTransport opening handshake. HTTP route failures are reported through `WebServerMetrics::error_occurred` when metrics are configured. See [Errors](ERRORS.md) for shared error variants.
`WebServerMetrics` has these callbacks:
```rust
use std::time::Duration;
fn connection_accepted(&self)
fn connection_closed(&self, duration: Duration, reason: &str)
fn request_started(&self, path: &str)
fn request_completed(&self, path: &str, status: u16, duration: Duration)
fn error_occurred(&self, error: &WebServerError)
```
Errors include invalid requests, body-limit failures, handler timeouts, response write failures, TLS failures, and transport failures. Completion callbacks include the final HTTP status for every supported HTTP version. Supply the metrics object with `WebServerConfig::with_metrics`.

View file

@ -1,45 +1,23 @@
# MTP Native Host # MTP Native Host
The native host is a Rust library (`mtp-host`) that runs a QUIC server, accepts The native host is a Rust library (`mtp-host`) that runs a QUIC server, accepts MTP client connections, negotiates protocol versions, and optionally performs a mutual-authentication handshake (login/register) using Ed25519 and ML-DSA-65 signatures.
MTP client connections, negotiates protocol versions, and optionally performs a
mutual-authentication handshake (login/register) using Ed25519 and ML-DSA-65 > **Note:** `MTPHost` serves native MTP clients over raw QUIC. For browser-facing HTTP/1.1, HTTP/2, HTTP/3, and WebTransport, use [`MTPWebServer`](NATIVE-HOST-WEB-SERVER.md). It accepts the same `HostConfig` but its UDP endpoint uses HTTP/3 rather than the native MTP QUIC ALPN.
signatures.
## Cargo Dependency ## Cargo Dependency
```toml Add the `mtp` umbrella crate with `host`. Add `crypto` for authenticated connections and `pipes` for raw streams. The feature table is in the [README](../README.md).
[dependencies]
mtp = { path = "/path/to/mtp", features = ["host"] }
# Add crypto for authenticated connections:
mtp = { path = "/path/to/mtp", features = ["host", "crypto"] }
```
## HostConfig ## HostConfig
```rust `HostConfig::new` takes the bind address, port, PEM certificate chain, and PEM private key. Configure authentication and transport behavior with builders:
use mtp::host::HostConfig;
use std::net::{IpAddr, Ipv4Addr};
let config = HostConfig::new( ```rust
IpAddr::V4(Ipv4Addr::UNSPECIFIED), let config = HostConfig::new(ip, port, certificate, private_key)
4433, .with_pongs(true)
std::fs::read("cert.pem")?, .with_policy(Policy::default())
std::fs::read("key.pem")?, .with_authentication(host_keyring, get_existing_client, complete_register)
) .with_authentication_policy(AuthenticationPolicy::ForceAuthentication);
.with_authentication(
/* Keyring */,
|client_id: u64| {
let db = CLIENT_DB.clone();
Box::pin(async move { db.lock().unwrap().get(&client_id).cloned() })
},
|bundle: PublicKeyBundle| {
let mut db = CLIENT_DB.lock().unwrap();
let id = next_id();
db.insert(id, bundle);
Box::pin(async move { id })
},
);
``` ```
| Field | Type | Description | | Field | Type | Description |
@ -48,15 +26,40 @@ let config = HostConfig::new(
| `port` | `u16` | Listen port | | `port` | `u16` | Listen port |
| `tls_fullchain` | `Vec<u8>` | PEM-encoded TLS certificate chain | | `tls_fullchain` | `Vec<u8>` | PEM-encoded TLS certificate chain |
| `tls_key` | `Vec<u8>` | PEM-encoded TLS private key | | `tls_key` | `Vec<u8>` | PEM-encoded TLS private key |
| `require_authentication` | `bool` (crypto) | Enable login/register handshake | | `send_pongs` | `bool` | Sends a Pong for each received Ping (default `true`) |
| `authentication_policy` | `AuthenticationPolicy` (crypto) | `ForceAuthentication`, `AllowAuthentication`, or `Unauthenticated` |
| `host_keyring` | `Keyring` (crypto) | Host's signing and KEM keys | | `host_keyring` | `Keyring` (crypto) | Host's signing and KEM keys |
| `get_existing_user` | `Fn(u64) -> Pin<Box<dyn Future<Output = Option<PublicKeyBundle>> + Send>> + Send + Sync` (crypto) | Async lookup callback for login | | `get_existing_client` | Async callback returning `Option<PublicKeyBundle>` | Receives `(client_id, description)`. `Some` supplies the stored key bundle. `description = None` is used for guest-ID collision checks. |
| `complete_register` | `Fn(PublicKeyBundle) -> Pin<Box<dyn Future<Output = u64> + Send>> + Send + Sync` (crypto) | Async registration callback, returns new client ID | | `guest_id_generator` | Async callback returning `Option<u64>` | Custom guest ID assignment. The default generates random IDs. |
| `complete_register` | Async callback returning `u64` | Stores the public bundle and returns its assigned client ID. |
### AuthenticationPolicy
`ForceAuthentication` requires every client to complete the login or registration handshake. `AllowAuthentication` accepts both authenticated and unauthenticated connections; unauthenticated clients receive an ID and `AuthState::Unauthenticated`. `Unauthenticated` rejects authentication attempts and is the default.
Authentication policy details are in [Security](SECURITY.md).
### TLS ### TLS
The host requires a TLS certificate. For development, generate a self-signed `HostConfig::new` always uses the certificate and key supplied by the caller.
certificate using `rcgen`. For production, use a CA-signed certificate. Certificate trust and development settings are in [Security](SECURITY.md).
### Ping-Pong
Keepalive behavior is defined in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive).
```rust
let config = HostConfig::new(ip, port, cert, key)
.with_pongs(true);
```
Disable automatic responses only when the application needs to handle Ping frames itself:
```rust
let config = HostConfig::new(ip, port, cert, key)
.with_pongs(false);
```
Follow the responder contract in [Protocol Reference](PROTOCOL-REFERENCE.md).
## Accepting Connections ## Accepting Connections
@ -73,164 +76,44 @@ while let Some(conn) = host.accept().await? {
### MTPConnection ### MTPConnection
Returned by `accept()` after version negotiation (and authentication if `accept()` returns the shared connection shape in [MTP Connections](CONNECTIONS.md)
enabled): after version negotiation and authentication, when enabled. The host-specific `codec` is scoped to the negotiated version, and `client_public_key` is set for authenticated clients.
The connection's `remote_addr` is the peer `SocketAddr` observed by QUIC. It is
```rust network metadata, not an authenticated client identity.
pub struct MTPConnection {
pub version: Version,
pub codec: VersionedCodec,
pub sender: Sender,
pub receiver: Receiver,
#[cfg(feature = "crypto")]
pub auth_state: AuthState,
#[cfg(feature = "crypto")]
pub client_id: u64,
#[cfg(feature = "crypto")]
pub client_public_key: Option<PublicKeyBundle>,
}
```
- `version` -- the negotiated protocol version
- `codec` -- a `VersionedCodec` scoped to the negotiated version (use for
version-aware encode/decode)
- `sender` / `receiver` -- for message I/O
- `client_id` -- the authenticated client's ID
- `client_public_key` -- the client's public key bundle (for signature
verification of subsequent messages)
## Version Negotiation ## Version Negotiation
When a client connects, `accept()` performs the following sequence: `accept()` uses the version-bearing opening frame and registry flow in [Connector](CONNECTOR.md). The host registry is built from the type maps in [`example/type-maps.yaml`](../example/type-maps.yaml) by `Registry::builtin()` in this repository; downstream builds can provide their own `MTP_TYPE_MAPS` configuration.
1. Accept the QUIC connection
2. Read the client's first `CommunicationValue` (always encoded with reserved
type IDs)
3. Extract the protocol version from `DataType::Version` (reserved data type ID 0) as a
`DataValue::Str("major.minor")`
4. Call `registry.negotiate(&[client_version])` to find the highest mutually
supported version
5. Return an `AcceptError` (closing the connection) if no compatible version exists
6. Return `Ok(Some(MTPConnection))` with the negotiated version
The `Registry` is built automatically from all type maps defined in your
`type-maps.yaml` via `Registry::builtin()`.
### Registry ### Registry
```rust ```rust
use mtp::codec::registry::Registry; use mtp::codec::Version;
let registry = host.registry(); let registry = host.registry();
assert!(registry.supports(&Version(2, 0))); assert!(registry.supports(&Version(3, 0)));
let negotiated = registry.negotiate(&[Version(1, 0), Version(2, 0)]); let negotiated = registry.negotiate(&[Version(2, 0), Version(3, 0)]);
// -> Some(Version(2, 0)) if both versions are registered // -> Some(Version(3, 0)) for this repository's builtin map
``` ```
## Authentication Flow ## Authentication Flow
When `require_authentication` is `true`, `accept()` runs a mutually-authenticated The connection lifecycle and authentication sequence are in [Protocol Reference](PROTOCOL-REFERENCE.md). Host callback contracts are documented below.
**challenge-response** handshake before returning the connection. The host issues
a fresh, random `server_challenge` that the client must sign, which is what makes
the client's proof unreplayable: a captured proof is bound to a one-time challenge
the host generates per connection and will never reissue. The challenge lives only
on the accepting task's stack; there is no replay database or shared state.
All signed payloads begin with a one-byte domain-separation tag (see After a successful handshake, `MTPConnection` exposes `AuthState::Authenticated`, the client ID, and the client's public key bundle when one is available.
`mtp::crypto::auth`) so a signature for one step can never be reused as another.
### Login
```
Client Host
| |
| QUIC connect |
|---------------------------------------->|
| |
| Identification { Version, Id } | (unsigned hello)
|---------------------------------------->|
| | lookup get_existing_user(id)
| | generate random server_challenge
| Challenge { |
| ServerNonce(server_challenge), |
| Signature, [PqSignature] | host signs the challenge
| } |
|<----------------------------------------|
| ChallengeResponse { |
| ClientNonce, Signature, [PqSignature]| client signs the challenge
| } |
|---------------------------------------->|
| | verify proof over server_challenge
| IdentificationResponse { |
| Connected=true, Id, |
| ClientNonce(echoed), |
| Signature, [PqSignature] |
| } |
|<----------------------------------------|
```
Payloads (`||` is concatenation, integers big-endian; `DS_*` are domain tags):
- Host challenge: `DS_CHALLENGE || id (8) || server_challenge (16)`
- Client proof: `DS_LOGIN_PROOF || version_string || id (8) || server_challenge (16) || client_nonce (16)`
- Host final: `DS_HOST_FINAL || assigned_id (8) || client_nonce (16) || server_challenge (16)`
### Register
```
Client Host
| |
| QUIC connect |
|---------------------------------------->|
| |
| Register { |
| Version, | (unsigned hello)
| PublicKeys (serialized PublicKeyBundle)
| } |
|---------------------------------------->|
| | generate random server_challenge
| Challenge { |
| ServerNonce(server_challenge), |
| Signature, [PqSignature] | (challenge binds id = 0)
| } |
|<----------------------------------------|
| ChallengeResponse { |
| ClientNonce, Signature, [PqSignature]|
| } |
|---------------------------------------->|
| | verify proof over server_challenge
| | call complete_register(bundle) -> new_id
| RegisterResponse { |
| Connected=true, Id(new_id), |
| ClientNonce(echoed), |
| Signature, [PqSignature] |
| } |
|<----------------------------------------|
```
The register client proof is:
`DS_REGISTER_PROOF || version_string || server_challenge (16) || client_nonce (16) || public_key_bytes`
After a successful handshake, `accept()` returns an `MTPConnection` with
`auth_state = Authenticated`, `client_id` set, and `client_public_key`
available for verifying subsequent signed messages from the client.
### Rejection
If verification fails or the client is not found (login), the host sends a
rejection response with `Connected=false` and closes the send stream, returning
`AcceptError::AuthenticationFailed` from `accept()`.
## Handling Messages ## Handling Messages
Use `conn.sender` and `conn.receiver` for bidirectional message exchange: Use `conn.sender` and `conn.receive()` for bidirectional message exchange. The
connection dispatcher owns the underlying receiver, especially when `pipes` is
enabled:
```rust ```rust
while let Some(conn) = host.accept().await? { while let Some(conn) = host.accept().await? {
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
match conn.receiver.receive().await { match conn.receive().await {
Ok(msg) => { Ok(msg) => {
let response = process_message(&msg, &conn); let response = process_message(&msg, &conn);
conn.sender.send(&response).await.ok(); conn.sender.send(&response).await.ok();
@ -244,8 +127,7 @@ while let Some(conn) = host.accept().await? {
### Versioned Codec ### Versioned Codec
The `conn.codec` is a `VersionedCodec` pre-configured with the negotiated The `conn.codec` is a `VersionedCodec` pre-configured with the negotiated version. Use it to encode/decode with version-specific type maps:
version. Use it to encode/decode with version-specific type maps:
```rust ```rust
let tm = conn.codec.registry().get(&conn.version).unwrap(); let tm = conn.codec.registry().get(&conn.version).unwrap();
@ -255,41 +137,67 @@ let desc_id = DataTypeId(tm.data_id_enum(DataType::Description).unwrap());
let value = msg.get_data(desc_id); let value = msg.get_data(desc_id);
``` ```
## Pipes
The complete pipe protocol and host API are documented in [Pipes](PIPES.md).
## Host Callbacks ## Host Callbacks
### get_existing_user ### get_existing_client
Called during login to retrieve a client's public key bundle for signature Called during login to retrieve a client's public key bundle for signature verification, and also during guest ID generation to check whether a random candidate collides with a registered client. When used for collision checking the `description` argument is `None`.
verification. Must return `Some(PublicKeyBundle)` if the client ID is known,
or `None` to reject. Must return `Some(PublicKeyBundle)` if the client ID is known, or `None` otherwise.
```rust ```rust
let get_existing_user = |id: u64| { // db: Arc<tokio::sync::Mutex<HashMap<u64, PublicKeyBundle>>>
let get_existing_client = |id: u64, _description: Option<String>| {
let db = db.clone(); let db = db.clone();
Box::pin(async move { db.lock().unwrap().get(&id).cloned() }) Box::pin(async move { db.lock().await.get(&id).cloned() })
}; };
``` ```
### guest_id_generator
Optional callback that controls how unauthenticated connections receive their client ID. When `None` (the default), the host generates a random full-width `u64` ID and checks it against `get_existing_client` to avoid collisions.
Return `Some(id)` to accept the guest with that full-width `u64` ID, or `None` to reject the connection.
```rust
use std::sync::atomic::{AtomicU64, Ordering};
// Sequential guest IDs:
let counter = AtomicU64::new(1);
let guest_id_generator = Box::new(move || {
Box::pin(async move { Some(counter.fetch_add(1, Ordering::SeqCst)) })
});
// Reject all guests (no unauthenticated connections):
let guest_id_generator = Box::new(|| Box::pin(async { None }));
let config = HostConfig::new(ip, port, cert, key)
.with_authentication(host_keyring, get_existing_client, complete_register)
.with_authentication_policy(AuthenticationPolicy::AllowAuthentication)
.with_guest_id_generator(guest_id_generator);
```
### complete_register ### complete_register
Called during registration to persist a new client's public key bundle and Called during registration to persist a new client's public key bundle and assign a client ID. The returned `u64` becomes the client's permanent identifier.
assign a client ID. The returned `u64` becomes the client's permanent
identifier.
```rust ```rust
let complete_register = |bundle: PublicKeyBundle| { // db: Arc<tokio::sync::Mutex<HashMap<u64, PublicKeyBundle>>>
let complete_register = |bundle: PublicKeyBundle, _description: Option<String>| {
let db = db.clone(); let db = db.clone();
let id = next_id.fetch_add(1, Ordering::SeqCst); let id = next_id.fetch_add(1, Ordering::SeqCst);
Box::pin(async move { Box::pin(async move {
db.lock().unwrap().insert(id, bundle); db.lock().await.insert(id, bundle);
id id
}) })
}; };
``` ```
Both callbacks are called from within `accept()` and must be `Send + Sync`. They All callbacks are called from within `accept()` and must be `Send + Sync`. They are `async` (returning `Pin<Box<dyn Future<...>>`) and are `.await`ed by the host, so they can perform I/O or other async work as needed. The `complete_register` callback returns no error value. A panic aborts the normal callback flow; validate storage and ID allocation before returning the ID.
are `async` (returning `Pin<Box<dyn Future<...>>`) and are `.await`ed by the
host, so they can perform I/O or other async work as needed.
## Host Key Generation ## Host Key Generation
@ -306,7 +214,7 @@ let (kem_sk, kem_pk) = HybridKem::generate_keypair();
let host_keyring = Keyring::new(kem_pk, kem_sk, sig_pq_pk, sig_pq_sk, sig_pk, sig_sk); let host_keyring = Keyring::new(kem_pk, kem_sk, sig_pq_pk, sig_pq_sk, sig_pk, sig_sk);
// Save to disk // Save to disk
let bytes = host_keyring.to_bytes(); let bytes = host_keyring.try_to_bytes()?;
std::fs::write("host_keys.bin", bytes)?; std::fs::write("host_keys.bin", bytes)?;
``` ```
@ -321,21 +229,17 @@ std::fs::write("host_sig_pq_pk.bin", bundle.sig_pq_public_key.as_bytes())?;
## Policy ## Policy
The transport `Policy` is set to defaults internally. To customise (timeouts, Customize transport limits and timeouts through `HostConfig::with_policy`:
send mode, etc.), use `mtp_transport::host()` directly instead of `MTPHost`:
```rust ```rust
use mtp_transport::{host, Policy}; let config = HostConfig::new(ip, port, cert, key)
.with_policy(custom_policy);
let transport = host(ip, port, cert, key, custom_policy).await?; let host = MTPHost::new(config).await?;
// Then build version negotiation on top:
// - accept transport.next()
// - read first frame
// - registry.negotiate()
// - return MTPConnection
``` ```
## Graceful Shutdown ## Graceful Shutdown
Drop the `MTPHost` to stop accepting new connections. Active connections Drop the `MTPHost` to stop accepting new connections. Active connections continue until their `Sender`/`Receiver` are dropped or the peer disconnects.
continue until their `Sender`/`Receiver` are dropped or the peer disconnects.
Run one accept loop per `MTPHost` and spawn one task per accepted connection.
Stop the accept loop before dropping the host, then close active senders and wait for application tasks to finish. Use [Operations](OPERATIONS.md) for the deployment sequence and monitoring signals.

42
docs/OPERATIONS.md Normal file
View file

@ -0,0 +1,42 @@
# Operations
## Monitoring
Expose counters and gauges around the host and transport callbacks:
| Metric | Interpretation |
| --- | --- |
| Accepted and rejected connections | Compare admission failures with traffic volume. A rise in rejected connections points to certificate, version, policy, or authentication problems. |
| Active connections and active pipe streams | Capacity currently consumed by sessions and raw streams. |
| Authentication failures and timeouts | Credential, policy, or reachability failures during the handshake. |
| Unsupported protocol versions | Clients that require a version still absent from the registry. |
| Message-too-large and decode errors | Peer or schema mismatch, malformed input, or an overly small policy limit. |
| Request latency and request timeout count | Application handler time and transport deadline pressure. |
| Ping round-trip time and missed pings | Peer reachability and path latency. |
| Pipe accept, reject, EOF, and reset counts | Application admission and stream completion behavior. |
Implement `WebServerMetrics` for HTTP/1.1, HTTP/2, HTTP/3, TLS, and WebTransport callbacks. Record the request path, status, duration, and `WebServerError` category without logging credentials, private keys, or message contents. Export host callback results through the application's metrics system for native deployments.
## Tuning
`Policy::default()` uses a 16 MiB message limit, a 64 KiB handshake limit, a 30 second read timeout, a 30 second idle timeout, a receiver queue capacity of 1000, and 128 concurrent stream tasks.
For low-latency request traffic, use `SendMode::SingleStreamPerMessage`, keep message sizes bounded, use shorter read and idle timeouts, and keep queue and concurrency limits near the amount of work the application can process.
For high-throughput bulk traffic, use persistent streams, raise `max_message_size` only when messages require it, and size `receiver_queue_capacity` and `max_concurrent_stream_tasks` for available memory and downstream processing capacity. Use pipes for large sequential byte streams instead of increasing message limits.
Every queued frame consumes memory until the application reads it. Test policy changes with realistic peer counts and payload sizes before deployment.
## Deployment
### Certificate Rotation
Publish the replacement certificate or pin before changing the server. Update clients to trust the replacement while the current certificate remains valid, switch the server, then remove the old trust value after clients migrate. Use system roots when certificate rotation is managed by the issuing authority.
### Key Backup
Back up host keyrings and client keyrings as protected secrets. Test restoring a backup before relying on it. Keep private key files owner-only on Unix, protect backup access, and store public key bundles separately from private material.
### Graceful Shutdown
Stop accepting new connections, reject new work at the application layer, and allow active requests and pipe writers to finish. For `MTPWebServer`, call `shutdown().await`; its `drain_timeout` controls graceful TCP HTTP completion and the QUIC drain period before remaining connection tasks are terminated.

198
docs/PIPES.md Normal file
View file

@ -0,0 +1,198 @@
# MTP Pipes
Pipes are unidirectional QUIC/WebTransport streams. The transport primitive is
byte-oriented, but raw pipe bytes are not confidential or authenticated by
MTP. The creator sends a `PipeRequest` communication value, the peer accepts
or rejects it, and an application that carries sensitive data must place the
encrypted record layer described below on top of the accepted stream.
The request's `Description` and `PipeRequest` type remain clear transport
metadata. Do not put identities, call details, file names, or other sensitive
protocol information in them.
The creator owns the writer. The accepting peer owns the reader. A writer finishes with a stream FIN or aborts with a stream reset. A reader returns EOF after FIN and reports a connection or stream error when the peer closes unexpectedly.
## Opening a Pipe
The creator calls `create_pipe` or the corresponding SDK `createPipe` method with a description. MTP assigns a pipe ID and sends a `PipeRequest` frame. The creator receives a handle, not an active writer, because the peer must decide whether to accept the request.
The request description is application metadata. It does not grant access to the stream, authenticate the creator, or negotiate an application protocol.
Use the authenticated MTP connection and the host's admission policy when a pipe carries sensitive data.
The browser SDK's `createEncryptedPipe` and `acceptEncryptedPipe` convenience
methods derive the local identity, actual pipe ID, random session ID, and
default application purpose from MTP state. Use the lower-level session
functions only when integrating a custom pipe transport. The low-level API
checks that a supplied pipe ID matches the actual pipe; it does not infer a
caller-provided sender or recipient identity.
The convenience methods intentionally require registered client credentials
because their endpoint identity is the transport client's registered MTP
identity. An application that needs a cryptographic identity independent from
transport registration must use the lower-level session functions and provide
the endpoint IDs and key material explicitly.
## Endpoint Encryption
`initiate_pipe_session`/`accept_pipe_session` in the native transport, or
`initiateMTPPipeSession`/`acceptMTPPipeSession` in the browser SDK, perform the
pipe-establishment step. The initiator sends an
`Encrypted(Signed(Array<...>))` offer containing a fresh 32-byte initial chain key,
session ID, pipe ID, direction, purpose, and both endpoint IDs. The recipient
decrypts it with its keyring, resolves the expected sender bundle, verifies
the signature, and checks every expected field before returning the record
reader. The offer is bounded and separately framed from application records.
The helpers then return `EncryptedPipeWriter`/`EncryptedPipeReader` (or their
browser equivalents) without changing the raw QUIC/WebTransport adapter. The
context contains the unique pipe/session identity, endpoint identities,
direction, and application protocol purpose. Do not derive the initial chain
key from the clear description or pipe ID alone.
The receiver's signature verification policy is explicit and independent from
its decryption keyring. Configure `signaturePolicy` on the browser accept
helper, or use the client's `defaultSignatureVerificationPolicy`. The
initiator and responder signing `signatureSuite` remain separate from this
receive policy. Both sides default to Ed25519; choose `signatureSuite: "dual"`
and a matching `signaturePolicy: "dual"` explicitly when hybrid signatures
are required.
Each record is encoded as:
```text
[4-byte big-endian ciphertext length]
[1-byte record type: DATA=0, FINAL=1]
[XChaCha20-Poly1305 nonce || ciphertext || tag]
```
The AEAD associated data is `MTP-PIPE-E2EE-1 || purpose || direction ||
transcript-hash || sequence || record length || record type`. The transcript
hash binds the session ID, pipe ID, sender, recipient, purpose, and direction.
The sequence starts at zero and advances only after successful authentication.
A missing, duplicated, reordered, or modified record causes authentication to
fail. Each record derives a one-use message key and the next chain key with
HKDF using the authenticated context and sequence number; the bootstrap key is
never used directly as an AEAD key. The record layer caps one encoded record at
16 MiB.
`FINAL` is an authenticated empty record. A reader returns clean EOF only
after validating it; transport EOF before `FINAL` is truncation.
Authentication, framing, sequence, and I/O failures permanently poison the
encrypted reader or writer and erase its current chain key. This is a one-way
chain, not a Diffie-Hellman ratchet, so the ordinary offer does not provide
forward secrecy.
The wrapper exposes `writeRecord`/`readRecord`. Callers that already have an
independently authenticated session may still construct it directly with a
key and context; otherwise use the establishment helpers.
For more than two members, native `initiate_group_pipe_session` and the browser
`initiateMTPPipeSession` recipient-array form encrypt one fresh session key to
each current member. Membership changes are rekeys: create a new session ID
and offer with the new recipient set, and stop using the old record chain. A
removed member must never receive a later session key; an added member must
not receive historical records.
When a live call needs forward secrecy, use the duplex handshake
`initiate_forward_secure_pipe_session`/`accept_forward_secure_pipe_session` or
the browser `initiateMTPForwardSecurePipeSession`/
`acceptMTPForwardSecurePipeSession`. The responder contributes a fresh
ephemeral hybrid-KEM key, while long-term signing keys authenticate the
exchange. These helpers require a bidirectional stream and bind the handshake
transcript into the record context.
## Accepting or Rejecting a Pipe
The receiving side reads pipe requests through `receive_pipe`, the host dispatcher, or the browser pipe callback. It calls `accept` to obtain a reader or `deny` to reject the request. A rejected request completes the creator's handle with `Rejected` and no raw byte stream becomes available.
With native pipes enabled, do not read the underlying `receiver` directly.
Normal messages and pipe requests share the transport and must pass through the connection facade so a dispatcher does not deliver one event to the wrong consumer.
## Closing a Pipe
The creator closes a successful encrypted pipe with `EncryptedPipeWriter::finish`
or the browser writer's `close`; this authenticates `FINAL` and then sends a
QUIC FIN. Use `abort` when the peer should discard the stream immediately; this
resets the stream and the reader receives an error instead of a clean EOF.
Dropping the connection closes all active pipes. Raw pipe FIN is not an
authenticated application completion signal.
The accepting side closes its reader by consuming it or dropping it. A reader does not send an application-level acknowledgement for EOF. If the application needs completion metadata, send an ordinary MTP message before finishing the pipe.
## Pipe Errors
| Error | Meaning |
| --- | --- |
| `Rejected` | The peer denied the request. |
| `HandshakeTimeout` | The peer did not complete the pipe handshake in time. |
| `StreamClosed` | The pipe stream ended unexpectedly. |
| `IoError` | The underlying byte stream returned an I/O error. |
| `ConnectionClosed` | The MTP connection closed while the pipe was active. |
Native applications use the pipe APIs on `MTPConnection`; browser applications use the SDK methods in [WASM Client](WASM-CLIENT.md#pipes). With native pipes enabled, normal messages and pipe requests must be read through the connection facade so the dispatcher can route each event to the correct queue.
## Native File Upload and Processing
The creator streams a file in encrypted records. The accepting side processes
each decrypted chunk without buffering the complete file. The `session_key`
below is obtained from the authenticated pipe-establishment protocol:
```rust
// Client
use mtp_transport::{PipeSessionParameters, initiate_pipe_session};
use tokio::io::AsyncReadExt;
let handle = conn.create_pipe("file-upload").await?;
let pipe_id = handle.pipe_id();
if let Some(writer) = handle.wait().await? {
let params = PipeSessionParameters::new(
format!("file-upload/{pipe_id}"), pipe_id, own_client_id, host_client_id, 0x40, 0,
)?;
let mut writer = initiate_pipe_session(
writer.into_inner(), params, &own_keyring, &host_public_bundle,
).await?;
let mut file = tokio::fs::File::open("input.bin").await?;
let mut buffer = [0u8; 64 * 1024];
loop {
let count = file.read(&mut buffer).await?;
if count == 0 {
break;
}
writer.write_record(&buffer[..count]).await?;
}
writer.finish().await?;
}
```
```rust
// Host
use mtp_transport::{PipeSessionParameters, accept_pipe_session};
use sha2::{Digest, Sha256};
// The streaming digest below requires `sha2` as a direct application dependency.
while let Ok(request) = conn.receive_pipe().await {
if request.description() != "file-upload" {
request.deny().await?;
continue;
}
let pipe_id = request.id();
let reader = request.accept().await?;
let params = PipeSessionParameters::new(
format!("file-upload/{pipe_id}"), pipe_id, client_id, own_client_id, 0x40, 0,
)?;
let mut reader = accept_pipe_session(
reader.into_inner(), &params, &own_keyring, &client_public_bundle,
).await?;
let mut hasher = Sha256::new();
while let Some(chunk) = reader.read_record().await? {
hasher.update(&chunk);
process_chunk(&chunk).await?;
}
let digest = hasher.finalize();
println!("processed upload with digest {digest:x}");
}
```
Send completion metadata as an ordinary MTP message after the reader observes EOF. A stream FIN means the writer finished; it does not authenticate file contents or provide a digest.

120
docs/PROTOCOL-REFERENCE.md Normal file
View file

@ -0,0 +1,120 @@
# Protocol Reference
This document owns the connection lifecycle, protocol keepalive, and application authentication flow. API guides link here for configuration.
## Connection Lifecycle
```text
bind -> accept QUIC -> negotiate version -> authenticate if enabled
-> dispatch application frames -> close or drain
```
The opening version frame is processed before application messages. The host selects a registered type map. Authentication then completes according to the host policy. A connection is returned to the application only after these stages complete.
## Protocol Keepalive
The client sends an MTP `Ping` communication value with a frame ID. The host returns a `Pong` with the same ID when automatic responses are enabled. The client records the matched round-trip duration and closes after its configured missed-Ping limit. These frames are handled by the keepalive dispatcher and do not reach ordinary message handlers.
If automatic responses are disabled, the application must read Ping frames and send compatible Pong frames. Keepalive configuration is documented in the [native client](NATIVE-CLIENT.md) and [native host](NATIVE-HOST.md) guides.
## Relay metadata version
Protected relay metadata declares the reserved `RelayVersion` field as an unsigned integer. Builders currently emit version `1` automatically. Receivers select the metadata schema from this field before interpreting any version-specific fields. Missing versions are unsupported legacy relays, and unknown versions are rejected.
Relay format versions are independent of application type-map versions. A type-map version selects application-defined communication and data types. It does not select the protected relay metadata schema.
## Relay `CreatedAt`
The reserved `CreatedAt` field in relay metadata is an unsigned integer containing milliseconds elapsed since `1970-01-01T00:00:00Z`. It is not an ISO timestamp and it is not measured in seconds.
For example:
```text
2026-08-11T12:00:00.000Z
Unix epoch milliseconds
CreatedAt = 1786449600000
```
Native relay builders and browser relay senders use this unit. Verified browser metadata exposes `createdAt` as a `bigint`; native verified metadata exposes `u64`.
## Direct protected envelope
The high-level direct protected API signs an MTP-owned envelope before it is
encrypted for the recipient. Its reserved fields are `ProtectedVersion`,
`MessageType`, `FinalRecipientId`, `MessageId`, `CreatedAt`, and `Content`.
Receivers verify the envelope before dispatching application content and require
the signed message type and final recipient to match the outer communication
type and receiver. If the outer sender is present, it must match the signed
signer ID. `MessageId` and `CreatedAt` are authenticated; callers can pass a
replay guard to reject a previously accepted `(signerId, MessageId)` pair.
Native and browser replay guards both receive `CreatedAt` as authenticated
metadata, but the timestamp is not part of the replay key.
Verified SDK results expose the authenticated `protectedVersion` and
`finalRecipientId` alongside the application content.
Native applications use the same schema through `ProtectedMessageBuilder` and
the replay-explicit `open_protected_checked` or `open_protected_without_replay`
APIs; language bindings delegate envelope construction and opening to this
codec boundary.
Message processing uses the replay-required native APIs
`open_protected_checked` and `open_relay_metadata_checked` (or the equivalent
browser client path). Stored-message or forensic tooling must opt into the
explicit `*_without_replay` APIs. Native in-memory guards are bounded and
configurable; durable guards must perform an atomic insert-if-absent on
`(signer ID, MessageId)`.
Protected identifiers have semantic limits separate from the generic codec
blob limit. The default maximum `MessageId` is 256 UTF-8 bytes and relay
metadata is limited to 1 MiB of encoded metadata. Deployments can provide
stricter limits through the receive policy. Limits are checked after
authentication and before retained values enter replay or application state.
Transport-derived resource policies use a conservative decoder allocation
factor of `4 * max_message_size`, in addition to the frame-size output limit.
This factor accounts for owned wrapper, recipient, ciphertext, and decoded
value copies; it is an implementation admission policy rather than a wire
field.
## Authentication Flow
```text
Client Host
| |
| Identification or Register, unsigned |
|------------------------------------------>|
| | generate challenge
| Challenge plus host signature |
|<------------------------------------------|
| ChallengeResponse plus client signature |
|------------------------------------------>|
| | verify proof and assign identity
| IdentificationResponse plus host signature|
|<------------------------------------------|
```
Login proof binds the protocol version, client ID, host challenge, and client nonce. Registration proof binds the protocol version, public key bundle, host challenge, and client nonce. The host challenge is generated per connection.
Authentication attempts pass through a deployment-configurable limiter before
client lookup, key validation, challenge signing, or registration callbacks.
The default host configuration uses a bounded in-memory window. Hosts may key
limits by connection, peer identity, claimed client ID, or registration flow.
When identity concealment is enabled, an unknown client ID follows a dummy
challenge/proof path and receives the same generic authentication failure as a
known client with an invalid proof; disabling concealment restores the legacy
identity-specific response for deployments where IDs are public.
`ForceAuthentication` requires login or registration. `AllowAuthentication` accepts authenticated and unauthenticated clients. `Unauthenticated` rejects authentication attempts. The connection states are `Pending`, `Authenticated`, `Unauthenticated`, and `Failed`.
## Version Negotiation
The client sends one compiled-in protocol version. The host compares it with the versions in its registry and returns the selected version in the opening response. Subsequent frames use that version's type map. An unsupported version closes the connection with `AcceptError::UnsupportedVersion`.
The current self-delimiting `DataValue` codec and three-bit communication header
are used by the repository's protocol 3.0 map. The checked-in builtin registry
contains only 3.0, so its native clients and hosts do not provide legacy map
fallbacks. Type-map versions are configuration-driven; a custom registry may
register another version number, but its map must use the current codec format
and is not a fallback for a different legacy wire format.

324
docs/SECURITY.md Normal file
View file

@ -0,0 +1,324 @@
# Security
This document describes the security controls implemented by MTP, the crypto APIs exposed by `mtp-crypto`, and the limits that operators and application developers must account for.
## Reporting Vulnerabilities
Report suspected vulnerabilities privately to the project maintainers. Include the affected crate, feature flags, protocol path, reproducible input, and the commit or release being tested. Do not include private keys or credentials in the report.
## Security Boundaries
MTP runs over QUIC and relies on TLS for transport confidentiality and peer authentication. The native transport uses `wtransport`; the browser client uses WebTransport. MTP authentication adds application-level signatures and does not replace TLS certificate verification.
MTP does not provide anonymity. Client identifiers and connection metadata are visible to the host. It also cannot protect data after a client or host endpoint has been compromised.
## TLS Certificate Verification
The native client uses the system root store by default. It also supports a pinned PEM certificate or an SPKI SHA-256 pin. Browser clients use the browser root store unless `serverCertificateHashes` is configured for WebTransport.
| Configuration | Trusts | Intended use |
| --- | --- | --- |
| System roots | Certificates trusted by the operating system or browser | Publicly trusted production certificates |
| Pinned PEM | The supplied PEM certificate chain | Private CA deployments and controlled environments |
| SPKI hash | The public key represented by the supplied certificate | A fixed server key, with planned rotation |
| Insecure verification | Any certificate | Local development only |
For rotation, publish the replacement certificate or key before changing the server, update clients to trust both values where the client API permits it, then remove the old value after all clients have migrated. A pin is a key constraint, not a substitute for a certificate rotation plan.
### Development Certificates
The `tls` feature exposes `mtp_crypto::tls::generate_self_signed_cert`. It creates an ECDSA P-256 server certificate for the requested domain, `127.0.0.1`, and `::1`; the certificate is valid for 13 days. The lower-level `mtp_transport::HostConfig::self_signed` provides a transport-level self-signed setup without the crypto certificate helper.
Self-signed certificates are for development. Production deployments should use a certificate trusted by the client or an explicitly pinned certificate.
### Insecure Verification
Native insecure verification has two gates:
1. Compile with the `insecure-tls` feature.
2. Set `MTP_INSECURE_TLS=1` at runtime.
Without the runtime variable, the connection fails rather than silently disabling verification. Do not use this mode on an untrusted network.
## Authentication Policies
Hosts choose one of three policies:
- `ForceAuthentication` requires login or registration.
- `AllowAuthentication` accepts authenticated and unauthenticated clients.
- `Unauthenticated` rejects authentication attempts and is the default.
An unauthenticated connection receives `AuthState::Unauthenticated`. Use `ForceAuthentication` when every client must have a registered identity.
The native host exposes four authentication states:
| State | Meaning |
| --- | --- |
| `Unauthenticated` | The connection completed without application authentication. |
| `Pending` | The authentication handshake is in progress. |
| `Authenticated` | The host verified the client proof and assigned or confirmed its identity. |
| `Failed` | Authentication started but validation failed or the handshake timed out. |
Authorize requests only after `Authenticated`. A failed handshake is reported through `AcceptError::AuthenticationFailed` or `AcceptError::AuthenticationTimedOut` on the host.
### Hybrid Signatures
Authenticated handshakes support Ed25519 and ML-DSA-65 dual signatures. The host and clients default to `require_pq = true`, so both signatures are required. Calling `with_require_pq(false)` permits Ed25519-only authentication and should be treated as an explicit compatibility decision.
The `ml-dsa` dependency is enabled by default in `mtp-crypto`. The project has not recorded an independent audit for `ml-dsa`; see [Cryptographic review status](#cryptographic-review-status).
### Challenge-Response Flow
The complete sequence is in [Protocol Reference](PROTOCOL-REFERENCE.md#authentication-flow). This section defines the signed fields and domain-separation tags used by that sequence.
### Domain Separation
Every signed handshake payload begins with a distinct byte:
| Tag | Payload |
| --- | --- |
| `0x10` | Host challenge |
| `0x11` | Client login proof |
| `0x12` | Client registration proof |
| `0x13` | Host final confirmation |
The tags prevent a valid signature for one handshake step from being accepted as a signature for another step.
## Cryptographic Primitives
`mtp-crypto` exposes the following building blocks:
| Area | Implementation | Availability |
| --- | --- | --- |
| AEAD | XChaCha20-Poly1305 | Default |
| AEAD | AES-256-GCM | `full` feature |
| Classical signatures | Ed25519 | Default |
| Post-quantum signatures | ML-DSA-65 | Default |
| KDF and hashing | HKDF-SHA-256, SHA-256 | Default |
| Password KDF for `.mk` files | Argon2id | `files` feature |
| Hybrid KEM | X25519 plus ML-KEM-768 | `pqc` feature |
AEAD output stores the nonce before the authenticated ciphertext. `DataValue::Encrypted` uses one canonical multi-recipient envelope and derives a content key through authenticated KEM key wrapping. `DataValue::Signed` authenticates a domain-separated purpose, signer ID, and exact serialized inner value. MTP does not accept caller-supplied AAD as a replacement for this context.
| Protection | Authenticated fields |
| --- | --- |
| `Signed<Value>` | `MTP-DATA-SIGN-1`, signature algorithm, purpose, signer ID, and the exact serialized inner value. |
| `Encrypted<Value>` | `MTP-DATA-ENC-1`, encryption suite, purpose, recipient count, recipient table, and the ciphertext. Each wrapped content key also authenticates `MTP-DATA-WRAP-1`, suite, purpose, and its KEM ciphertext. |
The communication header is routing metadata, not automatically part of either
generic value wrapper's authenticated data. The high-level direct protected API
adds an MTP-owned signed envelope that binds its application type, final
recipient, message ID, creation time, and content to the outer route. Callers
using the generic protection primitives must bind any routing or message
metadata they require in their own signed value.
Protection composition is significant: `Encrypted(Signed(Value))` hides signer metadata until decryption and is the construction used for sealed-sender payloads; `Signed(Encrypted(Value))` exposes the signer metadata while protecting the contents. A sealed-sender frame simply omits the outer communication sender, routes with its receiver field, and carries an `Encrypted(Signed(Value))` payload. There is no sealed-sender frame flag or wire type.
### Protected Frame Visibility
Before opening an `Encrypted(Signed(Value))` payload, a component with access to the MTP frame can read the frame length, communication type, presence flags, transport correlation ID, and next-hop receiver. Relayable application messages use the generic reserved `Relay` communication type; operation-specific names are inside the ciphertext. The outer encrypted value also reveals its encryption suite, generic relay protection purpose, recipient count, unlabeled KEM ciphertext and wrapped-key entries, and ciphertext length. Recipient entries contain no recipient IDs, although recipient count and the cryptographic entry material remain visible.
The signer algorithm, signature purpose, signer ID, signature, and application-defined inner value are encrypted. They become available only after a recipient opens the encrypted value. The recipient must still verify the inner signature before trusting its signer ID or contents.
Sealed sender is therefore a construction rule, not an anonymity guarantee or a separate protocol type. The frame sender is absent, the next-hop receiver remains visible for routing, and MTP does not inspect application containers to infer identities or protection flags.
Connection authentication and protected identity are separate. For a sealed
relay sent over an authenticated connection, the host knows the connection's
registered MTP identity even though the outer relay sender is absent. The
protected signer remains hidden until a metadata recipient decrypts and
verifies the relay metadata.
For a sealed relay sent over an unauthenticated connection, the host receives
no registered MTP identity from connection authentication. The outer relay
sender is still absent, and the protected signer is still hidden until metadata
decryption and verification. The network connection nevertheless has observable
metadata such as peer addressing, timing, sizes, and the visible frame fields
described above. Neither case provides network anonymity.
### Relay access model and replay protection
Relay messages separate metadata recipients from content recipients. A relay
service can receive the metadata key, verify the authenticated signer and
message identifiers, index the opaque encrypted-content value, and forward the
frame without receiving a content key. Only a content recipient can open the
content. The final recipient and application message type remain inside the
protected metadata/content structure; the outer frame exposes only the chosen
next hop.
The receiver must consume the authenticated `(signer ID, MessageId)` pair with
a replay guard. `CreatedAt` is authenticated metadata that the guard receives
for retention or observability, but it is not part of the replay identity and
must not be used as the replay defense. The native codec exposes `ReplayGuard`
and the browser SDK exposes the matching `MTPReplayGuard` contract. Both
high-level APIs use bounded process-local guards by default for direct and
relay subscriptions. Those defaults are duplicate suppression only while an
entry remains in the fixed cache: eviction, reloads, or multiple receiver
processes can permit a previously accepted message again. Low-level relay
metadata opening remains replay-optional for callers reopening stored frames.
Use a durable guard when replay state must survive cache eviction, reloads, or
process boundaries. A guard should atomically record a new ID before
dispatching application content. Transport frame IDs must not be used for
this purpose.
Native message-processing boundaries require a replay guard through the
checked opening APIs. Reopening stored or forensic frames without a guard is
available only through an explicitly named `without_replay` API. The reference
in-memory guard is bounded and FIFO-evicts old entries, so it is a duplicate
suppression cache rather than durable replay protection. A durable deployment
must use an atomic insert-if-absent operation keyed by `(signer ID, MessageId)`;
a separate read followed by insert is race-prone.
`VerifiedRelayMetadata` is an authenticated capability rather than a caller
constructed data transfer object. Rust fields are private and the browser
implementation keeps authenticated state behind a branded class. Content
opening consumes that authenticated state, so changing a message ID or
recipient in a normal object cannot make unrelated encrypted content inherit
those fields. Browser callers can call `dispose()` or `free()` on the metadata
capability for deterministic native-handle release; finalization remains a
fallback.
### Signature policy
Verification takes a receiver-side `SignaturePolicy`/`ProtectionPolicy`.
`AnySupported` is useful for compatibility at the low-level codec boundary,
but protocol receivers should select `Ed25519` or `Dual`. The browser SDK uses
an explicit `ed25519` default and permits an operation or client override. Its
`MTPSecurityProfile` resolves protected-message sender/receiver suites,
encrypted-pipe suites, and the authentication PQ requirement together;
`any-supported` remains an explicit compatibility value. It never derives
receive policy from the recipient keyring. Signature policy must be applied
independently to relay metadata, relay content, and pipe session establishment.
### Key history and rotation
Recipient KEM key history is tried locally without adding a stable recipient
key identifier to the visible encrypted-recipient table. Signing-key resolvers
receive a claimed, unverified signer ID only as a trusted-key lookup key; the
relay helpers authenticate that ID when they verify against the returned
history. Deployments should retain old
verification keys for at least as long as stored signed messages remain
accepted, and should make key-history lookup an authorization decision rather
than accepting any key supplied with a message.
[mtp-crypto API](../crypto/), [native client](NATIVE-CLIENT.md), and [native host](NATIVE-HOST.md).
The crate's feature groups are:
| Feature | Adds |
| --- | --- |
| Default | XChaCha20-Poly1305, Ed25519, ML-DSA-65, HKDF, and SHA-256 |
| `full` | AES-256-GCM in addition to the default features |
| `pqc` | Hybrid X25519 and ML-KEM-768 support |
| `serde` | Serialization support for key types |
| `wasm` | `getrandom` support for WebAssembly |
| `tls` | Development certificate generation |
| `password-kdf` | Argon2id password derivation for protected keyring files |
The main types are `Keyring`, `PublicKeyBundle`, `EncryptionType`, `HybridKem`, `XChaCha20Poly1305` (with the legacy `ChaCha20Poly1305` alias), `Aes256Gcm`, `Ed25519Signer`, and `MlDsaSigner`. Hashing and KDF helpers include `sha256`, `sha256_double`, `hkdf_extract`, `hkdf_expand`, and `derive_encryption_key`. Handshake payload builders are in `mtp_crypto::auth`.
## Cryptographic Review Status
The project records the following status for its cryptographic dependencies:
| Crate | Audited? | Notes |
| --- | --- | --- |
| `ed25519-dalek` | Yes | Used by Signal and Diem |
| `chacha20poly1305` | Yes | NCC Group audit, December 2019 |
| `aes-gcm` | Yes | NCC Group audit, December 2019 |
| `ml-dsa` | No | NIST vectors pass in project tests |
| `mlkem-tls` | No | Uses an unaudited `mlkem-rs` backend |
| `hkdf` | No | Standard construction |
| `sha2` | No | Standard construction |
| `zeroize` | No | Used for secret-key containers |
The audit entries describe the dependency projects. MTP's crypto tests cover round trips, wrong-key failures, wrong-AAD failures, and signature failures;
they do not replace a review of protocol composition or deployment.
## Browser End-to-End Encryption
The browser SDK's optional E2EE session uses XChaCha20-Poly1305 with message keys derived from a one-way HKDF chain. Each send and receive operation advances its chain and authenticates the message header as AAD. Initial messages can carry a hybrid KEM ciphertext for session setup.
This is a single-chain ratchet. It has no Diffie-Hellman ratchet step and does not provide post-compromise security. Out-of-order messages can create skipped keys; the SDK accepts a receive gap of at most 100 messages and retains at most 100 skipped keys. Consumed or evicted keys are zeroed in the SDK state where the implementation owns the buffer.
The session root key comes from the authenticated handshake's KEM shared secret. The initiator and responder derive separate send and receive chains.
Each message consumes one chain key, derives one message key with HKDF, and increments its counter. `sessionStorage` stores browser session state for the current origin. `encryptedSecretProvider` is an independent caller-managed encrypted-secret facility; it is not automatically used by `MTPSessionStorage` or `MTPSessionManager`. Applications that need encrypted session persistence must coordinate those stores explicitly. The provider must protect its wrapping secret outside the SDK; the SDK does not recover a lost secret or skipped message keys.
Relay envelopes, browser session E2EE, and encrypted pipes are separate
protocols:
| Model | State | Intended use |
| --- | --- | --- |
| `RelayEnvelope` | Stateless `Encrypted(Signed(Value))`, multi-recipient | Store-and-forward messages and routing |
| `SessionE2EE` | Stateful symmetric ratchet in `sessionStorage` | Active browser exchanges |
| `EncryptedPipeSession` | Authenticated setup plus ordered record chain | Protected streams |
Encrypted pipes bind the pipe/session transcript, direction, purpose, sequence,
record length, and record type to each record. `FINAL` is authenticated and
unexpected EOF is reported as truncation. The ordinary signed/KEM offer is not
forward-secure; the native and browser duplex helpers use an ephemeral
authenticated KEM exchange before deriving the record chain. Group membership
changes require a new session key and recipient set.
## Key Storage
`Keyring` contains three public and three private key values. Its private key fields use `ZeroizeOnDrop`, and serialized keyring output is held in a zeroizing buffer while it is constructed. Public key bundles contain only the three public values.
Role-specific protocol boundaries should validate only the material they need:
`validate_encryption()` checks that a KEM public/private pair corresponds, while
`validate_full()` additionally requires a complete hybrid signing identity.
This keeps partial browser keyrings usable without allowing an envelope sender
to proceed with an invalid local decryption key.
Applications remain responsible for storage at rest. The `files` feature writes passphrase-protected keyrings to `.mk` files and public bundles to `.mpkb` files. Protected `.mk` files store the Argon2id identifier, parameters, salt, and AEAD ciphertext; they do not derive their key with HKDF. On Unix, keyring files are created with owner-only `0600` permissions.
Restrict those files to the owning account and protect backups. Browser applications should treat the configured credential storage as sensitive application data.
Key-material parsing is explicit in the SDK: use the hex, Base64, or byte
helpers for encoded key material. Arbitrary strings are no longer treated as
passphrases by the compatibility `secretKeyFromString` helper. Applications
migrating data written by the old implicit-HKDF behavior can use the explicitly
named, deprecated `legacySecretKeyFromStringV1` helper only for that migration;
new data must not use it. Passwords must use the explicit Argon2id passphrase
API with a stored per-record salt and versioned parameters. The SDK's
`deriveKeyFromPassphrase` uses a worker when browser workers are available;
the explicitly named `deriveKeyFromPassphraseSync` form is for workers and
command-line migrations. HKDF helpers are for high-entropy key material and
are not password-hardening functions.
## Resource Limits and Operational Controls
`Policy::default()` sets a 16 MiB application message limit and a 64 KiB handshake message limit. It also sets a 30 second read timeout, a 30 second maximum idle timeout, a receiver queue capacity of 1000, and a maximum of 128 concurrent stream tasks. Tune these values for the deployment and peer trust level.
The recursive codec applies additional defaults while parsing untrusted values:
maximum nesting depth 64, 65,536 value nodes, 16 MiB per blob or envelope,
64 encrypted recipients, and a 64 MiB cumulative decoder allocation budget.
Decrypted values are parsed with the same limits. Transport derives the blob,
allocation, and encoder output budgets from its admitted frame size rather than
serializing an unrestricted recursive value first. The default transport
allocation budget is four times the admitted frame size to cover conservative
owned-copy and crypto-buffer accounting; deployments may choose another
factor with `DecodeLimits::for_transport_message_size_with_allocation_factor`.
The host applies an authentication-attempt limiter before storage lookups,
public-key validation, challenge signing, and registration callbacks. The
default limiter is a bounded in-memory sliding window; configure a durable or
distributed limiter when limits must coordinate across host instances. Unknown
client IDs are sent through a fixed dummy challenge/proof path by default, so
they receive a generic authentication failure instead of an enumeration hint.
Deployments that intentionally publish client IDs can disable this concealment.
Keepalive Pong observation is bounded and accepts only the currently pending
ping ID. Unsolicited Pongs are dropped before they can consume application
receiver capacity.
## Security Limitations
- The first version-negotiation frame is sent before authentication and is not signed.
- `AllowAuthentication` intentionally permits unauthenticated clients; it is not an authenticated-only mode.
- Browser-side Rust panics cannot be recovered by JavaScript. The WASM client contains panic paths from internal `expect` calls.
- The browser E2EE ratchet does not provide post-compromise security.
- The ordinary encrypted-pipe offer does not provide forward secrecy; use the
duplex handshake when recorded-call confidentiality after long-term KEM
compromise is required.
- Replay state is process-local by default for high-level subscriptions. Use a
durable replay guard when protection must survive reloads or coordinate
multiple receiver processes.

108
docs/TROUBLESHOOTING.md Normal file
View file

@ -0,0 +1,108 @@
# Troubleshooting
Use the failure stage to narrow the cause. MTP connections pass through TLS, the opening version frame, optional authentication, and application framing in that order.
Each entry identifies the symptom, diagnosis, fix, and prevention. Security labels identify workarounds that change certificate verification or keepalive.
## Diagnosis Flow
```text
Connection fails?
-> TLS or WebTransport error? Check certificate, origin, and endpoint.
-> UnsupportedVersion? Check compiled client version and host registry.
-> AuthenticationFailed? Check policy, key lookup, and key bundles.
-> CodecError? Check generated type maps and negotiated version.
-> MessageTooLarge? Compare peer payload with Policy limits.
-> Ping or pipe failure? Check the protocol reference or Pipes guide.
```
## TLS Connection Failures
**Security impact:** Safe when the certificate or pin is corrected. Insecure TLS is restricted to Development Mode.
Check the certificate before investigating MTP frames.
- With native clients, confirm the certificate chains to the system roots or pass the expected PEM certificate with `ClientConfig::with_pinned_pem`.
- With browser clients, confirm WebTransport is supported and that `serverCertificateHashes` contains the expected certificate hash when using a pinned certificate.
- For local self-signed certificates, pin the generated PEM certificate. The lower-level insecure mode requires both the `insecure-tls` feature and `MTP_INSECURE_TLS=1`.
- Confirm the hostname or IP address is present in the certificate's subject alternative names. A valid certificate with the wrong name still fails TLS.
Use [Security](SECURITY.md) for certificate trust and rotation rules.
## Version Negotiation Failures
An `AcceptError::UnsupportedVersion` means the client's compiled `PROTOCOL_VERSION` is absent from the host registry. Check that both peers were built from compatible `type-maps.yaml` files and that the host includes the required generated version.
An `AcceptError::MissingVersion` means the opening frame did not contain a valid `DataType::Version` string in `major.minor` form. Do not send an application frame before the opening version frame completes.
Use [Connector](CONNECTOR.md) for registry and migration rules.
## Authentication Failures
Check the selected `AuthenticationPolicy` first.
- `Unauthenticated` rejects login and registration by configuration.
- `AllowAuthentication` accepts both authenticated and guest connections.
- `ForceAuthentication` requires a registered login or a successful registration callback.
For login, verify the client ID lookup returns the expected public key bundle, the client keyring matches that bundle, and the client has the host public key used to verify the host signature. For registration, verify that `complete_register` persists the submitted public bundle and returns a valid client ID.
When `require_pq` is true, both Ed25519 and ML-DSA-65 keys and signatures must be available. Set `with_require_pq(false)` only for an explicit Ed25519-only compatibility deployment. Authentication sequence: [Protocol Reference](PROTOCOL-REFERENCE.md#authentication-flow).
## CodecError Failures
**Symptom:** `UnknownCommunicationType` or `UnknownDataType`.
**Diagnosis:** The peers use different generated type maps or the selected version does not define the value.
**Fix:** Build both peers from the same type-map configuration and send only types defined by the negotiated version.
**Prevention:** Treat generated type maps as versioned build artifacts.
`CodecError::UnknownVersion` means the codec was created for a version absent from its registry. `UnknownCommunicationType` and `UnknownDataType` mean the selected `TypeMap` has no mapping for the value being encoded. `MissingTypeMap` means a versioned value lost its retained negotiated map; `TypeMapMismatch` means it was combined with a value or codec for another version. Select the negotiated type map and do not send an unmapped variant.
`ReservedCommunicationType` means application code attempted to use a reserved wire ID. Use generated communication types instead of assigning protocol IDs manually. `MissingField` means a required typed field was not present.
`InvalidEncoding` indicates truncated, malformed, duplicate-field, reserved-kind, or structurally invalid bytes. `TooManyEntries` indicates that an array, container, or frame exceeds the codec's representable count or length. Protection operations return typed errors for malformed envelopes, authentication failures, invalid signatures, and missing recipients. The complete variant table is in [Errors](ERRORS.md).
## Frames and Message Limits
`MessageTooLarge` means the serialized frame exceeds the configured policy. Native transport defaults are a 16 MiB application message limit and a 64 KiB handshake limit. The browser SDK defaults `maxMessageSize` to 16 MiB.
`ParseCommunicationValue`, `ParseError`, or `CodecError::InvalidEncoding` means the received bytes do not match the length-prefixed frame and value format. Check that the sender writes the four-byte big-endian frame length exactly once and that the receiver reads exactly that many bytes. Frame layout: [Type Map](TYPE-MAP.md).
## Requests and Subscriptions
If `request()` times out, confirm that the peer sends a response with the same non-zero frame ID. If `responseType` is set, confirm that the response uses the expected communication type after the ID matches.
If a subscription callback does not run, confirm that the generated type map contains the message type and that the client is connected before the sender emits the frame. Enable the SDK logger to inspect state changes and errors.
## Protocol Pings
If `get_ping()` remains `None` or the connection closes after missed pings, check the keepalive configuration and responder mode in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive).
## Pipes
If a pipe handle resolves to `null` or `PipeError::Rejected`, the peer denied the request. If `receive_pipe()` never produces a request, use the connection facade instead of reading the underlying receiver directly. If a reader sees an error instead of EOF, the writer likely aborted the stream or the connection closed. Pipe lifecycle: [Pipes](PIPES.md).
## Browser Diagnostics
Use `MTPClient.isSupported()` before creating a browser client. Inspect browser console errors, WebTransport session state, certificate pins, and the SDK logger callback. Raw WASM bindings expose lower-level callbacks, but the SDK adds timeout and lifecycle handling.
| Browser or WebTransport signal | Meaning and next check |
| --- | --- |
| `WebTransportError.source = "stream"`, `streamErrorCode = 0` | The peer closed a one-frame stream normally in the supported browser behavior. Check session state before treating it as a failure. |
| `WebTransportError.source = "stream"`, non-zero `streamErrorCode` | A stream-level failure. Check whether the peer sent `STOP_SENDING` or reset the stream, then inspect the active pipe or frame. |
| `WebTransportError.source = "session"` | The WebTransport session failed. Check TLS, the endpoint, the `webtransport` CONNECT path, and server logs. |
| `WebTransportError` without `streamErrorCode` | The error is session-level or browser-specific. Inspect `error.message`, `error.source`, and the browser network panel. |
| Close code `0`, reason `mtp-webserver shutdown` | `MTPWebServer` performed an intentional shutdown. Reconnect after the process restarts. |
MTP logs stream-level `STOP_SENDING` and `RESET_STREAM` events with their `streamErrorCode`. QUIC transport error numbers are implementation-specific; use the browser's source, stream code, message, and server logs together.
## Development Mode
Use `mtp_crypto::tls::generate_self_signed_cert` for local certificates. Native insecure verification requires the `insecure-tls` feature and `MTP_INSECURE_TLS=1`; it disables certificate verification and is not a production fix.
## Getting More Help
Capture the negotiated version, connection state, error variant, endpoint, and relevant server log entries. Enable the SDK logger or Rust tracing, then remove credentials, private keys, and message contents before sharing a report.

View file

@ -1,6 +1,106 @@
# Type Map # Type Map
This file documents the Type Map & Registry configuration used by the MTP protocol. This file documents the type-map and registry configuration used by MTP. The
repository workspace uses [`example/type-maps.yaml`](../example/type-maps.yaml)
through [`.cargo/config.toml`](../.cargo/config.toml); that map currently
selects protocol version 3.0. The root [`example-type-maps.yaml`](../example-type-maps.yaml)
is a separate illustrative multi-version configuration used by the manual WASM
build script. Downstream applications should provide their own map.
The protocol version selects the generated codec/type-map build, while the
type-map entries define the available application types and their IDs.
## Binary Frame Format
Every transport frame is a four-byte big-endian length followed by one `CommunicationValue`. The length counts all bytes after the length field.
This is the only transport frame length prefix. Transports write the
`CommunicationValue` bytes directly and do not add another length before this
field. The close-frame sentinel occupies the same four-byte position.
```text
[4 bytes total length]
[2 bytes communication type]
[1 byte flags]
bit 0 = has ID
bit 1 = has sender ID
bit 2 = has receiver ID
bits 3-7 must be zero
[4 bytes ID] if bit 0
[8 bytes sender ID] if bit 1
[8 bytes receiver ID] if bit 2
[DataValue payload]
```
The only defined flag values are `0x01` for ID, `0x02` for sender, and `0x04` for receiver. Unknown flag bits are rejected. IDs are full-width unsigned big-endian values: the correlation ID is `u32`, while sender and receiver IDs are `u64`. Encryption and signing are properties of the `DataValue` payload, never of the frame header.
`Relay` is the reserved opaque application communication type. Relay frames
omit the outer sender, expose only the next-hop receiver and transport
correlation data, and carry the actual operation and application metadata in
their protected payload.
## DataValue Wire Format
Every `DataValue` begins with a one-byte kind marker. MTP assigns `0x01` and `0x02` to boolean true and false, `0x03` to signed `i128`, `0x04` to unsigned `u128`, `0x05` to `f64`, `0x06` to UTF-8 strings, `0x07` to bytes, `0x08` to arrays, `0x09` to containers, `0x0A` to `Encrypted<Value>`, `0x0B` to `Signed<Value>`, and `0xFF` to null. Kind `0x0C` is reserved and rejected. All multibyte numeric values, counts, and lengths are big-endian.
Strings and bytes have a four-byte byte length. Arrays have a two-byte element count followed by that many self-delimiting values. The protection wrappers have the following canonical layouts.
```text
Container
09
[2 bytes element count]
repeat for each element:
[2 bytes DataTypeId]
[DataValue]
```
Container field IDs must be unique. Each nested value is self-delimiting, so container elements have no generic per-element payload length.
```text
Signed
0B
[4 bytes wrapper length]
[1 byte signature algorithm]
[1 byte purpose]
[8 bytes signer ID]
[signature]
[DataValue]
```
The wrapper length counts the bytes after the length field. Signature length is determined by the signature algorithm. The signature covers `MTP-DATA-SIGN-1 || algorithm || purpose || signer ID || serialized inner value`.
```text
Encrypted
0A
[4 bytes envelope length]
[1 byte encryption suite]
[1 byte purpose]
[2 bytes recipient count]
[recipient entry]
...
[encrypted DataValue bytes]
```
The envelope length counts the bytes after the length field. A recipient entry is an unlabeled fixed-size KEM ciphertext and wrapped content-encryption key; both lengths are determined by the selected suite. The encrypted bytes are the AEAD output for the complete serialized inner `DataValue`.
Protection nesting directly represents both signer-visibility choices: `Encrypted(Signed(Container))` keeps signer metadata private, while `Signed(Encrypted(Container))` exposes it. A frame with no outer sender and an `Encrypted(Signed(Container))` payload uses sealed sender. Sealed sender adds no flag or distinct wire type.
### Container ordering and signatures
Container entries are ordered sequences in the current format. Insertion order
is therefore semantic: two containers with the same field/value pairs in a
different order have different serialized bytes and different signatures. The
decoder rejects duplicate field IDs. Applications that need map semantics must
canonicalize their own input before signing; a future canonical map encoding
requires a protocol-format version and cannot be inferred by a receiver.
## TypeMap & Compile-Time Type Safety ## TypeMap & Compile-Time Type Safety
@ -23,6 +123,12 @@ export default defineConfig({
Rust and manual WASM builds can set `MTP_TYPE_MAPS` directly (see [Customizing Type Maps in Downstream Projects](#customizing-type-maps-in-downstream-projects)). Rust and manual WASM builds can set `MTP_TYPE_MAPS` directly (see [Customizing Type Maps in Downstream Projects](#customizing-type-maps-in-downstream-projects)).
For browser builds, `protocol_version` selects the one application map compiled
into that WASM client. The Vite-generated `mtp/type-map` module contains the
reserved MTP names and the application names from that selected version only;
the selected version must be present in `type_maps`. This keeps its TypeScript
unions aligned with the client runtime.
### Using Generated Enums ### Using Generated Enums
After editing the config and rebuilding, `CommunicationType` and `DataType` enums are generated automatically. Use them in code: After editing the config and rebuilding, `CommunicationType` and `DataType` enums are generated automatically. Use them in code:
@ -30,44 +136,49 @@ After editing the config and rebuilding, `CommunicationType` and `DataType` enum
```rust ```rust
use mtp::type_map::{CommunicationType, DataType, TypeMap}; use mtp::type_map::{CommunicationType, DataType, TypeMap};
let tm = TypeMap::v2_0(); let tm = TypeMap::v3_0();
let id = tm.data_id_enum(DataType::SomeType).unwrap(); let id = tm.data_id_enum(DataType::ExampleText).unwrap();
``` ```
The enums are a **union across all versions**; every type name from every version is a variant. The version-specific `TypeMap` maps each variant to the correct wire ID for that version. Types not defined in a version return `None`: For native builds with the `registry` feature, the enums are a **union across
all versions**; every type name from every version is a variant. The
version-specific `TypeMap` maps each variant to the correct wire ID for that
version. For a type absent from a selected version, the lookup returns `None`.
Browser-generated TypeScript unions intentionally differ: they contain only
the selected `protocol_version` plus reserved names, matching the WASM client
compiled by the Vite plugin.
Encoding/decoding uses a `TypeMap` to resolve type names to wire IDs: Encoding/decoding uses a `TypeMap` to resolve type names to wire IDs:
```rust ```rust
use mtp::codec::{encode, decode, DataValue}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use mtp::type_map::TypeMap; use mtp::type_map::TypeMap;
let tm = TypeMap::v2_0(); let tm = TypeMap::v3_0();
let value = DataValue::Str("hello".into()); let value = CommunicationValue::new_with_type_map(CommunicationType::Ping, &tm)
.add_typed(DataType::Description, &tm, DataValue::Str("hello".into()));
let bytes = encode(&value, &tm).unwrap(); let bytes = value.to_bytes().unwrap();
let decoded = decode(&bytes, &tm).unwrap(); let decoded = CommunicationValue::from_bytes_with(&bytes, &tm).unwrap();
``` ```
```rust ```rust
let tm_v2 = TypeMap::v2_0(); let tm_v3 = TypeMap::v3_0();
assert!(tm_v2.data_id_enum(DataType::SomeType).is_some()); // defined in v2.0 assert!(tm_v3.data_id_enum(DataType::ExampleText).is_some());
assert!(tm_v2.data_id_enum(DataType::ExampleType).is_none()); // NOT in v2.0
let tm_v1 = TypeMap::v1_0();
assert!(tm_v1.data_id_enum(DataType::ExampleType).is_some()); // defined in v1.0
``` ```
When communicating with a peer on another version, encode only variants that map in the negotiated version. If an incoming frame names a type absent from the selected map, reject it as a protocol or type-map compatibility error; do not reinterpret its wire ID using another version's map. The current repository map uses the self-delimiting codec format for protocol version `3.0`; a custom registry may register other version numbers, but those maps are not legacy wire-format fallbacks.
### Forward/Backward Compatibility Between Versions ### Forward/Backward Compatibility Between Versions
Because enums are a union of all types across versions, a variant might exist that has no wire mapping in the *negotiated* version: Because enums are a union of all types across versions, a variant might exist that has no wire mapping in the *negotiated* version:
``` ```
v2.0 client sends DataType::SomeType → host encodes with v2.0 TypeMap → wire ID 32 v3.0 client sends DataType::ExampleText → host encodes with v3.0 TypeMap → wire ID 43
v2.0 host receives DataType::ExampleType (from v1.0 client) → not in v2.0 TypeMap → None → Error v3.0 host receives a version absent from the registry → version negotiation error
``` ```
This is by design: the host maps unknown types to `Error`, and the client should only send types that exist in its compiled-in version. Encoding a frame with an unmapped communication or data type returns `CodecError::UnknownCommunicationType` or `CodecError::UnknownDataType`. Select a mapped variant from the compiled-in version before sending it.
## Registry ## Registry
@ -82,33 +193,40 @@ mtp = { path = "..", features = ["host"] }
```rust ```rust
use mtp::codec::registry::{Registry, VersionedCodec}; use mtp::codec::registry::{Registry, VersionedCodec};
use mtp::codec::{CommunicationType, CommunicationValue, DataValue};
use mtp_type_map::Version;
let registry = Registry::builtin(); let registry = Registry::builtin();
let codec = VersionedCodec::new(registry); let codec = VersionedCodec::for_version(registry, Version(3, 0)).unwrap();
let value = CommunicationValue::new_with_type_map(
CommunicationType::Ping,
codec.type_map(),
).with_payload(DataValue::Null);
// Encode with a specific version // The value must retain the negotiated map used to construct it.
let bytes = codec.encode(&value, Version(2, 0)).unwrap(); let bytes = codec.encode(&value).unwrap();
// Decode with a specific version let decoded = codec.decode(&bytes).unwrap();
let decoded = codec.decode(&bytes, Version(2, 0)).unwrap();
// A clear value can be migrated explicitly when the application has chosen
// that behavior. Protected values are not silently remapped.
let migrated = codec.encode_migrating(&value).unwrap();
``` ```
`VersionedCodec::encode` compares the retained map identity (its protocol
version) and returns `CodecError::MissingTypeMap` or
`CodecError::TypeMapMismatch` on failure. `reply_to` retains the request's
map, while `try_merge` rejects frames from different maps before copying any
fields. The deprecated `merge` method records the error for compatibility; new
code should migrate to `try_merge` and handle the result.
## Customizing Type Maps in Downstream Projects ## Customizing Type Maps in Downstream Projects
External projects must provide their own type map configuration. Browser projects should install `mtp` and configure `mtp/vite`; they do not need to publish, fork, or copy a generated WASM package. External projects must provide their own type map configuration. Browser projects use the Vite plugin from [Defining Type Maps](#defining-type-maps) and do not need to publish, fork, or copy a generated WASM package.
```typescript
import { defineConfig } from "vite";
import { mtp } from "mtp/vite";
export default defineConfig({
plugins: [mtp({ typeMaps: "./type-maps.yaml" })],
});
```
For Rust builds, or when invoking `wasm-pack` manually, set the `MTP_TYPE_MAPS` environment variable. If the variable points to an invalid file, the build fails. If `MTP_TYPE_MAPS` is not set, the build script emits a warning and generates reserved protocol types only; application-specific communication and data types will not be available. For Rust builds, or when invoking `wasm-pack` manually, set the `MTP_TYPE_MAPS` environment variable. If the variable points to an invalid file, the build fails. If `MTP_TYPE_MAPS` is not set, the build script emits a warning and generates reserved protocol types only; application-specific communication and data types will not be available.
1. Create a `type-maps.yaml` in your project root 1. Create a `type-maps.yaml` in your project root (or anywhere else, adapt the path accordingly)
2. Set the `MTP_TYPE_MAPS` environment variable in `.cargo/config.toml`: 2. Set the `MTP_TYPE_MAPS` environment variable in `.cargo/config.toml`:
```toml ```toml

View file

@ -2,6 +2,17 @@
The browser client is exposed through the `mtp` npm package. Most applications should use the SDK-first `MTPClient` API; direct generated WASM bindings remain available from `mtp/raw` for advanced integrations. The browser client is exposed through the `mtp` npm package. Most applications should use the SDK-first `MTPClient` API; direct generated WASM bindings remain available from `mtp/raw` for advanced integrations.
## Browser Compatibility
The SDK requires the browser to expose `WebTransport`. `MTPClient.isSupported()` is the runtime check. A browser without WebTransport cannot connect through this client.
| Requirement | Check |
| --- | --- |
| WebTransport API | `MTPClient.isSupported()` |
| Certificate trust | Browser validation or `serverCertificateHashes` |
| Secure context | Serve the application from HTTPS where required by the browser |
| Generated bindings | Run the Vite integration during development and build |
## Package Entry Points ## Package Entry Points
```typescript ```typescript
@ -17,20 +28,17 @@ import { mtp } from "mtp/vite";
## Vite Type-Map Workflow ## Vite Type-Map Workflow
Browser apps provide their own type map. The Vite plugin runs `wasm-pack` during dev/build with `MTP_TYPE_MAPS` set, writes generated output under `node_modules/.vite/mtp/` by default, and aliases `mtp/raw` plus `mtp/type-map` to that generated output. Browser apps provide their own type map. The Vite plugin runs `wasm-pack` during dev and build with `MTP_TYPE_MAPS` set, writes generated output under `node_modules/.vite/mtp/` by default, and aliases `mtp/raw` plus `mtp/type-map` to that generated output. Configuration: [Type Map](TYPE-MAP.md).
```typescript The browser build uses the map named by `protocol_version` and includes the
// vite.config.ts reserved MTP names. It does not advertise application names from other map
import { defineConfig } from "vite"; versions, because the generated WASM client is compiled for that one protocol
import { mtp } from "mtp/vite"; version. The selected version must exist in `type_maps`.
export default defineConfig({
plugins: [mtp({ typeMaps: "./type-maps.yaml" })],
});
```
You do not need to publish, fork, or copy an app-specific generated WASM package. You do not need to publish, fork, or copy an app-specific generated WASM package.
The [web client example](../example/web-client/src/main.ts) shows the entry point. Its [Vite configuration](../example/web-client/vite.config.ts) shows the generated binding integration.
## SDK Quick Start ## SDK Quick Start
```typescript ```typescript
@ -80,6 +88,261 @@ if (!MTPClient.isSupported()) {
} }
``` ```
## MTPClient Options
| Option | Default | Purpose |
| --- | --- | --- |
| `url` | Required | WebTransport endpoint. |
| `descriptor` | None | Client label sent during connection setup. |
| `hostPublicKey` | None | Host public key bundle for authenticated login or registration. |
| `credentials` | None | Existing client ID and serialized keyring. |
| `credentialsStorageKey` | `mtp:credentials` | Key used by configured credential storage. |
| `storage` | None | Sync or async credential storage adapter. |
| `serverCertificateHashes` | Omitted | WebTransport certificate pins. |
| `maxMessageSize` | 16 MiB | Inbound and outbound frame limit. Values below frame overhead are rejected by the transport. |
| `authTimeoutMs` | No SDK timeout | Login and registration timeout. `undefined` leaves the promise pending until transport or peer failure. |
| `requestTimeoutMs` | 30 seconds | Default `request()` timeout. |
| `pings` | `false` | Protocol pings, or an object with `intervalMs`. |
| `logger` | No-op | Receives SDK state and error events. |
| `schemas` | None | Client-wide request and response schema registry. |
| `throwProtocolErrors` | `false` | Reject requests whose correlated response is an `Error*` frame. |
| `onValidationError` | No-op | Receives subscription validation failures. |
| `sessionStorage` | In-memory | E2EE session state storage. |
| `encryptedSecretProvider` | In-memory | Independent caller-managed encrypted secret storage. |
| `defaultSignatureVerificationPolicy` | `"ed25519"` | Receiver policy for protected signatures. |
`wasm` selects a custom generated WASM module. `MTPClient.create` validates positive safe-integer values for the numeric limits and timeout options.
## Differences from Native Client
The browser SDK uses WebTransport and JavaScript promises. The native client uses Rust futures, direct QUIC configuration, and `MTPConnection` handles. Browser pipes expose promise-based readers and writers; native pipes implement Tokio I/O traits.
### Native and Browser Credential Persistence
The `storage` option supplies the credential adapter. The adapter stores the client ID and serialized keyring after registration and returns them for later connections. The SDK does not select `localStorage` or IndexedDB for an application. Treat the serialized keyring as private key material.
`sessionStorage` and `encryptedSecretProvider` are separate caller-managed
stores. The latter exchanges `MTPEncryptedSecretRecord` values through
`set`, `get`, and `delete`; the `MTPClient` convenience methods are named
`setEncryptedSecret`, `getEncryptedSecret`, and `deleteEncryptedSecret`.
`MTPSessionManager` does not automatically route session state through the
provider. If session material must be encrypted at rest, the caller must make
that coordination explicit in its `MTPSessionStorage` implementation. Secret
IDs are opaque to MTP, so a caller can map its own state to the ID while
choosing the backing store and protecting its wrapping key.
### Direct Protected Messages
Use `sendProtected` when the destination is the frame receiver and no
intermediate relay needs a separately encrypted metadata layer. It keeps the
application communication type on the outer frame and encrypts an MTP-owned
signed envelope for the exact recipient bundles supplied by the caller. The
envelope authenticates `ProtectedVersion`, `MessageType`, `FinalRecipientId`,
`MessageId`, `CreatedAt`, and `Content`. The opening operation checks the
authenticated type and final recipient against the outer frame.
```typescript
await client.sendProtected("ProtectedMessage", { Content: "hello" }, {
receiverId: recipientId,
recipients: [recipientPublicKey],
signaturePurpose: 0x40,
encryptionPurpose: 0x41,
exposeSender: false,
});
```
The protection purposes are application-defined domain-separation values.
`exposeSender` controls only the outer frame sender; the protected value remains
signed in either case. If `identity` is omitted, the SDK uses stored registered
credentials and rejects the operation when no usable protection identity is
available.
An unauthenticated connection can still send a protected value when the caller
provides an explicit `identity` with the signer ID and keyring. The connection's
authentication state and the protected signer's identity are independent.
When `signatureSuite` is omitted, protected send helpers use Ed25519 even when
the signing keyring also contains post-quantum keys. This matches the default
receiver policy. Use `signatureSuite: "dual"` together with
`signaturePolicy: "dual"` when both sides explicitly require hybrid
signatures.
Open a direct protected frame with the recipient keyring and a resolver that
receives the claimed, unverified signer ID only as a trusted-key lookup key:
```typescript
const message = await client.openProtected(frame, {
recipient: {
id: recipientId,
keyring: recipientKeyring,
keyringHistory: previousRecipientKeyrings,
},
expectedReceiverId: recipientId,
expectedSignerId: signerId,
resolveSignerPublicKeys: (id) => signerDirectory.get(id) ?? [],
signaturePolicy: "dual",
signaturePurpose: 0x40,
encryptionPurpose: 0x41,
replayGuard,
});
console.log(message.type, message.signerId, message.messageId, message.data);
```
`protectedVersion`, `finalRecipientId`, `signerId`, `messageId`, and `createdAt`
are taken from the verified protected envelope. `outerSender`, when present,
must equal the authenticated signer.
Protected application data may be any supported MTP `DataValue`, including
scalar, byte, array, and container values. Direct opening uses a bounded
process-local duplicate-suppression guard by default. The bounded cache can
evict old entries, so supply a durable `replayGuard` keyed by authenticated
signer and message ID when replay protection must survive eviction, reloads, or
multiple receiver processes. The guard also receives authenticated
`createdAt` metadata, which is not part of the replay key.
`subscribeProtected` uses the same opening and verification path:
```typescript
const unsubscribe = client.subscribeProtected(
"ProtectedMessage",
(message, frame) => handleMessage(message.data, frame),
{
recipient: { id: recipientId, keyring: recipientKeyring },
resolveSignerPublicKeys: (id) => signerDirectory.get(id) ?? [],
signaturePolicy: "dual",
signaturePurpose: 0x40,
encryptionPurpose: 0x41,
},
);
```
Each `subscribeProtected` registration owns its own bounded default replay
guard, so multiple handlers receive the same raw frame through the WASM
fan-out dispatcher. Pass the same caller-owned `replayGuard` deliberately when
several subscriptions should share replay state.
### Sealed Relay Messages
`sendSealedRelay` uses the reserved opaque `Relay` communication type. Its
inner message type must be an application communication type, not an MTP
control type. The outer frame contains no sender and exposes only the next-hop
receiver. The
signed relay metadata contains the generic `signerId`, `finalRecipientId`,
`messageId`, `createdAt`, application `metadata`, and an opaque encrypted
content value. `createdAt` is generated as Unix epoch milliseconds. For
example, `2026-08-11T12:00:00.000Z` is `1786449600000`.
```typescript
const data = { Content: "hello" };
await client.sendSealedRelay("ProtectedMessage", data, {
finalRecipientId,
nextHopId,
metadataRecipients: [
relayPublicKey,
recipientPublicKey,
],
contentRecipients: [
recipientPublicKey,
],
metadata: {
ExampleMetadata: "routing context",
},
});
client.subscribeSealedRelay(
"ProtectedMessage",
(message, frame) => handleMessage(message.data, frame),
{
recipient: {
id: finalRecipientId,
keyring: recipientKeyring,
},
expectedSignerId: signerId,
resolveSignerPublicKeys: () => [senderPublicKey],
},
);
```
The caller supplies the exact metadata and content recipient sets; the SDK
does not infer application topology. Set `signaturePolicy: "dual"` to require
hybrid signatures explicitly, and install a durable `replayGuard` so a valid
`(signerId, messageId)` is dispatched only once.
Each sealed-relay or metadata subscription likewise gets an independent
bounded default guard. This preserves fan-out when multiple handlers inspect
the same outer `Relay` frame; an explicitly supplied guard is shared by the
subscriptions that receive it.
Applications choose between direct protected delivery and sealed relay based
on topology and metadata-access requirements. Prefer `sendProtected` for a
direct destination. Use `sendSealedRelay` when a next hop must route or store a
message and the application needs metadata recipients to differ from content
recipients. Neither construction requires connection authentication, although
the host can associate an authenticated connection with its registered MTP
identity.
For metadata-only access, call `openRelayMetadata` or subscribe with
`subscribeRelayMetadata`. These operations authenticate the metadata and
expose `encryptedContent` for forwarding without attempting content
decryption. A final recipient calls `openRelayContent` after metadata
verification; the returned `MTPVerifiedRelayContent` includes the application
type and data plus `signerId`, `finalRecipientId`, `messageId`, `createdAt`,
and generic metadata fields. These are authenticated protected identities, not
the clear outer sender and next-hop receiver.
Relay content inherits the authenticated metadata's `signaturePolicy` when no
content override is supplied. A different content policy is rejected so the
two relay layers cannot be verified under conflicting rules.
Metadata passed to a `subscribeRelayMetadata` handler is callback-scoped and is
disposed after the handler resolves. Do not retain it for a later
`openRelayContent` call; use `openRelayMetadata` directly when a longer-lived
verified capability is needed, and call `dispose()` when finished.
When signer key history is used, `signerPublicKeys` exposes the trusted
candidates, `matchedSignerKeyIndex` identifies the key that verified the
metadata, and `matchedSignerPublicKey` returns that exact bundle.
Protected receive operations accept an optional `recipient` decryption
identity. Its `keyring` controls decryption and its optional `id` is used only
for final-recipient validation. The identity is independent from connection
authentication. Metadata opening does not require the identity ID to match the
clear next-hop receiver, so a forwarded frame can be opened by a metadata
recipient or final recipient with the appropriate keyring. When `recipient` is
omitted, stored registered credentials remain the convenience fallback.
To open values encrypted for a rotated recipient, provide `keyringHistory` on
the decryption identity. The current `keyring` is tried first, followed by
history entries from newest to oldest. Exact duplicate byte sequences are
removed without changing the caller's input arrays. An empty current keyring
or an empty history entry is rejected.
Generic MTP `DataValue` inputs accept `bigint` for exact integer values. An
integral JavaScript `number` outside the safe-integer range is rejected, so it
cannot silently become an imprecise float. Use `bigint` for large signed or
unsigned integers.
For streams, prefer `createEncryptedPipe` and `acceptEncryptedPipe`; they bind
the actual pipe ID and local identity automatically. The lower-level
`initiateMTPPipeSession` API also accepts multiple recipient bundles for a
group bootstrap. Group membership changes require a fresh session ID and
recipient set. Live calls that need forward secrecy can use the exported
duplex `initiateMTPForwardSecurePipeSession` and
`acceptMTPForwardSecurePipeSession` helpers.
The convenience pipe methods intentionally require registered client
credentials because they use the connection's registered identity as the
endpoint identity. Use the lower-level session functions when transport
authentication and cryptographic endpoint identity must remain independent.
Receive-side signature policy is independent from the recipient keyring. Use
`signaturePolicy` on protected receive and encrypted-pipe accept operations,
or configure `defaultSignatureVerificationPolicy` on the client. The sender's
`signatureSuite` selects how local values are signed and is a separate choice.
Both sender and receiver default to Ed25519; `dual` is always an explicit
choice on each side.
### Native and Browser Certificate Checks
WebTransport certificate pins must match the server certificate hash. A pin mismatch is a TLS failure, not an MTP authentication failure. Check the browser network panel, endpoint origin, and WebTransport CONNECT path before inspecting frames.
## Credentials And Storage ## Credentials And Storage
Authenticated connections need stable key material. Pass `credentials` when you already have a client ID and serialized keyring, or pass a small `storage` object and let the SDK persist credentials after registration. Authenticated connections need stable key material. Pass `credentials` when you already have a client ID and serialized keyring, or pass a small `storage` object and let the SDK persist credentials after registration.
@ -148,6 +411,30 @@ If hashes are omitted, the browser uses its normal TLS root store.
`maxMessageSize` caps inbound and outbound MTP frames before buffering/sending. `maxMessageSize` caps inbound and outbound MTP frames before buffering/sending.
`authTimeoutMs` bounds connect/login/register promises at the SDK layer. `authTimeoutMs` bounds connect/login/register promises at the SDK layer.
`requestTimeoutMs` sets the default timeout for `request()` calls; a request can override it with `timeoutMs` in its options.
## Streams
The browser client uses one WebTransport session per `MTPClient` instance.
`send()`, `request()`, and `subscribe()` all operate over that session; the SDK does not expose browser stream objects directly.
Use the normal message APIs to send and receive over that session:
```typescript
const client = await MTPClient.create({ url, hostPublicKey });
await client.connect();
const unsubscribe = client.subscribe("SomeType", (message) => {
console.log(message.data);
});
await client.send("SomeType", { value: "hello" });
unsubscribe();
```
Internally, each outbound MTP frame is written to a new WebTransport unidirectional stream as a four-byte big-endian length followed by the frame, then that stream is closed. Incoming frames are read from the session's incoming unidirectional streams. The reader accepts both one-frame streams and native peers that place several frames on a persistent stream, so browser and native clients interoperate without stream configuration.
The SDK owns stream lifetime and framing. Do not create browser streams for MTP frames yourself through the SDK. For direct generated bindings, use `client.raw.client` or import `WasmClient` from `mtp/raw`; a `WasmClient` still owns one active WebTransport session, so create another instance for an independent connection.
## Sending, Requests, Subscriptions, And Pings ## Sending, Requests, Subscriptions, And Pings
@ -167,7 +454,7 @@ await client.send("SomeType", { value: "hello" }, {
}); });
``` ```
`request` sends one frame and resolves with the parsed response carrying the same frame id. `responseType` is validated after the id match: `request` sends one frame and resolves with the parsed response carrying the same frame id. If the matching response has a different `responseType`, the promise rejects with a response-type error. A timeout rejects the promise and removes the pending request:
```typescript ```typescript
const response = await client.request( const response = await client.request(
@ -187,7 +474,61 @@ const unsubscribe = client.subscribe("SomeType", (message) => {
unsubscribe(); unsubscribe();
``` ```
Protocol pings are real MTP `Ping` frames sent by the WASM client, not just transport keepalives: ### Zod request and response schemas
Applications can provide their request and response schemas once when creating
the client. MTP uses `parseAsync`, so synchronous schemas, async refinements,
defaults, coercions, and transforms all work. MTP has no runtime dependency on
Zod; the application supplies its preferred Zod version.
```typescript
import { z } from "zod";
import { MTPClient, MTPValidationError } from "mtp";
const schemas = {
GetUser: {
request: z.object({ UserId: z.number().int().positive() }),
response: z.object({
UserId: z.number().int().positive(),
Display: z.string(),
}),
},
};
const client = await MTPClient.create({
url,
schemas,
throwProtocolErrors: true,
onValidationError(error) {
console.error(error.messageType, error.cause);
},
});
const response = await client.request("GetUser", { UserId: 42 });
console.log(response.data.Display);
```
Request schemas run before frame encoding and transmission. Their transformed
output is sent. Response schemas run after request correlation, and their
transformed output replaces `frame.data`; `frame.raw`, when present, remains the
original wire frame. Invalid requests and responses reject with
`MTPValidationError`. Invalid subscription messages do not reach the handler
and are reported through `onValidationError`.
`throwProtocolErrors: true` converts correlated `Error*` frames into
`MTPProtocolError`. It defaults to `false` for compatibility.
`MTPProxyConnection` applies the same schema registry to another TypeScript
request/subscription transport, such as a Tauri command and event proxy:
```typescript
const connection = new MTPProxyConnection(adapter, {
schemas,
throwProtocolErrors: true,
});
```
Protocol ping behavior is defined in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive). The SDK configuration is:
```typescript ```typescript
await MTPClient.create({ await MTPClient.create({
@ -198,14 +539,139 @@ await MTPClient.create({
Use `pings: true` for the default interval. Use `pings: true` for the default interval.
## Pipes
Pipes are byte-oriented streams over WebTransport. The `PipeRequest` type and
description are clear transport metadata; raw stream bytes are not protected
by MTP. For sensitive calls, files, or application streams, wrap the accepted
pipe with `MTPEncryptedPipeWriter` or `MTPEncryptedPipeReader`.
### Outgoing Pipes
`createPipe` sends a `PipeRequest` frame and returns a handle. Call `wait()` to block until the remote peer accepts or denies:
```typescript
const handle = await client.createPipe("file-transfer");
const writer = await handle.wait();
if (writer == null) {
console.log("host denied the pipe");
return;
}
await writer.write(new Uint8Array([0x01, 0x02, 0x03]));
await writer.write(chunk);
await writer.close();
```
`writer.close()` sends a QUIC stream FIN. `writer.abort()` resets the stream abruptly. Each `write` resolves when the chunk has been handed to the transport; it does not wait for the peer to consume it.
### Encrypted Pipe Records
`initiateMTPPipeSession` and `acceptMTPPipeSession` perform the signed/KEM
protected pipe-session offer and return the encrypted record wrapper. The
offer binds the session ID, pipe ID, endpoint IDs, direction, and purpose. Do
not derive the initial chain key from the clear description or pipe ID alone.
```typescript
import {
initiateMTPPipeSession,
} from "mtp";
const encryptedWriter = await initiateMTPPipeSession(
writer,
{
sessionId: new TextEncoder().encode(`file-transfer/${writer.pipeId}`),
pipeId: writer.pipeId,
senderId: ownClientId,
recipientId: hostClientId,
purpose: 0x40,
direction: 0,
},
ownKeyring,
hostPublicKeyBundle,
);
await encryptedWriter.writeRecord(chunk);
await encryptedWriter.close();
```
`writeRecord` and `readRecord` use XChaCha20-Poly1305 with ordered sequence
numbers bound to the session context. Each record advances an HKDF chain and
uses a one-use message key. Record insertion, removal, reordering, or
modification fails authentication. The wrapper is intentionally separate from
the raw `PipeWriter`/`PipeReader` transport primitives.
The handle and writer expose `pipeId` and `description`:
```typescript
console.log(handle.pipeId, handle.description);
console.log(writer.pipeId);
```
### Incoming Pipes
Set a handler to receive pipe requests from the remote peer:
```typescript
client.setOnPipeRequest((request) => {
console.log("incoming pipe", request.pipeId, request.description);
// accept or deny asynchronously
});
```
Accept a request to receive a `PipeReader`:
```typescript
client.setOnPipeRequest(async (request) => {
if (request.description === "file-transfer") {
const reader = await client.acceptPipe(request.pipeId);
while (true) {
const chunk = await reader.read();
if (chunk == null) break; // stream closed by peer
processChunk(chunk);
}
} else {
await client.denyPipe(request.pipeId);
}
});
```
`reader.read()` resolves with a `Uint8Array` or `null` when the peer closes the stream. The reader exposes `pipeId` and `description`:
```typescript
console.log(reader.pipeId, reader.description);
```
### Pipe Handshake
1. The initiator calls `createPipe(description)`; the SDK sends a `PipeRequest` frame with a random `pipeId` and the description.
2. The receiver's `setOnPipeRequest` callback fires with `{ pipeId, description }`.
3. The receiver calls `acceptPipe(pipeId)`; the SDK sends a `PipeResponse` with `Accepted = true` and opens a new unidirectional stream for byte transport.
4. The initiator's `handle.wait()` resolves with a `PipeWriter` bound to that stream. Sensitive applications then perform their signed/encrypted session-key setup and construct an encrypted record wrapper.
5. If the receiver calls `denyPipe(pipeId)`, `handle.wait()` resolves with `null`.
Pipes share the same WebTransport session as message frames; they do not need a separate connection.
## Logger Events ## Logger Events
The SDK logger receives parsed events: The SDK logger receives parsed events:
```typescript ```typescript
type MTPLogEvent = type MTPLogEvent =
| { hint: "info" | "warning"; type: string; data: unknown } | {
| { hint: "error"; type: string | "error"; error: string }; hint: "info" | "warning";
type: string;
data: unknown;
direction?: "send" | "recv";
}
| {
hint: "error";
type: string | "error";
error: string;
data?: unknown;
direction?: "send" | "recv";
};
``` ```
Incoming non-error frames and sent frames are logged as `info`. Error frames and transport errors are logged as `error`. Incoming non-error frames and sent frames are logged as `info`. Error frames and transport errors are logged as `error`.
@ -240,16 +706,46 @@ config.free();
Raw callbacks receive parsed frames, not application-specific SDK objects: Raw callbacks receive parsed frames, not application-specific SDK objects:
```typescript ```typescript
interface ParsedEncryptedValue {
kind: "encrypted";
encryptionType: number;
purpose: number;
recipientCount: number;
encoded: Uint8Array;
}
interface ParsedSignedValue {
kind: "signed";
signatureType: number;
purpose: number;
signerId: bigint;
value: ParsedDataValue;
}
type ParsedDataValue =
| boolean
| number
| bigint
| string
| Uint8Array
| ParsedDataValue[]
| { [key: string]: ParsedDataValue }
| ParsedEncryptedValue
| ParsedSignedValue
| null;
interface ParsedFrame { interface ParsedFrame {
id?: number; id?: number;
type: string; type: string;
sender?: bigint; sender?: bigint;
receiver?: bigint; receiver?: bigint;
data: Record<string, unknown>; data: ParsedDataValue;
raw: Uint8Array; raw: Uint8Array;
} }
``` ```
### Frames
Raw message helpers that remain available include: Raw message helpers that remain available include:
- `build_frame(messageType, data, options?)` - `build_frame(messageType, data, options?)`
@ -258,6 +754,18 @@ Raw message helpers that remain available include:
- `format_frame(frame)` - `format_frame(frame)`
- `parse_auth_response(frame)` - `parse_auth_response(frame)`
The SDK export also exposes the same frame codec through `codec`:
```typescript
import { codec } from "mtp";
const frame = codec.encode("SomeType", { value: "hello" });
const parsed = codec.decode(frame);
const display = codec.format(frame);
```
### Crypto
Raw crypto and key helpers include: Raw crypto and key helpers include:
- `ed25519_generate()` - `ed25519_generate()`
@ -265,9 +773,13 @@ Raw crypto and key helpers include:
- `keyring_generate()` - `keyring_generate()`
- `keyring_from_ed25519(secretKey, publicKey)` - `keyring_from_ed25519(secretKey, publicKey)`
- `WasmKeyring.from_bytes(bytes)` and `keyring.to_bytes()` - `WasmKeyring.from_bytes(bytes)` and `keyring.to_bytes()`
- `keyring.validate_encryption()` for envelope decryption roles
- `keyring.validate_full()` for complete hybrid identities
- `WasmPublicKeyBundle.from_bytes(bytes)` and `bundle.to_bytes()` - `WasmPublicKeyBundle.from_bytes(bytes)` and `bundle.to_bytes()`
- `WasmEd25519Signer` - `WasmEd25519Signer`
- `WasmChaCha20Poly1305` - `WasmChaCha20Poly1305`
- `sign_data_value_with_keyring` and `verify_data_value_with_policy` (both require an explicit signature suite), plus `encrypt_data_value`, `encrypt_data_value_for_recipients`, and `decrypt_data_value`
- `parse_data_value` and `encode_data_value`
- `wasm_sha256`, `wasm_sha256_double`, `wasm_hkdf_expand`, and `wasm_derive_encryption_key` - `wasm_sha256`, `wasm_sha256_double`, `wasm_hkdf_expand`, and `wasm_derive_encryption_key`
Raw authenticated login and registration map directly to the Rust WASM layer: Raw authenticated login and registration map directly to the Rust WASM layer:
@ -290,4 +802,25 @@ const confirmedId = await rawClient.auth_connect(
); );
``` ```
### Pipes
The raw `WasmClient` exposes the same pipe operations as the SDK wrapper. The shared lifecycle is in [Pipes](PIPES.md); raw bindings use snake_case names.
```typescript
rawClient.set_on_pipe_request((event) => {
void rawClient.accept_pipe(event.pipeId);
});
const handle = await rawClient.create_pipe("file-transfer");
const writer = await handle.wait();
if (writer) {
await writer.write(chunk);
await writer.close();
}
```
A `WasmClient` manages one active WebTransport session. Create a new instance for independent connections, and call `free()` or `[Symbol.dispose]()` on raw WASM objects when you want to release memory eagerly. A `WasmClient` manages one active WebTransport session. Create a new instance for independent connections, and call `free()` or `[Symbol.dispose]()` on raw WASM objects when you want to release memory eagerly.
### State Management
A `WasmClient` owns one active WebTransport session. Create a separate client for each independent connection. Call `free()` or `[Symbol.dispose]()` on raw WASM objects when the application no longer needs them. SDK session and encrypted secret persistence are documented in [Security](SECURITY.md#browser-end-to-end-encryption).

View file

@ -1,53 +1,78 @@
#################################################################################
# This is an example, overwrite it for your project to register your own types. #
#################################################################################
# The version a Client should use # The version a Client should use
protocol_version: "0.0" protocol_version: "0.0"
# Note that markers 0 to 31 are reserved for default use, manually working with them is not recommended # Note that markers 0 to 31 are reserved for default use, manually working with them is not recommended
# Fixed CommunicationType markers are: # Fixed CommunicationType markers are:
# Error: 0 # Identification: 0
# ErrorParsing: 1 # IdentificationResponse: 1
# ErrorBadVersion: 2 # Register: 2
# Disconnect: 3 # RegisterResponse: 3
# Redirect: 4 # Challenge: 4
# Shutdown: 5 # ChallengeResponse: 5
# BadRequest: 6 # Ping: 6
# Unauthorized: 7 # Pong: 7
# Forbidden: 8 # Disconnect: 8
# NotFound: 9 # Redirect: 9
# TooManyRequests: 10 # Shutdown: 10
# InternalServerError: 11 # Error: 11
# BadGateway: 12 # ErrorParsing: 12
# ServiceUnavailable: 13 # ErrorBadVersion: 13
# GatewayTimeout: 14 # BadRequest: 14
# Identification: 15 # Unauthorized: 15
# IdentificationResponse: 16 # Forbidden: 16
# Register: 17 # NotFound: 17
# RegisterResponse: 18 # TooManyRequests: 18
# Ping: 19 # InternalServerError: 19
# Pong: 20 # BadGateway: 20
# ServiceUnavailable: 21
# GatewayTimeout: 22
# Relay: 26
# PipeRequest: 23
# PipeResponse: 24
# PipeAbort: 25
# #
# Fixed Data Type markers are: # Fixed Data Type markers are:
# Error: 0 # Version: 0
# ErrorParsing: 1 # id: 1
# ErrorMessage: 2 # ClientNonce: 2
# Version: 3 # ServerNonce: 3
# Description: 4 # PublicKeys: 4
# Timestamp: 5 # Signature: 5
# Id: 6 # PqSignature: 6
# ClientNonce: 7 # Description: 7
# ServerNonce: 8 # Connected: 8
# PublicKeys: 9 # Timestamp: 9
# Signature: 10 # Error: 10
# Connected: 11 # ErrorParsing: 11
# ErrorMessage: 12
# Accepted: 13,
# RequirePq: 14
# MessageId: 15
# FinalRecipientId: 18
# CreatedAt: 21
# MessageType: 22
# Content: 23
# Metadata: 24
# RelayVersion: 25
# ProtectedVersion: 26
# #
# If a Type can't be used it will be mapped to 0 # Types absent from a protocol version cannot be encoded for that version.
type_maps: type_maps:
"0.0": # Protocol version 0.0 "0.0": # Protocol version 0.0
CommunicationTypes: CommunicationTypes:
ProtectedMessage: 32
AlternateMessage: 33
DataTypes: DataTypes:
ExampleType: 32 ExampleType: 32
"1.0": "1.0":
CommunicationTypes: CommunicationTypes:
ProtectedMessage: 32
AlternateMessage: 33
DataTypes: DataTypes:
# If a v0.0 client connects # If a v0.0 client connects
# - the server can't use "AnotherType" # - the server can't use "AnotherType"
@ -57,6 +82,8 @@ type_maps:
SomeType: 34 SomeType: 34
"2.0": "2.0":
CommunicationTypes: CommunicationTypes:
ProtectedMessage: 32
AlternateMessage: 33
DataTypes: DataTypes:
# If a v0.0 client connects # If a v0.0 client connects
# - the server can't use "AnotherType" # - the server can't use "AnotherType"

5
example/.gitignore vendored
View file

@ -10,3 +10,8 @@ web-client/node_modules
web-client/public/host_public_key_bundle.hex web-client/public/host_public_key_bundle.hex
web-client/public/mtp_dev_cert_hash.txt web-client/public/mtp_dev_cert_hash.txt
web-client/dist/ web-client/dist/
client.id
*.mk
*.mpkb
metrics/

1345
example/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[workspace] [workspace]
members = [ members = [
"server", "server",
"client", "client", "keygen",
] ]
resolver = "3" resolver = "3"

1
example/client.id Normal file
View file

@ -0,0 +1 @@
1000

View file

@ -1,6 +1,6 @@
[package] [package]
name = "client" name = "client"
version = "0.1.0" version = "0.3.0"
edition = "2024" edition = "2024"
[[bin]] [[bin]]
@ -8,7 +8,9 @@ name = "client"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
mtp = { version = "0.1.0", path = "../../", features = ["client", "crypto"] } mtp = { version = "0.3.0", path = "../../", features = ["client", "crypto", "files", "pipes", "raw"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
rand = "0.10.1"
tracing-subscriber = "0.3.23"
serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
hex = "0.4"

View file

@ -1,52 +1,70 @@
use std::fs; use std::time::{Duration, Instant};
use tokio::fs;
use mtp::client::{ClientConfig, MTPClient, MTPConnection}; use mtp::client::{ClientConfig, MTPClient, MTPConnection};
use mtp::crypto::{Ed25519Signer, Keyring, MlDsaSigner, PublicKeyBundle}; use mtp::crypto::{Keyring, PublicKeyBundle};
use mtp::files::{load_keyring_raw, save_keyring_raw};
pub async fn connect_or_register( pub async fn connect_or_register(
mut config: ClientConfig, mut config: ClientConfig,
host_public_key: PublicKeyBundle, host_public_key: PublicKeyBundle,
client_key_path: &str, key_prefix: &str,
) -> Result<(MTPConnection, Keyring), Box<dyn std::error::Error>> { ) -> Result<(MTPConnection, Keyring, String, Duration), Box<dyn std::error::Error>> {
if let Ok(data) = fs::read_to_string(client_key_path) { let keyring_path = format!("{key_prefix}.mk");
let json: serde_json::Value = serde_json::from_str(&data)?; let id_path = format!("{key_prefix}.id");
let client_id = json["client_id"].as_u64().expect("Invalid client_id");
let keyring = Keyring::from_bytes(&hex::decode(
json["keyring"].as_str().expect("Missing keyring"),
)?)?;
println!("Loaded client keys (ID: {})", client_id); let file_load_started = Instant::now();
if let (Ok(keyring), Ok(id)) = (
config.client_id = client_id; load_keyring_raw(&keyring_path),
let conn = MTPClient::auth_connect(config, &keyring, &host_public_key).await?; fs::read_to_string(&id_path).await,
println!("Authenticated (version {})", conn.version); ) {
Ok((conn, keyring)) let client_id: u64 = id.trim().parse()?;
} else { println!(
println!("No existing keys found: registering new client"); "Loaded client keys (ID: {client_id}) in {:?}",
file_load_started.elapsed()
let (_ed_signer, sig_sk, sig_pk) = Ed25519Signer::generate();
let (_pq_signer, sig_pq_sk, sig_pq_pk) = MlDsaSigner::generate();
let keyring = Keyring::new(
mtp::crypto::KemPublicKey::new(vec![]),
mtp::crypto::KemPrivateKey::new(vec![]),
sig_pq_pk,
sig_pq_sk,
sig_pk,
sig_sk,
); );
let keyring_bytes = keyring.to_bytes(); config.client_id = client_id;
let conn = MTPClient::auth_register(config, &keyring, &host_public_key).await?; let auth_started = Instant::now();
println!("Registered with ID: {}", conn.client_id); let conn = MTPClient::auth_connect(config, &keyring, &host_public_key).await?;
let auth_duration = auth_started.elapsed();
let json = serde_json::json!({ println!(
"client_id": conn.client_id, "Authenticated (version {}) in {:?}",
"keyring": hex::encode(&keyring_bytes), conn.version, auth_duration
}); );
fs::write(client_key_path, serde_json::to_string_pretty(&json)?)?; return Ok((conn, keyring, "connect".into(), auth_duration));
println!("Saved client keys -> {client_key_path}");
let keyring = Keyring::from_bytes(&keyring_bytes)?;
Ok((conn, keyring))
} }
println!("No existing keys found: registering new client");
/* Registration publishes a complete MTP identity for later protection. */
let keyring = Keyring::generate();
let reg_started = Instant::now();
let conn = MTPClient::auth_register(config, &keyring, &host_public_key).await?;
let reg_duration = reg_started.elapsed();
println!("Registered with ID: {} in {:?}", conn.client_id, reg_duration);
save_keyring_raw(&keyring, &keyring_path)?;
fs::write(&id_path, conn.client_id.to_string()).await?;
println!("Saved client keys -> {keyring_path}");
Ok((conn, keyring, "register".into(), reg_duration))
}
/// Open a guest transport even when the caller already owns registered
/// credentials. The credentials stay with the caller for protected signing.
pub async fn connect_unauthenticated(
config: ClientConfig,
) -> Result<MTPConnection, Box<dyn std::error::Error>> {
let conn = MTPClient::connect(config).await?;
if conn.auth_state != mtp::client::AuthState::Unauthenticated {
return Err("guest connection did not report Unauthenticated state".into());
}
println!(
"Opened unauthenticated transport with host-assigned guest ID {}",
conn.client_id
);
Ok(conn)
} }

View file

@ -1,11 +1,15 @@
mod auth; mod auth;
mod metrics;
mod messages; mod messages;
mod pipes;
mod protected;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use std::time::Duration;
use mtp::client::ClientConfig; use mtp::client::{AuthState, ClientConfig};
use mtp::crypto::{KemPublicKey, PublicKeyBundle, SignaturePqPublicKey, SignaturePublicKey}; use mtp::files::load_public_key_bundle;
fn dev_cert_path() -> String { fn dev_cert_path() -> String {
std::env::var("MTP_DEV_CERT").unwrap_or_else(|_| { std::env::var("MTP_DEV_CERT").unwrap_or_else(|_| {
@ -19,35 +23,118 @@ fn dev_cert_path() -> String {
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt::init();
let cert_path = dev_cert_path(); let cert_path = dev_cert_path();
let cert_pem = fs::read(&cert_path).unwrap_or_else(|e| { let cert_pem = fs::read(&cert_path).unwrap_or_else(|e| {
panic!( panic!(
"Missing TLS certificate at {cert_path}: enter the Nix shell first or run the server to generate it: {e}" "Missing TLS certificate at {cert_path}: enter the Nix shell first or run the server to generate it: {e}"
) )
}); });
let host_public_key = PublicKeyBundle::new( let host_public_key = match load_public_key_bundle("host.mpkb") {
KemPublicKey::new( Ok(bundle) => bundle,
fs::read("host_enc_kem_pk.bin") Err(e) => {
.expect("Missing host_enc_kem_pk.bin: run server first"), return Err(
), format!("Missing host.mpkb: run the server first to export it ({e})").into(),
SignaturePqPublicKey::new(
fs::read("host_sig_pq_pk.bin")
.expect("Missing host_sig_pq_pk.bin: run server first"),
),
SignaturePublicKey::new(
fs::read("host_sig_pk.bin").expect("Missing host_sig_pk.bin: run server first"),
),
); );
}
};
let mut client_metrics = metrics::ClientMetrics::load("metrics/client_sessions.json");
println!("Connecting to 127.0.0.1:8080 ..."); println!("Connecting to 127.0.0.1:8080 ...");
let config = ClientConfig::new("https://127.0.0.1:8080").with_pinned_pem(cert_pem); let config = ClientConfig::new("https://127.0.0.1:8080")
.with_pinned_pem(cert_pem.clone())
.with_description("MTP example client");
let server_bundle = host_public_key.clone(); let server_bundle = host_public_key.clone();
let (conn, keyring) = let (conn, keyring, auth_method, auth_duration) =
auth::connect_or_register(config, host_public_key, "client_keys.json").await?; match auth::connect_or_register(config, host_public_key, "client").await {
messages::send_and_receive(&conn, &keyring, &server_bundle).await?; Ok(result) => result,
Err(e) => {
let mut builder = metrics::SessionBuilder::new("failed", Duration::from_secs(0));
builder.set_error(e.to_string());
client_metrics.record_session(builder.build());
client_metrics.save("metrics/client_sessions.json");
client_metrics.build_overview("metrics/client_overview.json");
return Err(e);
}
};
let mut builder = metrics::SessionBuilder::new(&auth_method, auth_duration);
if conn.auth_state != AuthState::Authenticated {
return Err("authenticated example connection did not report Authenticated state".into());
}
println!(
"Receive connection A: authenticated client {}",
conn.client_id
);
let unauthenticated_config = ClientConfig::new("https://127.0.0.1:8080")
.with_pinned_pem(cert_pem.clone())
.with_description("MTP example unauthenticated sender");
let unauthenticated_conn = auth::connect_unauthenticated(unauthenticated_config).await?;
println!(
"Send connection B: unauthenticated guest transport ID {}",
unauthenticated_conn.client_id
);
let direct_roundtrip = protected::send_direct_protected(
&unauthenticated_conn,
conn.client_id,
&keyring,
&server_bundle,
)
.await?;
println!(
"Protected signer {} was accepted through unauthenticated connection B",
conn.client_id
);
let relay_roundtrip = protected::send_sealed_relay(
&unauthenticated_conn,
conn.client_id,
&keyring,
&server_bundle,
)
.await?;
println!(
"Sealed relay round-trip completed in {:.3}ms",
relay_roundtrip.as_secs_f64() * 1000.0
);
unauthenticated_conn.sender.close().await;
let roundtrip = messages::send_and_receive(&conn, &keyring, &server_bundle).await?;
builder.set_message_roundtrip(roundtrip);
println!(
"Direct protected round-trip: {:.3}ms",
direct_roundtrip.as_secs_f64() * 1000.0
);
println!("\n--- Pipe demo ---");
let pipe_results = pipes::run_pipe_demo(&conn, 1).await?;
for result in &pipe_results {
builder.add_pipe_result(result.clone());
}
let session_record = builder.build();
println!(
"\nSession {} complete: auth={}ms, msg_roundtrip={}ms, pipes={} results, pipe_bytes={}",
session_record.session_id,
session_record.auth_duration_ms,
session_record.message_roundtrip_ms,
session_record.pipe_results.len(),
session_record.total_pipe_bytes,
);
client_metrics.record_session(session_record);
client_metrics.save("metrics/client_sessions.json");
client_metrics.build_overview("metrics/client_overview.json");
conn.sender.close().await;
println!("\nDone"); println!("\nDone");
Ok(()) Ok(())
} }

View file

@ -1,48 +1,59 @@
use std::time::{Duration, Instant};
use mtp::client::MTPConnection; use mtp::client::MTPConnection;
use mtp::codec::ProtectionPurpose;
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use mtp::crypto::{Ed25519Signer, EncryptionType, Keyring, PublicKeyBundle, SigAlgorithm}; use mtp::crypto::{Ed25519Signer, Keyring, PublicKeyBundle};
use mtp::type_map::TypeMap; use mtp::type_map::TypeMap;
pub fn build_demo_message( pub fn build_demo_message(
client_id: u64, client_id: u64,
keyring: &Keyring, keyring: &Keyring,
server_bundle: &PublicKeyBundle, server_bundle: &PublicKeyBundle,
) -> CommunicationValue { ) -> Result<CommunicationValue, Box<dyn std::error::Error>> {
// Encrypt to the server's KEM public key; the server decrypts with its keyring. // Encrypt to the server's KEM public key; the server decrypts with its keyring.
let enc_type = EncryptionType::MlKemChaCha20Poly1305; let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)?;
let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)
.expect("Ed25519 signer from keyring");
let tm = TypeMap::latest(); let tm = TypeMap::latest();
let version_id = DataType::Version
.try_to_id(&tm)
.ok_or_else(|| std::io::Error::other("Version is absent from the type map"))?;
let id_id = DataType::Id
.try_to_id(&tm)
.ok_or_else(|| std::io::Error::other("Id is absent from the type map"))?;
let inner_enc = DataValue::Container(vec![ let inner_enc = DataValue::Container(vec![
(DataType::Version.to_id(&tm), DataValue::Str("secret inner data".into())), (version_id, DataValue::Str("secret inner data".into())),
(DataType::Id.to_id(&tm), DataValue::UnsignedNumber(42)), (id_id, DataValue::UnsignedNumber(42)),
]); ]);
let mut dv_enc = inner_enc; let dv_enc = inner_enc.encrypt_for(
dv_enc.encrypt_container(enc_type, server_bundle, b"demo-aad"); std::slice::from_ref(server_bundle),
ProtectionPurpose::from(1),
)?;
let inner_sig = DataValue::Container(vec![ let inner_sig = DataValue::Container(vec![
(DataType::Version.to_id(&tm), DataValue::Str("signed by client".into())), (version_id, DataValue::Str("signed by client".into())),
(DataType::Id.to_id(&tm), DataValue::UnsignedNumber(99)), (id_id, DataValue::UnsignedNumber(99)),
]); ]);
let mut dv_sig = inner_sig; let dv_sig = inner_sig.sign(client_id, ProtectionPurpose::from(2), &signer)?;
dv_sig.sign_container(SigAlgorithm::ED25519, &signer);
let inner_sec = DataValue::Container(vec![ let inner_sec = DataValue::Container(vec![
( (
DataType::Version.to_id(&tm), version_id,
DataValue::Str("signed+encrypted payload".into()), DataValue::Str("signed+encrypted payload".into()),
), ),
(DataType::Id.to_id(&tm), DataValue::UnsignedNumber(7)), (id_id, DataValue::UnsignedNumber(7)),
]); ]);
let mut dv_sec = inner_sec; let dv_sec = inner_sec
dv_sec.sign_and_encrypt_container(SigAlgorithm::ED25519, &signer, enc_type, server_bundle, b"demo-aad"); .sign(client_id, ProtectionPurpose::from(3), &signer)?
.encrypt_for(
std::slice::from_ref(server_bundle),
ProtectionPurpose::from(4),
)?;
let timestamp = std::time::SystemTime::now() let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)?
.unwrap() .as_millis();
.as_secs();
let msg = CommunicationValue::new(CommunicationType::Ping) let msg = CommunicationValue::new(CommunicationType::Ping)
.add_typed_default( .add_typed_default(
@ -51,11 +62,11 @@ pub fn build_demo_message(
) )
.add_typed_default( .add_typed_default(
DataType::Timestamp, DataType::Timestamp,
DataValue::UnsignedNumber(timestamp as u128), DataValue::UnsignedNumber(timestamp),
) )
.add_typed_default(DataType::Data, DataValue::Str("Hello, MTP!".into())) .add_typed_default(DataType::Data, DataValue::Str("Hello, MTP!".into()))
.add_typed_default(DataType::Flags, DataValue::BoolTrue) .add_typed_default(DataType::Flags, DataValue::BoolTrue)
.add_typed_default(DataType::Value, DataValue::Float(2, 12345)) .add_typed_default(DataType::Value, DataValue::Float(1_234_500.0))
.add_typed_default( .add_typed_default(
DataType::BinaryData, DataType::BinaryData,
DataValue::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42]), DataValue::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42]),
@ -72,25 +83,32 @@ pub fn build_demo_message(
.add_typed_default(DataType::SignedPayload, dv_sig) .add_typed_default(DataType::SignedPayload, dv_sig)
.add_typed_default(DataType::SecurePayload, dv_sec) .add_typed_default(DataType::SecurePayload, dv_sec)
.with_sender(client_id); .with_sender(client_id);
msg Ok(msg)
} }
pub async fn send_and_receive( pub async fn send_and_receive(
conn: &MTPConnection, conn: &MTPConnection,
keyring: &Keyring, keyring: &Keyring,
server_bundle: &PublicKeyBundle, server_bundle: &PublicKeyBundle,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<Duration, Box<dyn std::error::Error>> {
let msg = build_demo_message(conn.client_id, keyring, server_bundle); let msg = build_demo_message(conn.client_id, keyring, server_bundle)?;
println!("Sending: {msg}"); println!("Sending: {msg}");
let start = Instant::now();
conn.sender.send(&msg).await?; conn.sender.send(&msg).await?;
match conn.receiver.receive().await { match conn.receive().await {
Ok(resp) => { Ok(resp) => {
let roundtrip = start.elapsed();
println!("Received: {resp}"); println!("Received: {resp}");
println!(
"Message round-trip: {:.3}ms",
roundtrip.as_secs_f64() * 1000.0
);
Ok(roundtrip)
}
Err(e) => {
eprintln!("Receive error: {e}");
Err(e.into())
} }
Err(e) => eprintln!("Receive error: {e}"),
} }
conn.sender.close();
Ok(())
} }

View file

@ -0,0 +1,558 @@
use mtp::common::unix_time_millis;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
fn now_epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn now_epoch_millis() -> u64 {
unix_time_millis().unwrap_or_default()
}
fn generate_session_id() -> String {
let ts = now_epoch_secs();
let rand_part: u32 = rand::random();
format!("{ts}-{rand_part:08x}")
}
// ---------------------------------------------------------------------------
// Persisted data types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PipeResult {
pub size: usize,
pub iteration: usize,
pub total_ms: f64,
pub data_only_ms: f64,
pub bytes_matched: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ClientSessionRecord {
pub session_id: String,
pub timestamp: u64,
pub auth_method: String,
pub auth_duration_ms: f64,
pub error: Option<String>,
pub message_roundtrip_ms: f64,
pub pipe_results: Vec<PipeResult>,
pub total_pipe_bytes: u64,
pub overall_pipe_avg_total_ms: f64,
pub overall_pipe_avg_data_ms: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ClientAggregateStats {
pub total_sessions: u64,
pub auth_failures: u64,
pub avg_auth_duration_ms: f64,
pub avg_message_roundtrip_ms: f64,
pub avg_pipe_total_ms: f64,
pub avg_pipe_data_ms: f64,
pub total_pipe_bytes: u64,
pub avg_pipe_throughput_mbps: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientOverview {
pub total_sessions: u64,
pub aggregate: ClientAggregateStats,
pub sessions: Vec<ClientSessionRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ClientMetricsFile {
pub sessions: Vec<ClientSessionRecord>,
}
// ---------------------------------------------------------------------------
// Live metrics state
// ---------------------------------------------------------------------------
pub struct ClientMetrics {
sessions: Vec<ClientSessionRecord>,
}
impl ClientMetrics {
#[cfg(test)]
pub fn new() -> Self {
Self {
sessions: Vec::new(),
}
}
pub fn load(path: &str) -> Self {
let file = std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str::<ClientMetricsFile>(&s).ok());
Self {
sessions: file.map(|f| f.sessions).unwrap_or_default(),
}
}
pub fn save(&self, path: &str) {
let data = ClientMetricsFile {
sessions: self.sessions.clone(),
};
if let Some(parent) = Path::new(path).parent() {
let _ = std::fs::create_dir_all(parent);
}
let json = serde_json::to_string_pretty(&data).unwrap_or_default();
let _ = std::fs::write(path, json);
}
pub fn record_session(&mut self, record: ClientSessionRecord) {
self.sessions.push(record);
}
pub fn build_overview(&self, overview_path: &str) {
let total = self.sessions.len() as u64;
if total == 0 {
let overview = ClientOverview {
total_sessions: 0,
aggregate: ClientAggregateStats::default(),
sessions: Vec::new(),
};
if let Some(parent) = Path::new(overview_path).parent() {
let _ = std::fs::create_dir_all(parent);
}
let json = serde_json::to_string_pretty(&overview).unwrap_or_default();
let _ = std::fs::write(overview_path, json);
return;
}
let mut auth_sum: f64 = 0.0;
let mut msg_sum: f64 = 0.0;
let mut pipe_total_sum: f64 = 0.0;
let mut pipe_data_sum: f64 = 0.0;
let mut total_pipe_bytes: u64 = 0;
let mut total_pipe_duration_secs: f64 = 0.0;
let mut auth_failures: u64 = 0;
let mut success_count: u64 = 0;
for s in &self.sessions {
if s.error.is_some() {
auth_failures += 1;
} else {
success_count += 1;
auth_sum += s.auth_duration_ms;
msg_sum += s.message_roundtrip_ms;
pipe_total_sum += s.overall_pipe_avg_total_ms;
pipe_data_sum += s.overall_pipe_avg_data_ms;
total_pipe_bytes += s.total_pipe_bytes;
for pr in &s.pipe_results {
total_pipe_duration_secs += pr.total_ms / 1000.0;
}
}
}
let divisor = if success_count > 0 { success_count } else { 1 };
let aggregate = ClientAggregateStats {
total_sessions: total,
auth_failures,
avg_auth_duration_ms: auth_sum / divisor as f64,
avg_message_roundtrip_ms: msg_sum / divisor as f64,
avg_pipe_total_ms: pipe_total_sum / divisor as f64,
avg_pipe_data_ms: pipe_data_sum / divisor as f64,
total_pipe_bytes,
avg_pipe_throughput_mbps: if total_pipe_duration_secs > 0.0 {
(total_pipe_bytes as f64 / 1_048_576.0) / total_pipe_duration_secs
} else {
0.0
},
};
let overview = ClientOverview {
total_sessions: total,
aggregate,
sessions: self.sessions.clone(),
};
if let Some(parent) = Path::new(overview_path).parent() {
let _ = std::fs::create_dir_all(parent);
}
let json = serde_json::to_string_pretty(&overview).unwrap_or_default();
let _ = std::fs::write(overview_path, json);
}
}
// ---------------------------------------------------------------------------
// Builder for constructing a session record piece by piece
// ---------------------------------------------------------------------------
pub struct SessionBuilder {
session_id: String,
timestamp: u64,
auth_method: String,
auth_duration_ms: f64,
error: Option<String>,
message_roundtrip_ms: f64,
pipe_results: Vec<PipeResult>,
}
impl SessionBuilder {
pub fn new(auth_method: &str, auth_duration: Duration) -> Self {
Self {
session_id: generate_session_id(),
timestamp: now_epoch_millis(),
auth_method: auth_method.to_string(),
auth_duration_ms: auth_duration.as_secs_f64() * 1000.0,
error: None,
message_roundtrip_ms: 0.0,
pipe_results: Vec::new(),
}
}
pub fn set_error(&mut self, error: String) {
self.error = Some(error);
}
pub fn set_message_roundtrip(&mut self, duration: Duration) {
self.message_roundtrip_ms = duration.as_secs_f64() * 1000.0;
}
pub fn add_pipe_result(&mut self, result: PipeResult) {
self.pipe_results.push(result);
}
pub fn build(self) -> ClientSessionRecord {
let total_pipe_bytes: u64 = self.pipe_results.iter().map(|r| r.size as u64).sum();
let overall_pipe_avg_total_ms = if self.pipe_results.is_empty() {
0.0
} else {
self.pipe_results.iter().map(|r| r.total_ms).sum::<f64>()
/ self.pipe_results.len() as f64
};
let overall_pipe_avg_data_ms = if self.pipe_results.is_empty() {
0.0
} else {
self.pipe_results
.iter()
.map(|r| r.data_only_ms)
.sum::<f64>()
/ self.pipe_results.len() as f64
};
ClientSessionRecord {
session_id: self.session_id,
timestamp: self.timestamp,
auth_method: self.auth_method,
auth_duration_ms: self.auth_duration_ms,
error: self.error,
message_roundtrip_ms: self.message_roundtrip_ms,
pipe_results: self.pipe_results,
total_pipe_bytes,
overall_pipe_avg_total_ms,
overall_pipe_avg_data_ms,
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn tmp_path(name: &str) -> String {
let dir = std::env::temp_dir().join("mtp_client_metrics_test");
let _ = std::fs::create_dir_all(&dir);
dir.join(name).to_str().unwrap().to_string()
}
#[test]
fn test_pipe_result_roundtrip() {
let pr = PipeResult {
size: 1024,
iteration: 0,
total_ms: 5.5,
data_only_ms: 3.2,
bytes_matched: true,
};
let json = serde_json::to_string(&pr).unwrap();
let decoded: PipeResult = serde_json::from_str(&json).unwrap();
assert_eq!(pr, decoded);
}
#[test]
fn test_client_session_roundtrip() {
let record = ClientSessionRecord {
session_id: "test-session".into(),
timestamp: 12345,
auth_method: "connect".into(),
auth_duration_ms: 42.5,
error: None,
message_roundtrip_ms: 10.3,
pipe_results: vec![
PipeResult {
size: 64,
iteration: 0,
total_ms: 1.0,
data_only_ms: 0.5,
bytes_matched: true,
},
PipeResult {
size: 256,
iteration: 0,
total_ms: 2.0,
data_only_ms: 1.0,
bytes_matched: true,
},
],
total_pipe_bytes: 320,
overall_pipe_avg_total_ms: 1.5,
overall_pipe_avg_data_ms: 0.75,
};
let json = serde_json::to_string(&record).unwrap();
let decoded: ClientSessionRecord = serde_json::from_str(&json).unwrap();
assert_eq!(record, decoded);
}
#[test]
fn test_client_metrics_load_missing() {
let metrics = ClientMetrics::load("/nonexistent/path.json");
assert!(metrics.sessions.is_empty());
}
#[test]
fn test_multiple_client_sessions() {
let path = tmp_path("multi_session.json");
let mut metrics = ClientMetrics::load(&path);
for i in 0..3 {
let mut builder = SessionBuilder::new("connect", Duration::from_millis(10 + i));
builder.set_message_roundtrip(Duration::from_millis(5 + i));
builder.add_pipe_result(PipeResult {
size: 64,
iteration: 0,
total_ms: 1.0 + i as f64,
data_only_ms: 0.5 + i as f64 * 0.5,
bytes_matched: true,
});
metrics.record_session(builder.build());
}
metrics.save(&path);
let metrics2 = ClientMetrics::load(&path);
assert_eq!(metrics2.sessions.len(), 3);
assert_eq!(metrics2.sessions[0].auth_method, "connect");
assert_eq!(metrics2.sessions[1].pipe_results[0].size, 64);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_client_overview_stats() {
let mut metrics = ClientMetrics::new();
for i in 0..4 {
let mut builder = SessionBuilder::new("connect", Duration::from_millis(20));
builder.set_message_roundtrip(Duration::from_millis(10 + i as u64));
builder.add_pipe_result(PipeResult {
size: 256,
iteration: 0,
total_ms: 2.0,
data_only_ms: 1.0,
bytes_matched: true,
});
metrics.record_session(builder.build());
}
let overview_path = tmp_path("client_overview.json");
metrics.build_overview(&overview_path);
let json = std::fs::read_to_string(&overview_path).unwrap();
let overview: ClientOverview = serde_json::from_str(&json).unwrap();
assert_eq!(overview.total_sessions, 4);
assert_eq!(overview.aggregate.avg_auth_duration_ms, 20.0);
assert_eq!(overview.aggregate.avg_message_roundtrip_ms, 11.5);
assert_eq!(overview.aggregate.avg_pipe_total_ms, 2.0);
assert_eq!(overview.aggregate.avg_pipe_data_ms, 1.0);
assert_eq!(overview.aggregate.total_pipe_bytes, 1024);
assert!(overview.aggregate.avg_pipe_throughput_mbps > 0.0);
assert_eq!(overview.sessions.len(), 4);
let _ = std::fs::remove_file(&overview_path);
}
#[test]
fn test_client_overview_empty() {
let metrics = ClientMetrics::new();
let overview_path = tmp_path("client_empty_overview.json");
metrics.build_overview(&overview_path);
let json = std::fs::read_to_string(&overview_path).unwrap();
let overview: ClientOverview = serde_json::from_str(&json).unwrap();
assert_eq!(overview.total_sessions, 0);
assert!(overview.sessions.is_empty());
let _ = std::fs::remove_file(&overview_path);
}
#[test]
fn test_auth_failure_recording() {
let path = tmp_path("auth_failure.json");
let overview_path = tmp_path("auth_failure_overview.json");
let mut metrics = ClientMetrics::load(&path);
// Successful session
let mut b1 = SessionBuilder::new("connect", Duration::from_millis(42));
b1.set_message_roundtrip(Duration::from_millis(10));
metrics.record_session(b1.build());
// Failed auth session
let mut b2 = SessionBuilder::new("connect", Duration::from_millis(5000));
b2.set_error("authentication timed out".into());
metrics.record_session(b2.build());
// Another successful session
let mut b3 = SessionBuilder::new("register", Duration::from_millis(100));
b3.set_message_roundtrip(Duration::from_millis(8));
metrics.record_session(b3.build());
metrics.save(&path);
let metrics2 = ClientMetrics::load(&path);
assert_eq!(metrics2.sessions.len(), 3);
assert!(metrics2.sessions[0].error.is_none());
assert_eq!(
metrics2.sessions[1].error.as_deref(),
Some("authentication timed out")
);
assert!(metrics2.sessions[2].error.is_none());
metrics2.build_overview(&overview_path);
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
let overview: ClientOverview = serde_json::from_str(&overview_json).unwrap();
assert_eq!(overview.total_sessions, 3);
assert_eq!(overview.aggregate.auth_failures, 1);
// Averages should only count successful sessions
assert!((overview.aggregate.avg_auth_duration_ms - 71.0).abs() < 0.01); // (42+100)/2
assert!((overview.aggregate.avg_message_roundtrip_ms - 9.0).abs() < 0.01); // (10+8)/2
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&overview_path);
}
// -----------------------------------------------------------------------
// Integration-style tests
// -----------------------------------------------------------------------
fn make_pr(size: usize, iteration: usize, total_ms: f64, data_only_ms: f64) -> PipeResult {
PipeResult {
size,
iteration,
total_ms,
data_only_ms,
bytes_matched: true,
}
}
#[test]
fn test_full_client_lifecycle() {
let path = tmp_path("client_lifecycle.json");
let overview_path = tmp_path("client_lifecycle_overview.json");
let mut metrics = ClientMetrics::load(&path);
let mut b1 = SessionBuilder::new("connect", Duration::from_millis(42));
b1.set_message_roundtrip(Duration::from_millis(10));
b1.add_pipe_result(make_pr(64, 0, 1.5, 0.8));
b1.add_pipe_result(make_pr(256, 0, 2.5, 1.2));
metrics.record_session(b1.build());
let mut b2 = SessionBuilder::new("register", Duration::from_millis(150));
b2.set_message_roundtrip(Duration::from_millis(15));
b2.add_pipe_result(make_pr(64, 0, 2.0, 1.0));
b2.add_pipe_result(make_pr(1024, 0, 5.0, 3.0));
metrics.record_session(b2.build());
metrics.save(&path);
let metrics2 = ClientMetrics::load(&path);
assert_eq!(metrics2.sessions.len(), 2);
let s1 = &metrics2.sessions[0];
assert_eq!(s1.auth_method, "connect");
assert!((s1.auth_duration_ms - 42.0).abs() < 0.01);
assert!((s1.message_roundtrip_ms - 10.0).abs() < 0.01);
assert_eq!(s1.pipe_results.len(), 2);
assert_eq!(s1.total_pipe_bytes, 320);
assert!((s1.overall_pipe_avg_total_ms - 2.0).abs() < 0.01);
assert!((s1.overall_pipe_avg_data_ms - 1.0).abs() < 0.01);
let s2 = &metrics2.sessions[1];
assert_eq!(s2.auth_method, "register");
assert_eq!(s2.pipe_results.len(), 2);
assert_eq!(s2.total_pipe_bytes, 1088);
metrics2.build_overview(&overview_path);
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
let overview: ClientOverview = serde_json::from_str(&overview_json).unwrap();
assert_eq!(overview.total_sessions, 2);
assert!((overview.aggregate.avg_auth_duration_ms - 96.0).abs() < 0.01);
assert!((overview.aggregate.avg_message_roundtrip_ms - 12.5).abs() < 0.01);
assert_eq!(overview.aggregate.total_pipe_bytes, 1408);
assert!(overview.aggregate.avg_pipe_throughput_mbps > 0.0);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&overview_path);
}
#[test]
fn test_cross_session_accumulation() {
let path = tmp_path("client_accumulate.json");
let overview_path = tmp_path("client_accumulate_overview.json");
{
let mut metrics = ClientMetrics::load(&path);
let mut b = SessionBuilder::new("connect", Duration::from_millis(30));
b.set_message_roundtrip(Duration::from_millis(8));
b.add_pipe_result(make_pr(64, 0, 1.0, 0.5));
metrics.record_session(b.build());
metrics.save(&path);
}
{
let mut metrics = ClientMetrics::load(&path);
assert_eq!(metrics.sessions.len(), 1);
let mut b = SessionBuilder::new("register", Duration::from_millis(200));
b.set_message_roundtrip(Duration::from_millis(12));
b.add_pipe_result(make_pr(1024, 0, 4.0, 2.5));
metrics.record_session(b.build());
metrics.save(&path);
}
let metrics = ClientMetrics::load(&path);
assert_eq!(metrics.sessions.len(), 2);
assert_eq!(metrics.sessions[0].auth_method, "connect");
assert_eq!(metrics.sessions[1].auth_method, "register");
metrics.build_overview(&overview_path);
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
let overview: ClientOverview = serde_json::from_str(&overview_json).unwrap();
assert_eq!(overview.total_sessions, 2);
assert!((overview.aggregate.avg_auth_duration_ms - 115.0).abs() < 0.01);
assert!((overview.aggregate.avg_message_roundtrip_ms - 10.0).abs() < 0.01);
assert_eq!(overview.aggregate.total_pipe_bytes, 1088);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&overview_path);
}
}

147
example/client/src/pipes.rs Normal file
View file

@ -0,0 +1,147 @@
use mtp::client::MTPConnection;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::oneshot;
use tokio::time::{Duration, Instant};
use crate::metrics::PipeResult;
pub async fn run_pipe_demo(
conn: &MTPConnection,
iterations: usize,
) -> Result<Vec<PipeResult>, Box<dyn std::error::Error>> {
let sizes = [64, 256, 1024, 4096];
let mut all_elapsed = Vec::with_capacity(sizes.len() * iterations);
let mut all_data_only = Vec::with_capacity(sizes.len() * iterations);
let mut pipe_results = Vec::with_capacity(sizes.len() * iterations);
for (i, &size) in sizes.iter().enumerate() {
let mut size_elapsed = Vec::with_capacity(iterations);
let mut size_data_only = Vec::with_capacity(iterations);
for run in 0..iterations {
let random_bytes: Vec<u8> = (0..size).map(|_| rand::random::<u8>()).collect();
let description = format!("pipe-demo-{i}-run{run}");
println!(" [pipe {i}.{run}] creating pipe ({size} bytes): {description}");
let handle = conn.create_pipe(&description).await?;
let pipe_id = handle.pipe_id();
println!(" [pipe {i}.{run}] create_pipe returned (pipe_id={pipe_id})");
// Overall timer starts before any I/O
let overall_start = Instant::now();
// Channel to capture the instant the writer actually starts writing
let (write_start_tx, write_start_rx) = oneshot::channel();
let write_bytes = random_bytes.clone();
let writer_handle = tokio::spawn(async move {
println!(" [pipe {i}.{run}] writer: waiting for server accept ...");
match handle.wait().await {
Ok(Some(mut writer)) => {
// Record the instant we begin writing
let _ = write_start_tx.send(Instant::now());
println!(
" [pipe {i}.{run}] writer: pipe accepted (pipe_id={pipe_id}), writing {} bytes ...",
write_bytes.len()
);
writer
.write_all(&write_bytes)
.await
.map_err(|e| mtp::common::PipeError::IoError(e.to_string()))?;
writer
.finish()
.await
.map_err(|e| mtp::common::PipeError::IoError(e.to_string()))?;
Ok::<(), mtp::common::PipeError>(())
}
Ok(None) => {
eprintln!(" [pipe {i}.{run}] writer: pipe denied by server");
Err(mtp::common::PipeError::Rejected)
}
Err(e) => {
eprintln!(" [pipe {i}.{run}] writer: error: {e}");
Err(e)
}
}
});
println!(" [pipe {i}.{run}] waiting for server's return pipe via receive_pipe() ...");
let pipe_req = conn.receive_pipe().await?;
println!(
" [pipe {i}.{run}] received return pipe: id={} desc={:?}",
pipe_req.id(),
pipe_req.description()
);
let mut reader = pipe_req.accept().await?;
println!(" [pipe {i}.{run}] return pipe accepted, reading data ...");
let mut buf = Vec::new();
reader.read_to_end(&mut buf).await?;
let overall_elapsed = overall_start.elapsed();
// Receive the instant the writer started writing
let data_start = write_start_rx.await?;
let data_only_elapsed = Instant::now() - data_start;
match writer_handle.await {
Ok(Ok(())) => {}
Ok(Err(e)) => eprintln!(" [pipe {i}.{run}] writer error: {e}"),
Err(e) => eprintln!(" [pipe {i}.{run}] writer task panicked: {e}"),
}
let matches = buf == random_bytes;
println!(
" [pipe {i}.{run}] round-trip: {} bytes, \
total={:.3}ms, data-only={:.3}ms, match={matches}",
size,
overall_elapsed.as_secs_f64() * 1000.0,
data_only_elapsed.as_secs_f64() * 1000.0,
);
pipe_results.push(PipeResult {
size,
iteration: run,
total_ms: overall_elapsed.as_secs_f64() * 1000.0,
data_only_ms: data_only_elapsed.as_secs_f64() * 1000.0,
bytes_matched: matches,
});
size_elapsed.push(overall_elapsed);
size_data_only.push(data_only_elapsed);
all_elapsed.push(overall_elapsed);
all_data_only.push(data_only_elapsed);
}
// ---- per-size averages ----
let avg_total = average_duration(&size_elapsed);
let avg_data = average_duration(&size_data_only);
println!(
" [pipe {i}] AVERAGE for size {size}: \
total={avg_total:.3}ms, data-only={avg_data:.3}ms \
(over {iterations} runs)"
);
}
// ---- overall averages ----
let overall_total = average_duration(&all_elapsed);
let overall_data = average_duration(&all_data_only);
println!(
" [summary] OVERALL AVERAGE loopback time: \
total={overall_total:.3}ms, data-only={overall_data:.3}ms \
({} measurements)",
all_elapsed.len()
);
Ok(pipe_results)
}
/// Helper: average a slice of Durations without overflowing.
fn average_duration(durations: &[Duration]) -> f64 {
if durations.is_empty() {
return 0.0;
}
let sum_ms: f64 = durations.iter().map(|d| d.as_secs_f64() * 1000.0).sum();
sum_ms / durations.len() as f64
}

View file

@ -0,0 +1,199 @@
use std::time::{Duration, Instant};
use mtp::client::MTPConnection;
use mtp::codec::{
CommunicationType, DataType, DataValue, ProtectionPolicy, ProtectedMessageBuilder,
ProtectionPurpose, RelayOpenOptions, SealedRelayBuilder, SignaturePolicy, TypeMap,
open_relay_content_with_limits_without_replay,
open_relay_metadata_without_replay,
};
use mtp::common::unix_time_millis;
use mtp::crypto::{Ed25519Signer, Keyring, PublicKeyBundle};
/// The direct protected example sends to the host as the destination MTP ID.
pub const DIRECT_DESTINATION_ID: u64 = 1;
/// The example host acts as the metadata relay and uses this stable MTP ID.
pub const METADATA_RELAY_ID: u64 = 1;
/// This keyring represents a final recipient independently of the transport
/// identity used by the example client.
pub const FINAL_RECIPIENT_ID: u64 = 7_002;
const DIRECT_SIGNATURE_PURPOSE: u8 = 0x40;
const DIRECT_ENCRYPTION_PURPOSE: u8 = 0x41;
const RELAY_SIGNATURE_POLICY: ProtectionPolicy = ProtectionPolicy {
signature: SignaturePolicy::Ed25519,
};
fn type_id(
data_type: DataType,
type_map: &TypeMap,
) -> Result<mtp::codec::DataTypeId, Box<dyn std::error::Error>> {
data_type
.try_to_id(type_map)
.ok_or_else(|| format!("missing example data type mapping for {data_type}").into())
}
fn application_value(text: &str, number: u128) -> Result<DataValue, Box<dyn std::error::Error>> {
let type_map = TypeMap::latest();
Ok(DataValue::Container(vec![
(
type_id(DataType::ExampleText, &type_map)?,
DataValue::Str(text.to_owned()),
),
(
type_id(DataType::ExampleNumber, &type_map)?,
DataValue::UnsignedNumber(number),
),
]))
}
fn relay_metadata() -> Result<DataValue, Box<dyn std::error::Error>> {
let type_map = TypeMap::latest();
Ok(DataValue::Container(vec![
(
type_id(DataType::ExampleRole, &type_map)?,
DataValue::Str("metadata relay".into()),
),
(
type_id(DataType::ExampleMetadata, &type_map)?,
DataValue::Str("application metadata remains authenticated and opaque to MTP".into()),
),
]))
}
/// Send an application value directly to the host without constructing a
/// Relay frame. The outer sender is deliberately absent so the example also
/// demonstrates that the protected signer is independent of transport auth.
pub async fn send_direct_protected(
conn: &MTPConnection,
signer_id: u64,
signer_keyring: &Keyring,
recipient_public_key: &PublicKeyBundle,
) -> Result<Duration, Box<dyn std::error::Error>> {
let signer = Ed25519Signer::new(&signer_keyring.sig_cl_secret_key)?;
let created_at = unix_time_millis()?;
let message_id = format!(
"example-direct-{created_at}-{}",
rand::random::<u32>()
);
let content = application_value("direct protected delivery", 40)?;
let frame = ProtectedMessageBuilder::new(
"ProtectedMessage",
content,
signer_id,
DIRECT_DESTINATION_ID,
&signer,
ProtectionPurpose::from(DIRECT_SIGNATURE_PURPOSE),
ProtectionPurpose::from(DIRECT_ENCRYPTION_PURPOSE),
)
.message_id(message_id)
.created_at(created_at)
.recipients(vec![recipient_public_key.clone()])
.type_map(&TypeMap::latest())
.build()?;
println!(
"Sending direct protected frame: type=ProtectedMessage receiver={} outer_sender=absent signer={signer_id}",
DIRECT_DESTINATION_ID
);
let started = Instant::now();
conn.sender.send(&frame).await?;
let response = conn.receive().await?;
if !response.is_type(CommunicationType::Pong) {
return Err(format!("direct protected response was not Pong: {response}").into());
}
let elapsed = started.elapsed();
println!(
"Direct protected value verified and acknowledged in {:.3}ms",
elapsed.as_secs_f64() * 1000.0
);
Ok(elapsed)
}
/// Send a sealed relay through the host, which can open metadata but cannot
/// decrypt the content. The final recipient is represented by a separate
/// keyring so the example does not conflate relay and content access.
pub async fn send_sealed_relay(
conn: &MTPConnection,
signer_id: u64,
signer_keyring: &Keyring,
metadata_relay_public_key: &PublicKeyBundle,
) -> Result<Duration, Box<dyn std::error::Error>> {
let type_map = TypeMap::latest();
let final_recipient_keyring = Keyring::generate();
let final_recipient_public_key = final_recipient_keyring.public_key_bundle();
let signer = Ed25519Signer::new(&signer_keyring.sig_cl_secret_key)?;
let created_at = unix_time_millis()?;
let message_id = format!("example-relay-{created_at}-{}", rand::random::<u32>());
let frame = SealedRelayBuilder::new(
"ProtectedMessage",
application_value("sealed relay delivery", 41)?,
signer_id,
FINAL_RECIPIENT_ID,
METADATA_RELAY_ID,
&signer,
)
.message_id(message_id)
.created_at(created_at)
.metadata(relay_metadata()?)
.metadata_recipients(vec![
metadata_relay_public_key.clone(),
final_recipient_public_key.clone(),
])
.content_recipients(vec![final_recipient_public_key])
.type_map(&type_map)
.build()?;
println!(
"Sending sealed relay: next_hop={} final_recipient={} metadata_recipients=2 content_recipients=1",
METADATA_RELAY_ID, FINAL_RECIPIENT_ID
);
let started = Instant::now();
conn.sender.send(&frame).await?;
let forwarded = conn.receive().await?;
if !forwarded.is_type(CommunicationType::Relay) {
return Err(format!("relay response was not Relay: {forwarded}").into());
}
if forwarded.sender().is_some() || forwarded.receiver() != Some(FINAL_RECIPIENT_ID) {
return Err("relay forwarding changed the sealed-sender boundary".into());
}
let metadata = open_relay_metadata_without_replay(
&forwarded,
&final_recipient_keyring,
signer_id,
&signer_keyring.public_key_bundle(),
RelayOpenOptions::new(RELAY_SIGNATURE_POLICY),
)?;
let application_metadata = metadata
.metadata()
.ok_or("forwarded relay metadata was missing")?;
let content = open_relay_content_with_limits_without_replay(
&metadata,
&[&final_recipient_keyring],
&[signer_keyring.public_key_bundle()],
Some(FINAL_RECIPIENT_ID),
RelayOpenOptions::new(RELAY_SIGNATURE_POLICY),
)?;
if content.message_type != "ProtectedMessage" {
return Err(format!("unexpected relay message type: {}", content.message_type).into());
}
let expected_metadata = relay_metadata()?;
if application_metadata != &expected_metadata {
return Err("relay application metadata changed during forwarding".into());
}
let expected_content = application_value("sealed relay delivery", 41)?;
if content.content != expected_content {
return Err("relay application content changed during forwarding".into());
}
let elapsed = started.elapsed();
println!(
"Final recipient opened authenticated metadata and content in {:.3}ms (message_id={})",
elapsed.as_secs_f64() * 1000.0,
metadata.message_id()
);
Ok(elapsed)
}

View file

@ -0,0 +1,7 @@
[package]
name = "keygen"
version = "0.3.0"
edition = "2024"
[dependencies]
mtp = { version = "0.3.0", path = "../../", features = ["files", "raw"] }

View file

@ -0,0 +1,40 @@
use std::path::PathBuf;
use mtp::crypto::Keyring;
use mtp::files::{
self, BUNDLE_EXTENSION, KEYRING_EXTENSION, load_keyring_raw, load_public_key_bundle,
save_keyring_raw, save_public_key_bundle,
};
fn main() -> Result<(), files::FileError> {
let keyring_path = PathBuf::from(format!("keyring.{KEYRING_EXTENSION}"));
let bundle_path = PathBuf::from(format!("bundle.{BUNDLE_EXTENSION}"));
let keyring = Keyring::generate();
save_keyring_raw(&keyring, &keyring_path)?;
save_public_key_bundle(&keyring.public_key_bundle(), &bundle_path)?;
/* Read both back to confirm the files round-trip through the on-disk format. */
let loaded_keyring = load_keyring_raw(&keyring_path)?;
let loaded_bundle = load_public_key_bundle(&bundle_path)?;
assert_eq!(keyring.try_to_bytes()?, loaded_keyring.try_to_bytes()?);
let bundle_bytes = keyring.public_key_bundle().try_as_bytes()?;
let loaded_bundle_bytes = loaded_bundle.try_as_bytes()?;
assert_eq!(
bundle_bytes,
loaded_bundle_bytes
);
println!(
"\nPrivateKeyRing (base64):\n{}",
keyring.try_to_base64()?
);
println!(
"\nPublicKeyBundle (base64):\n{}",
loaded_bundle.try_to_base64()?
);
println!("Wrote keyring -> {}", keyring_path.display());
println!("Wrote bundle -> {}", bundle_path.display());
Ok(())
}

View file

@ -1,6 +1,6 @@
[package] [package]
name = "server" name = "server"
version = "0.1.0" version = "0.3.0"
edition = "2024" edition = "2024"
[[bin]] [[bin]]
@ -8,9 +8,12 @@ name = "server"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
mtp = { version = "0.1.0", path = "../../", features = ["crypto", "host"] } mtp = { version = "0.3.0", path = "../../", features = ["crypto", "tls", "web-server", "files", "pipes", "raw"] }
rcgen = "0.14"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
http = "1"
serde_json = { version = "1" } serde_json = { version = "1" }
hex = "0.4" hex = "0.4"
base64 = "0.22" base64 = "0.23"
tracing-subscriber = "0.3.23"
serde = { version = "1", features = ["derive"] }
rand = "0.10.1"

View file

@ -1,14 +1,14 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::fs;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tokio::fs;
use mtp::crypto::PublicKeyBundle; use mtp::crypto::PublicKeyBundle;
pub fn load_client_db( pub async fn load_client_db(
path: &str, path: &str,
) -> Result<(Arc<Mutex<HashMap<u64, PublicKeyBundle>>>, Arc<Mutex<u64>>), Box<dyn std::error::Error>> ) -> Result<(Arc<Mutex<HashMap<u64, PublicKeyBundle>>>, Arc<Mutex<u64>>), Box<dyn std::error::Error>>
{ {
let clients_map = match fs::read_to_string(path) { let clients_map = match fs::read_to_string(path).await {
Ok(data) => match serde_json::from_str(&data) { Ok(data) => match serde_json::from_str(&data) {
Ok(clients) => clients, Ok(clients) => clients,
Err(e) => { Err(e) => {
@ -19,7 +19,12 @@ pub fn load_client_db(
Err(_) => HashMap::new(), Err(_) => HashMap::new(),
}; };
let clients: Arc<Mutex<HashMap<u64, PublicKeyBundle>>> = Arc::new(Mutex::new(clients_map)); let clients: Arc<Mutex<HashMap<u64, PublicKeyBundle>>> = Arc::new(Mutex::new(clients_map));
let next_value = clients.lock().unwrap().keys().max().unwrap_or(&999) + 1; let next_value = {
let guard = clients
.lock()
.map_err(|_| std::io::Error::other("client database mutex poisoned"))?;
guard.keys().max().copied().unwrap_or(999) + 1
};
let next_id = Arc::new(Mutex::new(next_value)); let next_id = Arc::new(Mutex::new(next_value));
Ok((clients, next_id)) Ok((clients, next_id))
} }

View file

@ -1,43 +1,282 @@
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, TypeMap}; use std::collections::HashMap;
use mtp::crypto::{
CryptoError, Keyring, SignaturePublicKey, SignatureScheme, verify_ed25519, use mtp::codec::{
CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, InMemoryReplayGuard,
ProtectedOpenOptions, ProtectionPolicy, ProtectionPurpose, RelayOpenOptions, SignaturePolicy,
TypeMap,
forward_relay_frame, open_protected_with_checked,
open_relay_content_with_limits_without_replay,
open_relay_metadata_with_checked,
};
use mtp::crypto::{Keyring, PublicKeyBundle};
const DIRECT_DESTINATION_ID: u64 = 1;
const METADATA_RELAY_ID: u64 = 1;
const FINAL_RECIPIENT_ID: u64 = 7_002;
const DIRECT_SIGNATURE_PURPOSE: u8 = 0x40;
const DIRECT_ENCRYPTION_PURPOSE: u8 = 0x41;
const SIGNATURE_POLICY: ProtectionPolicy = ProtectionPolicy {
signature: SignaturePolicy::Ed25519,
}; };
struct Ed25519Verifier(SignaturePublicKey); fn resolve_signer_key(
signer_id: u64,
registered_clients: &HashMap<u64, PublicKeyBundle>,
) -> Option<PublicKeyBundle> {
registered_clients.get(&signer_id).cloned()
}
impl SignatureScheme for Ed25519Verifier { fn pong(tm: &TypeMap, data: impl Into<String>) -> Result<CommunicationValue, String> {
fn sign(&self, _msg: &[u8]) -> Result<Vec<u8>, CryptoError> { let desc_id = DataTypeId(
Err(CryptoError::SigningFailed) tm.data_id_enum(DataType::Description)
.ok_or("missing Description type mapping")?,
);
let ts_id = DataTypeId(
tm.data_id_enum(DataType::Timestamp)
.ok_or("missing Timestamp type mapping")?,
);
let data_id = DataTypeId(
tm.data_id_enum(DataType::Data)
.ok_or("missing Data type mapping")?,
);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_millis();
CommunicationValue::from_comm(CommunicationType::Pong, tm)
.add_data(desc_id, DataValue::Str("MTP example response".into()))
.map_err(|e| e.to_string())?
.add_data(ts_id, DataValue::UnsignedNumber(now))
.map_err(|e| e.to_string())?
.add_data(data_id, DataValue::Str(data.into()))
.map_err(|e| e.to_string())
}
fn process_direct_protected(
msg: &CommunicationValue,
tm: &TypeMap,
client_pk: Option<&PublicKeyBundle>,
registered_clients: &HashMap<u64, PublicKeyBundle>,
host_keyring: &Keyring,
accepted_messages: &mut InMemoryReplayGuard,
) -> Result<CommunicationValue, String> {
if msg.receiver() != Some(DIRECT_DESTINATION_ID) {
return Err(format!(
"direct protected frame was addressed to {:?}, expected destination {DIRECT_DESTINATION_ID}",
msg.receiver()
));
} }
fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError> {
verify_ed25519(&self.0, msg, signature) let opened = open_protected_with_checked(
msg,
std::slice::from_ref(&host_keyring),
None,
|signer_id| resolve_signer_key(signer_id, registered_clients).map(|key| vec![key]),
ProtectedOpenOptions::new(
Some(DIRECT_DESTINATION_ID),
ProtectionPurpose::from(DIRECT_SIGNATURE_PURPOSE),
ProtectionPurpose::from(DIRECT_ENCRYPTION_PURPOSE),
SIGNATURE_POLICY,
),
accepted_messages,
)
.map_err(|e| format!("direct protected message could not be authenticated: {e}"))?;
let signer_id = opened.signer_id;
let message_id = opened.message_id;
let value = opened
.content
.as_container()
.ok_or("direct protected application value is not a container")?;
let text_id = DataTypeId(
tm.data_id_enum(DataType::ExampleText)
.ok_or("missing ExampleText type mapping")?,
);
let number_id = DataTypeId(
tm.data_id_enum(DataType::ExampleNumber)
.ok_or("missing ExampleNumber type mapping")?,
);
let text = value
.iter()
.find(|(id, _)| *id == text_id)
.and_then(|(_, value)| value.as_str())
.ok_or("direct protected value is missing ExampleText")?;
let number = value
.iter()
.find(|(id, _)| *id == number_id)
.and_then(|(_, value)| value.as_unsigned_number())
.ok_or("direct protected value is missing ExampleNumber")?;
println!(
" Direct protected message: signer={signer_id}, message_id={message_id}, transport_key_available={}, ExampleText={text:?}, ExampleNumber={number}",
client_pk.is_some()
);
if client_pk.is_none() {
println!(
" Protected signer was verified from the registered key map; transport is unauthenticated"
);
} }
pong(
tm,
format!("direct protected value verified for signer {signer_id}"),
)
}
fn process_sealed_relay(
msg: &CommunicationValue,
registered_clients: &HashMap<u64, PublicKeyBundle>,
host_keyring: &Keyring,
accepted_messages: &mut InMemoryReplayGuard,
) -> Result<CommunicationValue, String> {
if msg.receiver() != Some(METADATA_RELAY_ID) {
return Err(format!(
"sealed relay next hop was {:?}, expected metadata relay {METADATA_RELAY_ID}",
msg.receiver()
));
}
let metadata = open_relay_metadata_with_checked(
msg,
std::slice::from_ref(&host_keyring),
None,
|signer_id| {
resolve_signer_key(signer_id, registered_clients).map(|key| vec![key])
},
RelayOpenOptions::new(SIGNATURE_POLICY),
accepted_messages,
)
.map_err(|e| format!("metadata relay could not authenticate metadata: {e}"))?;
println!(
" Metadata relay opened message_id={} signer={} final_recipient={} metadata={:?}",
metadata.message_id(),
metadata.signer_id(),
metadata.final_recipient_id(),
metadata.metadata()
);
println!(
" Metadata relay retained opaque encrypted content ({} bytes)",
metadata
.encrypted_content()
.to_bytes()
.map_err(|e| format!("opaque content serialization failed: {e}"))?
.len()
);
let content_result = open_relay_content_with_limits_without_replay(
&metadata,
&[host_keyring],
&[resolve_signer_key(metadata.signer_id(), registered_clients)
.ok_or("metadata signer key disappeared")?],
Some(FINAL_RECIPIENT_ID),
RelayOpenOptions::new(SIGNATURE_POLICY),
);
if content_result.is_ok() {
return Err("metadata relay unexpectedly decrypted final-recipient content".into());
}
println!(" Metadata relay cannot decrypt final-recipient content (expected)");
forward_relay_frame(msg, metadata.final_recipient_id())
.map_err(|e| format!("metadata relay forwarding failed: {e}"))
} }
pub fn process_and_respond( pub fn process_and_respond(
msg: &CommunicationValue, msg: &CommunicationValue,
tm: &TypeMap, tm: &TypeMap,
client_pk: Option<&mtp::crypto::PublicKeyBundle>, client_pk: Option<&mtp::crypto::PublicKeyBundle>,
registered_clients: &HashMap<u64, PublicKeyBundle>,
host_keyring: &Keyring, host_keyring: &Keyring,
) -> CommunicationValue { accepted_direct_messages: &mut InMemoryReplayGuard,
let desc_id = DataTypeId(tm.data_id_enum(DataType::Description).unwrap()); accepted_relay_messages: &mut InMemoryReplayGuard,
let ts_id = DataTypeId(tm.data_id_enum(DataType::Timestamp).unwrap()); ) -> Result<CommunicationValue, String> {
let data_id = DataTypeId(tm.data_id_enum(DataType::Data).unwrap()); if msg.is_type(CommunicationType::ProtectedMessage) {
let flags_id = DataTypeId(tm.data_id_enum(DataType::Flags).unwrap()); return process_direct_protected(
let value_id = DataTypeId(tm.data_id_enum(DataType::Value).unwrap()); msg,
let bin_id = DataTypeId(tm.data_id_enum(DataType::BinaryData).unwrap()); tm,
let items_id = DataTypeId(tm.data_id_enum(DataType::Items).unwrap()); client_pk,
let _enc_id = DataTypeId(tm.data_id_enum(DataType::EncryptedPayload).unwrap()); registered_clients,
let _sig_id = DataTypeId(tm.data_id_enum(DataType::SignedPayload).unwrap()); host_keyring,
let _secure_id = DataTypeId(tm.data_id_enum(DataType::SecurePayload).unwrap()); accepted_direct_messages,
);
}
if msg.is_type(CommunicationType::Relay) {
return process_sealed_relay(
msg,
registered_clients,
host_keyring,
accepted_relay_messages,
);
}
let description = msg.get_data(DataType::Description); let desc_id = DataTypeId(
let timestamp = msg.get_data(DataType::Timestamp); tm.data_id_enum(DataType::Description)
let data = msg.get_data(DataType::Data); .ok_or("missing Description type mapping")?,
let flags = msg.get_data(DataType::Flags); );
let value = msg.get_data(DataType::Value); let ts_id = DataTypeId(
let binary = msg.get_data(DataType::BinaryData); tm.data_id_enum(DataType::Timestamp)
let items = msg.get_data(DataType::Items); .ok_or("missing Timestamp type mapping")?,
);
let data_id = DataTypeId(
tm.data_id_enum(DataType::Data)
.ok_or("missing Data type mapping")?,
);
let flags_id = DataTypeId(
tm.data_id_enum(DataType::Flags)
.ok_or("missing Flags type mapping")?,
);
let value_id = DataTypeId(
tm.data_id_enum(DataType::Value)
.ok_or("missing Value type mapping")?,
);
let bin_id = DataTypeId(
tm.data_id_enum(DataType::BinaryData)
.ok_or("missing BinaryData type mapping")?,
);
let items_id = DataTypeId(
tm.data_id_enum(DataType::Items)
.ok_or("missing Items type mapping")?,
);
let _enc_id = DataTypeId(
tm.data_id_enum(DataType::EncryptedPayload)
.ok_or("missing EncryptedPayload type mapping")?,
);
let _sig_id = DataTypeId(
tm.data_id_enum(DataType::SignedPayload)
.ok_or("missing SignedPayload type mapping")?,
);
let _secure_id = DataTypeId(
tm.data_id_enum(DataType::SecurePayload)
.ok_or("missing SecurePayload type mapping")?,
);
let description = msg
.get_data(DataType::Description)
.cloned()
.unwrap_or(DataValue::Null);
let timestamp = msg
.get_data(DataType::Timestamp)
.cloned()
.unwrap_or(DataValue::Null);
let data = msg
.get_data(DataType::Data)
.cloned()
.unwrap_or(DataValue::Null);
let flags = msg
.get_data(DataType::Flags)
.cloned()
.unwrap_or(DataValue::Null);
let value = msg
.get_data(DataType::Value)
.cloned()
.unwrap_or(DataValue::Null);
let binary = msg
.get_data(DataType::BinaryData)
.cloned()
.unwrap_or(DataValue::Null);
let items = msg
.get_data(DataType::Items)
.cloned()
.unwrap_or(DataValue::Null);
println!( println!(
" Description: {}", " Description: {}",
@ -54,10 +293,8 @@ pub fn process_and_respond(
let mut sig_status = String::from("SignedPayload: not present"); let mut sig_status = String::from("SignedPayload: not present");
let mut secure_status = String::from("SecurePayload: not present"); let mut secure_status = String::from("SecurePayload: not present");
let enc = msg.get_data(DataType::EncryptedPayload); if let Some(enc @ DataValue::Encrypted(_)) = msg.get_data(DataType::EncryptedPayload) {
if matches!(enc, DataValue::EncryptedContainer(_)) { if let Ok(dv) = enc.decrypt(host_keyring, mtp::codec::ProtectionPurpose::from(1)) {
let mut dv = enc.clone();
if dv.decrypt_into_container(host_keyring, b"demo-aad").is_some() {
if let Some(entries) = dv.as_container() { if let Some(entries) = dv.as_container() {
println!(" Decrypted EncryptedPayload: {:?}", entries); println!(" Decrypted EncryptedPayload: {:?}", entries);
enc_status = format!("EncryptedPayload decrypted OK ({} entries)", entries.len()); enc_status = format!("EncryptedPayload decrypted OK ({} entries)", entries.len());
@ -68,13 +305,29 @@ pub fn process_and_respond(
} }
} }
let sig = msg.get_data(DataType::SignedPayload); if let Some(sig @ DataValue::Signed(_)) = msg.get_data(DataType::SignedPayload) {
if matches!(sig, DataValue::SignedContainer(_)) {
if let Some(pk_bundle) = client_pk { if let Some(pk_bundle) = client_pk {
let verifier = Ed25519Verifier(pk_bundle.sig_cl_public_key.clone()); let signer_id = sig.as_signed().map(|signed| signed.signer_id);
let mut dv = sig.clone(); if let Some(signer_id) = signer_id
if dv.verify_into_container(&verifier).is_some() { && sig
if let Some(entries) = dv.as_container() { .verify_with_policy(
signer_id,
pk_bundle,
mtp::codec::ProtectionPurpose::from(2),
SIGNATURE_POLICY,
)
.is_ok()
{
let dv = sig
.clone()
.into_verified_with_policy(
signer_id,
pk_bundle,
mtp::codec::ProtectionPurpose::from(2),
SIGNATURE_POLICY,
)
.ok();
if let Some(entries) = dv.and_then(|value| value.as_container()) {
println!(" Verified SignedPayload: {:?}", entries); println!(" Verified SignedPayload: {:?}", entries);
sig_status = format!("SignedPayload verified OK ({} entries)", entries.len()); sig_status = format!("SignedPayload verified OK ({} entries)", entries.len());
} }
@ -88,15 +341,29 @@ pub fn process_and_respond(
} }
} }
let secure = msg.get_data(DataType::SecurePayload); if let Some(secure @ DataValue::Encrypted(_)) = msg.get_data(DataType::SecurePayload) {
if matches!(secure, DataValue::SignedEncryptedContainer(_)) {
if let Some(pk_bundle) = client_pk { if let Some(pk_bundle) = client_pk {
let verifier = Ed25519Verifier(pk_bundle.sig_cl_public_key.clone()); if let Ok(opened) = secure.decrypt(host_keyring, mtp::codec::ProtectionPurpose::from(4))
let mut dv = secure.clone(); && let Some(signed) = opened.as_signed()
if dv.decrypt_signed_encrypted_container(host_keyring, b"demo-aad").is_some() && opened
&& dv.verify_into_container(&verifier).is_some() .verify_with_policy(
signed.signer_id,
pk_bundle,
mtp::codec::ProtectionPurpose::from(3),
SIGNATURE_POLICY,
)
.is_ok()
{ {
if let Some(entries) = dv.as_container() { let signer_id = signed.signer_id;
let dv = opened
.into_verified_with_policy(
signer_id,
pk_bundle,
mtp::codec::ProtectionPurpose::from(3),
SIGNATURE_POLICY,
)
.ok();
if let Some(entries) = dv.and_then(|value| value.as_container()) {
println!(" Verified SecurePayload: {:?}", entries); println!(" Verified SecurePayload: {:?}", entries);
secure_status = format!( secure_status = format!(
"SecurePayload decrypted+verified OK ({} entries)", "SecurePayload decrypted+verified OK ({} entries)",
@ -115,12 +382,14 @@ pub fn process_and_respond(
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap() .map_err(|e| e.to_string())?
.as_secs(); .as_millis();
CommunicationValue::from_comm(CommunicationType::Pong, tm) let response = CommunicationValue::from_comm(CommunicationType::Pong, tm)
.add_data(desc_id, description.clone()) .add_data(desc_id, description)
.add_data(ts_id, DataValue::UnsignedNumber(now as u128)) .map_err(|e| e.to_string())?
.add_data(ts_id, DataValue::UnsignedNumber(now))
.map_err(|e| e.to_string())?
.add_data( .add_data(
data_id, data_id,
DataValue::Str(format!( DataValue::Str(format!(
@ -128,8 +397,14 @@ pub fn process_and_respond(
enc_status, sig_status, secure_status enc_status, sig_status, secure_status
)), )),
) )
.add_data(flags_id, flags.clone()) .map_err(|e| e.to_string())?
.add_data(value_id, value.clone()) .add_data(flags_id, flags)
.add_data(bin_id, binary.clone()) .map_err(|e| e.to_string())?
.add_data(items_id, items.clone()) .add_data(value_id, value)
.map_err(|e| e.to_string())?
.add_data(bin_id, binary)
.map_err(|e| e.to_string())?
.add_data(items_id, items)
.map_err(|e| e.to_string())?;
Ok(response)
} }

View file

@ -1,50 +1,35 @@
use std::fs; use tokio::fs;
use mtp::crypto::kem::HybridKem; use mtp::crypto::Keyring;
use mtp::crypto::{Ed25519Signer, Keyring, MlDsaSigner}; use mtp::files::{load_keyring_raw, save_keyring_raw, save_public_key_bundle};
/* Host id is fixed for the example; only the keyring itself is persisted. */
const HOST_ID: u64 = 1;
pub fn load_or_generate_host_keys( pub fn load_or_generate_host_keys(
path: &str, keyring_path: &str,
) -> Result<(u64, Keyring), Box<dyn std::error::Error>> { ) -> Result<(u64, Keyring), Box<dyn std::error::Error>> {
if let Ok(data) = fs::read_to_string(path) { if let Ok(keyring) = load_keyring_raw(keyring_path) {
let json: serde_json::Value = serde_json::from_str(&data)?; println!("Loaded host keyring from {keyring_path}");
let hid = json["host_id"].as_u64().unwrap_or(1); return Ok((HOST_ID, keyring));
let keyring = Keyring::from_bytes(&hex::decode(json["keyring"].as_str().unwrap())?)?;
println!("Loaded host keys (ID: {})", hid);
return Ok((hid, keyring));
} }
let (_ed_signer, sig_sk, sig_pk) = Ed25519Signer::generate(); let keyring = Keyring::generate();
let (_pq_signer, sig_pq_sk, sig_pq_pk) = MlDsaSigner::generate(); save_keyring_raw(&keyring, keyring_path)?;
let (kem_sk, kem_pk) = HybridKem::generate_keypair(); println!("Generated host keyring -> {keyring_path}");
let keyring = Keyring::new(kem_pk, kem_sk, sig_pq_pk, sig_pq_sk, sig_pk, sig_sk); Ok((HOST_ID, keyring))
let json = serde_json::json!({
"host_id": 1,
"keyring": hex::encode(keyring.to_bytes()),
});
fs::write(path, serde_json::to_string_pretty(&json)?)?;
println!("Generated host keys -> {path}");
Ok((1u64, keyring))
} }
pub fn export_host_public_keys(host_keyring: &Keyring) -> Result<(), Box<dyn std::error::Error>> { pub async fn export_host_public_keys(
let public_key_bundle_hex = hex::encode(host_keyring.public_key_bundle().as_bytes()); host_keyring: &Keyring,
) -> Result<(), Box<dyn std::error::Error>> {
let bundle = host_keyring.public_key_bundle();
save_public_key_bundle(&bundle, "host.mpkb")?;
fs::write("host_public_key_bundle.hex", &public_key_bundle_hex)?; /* The web client fetches the bundle as hex over HTTP. */
fs::create_dir_all("web-client/public")?; let bundle_hex = hex::encode(bundle.try_as_bytes()?);
fs::write( fs::write("host_public_key_bundle.hex", &bundle_hex).await?;
"web-client/public/host_public_key_bundle.hex", fs::create_dir_all("web-client/public").await?;
&public_key_bundle_hex, fs::write("web-client/public/host_public_key_bundle.hex", &bundle_hex).await?;
)?;
fs::write(
"host_enc_kem_pk.bin",
host_keyring.kem_public_key.as_bytes(),
)?;
fs::write("host_sig_pk.bin", host_keyring.sig_cl_public_key.as_bytes())?;
fs::write(
"host_sig_pq_pk.bin",
host_keyring.sig_pq_public_key.as_bytes(),
)?;
Ok(()) Ok(())
} }

View file

@ -1,13 +1,20 @@
mod clients; mod clients;
mod handlers; mod handlers;
mod keys; mod keys;
mod metrics;
mod tls; mod tls;
#[path = "web-server.rs"]
mod web_server;
use mtp::host::{HostConfig, MTPHost}; use mtp::host::{AuthenticationPolicy, AuthState, HostConfig};
use mtp::type_map::TypeMap; use mtp::type_map::TypeMap;
use std::future::Future; use std::future::Future;
use std::path::Path; use std::path::Path;
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc;
const CONNECTION_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
const MAX_MESSAGES_PER_CONNECTION: u64 = 10_000;
fn dev_cert_paths() -> (String, String) { fn dev_cert_paths() -> (String, String) {
let cert = std::env::var("MTP_DEV_CERT").unwrap_or_else(|_| { let cert = std::env::var("MTP_DEV_CERT").unwrap_or_else(|_| {
@ -27,64 +34,99 @@ fn dev_cert_paths() -> (String, String) {
(cert, key) (cert, key)
} }
#[tokio::main] async fn handle_pipe_loopback(
async fn main() -> Result<(), Box<dyn std::error::Error>> { conn: &mtp::webserver::WebMTPConnection,
let (cert_path, key_path) = dev_cert_paths(); request: mtp::host::PipeRequest<
let (cert_pem, key_pem) = tls::load_or_generate_tls(&cert_path, &key_path)?; mtp::webserver::WebMtpSender,
let cert_hash = tls::certificate_sha256_hex(&cert_pem)?; mtp::webserver::WebMtpReceiver,
tls::export_webtransport_cert_hash(&cert_hash)?; mtp::webserver::H3TransportReceiver,
println!("WebTransport certificate sha256: {cert_hash}"); >,
) -> Result<u64, Box<dyn std::error::Error>> {
let pipe_id = request.id();
println!(" [loopback] Accepting pipe {pipe_id} ...");
let mut reader = request.accept().await?;
let (_host_id, host_keyring) = keys::load_or_generate_host_keys("host_keys.json")?; let return_pipe = conn.create_pipe("loopback").await?;
keys::export_host_public_keys(&host_keyring)?; println!(
" [loopback] Requested return pipe {}; waiting for client acceptance ...",
// The keyring is moved into the host config; keep a copy for decrypting the return_pipe.pipe_id()
// demo payloads clients encrypt to our KEM public key. );
let decrypt_keyring = mtp::crypto::Keyring::from_bytes(&host_keyring.to_bytes()) let Some(mut writer) = return_pipe.wait().await? else {
.expect("re-load host keyring for decryption"); return Err("client denied the return pipe".into());
let (clients, next_id) = clients::load_client_db("clients.json")?;
let clients_for_get = clients.clone();
let get_existing_user = move |id: u64| {
let clients = clients_for_get.clone();
Box::pin(async move {
let result = clients.lock().unwrap().get(&id).cloned();
if result.is_some() {
println!("Auth lookup: client ID {id} found");
} else {
eprintln!("Auth lookup: unknown client ID {id}");
}
result
}) as Pin<Box<dyn Future<Output = Option<mtp::crypto::PublicKeyBundle>> + Send>>
}; };
let copied = tokio::io::copy(&mut reader, &mut writer).await?;
writer.finish_async().await?;
println!(" [loopback] Pipe {pipe_id} complete ({copied} bytes)");
Ok(copied)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt::init();
let (cert_path, key_path) = dev_cert_paths();
let (cert_pem, key_pem) = tls::load_or_generate_tls(&cert_path, &key_path).await?;
let cert_hash = tls::certificate_sha256_hex(&cert_pem).await?;
tls::export_webtransport_cert_hash(&cert_hash).await?;
println!("WebTransport certificate sha256: {cert_hash}");
let (_host_id, host_keyring) = keys::load_or_generate_host_keys("host.mk")?;
keys::export_host_public_keys(&host_keyring).await?;
let (clients, next_id) = clients::load_client_db("clients.json").await?;
let clients_for_get = clients.clone();
let get_existing_client = move |id: u64, _description: Option<String>| {
let clients = clients_for_get.clone();
Box::pin(async move { clients.lock().ok()?.get(&id).cloned() })
as Pin<Box<dyn Future<Output = Option<mtp::crypto::PublicKeyBundle>> + Send>>
};
let clients_for_register = clients.clone(); let clients_for_register = clients.clone();
let next_id_for_register = next_id.clone(); let next_id_for_register = next_id.clone();
let clients_path = "clients.json".to_string(); let complete_register = move |bundle: mtp::crypto::PublicKeyBundle,
let complete_register = move |bundle: mtp::crypto::PublicKeyBundle| { _description: Option<String>| {
let db_arc = clients_for_register.clone(); let clients = clients_for_register.clone();
let nid_arc = next_id_for_register.clone(); let next_id = next_id_for_register.clone();
let path = clients_path.clone();
Box::pin(async move { Box::pin(async move {
let mut db = db_arc.lock().unwrap(); let id = {
let mut nid = nid_arc.lock().unwrap(); let mut next = next_id.lock().expect("client id mutex poisoned");
let id = *nid; let id = *next;
*nid += 1; *next += 1;
id
};
let json = {
let mut db = clients.lock().expect("client database mutex poisoned");
db.insert(id, bundle); db.insert(id, bundle);
match serde_json::to_string_pretty(&*db) { serde_json::to_string_pretty(&*db).ok()
Ok(json) => match std::fs::write(&path, json) { };
Ok(()) => {}
Err(e) => eprintln!("Failed to persist client database to {path}: {e}"), if let Some(json) = json
}, && let Err(error) = tokio::fs::write("clients.json", json).await
Err(e) => eprintln!("Failed to serialize client database after registering {id}: {e}"), {
eprintln!("Failed to persist clients.json: {error}");
} }
println!("Registered new client with ID: {}", id);
println!("Registered new client with ID: {id}");
id id
}) as Pin<Box<dyn Future<Output = u64> + Send>> }) as Pin<Box<dyn Future<Output = u64> + Send>>
}; };
println!("Starting MTP server on port 8080 ..."); let decrypt_keyring_bytes = host_keyring.try_to_bytes()?;
let decrypt_keyring = Arc::new(
match mtp::crypto::Keyring::from_bytes(&decrypt_keyring_bytes) {
Ok(keyring) => keyring,
Err(e) => {
return Err(format!("failed to re-load host keyring for decryption: {e}").into());
}
},
);
let metrics = std::sync::Arc::new(metrics::ServerMetrics::load(
"metrics/server_sessions.json",
));
println!("Starting integrated MTP web server on port 8080 ...");
let config = HostConfig::new( let config = HostConfig::new(
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
@ -92,39 +134,169 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
cert_pem, cert_pem,
key_pem, key_pem,
) )
.with_authentication(host_keyring, get_existing_user, complete_register); .with_authentication(
host_keyring,
Box::new(get_existing_client),
Box::new(complete_register),
)
.with_authentication_policy(AuthenticationPolicy::AllowAuthentication);
let mut host = MTPHost::new(config).await?; let mut host = mtp::webserver::MTPWebServer::new(config, web_server::config()?).await?;
println!("Server listening on {}", host.local_addr()); println!("Server listening on https://{}", host.local_addr());
println!("TCP: HTTP/1.1 and HTTP/2");
println!("UDP: HTTP/3 and WebTransport");
while let Some(conn) = host.accept().await? { loop {
let conn = match host.accept().await {
Ok(Some(conn)) => conn,
Ok(None) => break,
Err(e) => {
let msg = e.to_string();
eprintln!("Accept error: {msg}");
metrics.record_accept_error();
metrics.save("metrics/server_sessions.json");
metrics.build_overview("metrics/server_overview.json");
continue;
}
};
let decrypt_keyring = Arc::clone(&decrypt_keyring);
let metrics = Arc::clone(&metrics);
let registered_clients = Arc::clone(&clients);
metrics.record_connection_version(&conn.version.to_string());
tokio::spawn(async move {
let desc = conn.description.as_deref().unwrap_or("(no description)");
let connection_state = match &conn.auth_state {
AuthState::Authenticated => "authenticated client",
AuthState::Unauthenticated => "unauthenticated client",
AuthState::Pending => "pending client",
AuthState::Failed => "failed client",
};
println!( println!(
"\n--- New authenticated connection (version {}) ---", "\n--- New connection (version {}, remote: {}, description: {desc}) ---",
conn.version conn.version,
conn.remote_addr
.map(|addr| addr.to_string())
.unwrap_or_else(|| "unknown".into())
); );
println!("Client ID: {}", conn.client_id); println!("Connection state: {connection_state}; MTP ID: {}", conn.client_id);
let mut session = metrics.start_session(conn.client_id, desc.to_string());
let tm: &TypeMap = conn.codec.registry().get(&conn.version).unwrap(); let tm: &TypeMap = conn.codec.registry().get(&conn.version).unwrap();
match conn.receiver.receive().await { println!("Waiting for messages / pipe requests ...");
Ok(msg) => { let mut pipe_open = true;
println!("Received: {msg}"); let mut message_open = true;
let response = handlers::process_and_respond( let mut accepted_direct_messages = mtp::codec::InMemoryReplayGuard::default();
&msg, let mut accepted_relay_messages = mtp::codec::InMemoryReplayGuard::default();
let mut exit_reason = "normal".to_string();
while pipe_open || message_open {
let activity = tokio::time::timeout(CONNECTION_IDLE_TIMEOUT, async {
tokio::select! {
biased;
pipe_request = conn.receive_pipe(), if pipe_open => {
match pipe_request {
Ok(request) => {
match handle_pipe_loopback(&conn, request).await {
Ok(bytes) => {
session.record_pipe(bytes);
}
Err(error) => {
let msg = error.to_string();
if msg.contains("denied") {
session.record_pipe_denial();
}
eprintln!(" [loopback] Pipe error: {msg}");
}
}
}
Err(mtp::common::CommunicationError::StreamClosed)
| Err(mtp::common::CommunicationError::ClosedByPeer) => {
println!("Pipe channel closed normally");
pipe_open = false;
}
Err(error) => {
println!("Pipe channel closed: {error}");
pipe_open = false;
}
}
}
message = conn.receive(), if message_open => {
match message {
Ok(message) => {
println!("Received: {message}");
let msg_start = std::time::Instant::now();
let registered_clients = registered_clients
.lock()
.map(|clients| clients.clone())
.unwrap_or_default();
let result = handlers::process_and_respond(
&message,
tm, tm,
conn.client_public_key.as_ref(), conn.client_public_key.as_ref(),
&registered_clients,
&decrypt_keyring, &decrypt_keyring,
&mut accepted_direct_messages,
&mut accepted_relay_messages,
); );
let latency = msg_start.elapsed();
let ok = result.is_ok();
session.record_message(latency, ok);
match result {
Ok(response) => {
println!("Sending: {response}"); println!("Sending: {response}");
conn.sender.send(&response).await?; if let Err(error) = conn.sender.send(&response).await {
eprintln!("Send error: {error}");
session.record_send_error();
pipe_open = false;
message_open = false;
} }
Err(e) => { }
eprintln!("Receive error: {e}"); Err(error) => {
eprintln!("Failed to build response: {error}");
}
}
}
Err(mtp::common::CommunicationError::StreamClosed)
| Err(mtp::common::CommunicationError::ClosedByPeer) => {
println!("Message channel closed normally");
message_open = false;
}
Err(error) => {
println!("Message channel closed: {error}");
message_open = false;
}
}
}
}
})
.await;
if activity.is_err() {
exit_reason = "idle timeout".to_string();
println!("Connection idle timeout reached");
break;
}
if session.messages_received() >= MAX_MESSAGES_PER_CONNECTION {
exit_reason = "message limit".to_string();
println!("Connection message limit reached");
break;
} }
} }
conn.sender.close(); let record = session.finish(exit_reason);
println!("Connection closed\n"); println!(
"Connection closed (messages: {}, pipes: {}, duration: {:.1}s)\n",
record.messages_received,
record.pipes_handled,
record.duration_secs
);
metrics.save("metrics/server_sessions.json");
metrics.build_overview("metrics/server_overview.json");
});
} }
Ok(()) Ok(())

View file

@ -0,0 +1,885 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
fn now_epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn generate_session_id() -> String {
let ts = now_epoch_secs();
let rand_part: u32 = rand::random();
format!("{ts}-{rand_part:08x}")
}
// ---------------------------------------------------------------------------
// Persisted data types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionRecord {
pub session_id: String,
pub client_id: u64,
pub description: String,
pub start_time: u64,
pub end_time: u64,
pub duration_secs: f64,
pub messages_received: u64,
pub messages_ok: u64,
pub messages_failed: u64,
pub pipes_handled: u64,
pub pipe_bytes_copied: u64,
pub pipe_denials: u64,
pub send_errors: u64,
pub avg_message_latency_ms: f64,
pub max_message_latency_ms: f64,
pub exit_reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AggregateStats {
pub total_connections: u64,
pub total_messages: u64,
pub total_messages_ok: u64,
pub total_messages_failed: u64,
pub total_pipes: u64,
pub total_pipe_bytes: u64,
pub total_pipe_denials: u64,
pub total_send_errors: u64,
pub total_accept_errors: u64,
pub avg_session_duration_secs: f64,
pub avg_messages_per_session: f64,
pub avg_pipes_per_session: f64,
pub avg_message_latency_ms: f64,
pub max_message_latency_ms: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Overview {
pub total_sessions: u64,
pub first_session_timestamp: u64,
pub last_session_timestamp: u64,
pub aggregate: AggregateStats,
pub connection_versions: HashMap<String, u64>,
pub sessions: Vec<SessionRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServerMetricsFile {
pub total_connections: u64,
pub total_messages: u64,
pub total_messages_ok: u64,
pub total_messages_failed: u64,
pub total_pipes: u64,
pub total_pipe_bytes: u64,
pub total_pipe_denials: u64,
pub total_send_errors: u64,
pub total_accept_errors: u64,
pub connection_versions: HashMap<String, u64>,
pub sessions: Vec<SessionRecord>,
}
// ---------------------------------------------------------------------------
// Live metrics state
// ---------------------------------------------------------------------------
struct Inner {
total_connections: u64,
total_messages: u64,
total_messages_ok: u64,
total_messages_failed: u64,
total_pipes: u64,
total_pipe_bytes: u64,
total_pipe_denials: u64,
total_send_errors: u64,
total_accept_errors: u64,
connection_versions: HashMap<String, u64>,
active_connections: u64,
completed_sessions: Vec<SessionRecord>,
}
pub struct ServerMetrics {
inner: Mutex<Inner>,
}
impl ServerMetrics {
#[cfg(test)]
pub fn new() -> Self {
Self {
inner: Mutex::new(Inner {
total_connections: 0,
total_messages: 0,
total_messages_ok: 0,
total_messages_failed: 0,
total_pipes: 0,
total_pipe_bytes: 0,
total_pipe_denials: 0,
total_send_errors: 0,
total_accept_errors: 0,
connection_versions: HashMap::new(),
active_connections: 0,
completed_sessions: Vec::new(),
}),
}
}
pub fn load(path: &str) -> Self {
let file = std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str::<ServerMetricsFile>(&s).ok());
let mut inner = Inner {
total_connections: 0,
total_messages: 0,
total_messages_ok: 0,
total_messages_failed: 0,
total_pipes: 0,
total_pipe_bytes: 0,
total_pipe_denials: 0,
total_send_errors: 0,
total_accept_errors: 0,
connection_versions: HashMap::new(),
active_connections: 0,
completed_sessions: Vec::new(),
};
if let Some(data) = file {
inner.total_connections = data.total_connections;
inner.total_messages = data.total_messages;
inner.total_messages_ok = data.total_messages_ok;
inner.total_messages_failed = data.total_messages_failed;
inner.total_pipes = data.total_pipes;
inner.total_pipe_bytes = data.total_pipe_bytes;
inner.total_pipe_denials = data.total_pipe_denials;
inner.total_send_errors = data.total_send_errors;
inner.total_accept_errors = data.total_accept_errors;
inner.connection_versions = data.connection_versions;
inner.completed_sessions = data.sessions;
}
Self {
inner: Mutex::new(inner),
}
}
pub fn save(&self, path: &str) {
let inner = self.inner.lock().unwrap();
let data = self.to_file(&inner);
if let Some(parent) = Path::new(path).parent() {
let _ = std::fs::create_dir_all(parent);
}
let json = serde_json::to_string_pretty(&data).unwrap_or_default();
let _ = std::fs::write(path, json);
}
fn to_file(&self, inner: &Inner) -> ServerMetricsFile {
ServerMetricsFile {
total_connections: inner.total_connections,
total_messages: inner.total_messages,
total_messages_ok: inner.total_messages_ok,
total_messages_failed: inner.total_messages_failed,
total_pipes: inner.total_pipes,
total_pipe_bytes: inner.total_pipe_bytes,
total_pipe_denials: inner.total_pipe_denials,
total_send_errors: inner.total_send_errors,
total_accept_errors: inner.total_accept_errors,
connection_versions: inner.connection_versions.clone(),
sessions: inner.completed_sessions.clone(),
}
}
pub fn start_session(&self, client_id: u64, description: String) -> SessionHandle<'_> {
let session_id = generate_session_id();
let start = Instant::now();
let start_time = now_epoch_secs();
self.inner.lock().unwrap().total_connections += 1;
self.inner.lock().unwrap().active_connections += 1;
SessionHandle {
metrics: self,
session_id,
client_id,
description,
start,
start_time,
messages_received: 0,
messages_ok: 0,
messages_failed: 0,
pipes_handled: 0,
pipe_bytes: 0,
pipe_denials: 0,
send_errors: 0,
latencies: Vec::new(),
}
}
#[cfg(test)]
pub fn snapshot(&self) -> ServerMetricsFile {
let inner = self.inner.lock().unwrap();
self.to_file(&inner)
}
pub fn record_accept_error(&self) {
self.inner.lock().unwrap().total_accept_errors += 1;
}
pub fn record_connection_version(&self, version: &str) {
*self
.inner
.lock()
.unwrap()
.connection_versions
.entry(version.to_string())
.or_insert(0) += 1;
}
pub fn build_overview(&self, overview_path: &str) {
let inner = self.inner.lock().unwrap();
let sessions = &inner.completed_sessions;
let total = sessions.len() as u64;
if total == 0 {
let overview = Overview {
total_sessions: 0,
first_session_timestamp: 0,
last_session_timestamp: 0,
aggregate: AggregateStats::default(),
connection_versions: HashMap::new(),
sessions: Vec::new(),
};
if let Some(parent) = Path::new(overview_path).parent() {
let _ = std::fs::create_dir_all(parent);
}
let json = serde_json::to_string_pretty(&overview).unwrap_or_default();
let _ = std::fs::write(overview_path, json);
return;
}
let first_ts = sessions.first().map(|s| s.start_time).unwrap_or(0);
let last_ts = sessions.last().map(|s| s.end_time).unwrap_or(0);
let total_duration: f64 = sessions.iter().map(|s| s.duration_secs).sum();
let total_msgs: u64 = sessions.iter().map(|s| s.messages_received).sum();
let total_pipes: u64 = sessions.iter().map(|s| s.pipes_handled).sum();
let mut max_latency: f64 = 0.0;
let mut latency_sum: f64 = 0.0;
let mut latency_count: u64 = 0;
for s in sessions {
if s.avg_message_latency_ms > 0.0 {
latency_sum += s.avg_message_latency_ms * s.messages_ok as f64;
latency_count += s.messages_ok;
}
if s.max_message_latency_ms > max_latency {
max_latency = s.max_message_latency_ms;
}
}
let aggregate = AggregateStats {
total_connections: inner.total_connections,
total_messages: inner.total_messages,
total_messages_ok: inner.total_messages_ok,
total_messages_failed: inner.total_messages_failed,
total_pipes: inner.total_pipes,
total_pipe_bytes: inner.total_pipe_bytes,
total_pipe_denials: inner.total_pipe_denials,
total_send_errors: inner.total_send_errors,
total_accept_errors: inner.total_accept_errors,
avg_session_duration_secs: total_duration / total as f64,
avg_messages_per_session: total_msgs as f64 / total as f64,
avg_pipes_per_session: total_pipes as f64 / total as f64,
avg_message_latency_ms: if latency_count > 0 {
latency_sum / latency_count as f64
} else {
0.0
},
max_message_latency_ms: max_latency,
};
let overview = Overview {
total_sessions: total,
first_session_timestamp: first_ts,
last_session_timestamp: last_ts,
aggregate,
connection_versions: inner.connection_versions.clone(),
sessions: sessions.clone(),
};
if let Some(parent) = Path::new(overview_path).parent() {
let _ = std::fs::create_dir_all(parent);
}
let json = serde_json::to_string_pretty(&overview).unwrap_or_default();
let _ = std::fs::write(overview_path, json);
}
fn finish_session(&self, record: SessionRecord) {
let mut inner = self.inner.lock().unwrap();
inner.active_connections -= 1;
inner.total_messages += record.messages_received;
inner.total_messages_ok += record.messages_ok;
inner.total_messages_failed += record.messages_failed;
inner.total_pipes += record.pipes_handled;
inner.total_pipe_bytes += record.pipe_bytes_copied;
inner.total_pipe_denials += record.pipe_denials;
inner.total_send_errors += record.send_errors;
inner.completed_sessions.push(record);
}
}
// ---------------------------------------------------------------------------
// Session handle, local accumulators, no mutex contention during connection
// ---------------------------------------------------------------------------
pub struct SessionHandle<'a> {
metrics: &'a ServerMetrics,
session_id: String,
client_id: u64,
description: String,
start: Instant,
start_time: u64,
messages_received: u64,
messages_ok: u64,
messages_failed: u64,
pipes_handled: u64,
pipe_bytes: u64,
pipe_denials: u64,
send_errors: u64,
latencies: Vec<f64>,
}
impl<'a> SessionHandle<'a> {
pub fn messages_received(&self) -> u64 {
self.messages_received
}
pub fn record_message(&mut self, latency: Duration, ok: bool) {
self.messages_received += 1;
if ok {
self.messages_ok += 1;
} else {
self.messages_failed += 1;
}
self.latencies.push(latency.as_secs_f64() * 1000.0);
}
pub fn record_pipe(&mut self, bytes: u64) {
self.pipes_handled += 1;
self.pipe_bytes += bytes;
}
pub fn record_pipe_denial(&mut self) {
self.pipe_denials += 1;
}
pub fn record_send_error(&mut self) {
self.send_errors += 1;
}
pub fn finish(self, exit_reason: String) -> SessionRecord {
let elapsed = self.start.elapsed();
let end_time = self.start_time + elapsed.as_secs();
let avg_latency = if self.latencies.is_empty() {
0.0
} else {
self.latencies.iter().sum::<f64>() / self.latencies.len() as f64
};
let max_latency = self.latencies.iter().copied().fold(0.0_f64, f64::max);
let record = SessionRecord {
session_id: self.session_id,
client_id: self.client_id,
description: self.description,
start_time: self.start_time,
end_time,
duration_secs: elapsed.as_secs_f64(),
messages_received: self.messages_received,
messages_ok: self.messages_ok,
messages_failed: self.messages_failed,
pipes_handled: self.pipes_handled,
pipe_bytes_copied: self.pipe_bytes,
pipe_denials: self.pipe_denials,
send_errors: self.send_errors,
avg_message_latency_ms: avg_latency,
max_message_latency_ms: max_latency,
exit_reason,
};
self.metrics.finish_session(record.clone());
record
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn tmp_path(name: &str) -> String {
let dir = std::env::temp_dir().join("mtp_server_metrics_test");
let _ = std::fs::create_dir_all(&dir);
dir.join(name).to_str().unwrap().to_string()
}
#[test]
fn test_session_record_roundtrip() {
let record = SessionRecord {
session_id: "test-123".into(),
client_id: 1000,
description: "test session".into(),
start_time: 1000,
end_time: 1010,
duration_secs: 10.0,
messages_received: 5,
messages_ok: 4,
messages_failed: 1,
pipes_handled: 2,
pipe_bytes_copied: 4096,
pipe_denials: 0,
send_errors: 0,
avg_message_latency_ms: 1.5,
max_message_latency_ms: 3.0,
exit_reason: "normal".into(),
};
let json = serde_json::to_string(&record).unwrap();
let decoded: SessionRecord = serde_json::from_str(&json).unwrap();
assert_eq!(record, decoded);
}
#[test]
fn test_metrics_file_roundtrip() {
let file = ServerMetricsFile {
total_connections: 10,
total_messages: 50,
total_messages_ok: 48,
total_messages_failed: 2,
total_pipes: 5,
total_pipe_bytes: 20480,
total_pipe_denials: 1,
total_send_errors: 0,
total_accept_errors: 3,
connection_versions: HashMap::from([("2.0".into(), 8), ("1.0".into(), 2)]),
sessions: vec![
SessionRecord {
session_id: "s1".into(),
client_id: 1000,
description: "first".into(),
start_time: 100,
end_time: 110,
duration_secs: 10.0,
messages_received: 3,
messages_ok: 3,
messages_failed: 0,
pipes_handled: 1,
pipe_bytes_copied: 1024,
pipe_denials: 0,
send_errors: 0,
avg_message_latency_ms: 0.5,
max_message_latency_ms: 1.0,
exit_reason: "normal".into(),
},
SessionRecord {
session_id: "s2".into(),
client_id: 1001,
description: "second".into(),
start_time: 200,
end_time: 230,
duration_secs: 30.0,
messages_received: 7,
messages_ok: 6,
messages_failed: 1,
pipes_handled: 4,
pipe_bytes_copied: 19456,
pipe_denials: 1,
send_errors: 0,
avg_message_latency_ms: 2.0,
max_message_latency_ms: 5.0,
exit_reason: "idle timeout".into(),
},
],
};
let json = serde_json::to_string_pretty(&file).unwrap();
let decoded: ServerMetricsFile = serde_json::from_str(&json).unwrap();
assert_eq!(file.total_connections, decoded.total_connections);
assert_eq!(file.sessions.len(), decoded.sessions.len());
assert_eq!(file.sessions[0], decoded.sessions[0]);
assert_eq!(file.sessions[1], decoded.sessions[1]);
}
#[test]
fn test_session_handle_lifecycle() {
let metrics = ServerMetrics::new();
let mut session = metrics.start_session(1000, "test".into());
session.record_message(Duration::from_millis(1), true);
session.record_message(Duration::from_millis(3), true);
session.record_message(Duration::from_millis(2), false);
session.record_pipe(512);
let record = session.finish("test exit".into());
assert_eq!(record.client_id, 1000);
assert_eq!(record.messages_received, 3);
assert_eq!(record.messages_ok, 2);
assert_eq!(record.messages_failed, 1);
assert_eq!(record.pipes_handled, 1);
assert_eq!(record.pipe_bytes_copied, 512);
assert!(record.avg_message_latency_ms > 0.0);
assert_eq!(record.max_message_latency_ms, 3.0);
assert_eq!(record.exit_reason, "test exit");
let snap = metrics.snapshot();
assert_eq!(snap.total_connections, 1);
assert_eq!(snap.total_messages, 3);
assert_eq!(snap.total_messages_ok, 2);
assert_eq!(snap.total_messages_failed, 1);
assert_eq!(snap.total_pipes, 1);
assert_eq!(snap.total_pipe_bytes, 512);
assert_eq!(snap.sessions.len(), 1);
}
#[test]
fn test_overview_generation() {
let metrics = ServerMetrics::new();
for i in 0..3 {
let mut session = metrics.start_session(1000 + i, format!("session {i}"));
for _ in 0..(i + 1) * 2 {
session.record_message(Duration::from_millis(1 + i), true);
}
session.record_pipe((i + 1) * 1000);
session.finish(format!("exit {i}"));
}
let overview_path = tmp_path("overview_test.json");
metrics.build_overview(&overview_path);
let json = std::fs::read_to_string(&overview_path).unwrap();
let overview: Overview = serde_json::from_str(&json).unwrap();
assert_eq!(overview.total_sessions, 3);
assert!(overview.first_session_timestamp > 0);
assert!(overview.last_session_timestamp >= overview.first_session_timestamp);
assert_eq!(overview.aggregate.total_connections, 3);
assert_eq!(overview.aggregate.total_messages, 12); // 2+4+6
assert_eq!(overview.aggregate.total_pipes, 3);
assert_eq!(overview.aggregate.total_pipe_bytes, 6000); // 1000+2000+3000
assert!(overview.aggregate.avg_session_duration_secs >= 0.0);
assert_eq!(overview.sessions.len(), 3);
let _ = std::fs::remove_file(&overview_path);
}
#[test]
fn test_load_missing_file() {
let metrics = ServerMetrics::load("/nonexistent/path/metrics.json");
let snap = metrics.snapshot();
assert_eq!(snap.total_connections, 0);
assert!(snap.sessions.is_empty());
}
#[test]
fn test_multiple_sessions_accumulate() {
let path = tmp_path("accumulate_test.json");
let metrics = ServerMetrics::load(&path);
for i in 0..5 {
let mut session = metrics.start_session(1000, format!("s{i}"));
session.record_message(Duration::from_millis(1), true);
session.record_pipe(100);
session.finish(format!("done {i}"));
}
metrics.save(&path);
let metrics2 = ServerMetrics::load(&path);
let snap = metrics2.snapshot();
assert_eq!(snap.total_connections, 5);
assert_eq!(snap.total_messages, 5);
assert_eq!(snap.total_messages_ok, 5);
assert_eq!(snap.total_pipes, 5);
assert_eq!(snap.total_pipe_bytes, 500);
assert_eq!(snap.sessions.len(), 5);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_overview_latencies() {
let metrics = ServerMetrics::new();
let mut s1 = metrics.start_session(1000, "s1".into());
s1.record_message(Duration::from_millis(2), true);
s1.record_message(Duration::from_millis(4), true);
s1.finish("done".into());
let mut s2 = metrics.start_session(1001, "s2".into());
s2.record_message(Duration::from_millis(1), true);
s2.finish("done".into());
let overview_path = tmp_path("latency_overview.json");
metrics.build_overview(&overview_path);
let json = std::fs::read_to_string(&overview_path).unwrap();
let overview: Overview = serde_json::from_str(&json).unwrap();
// s1 avg = 3.0, s2 avg = 1.0
// weighted avg = (3*2 + 1*1) / 3 = 7/3 ≈ 2.333
assert!(
(overview.aggregate.avg_message_latency_ms - 7.0 / 3.0).abs() < 0.01,
"avg latency: {}",
overview.aggregate.avg_message_latency_ms
);
assert_eq!(overview.aggregate.max_message_latency_ms, 4.0);
let _ = std::fs::remove_file(&overview_path);
}
#[test]
fn test_overview_empty() {
let metrics = ServerMetrics::new();
let overview_path = tmp_path("empty_overview.json");
metrics.build_overview(&overview_path);
let json = std::fs::read_to_string(&overview_path).unwrap();
let overview: Overview = serde_json::from_str(&json).unwrap();
assert_eq!(overview.total_sessions, 0);
assert!(overview.sessions.is_empty());
let _ = std::fs::remove_file(&overview_path);
}
// -----------------------------------------------------------------------
// Integration-style tests
// -----------------------------------------------------------------------
#[test]
fn test_full_session_lifecycle() {
let path = tmp_path("lifecycle.json");
let overview_path = tmp_path("lifecycle_overview.json");
let metrics = ServerMetrics::load(&path);
let mut s1 = metrics.start_session(1000, "first".into());
s1.record_message(Duration::from_millis(1), true);
s1.record_message(Duration::from_millis(2), true);
let r1 = s1.finish("normal".into());
let mut s2 = metrics.start_session(1001, "second".into());
s2.record_message(Duration::from_millis(5), true);
s2.record_message(Duration::from_millis(3), false);
s2.record_pipe(2048);
s2.record_pipe(4096);
let r2 = s2.finish("idle timeout".into());
let mut s3 = metrics.start_session(1002, "third".into());
s3.record_pipe(1024);
let r3 = s3.finish("normal".into());
assert_eq!(r1.client_id, 1000);
assert_eq!(r1.messages_received, 2);
assert_eq!(r1.messages_ok, 2);
assert_eq!(r1.pipes_handled, 0);
assert_eq!(r2.client_id, 1001);
assert_eq!(r2.messages_received, 2);
assert_eq!(r2.messages_ok, 1);
assert_eq!(r2.messages_failed, 1);
assert_eq!(r2.pipes_handled, 2);
assert_eq!(r2.pipe_bytes_copied, 6144);
assert_eq!(r2.exit_reason, "idle timeout");
assert_eq!(r3.client_id, 1002);
assert_eq!(r3.messages_received, 0);
assert_eq!(r3.pipes_handled, 1);
assert_eq!(r3.pipe_bytes_copied, 1024);
let snap = metrics.snapshot();
assert_eq!(snap.total_connections, 3);
assert_eq!(snap.total_messages, 4);
assert_eq!(snap.total_messages_ok, 3);
assert_eq!(snap.total_messages_failed, 1);
assert_eq!(snap.total_pipes, 3);
assert_eq!(snap.total_pipe_bytes, 7168);
assert_eq!(snap.sessions.len(), 3);
metrics.save(&path);
let metrics2 = ServerMetrics::load(&path);
let snap2 = metrics2.snapshot();
assert_eq!(snap2.total_connections, 3);
assert_eq!(snap2.total_messages, 4);
assert_eq!(snap2.sessions.len(), 3);
assert_eq!(snap2.sessions[1].exit_reason, "idle timeout");
metrics2.build_overview(&overview_path);
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
let overview: Overview = serde_json::from_str(&overview_json).unwrap();
assert_eq!(overview.total_sessions, 3);
assert_eq!(overview.aggregate.total_connections, 3);
assert_eq!(overview.aggregate.total_messages, 4);
assert_eq!(overview.aggregate.total_messages_ok, 3);
assert_eq!(overview.aggregate.total_messages_failed, 1);
assert_eq!(overview.aggregate.total_pipes, 3);
assert_eq!(overview.aggregate.total_pipe_bytes, 7168);
assert!(overview.aggregate.avg_session_duration_secs >= 0.0);
assert!(overview.aggregate.avg_messages_per_session > 0.0);
assert_eq!(overview.sessions.len(), 3);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&overview_path);
}
#[test]
fn test_overview_rebuild_accuracy() {
let path = tmp_path("accuracy.json");
let overview_path = tmp_path("accuracy_overview.json");
let metrics = ServerMetrics::load(&path);
for i in 0..10u32 {
let mut session = metrics.start_session(1000 + i as u64, format!("session {i}"));
let msg_count = (i + 1) * 2;
for j in 0..msg_count {
session.record_message(Duration::from_millis((j + 1) as u64), j % 3 != 0);
}
session.record_pipe((i as u64 + 1) * 512);
session.finish(format!("exit {i}"));
}
metrics.save(&path);
let metrics2 = ServerMetrics::load(&path);
metrics2.build_overview(&overview_path);
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
let overview: Overview = serde_json::from_str(&overview_json).unwrap();
assert_eq!(overview.total_sessions, 10);
assert_eq!(overview.aggregate.total_connections, 10);
assert_eq!(overview.aggregate.total_messages, 110);
assert_eq!(overview.aggregate.total_pipes, 10);
assert_eq!(overview.aggregate.total_pipe_bytes, 28160);
assert!(overview.aggregate.avg_session_duration_secs >= 0.0);
assert!((overview.aggregate.avg_messages_per_session - 11.0).abs() < 0.01);
assert!((overview.aggregate.avg_pipes_per_session - 1.0).abs() < 0.01);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&overview_path);
}
#[test]
fn test_persistence_across_instances() {
let path = tmp_path("persistence.json");
let overview_path = tmp_path("persistence_overview.json");
{
let metrics = ServerMetrics::load(&path);
let mut s1 = metrics.start_session(1000, "inst1-s1".into());
s1.record_message(Duration::from_millis(10), true);
s1.record_pipe(100);
s1.finish("done".into());
let mut s2 = metrics.start_session(1001, "inst1-s2".into());
s2.record_message(Duration::from_millis(20), true);
s2.finish("done".into());
metrics.save(&path);
metrics.build_overview(&overview_path);
}
{
let metrics = ServerMetrics::load(&path);
let snap = metrics.snapshot();
assert_eq!(snap.sessions.len(), 2);
assert_eq!(snap.total_connections, 2);
let mut s3 = metrics.start_session(1002, "inst2-s1".into());
s3.record_message(Duration::from_millis(5), true);
s3.record_pipe(200);
s3.record_pipe(300);
s3.finish("done".into());
metrics.save(&path);
metrics.build_overview(&overview_path);
}
let metrics = ServerMetrics::load(&path);
let snap = metrics.snapshot();
assert_eq!(snap.sessions.len(), 3);
assert_eq!(snap.total_connections, 3);
assert_eq!(snap.total_messages, 3);
assert_eq!(snap.total_messages_ok, 3);
assert_eq!(snap.total_pipes, 3);
assert_eq!(snap.total_pipe_bytes, 600);
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
let overview: Overview = serde_json::from_str(&overview_json).unwrap();
assert_eq!(overview.total_sessions, 3);
assert_eq!(overview.sessions[0].description, "inst1-s1");
assert_eq!(overview.sessions[1].description, "inst1-s2");
assert_eq!(overview.sessions[2].description, "inst2-s1");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&overview_path);
}
#[test]
fn test_accept_errors_and_versions() {
let path = tmp_path("accept_errors.json");
let overview_path = tmp_path("accept_errors_overview.json");
let metrics = ServerMetrics::load(&path);
// Simulate 5 accept errors
for _ in 0..5 {
metrics.record_accept_error();
}
// Simulate connection versions
metrics.record_connection_version("2.0");
metrics.record_connection_version("2.0");
metrics.record_connection_version("1.0");
// A normal session with pipe denials and send errors
let mut s1 = metrics.start_session(1000, "normal".into());
s1.record_message(Duration::from_millis(1), true);
s1.record_pipe_denial();
s1.record_send_error();
s1.record_send_error();
s1.finish("done".into());
metrics.save(&path);
let metrics2 = ServerMetrics::load(&path);
let snap = metrics2.snapshot();
assert_eq!(snap.total_accept_errors, 5);
assert_eq!(snap.connection_versions["2.0"], 2);
assert_eq!(snap.connection_versions["1.0"], 1);
assert_eq!(snap.total_pipe_denials, 1);
assert_eq!(snap.total_send_errors, 2);
assert_eq!(snap.sessions.len(), 1);
assert_eq!(snap.sessions[0].pipe_denials, 1);
assert_eq!(snap.sessions[0].send_errors, 2);
metrics2.build_overview(&overview_path);
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
let overview: Overview = serde_json::from_str(&overview_json).unwrap();
assert_eq!(overview.aggregate.total_accept_errors, 5);
assert_eq!(overview.aggregate.total_pipe_denials, 1);
assert_eq!(overview.aggregate.total_send_errors, 2);
assert_eq!(overview.connection_versions["2.0"], 2);
assert_eq!(overview.connection_versions["1.0"], 1);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&overview_path);
}
}

View file

@ -1,39 +1,34 @@
use std::fs;
use std::path::Path;
use base64::Engine; use base64::Engine;
use std::path::Path;
use tokio::fs;
pub fn load_or_generate_tls( pub async fn load_or_generate_tls(
cert_path: &str, cert_path: &str,
key_path: &str, key_path: &str,
) -> Result<(Vec<u8>, Vec<u8>), Box<dyn std::error::Error>> { ) -> Result<(Vec<u8>, Vec<u8>), Box<dyn std::error::Error>> {
if let (Ok(c), Ok(k)) = (fs::read(cert_path), fs::read(key_path)) { if let (Ok(c), Ok(k)) = (fs::read(cert_path).await, fs::read(key_path).await) {
println!("Using existing TLS cert from {cert_path}"); println!("Using existing TLS cert from {cert_path}");
return Ok((c, k)); return Ok((c, k));
} }
println!("Generating self-signed TLS certificate ..."); println!("Generating self-signed TLS certificate ...");
if let Some(parent) = Path::new(cert_path).parent() { if let Some(parent) = Path::new(cert_path).parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent).await?;
} }
if let Some(parent) = Path::new(key_path).parent() { if let Some(parent) = Path::new(key_path).parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent).await?;
} }
let key_pair = rcgen::KeyPair::generate()?;
let params = rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()])?;
let cert = params.self_signed(&key_pair)?;
let cert_str = cert.pem(); let (cert_pem, key_pem) = mtp::crypto::tls::generate_self_signed_cert("localhost")?;
let key_str = key_pair.serialize_pem();
fs::write(cert_path, cert_str.as_bytes())?; fs::write(cert_path, &cert_pem).await?;
fs::write(key_path, key_str.as_bytes())?; fs::write(key_path, &key_pem).await?;
println!("Wrote {cert_path} and {key_path}"); println!("Wrote {cert_path} and {key_path}");
Ok((cert_str.into_bytes(), key_str.into_bytes())) Ok((cert_pem, key_pem))
} }
pub fn certificate_sha256_hex(cert: &[u8]) -> Result<String, Box<dyn std::error::Error>> { pub async fn certificate_sha256_hex(cert: &[u8]) -> Result<String, Box<dyn std::error::Error>> {
let der = if cert.starts_with(b"-----BEGIN CERTIFICATE-----") { let der = if cert.starts_with(b"-----BEGIN CERTIFICATE-----") {
let pem = std::str::from_utf8(cert)?; let pem = std::str::from_utf8(cert)?;
let base64 = pem let base64 = pem
@ -48,14 +43,14 @@ pub fn certificate_sha256_hex(cert: &[u8]) -> Result<String, Box<dyn std::error:
Ok(hex::encode(mtp::crypto::sha256(&der))) Ok(hex::encode(mtp::crypto::sha256(&der)))
} }
pub fn export_webtransport_cert_hash(hash: &str) -> Result<(), Box<dyn std::error::Error>> { pub async fn export_webtransport_cert_hash(hash: &str) -> Result<(), Box<dyn std::error::Error>> {
let public_dir = if Path::new("web-client").exists() { let public_dir = if Path::new("web-client").exists() {
Path::new("web-client/public") Path::new("web-client/public")
} else { } else {
Path::new("example/web-client/public") Path::new("example/web-client/public")
}; };
fs::create_dir_all(public_dir)?; fs::create_dir_all(public_dir).await?;
fs::write(public_dir.join("mtp_dev_cert_hash.txt"), hash)?; fs::write(public_dir.join("mtp_dev_cert_hash.txt"), hash).await?;
let dev_cert_dir = if Path::new("dev-cert").exists() { let dev_cert_dir = if Path::new("dev-cert").exists() {
Path::new("dev-cert") Path::new("dev-cert")
@ -63,7 +58,7 @@ pub fn export_webtransport_cert_hash(hash: &str) -> Result<(), Box<dyn std::erro
Path::new("example/dev-cert") Path::new("example/dev-cert")
}; };
if dev_cert_dir.exists() { if dev_cert_dir.exists() {
fs::write(dev_cert_dir.join("sha256.txt"), hash)?; fs::write(dev_cert_dir.join("sha256.txt"), hash).await?;
} }
Ok(()) Ok(())

View file

@ -0,0 +1,130 @@
use mtp::webserver::{HttpRequest, HttpResponse, RouteParams, WebServerConfig};
use std::{
path::{Component, Path, PathBuf},
sync::Arc,
};
async fn health(request: HttpRequest, response: HttpResponse) -> HttpResponse {
response
.header("content-type", "text/plain; charset=utf-8")
.body(format!("OK\nclient: {}\n", request.remote_addr))
}
async fn profile(
request: HttpRequest,
response: HttpResponse,
params: RouteParams,
) -> HttpResponse {
let Some(user) = params.get("user") else {
return response.body("missing user");
};
let body = serde_json::json!({
"user": user,
"remote_addr": request.remote_addr.to_string(),
"profile": { "display_name": format!("Example user {user}"), "status": "active" }
});
response
.header("content-type", "application/json; charset=utf-8")
.body(body.to_string())
}
pub fn config() -> Result<WebServerConfig, mtp::webserver::RouterError> {
let root = Arc::new(web_client_dist());
if root.is_none() {
eprintln!(
"Web client build not found; requests will show setup instructions. Run `pnpm --dir example/web-client build`."
);
}
WebServerConfig::new()
.route("/health", health)?
.route_pattern("/api/get/{user}/profile", profile)?
.fallback(move |request, response| {
let root = Arc::clone(&root);
async move { static_assets(request, response, root).await }
})
}
fn web_client_dist() -> Option<PathBuf> {
[
PathBuf::from("web-client/dist"),
PathBuf::from("example/web-client/dist"),
]
.into_iter()
.find(|path| path.join("index.html").is_file())
}
async fn static_assets(
request: HttpRequest,
response: HttpResponse,
root: Arc<Option<PathBuf>>,
) -> HttpResponse {
if request.method != http::Method::GET && request.method != http::Method::HEAD {
return response.status(http::StatusCode::METHOD_NOT_ALLOWED);
}
let Some(root) = root.as_ref() else {
return response
.status(http::StatusCode::SERVICE_UNAVAILABLE)
.header("content-type", "text/html; charset=utf-8")
.body("<!doctype html><title>MTP web client not built</title><p>Run <code>pnpm --dir example/web-client build</code>.</p>");
};
let relative = request.uri.path().trim_start_matches('/');
let path = Path::new(relative);
if relative.contains('\\')
|| path.components().any(|part| {
matches!(
part,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
{
return response
.status(http::StatusCode::BAD_REQUEST)
.body("Invalid path");
}
let requested = if relative.is_empty() {
root.join("index.html")
} else {
root.join(path)
};
let file = if requested.is_file() {
requested
} else if path.extension().is_none() {
root.join("index.html")
} else {
return response
.status(http::StatusCode::NOT_FOUND)
.body("Not found");
};
match tokio::fs::read(&file).await {
Ok(body) => {
let response = response.header("content-type", content_type(&file));
if request.method == http::Method::HEAD {
response.header("content-length", &body.len().to_string())
} else {
response.body(body)
}
}
Err(_) => response
.status(http::StatusCode::NOT_FOUND)
.body("Not found"),
}
}
fn content_type(file: &Path) -> &'static str {
match file.extension().and_then(|extension| extension.to_str()) {
Some("html") => "text/html; charset=utf-8",
Some("js" | "mjs") => "text/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("wasm") => "application/wasm",
Some("svg") => "image/svg+xml",
Some("json" | "map") => "application/json",
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("ico") => "image/x-icon",
Some("woff") => "font/woff",
Some("woff2") => "font/woff2",
_ => "application/octet-stream",
}
}

View file

@ -1,28 +1,22 @@
protocol_version: "1.0" protocol_version: "3.0"
type_maps: type_maps:
"0.0": "3.0":
CommunicationTypes: CommunicationTypes:
ProtectedMessage: 32
AlternateMessage: 33
DataTypes: DataTypes:
"1.0":
CommunicationTypes:
DataTypes:
Data: 32
Flags: 33 Flags: 33
Value: 34
BinaryData: 35
Items: 36
EncryptedPayload: 37
SignedPayload: 38
SecurePayload: 39
"2.0":
CommunicationTypes:
DataTypes:
Data: 34 Data: 34
Flags: 33
Value: 35 Value: 35
BinaryData: 36 BinaryData: 36
Items: 37 Items: 37
EncryptedPayload: 38 EncryptedPayload: 38
SignedPayload: 39 SignedPayload: 39
SecurePayload: 40 SecurePayload: 40
CommunicationType: 41
DataType: 42
ExampleText: 43
ExampleNumber: 44
ExampleRole: 45
ExampleMetadata: 46

View file

@ -1,39 +1,97 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<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>MTP Web Client</title> <title>MTP Web Client</title>
<style> <style>
body { background: #111; color: #eee; font-family: "Public Sans", sans-serif; } body {
label, input, textarea { display: block; margin-bottom: 0.5rem; } background: #111;
input, textarea, button { font-family: "Public Sans", sans-serif; } color: #eee;
input, textarea { background: #222; color: #eee; } font-family: "Public Sans", sans-serif;
#status, #key-status { white-space: pre-wrap; } }
.state { color: #ff0; } label,
.received { color: #0ff; } input,
.error { color: #f00; } textarea {
display: block;
margin-bottom: 0.5rem;
}
input,
textarea,
button {
font-family: "Public Sans", sans-serif;
}
input,
textarea {
background: #222;
color: #eee;
}
#status,
#key-status {
white-space: pre-wrap;
}
.state {
color: #ff0;
}
.received {
color: #0ff;
}
.error {
color: #f00;
}
.pipe {
color: #0f0;
}
hr {
border-color: #444;
margin: 1.5rem 0;
}
</style> </style>
</head> </head>
<body> <body>
<h1>MTP WebTransport Client</h1> <h1>MTP WebTransport Client</h1>
<label for="server-url">Server URL</label> <label for="server-url">Server URL</label>
<input id="server-url" value="https://127.0.0.1:8080" /> <input id="server-url" value="https://127.0.0.1:8080" />
<label for="host-public-key">Host public key bundle hex</label> <label for="host-public-key">Host public key bundle hex</label>
<textarea id="host-public-key" placeholder="Paste PublicKeyBundle bytes as hex"></textarea> <textarea
id="host-public-key"
placeholder="Paste PublicKeyBundle bytes as hex"
></textarea>
<label for="client-credentials">Saved SDK credentials</label> <label for="client-credentials">Saved SDK credentials</label>
<textarea id="client-credentials" readonly></textarea> <textarea id="client-credentials" readonly></textarea>
<div> <div>
<button id="generate-keypair" type="button">Use new credentials</button> <button id="generate-keypair" type="button">
Use new credentials
</button>
<button id="connect" type="button" disabled>Connect</button> <button id="connect" type="button" disabled>Connect</button>
<button id="connect-unauthenticated" type="button" disabled>
Connect Unauthenticated
</button>
<button id="clear-keys" type="button">Clear saved keys</button> <button id="clear-keys" type="button">Clear saved keys</button>
</div> </div>
<hr />
<h2>Pipe Demo</h2>
<div>
<button id="stream-mic" type="button" disabled>
Stream Microphone (pipe loopback)
</button>
<button id="stop-mic" type="button" disabled>
Stop Microphone
</button>
</div>
<div id="pipe-status"></div>
<hr />
<h2>Metrics</h2>
<div id="metrics"></div>
<hr />
<div id="key-status">Initializing...</div> <div id="key-status">Initializing...</div>
<div id="status"></div> <div id="status"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

View file

@ -1,7 +1,7 @@
{ {
"name": "mtp-web-client", "name": "mtp-web-client",
"private": true, "private": true,
"version": "0.1.0", "version": "0.3.0",
"type": "module", "type": "module",
"packageManager": "pnpm@11.8.0", "packageManager": "pnpm@11.8.0",
"scripts": { "scripts": {
@ -13,7 +13,7 @@
"mtp": "workspace:*" "mtp": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^6.0.3", "typescript": "^7.0.0",
"vite": "^8.1.0" "vite": "^8.1.0"
} }
} }

View file

@ -1,14 +1,33 @@
import { MTPClient } from "mtp"; import { MTPClient } from "mtp";
import type { MTPCredentialStorage, MTPLogEvent, ParsedFrame } from "mtp"; import type {
MTPCredentialStorage,
MTPLogEvent,
MTPPipeReader,
MTPPipeWriter,
ParsedFrame,
} from "mtp";
const STATUS = document.getElementById("status")!; const STATUS = document.getElementById("status")!;
const KEY_STATUS = document.getElementById("key-status")!; const KEY_STATUS = document.getElementById("key-status")!;
const SERVER_URL = document.getElementById("server-url") as HTMLInputElement; const SERVER_URL = document.getElementById("server-url") as HTMLInputElement;
const HOST_PUBLIC_KEY = document.getElementById("host-public-key") as HTMLTextAreaElement; const HOST_PUBLIC_KEY = document.getElementById(
const CLIENT_CREDENTIALS = document.getElementById("client-credentials") as HTMLTextAreaElement; "host-public-key",
const GENERATE_KEYPAIR = document.getElementById("generate-keypair") as HTMLButtonElement; ) as HTMLTextAreaElement;
const CLIENT_CREDENTIALS = document.getElementById(
"client-credentials",
) as HTMLTextAreaElement;
const GENERATE_KEYPAIR = document.getElementById(
"generate-keypair",
) as HTMLButtonElement;
const CONNECT = document.getElementById("connect") as HTMLButtonElement; const CONNECT = document.getElementById("connect") as HTMLButtonElement;
const CONNECT_UNAUTHENTICATED = document.getElementById(
"connect-unauthenticated",
) as HTMLButtonElement;
const CLEAR_KEYS = document.getElementById("clear-keys") as HTMLButtonElement; const CLEAR_KEYS = document.getElementById("clear-keys") as HTMLButtonElement;
const STREAM_MIC = document.getElementById("stream-mic") as HTMLButtonElement;
const STOP_MIC = document.getElementById("stop-mic") as HTMLButtonElement;
const PIPE_STATUS = document.getElementById("pipe-status")!;
const METRICS = document.getElementById("metrics")!;
const CREDENTIALS_KEY = "mtp-web-client-credentials"; const CREDENTIALS_KEY = "mtp-web-client-credentials";
const HOST_PUBLIC_KEY_KEY = "mtp-web-client-host-public-key"; const HOST_PUBLIC_KEY_KEY = "mtp-web-client-host-public-key";
@ -16,12 +35,35 @@ const HOST_PUBLIC_KEY_KEY = "mtp-web-client-host-public-key";
type SavedKeys = { type SavedKeys = {
clientId: string | null; clientId: string | null;
keyring?: number[]; keyring?: number[];
keyringBytes?: number[];
hostPublicKey?: number[]; hostPublicKey?: number[];
}; };
let clientId: bigint | null = null; let clientId: bigint | null = null;
let devCertHash = ""; let devCertHash = "";
let activeClient: ReturnType<typeof createClient> extends Promise<infer T>
? T
: never;
let micStream: MediaStream | null = null;
let mediaRecorder: MediaRecorder | null = null;
let activePipeWriter: MTPPipeWriter | null = null;
let loopbackAudioContext: AudioContext | null = null;
let micStreamGeneration = 0;
let pipeSendCount = 0;
let pendingPipeReaders: MTPPipeReader[] = [];
let currentPipePingMs: number | null = null;
let lastPipeSendStartedAt = 0;
let currentPipeId: number | null = null;
let currentPipeDescription = "";
let currentPipeState = "idle";
let loopbackPlaybackCount = 0;
let hasPipeRequestHandler = false;
// ===== AUDIO LOOPBACK STATE =====
// We accumulate all chunks into a single Blob, then decode and play it
// when the pipe closes. decodeAudioData needs a complete file, not fragments.
let loopbackBlobParts: BlobPart[] = [];
let loopbackMimeType = "";
let loopbackAudioElement: HTMLAudioElement | null = null;
const credentialStorage: MTPCredentialStorage = { const credentialStorage: MTPCredentialStorage = {
getItem: (key) => localStorage.getItem(key), getItem: (key) => localStorage.getItem(key),
@ -36,6 +78,140 @@ function log(msg: string, cls = "") {
STATUS.appendChild(line); STATUS.appendChild(line);
} }
function pipeLog(msg: string, cls = "pipe") {
const line = document.createElement("div");
line.textContent = msg;
if (cls) line.className = cls;
PIPE_STATUS.prepend(line);
}
function setMetric(name: string, value: string) {
const row = document.querySelector(`[data-metric="${name}"]`);
if (row) {
row.querySelector<HTMLElement>(".metric-value")!.textContent = value;
return;
}
const wrapper = document.createElement("div");
wrapper.dataset.metric = name;
wrapper.innerHTML = `<span class="metric-name"></span>: <span class="metric-value"></span>`;
wrapper.querySelector<HTMLElement>(".metric-name")!.textContent = name;
wrapper.querySelector<HTMLElement>(".metric-value")!.textContent = value;
METRICS.appendChild(wrapper);
}
function updateMetrics() {
setMetric(
"Current Pipe",
currentPipeId == null ? "none" : String(currentPipeId),
);
setMetric("Pipe State", currentPipeState);
setMetric("Pipe Description", currentPipeDescription || "n/a");
setMetric(
"Current Pipe Ping",
currentPipePingMs == null ? "n/a" : `${currentPipePingMs.toFixed(1)} ms`,
);
setMetric("Loopback Playback", String(loopbackPlaybackCount));
setMetric("Sent Chunks", String(pipeSendCount));
}
function setPipeState(
state: string,
details: Partial<{
pipeId: number | null;
description: string;
pingMs: number | null;
}>,
) {
if ("pipeId" in details) currentPipeId = details.pipeId ?? null;
if ("description" in details)
currentPipeDescription = details.description ?? "";
if ("pingMs" in details)
currentPipePingMs = details.pingMs ?? currentPipePingMs;
currentPipeState = state;
updateMetrics();
}
function getPipeId(handle: unknown): number | null {
if (handle && typeof handle === "object") {
const candidate = handle as Record<string, unknown>;
let value =
candidate.pipeId ??
candidate.pipe_id ??
candidate["pipe-id"] ??
candidate.id;
if (typeof value === "function") {
try {
value = value.call(handle);
} catch {
return null;
}
}
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
}
return null;
}
// ===== FIXED AUDIO LOOPBACK: accumulate chunks, play as single file =====
function startLoopbackAccumulation(mimeType: string) {
loopbackBlobParts = [];
loopbackMimeType = mimeType;
pipeLog("Loopback: accumulating audio chunks...");
}
function queueLoopbackChunk(data: Uint8Array) {
loopbackBlobParts.push(data.slice());
}
async function finishLoopbackPlayback() {
if (loopbackBlobParts.length === 0) {
pipeLog("Loopback: no chunks received.", "error");
return;
}
// Stop any previous playback
if (loopbackAudioElement) {
loopbackAudioElement.pause();
const src = loopbackAudioElement.src;
loopbackAudioElement.src = "";
if (src.startsWith("blob:")) {
URL.revokeObjectURL(src);
}
loopbackAudioElement = null;
}
// Concatenate all chunks into one Blob
const blob = new Blob(loopbackBlobParts, { type: loopbackMimeType });
loopbackBlobParts = [];
pipeLog(`Loopback: assembled ${blob.size} bytes, decoding...`);
try {
const arrayBuffer = await blob.arrayBuffer();
const audioContext = new AudioContext();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContext.destination);
source.start();
loopbackPlaybackCount += 1;
updateMetrics();
pipeLog(`Loopback playback started (${audioBuffer.duration.toFixed(2)}s).`);
// Clean up audio context when done
source.onended = () => {
audioContext.close().catch(() => {});
};
} catch (e) {
pipeLog(`Loopback decode/playback failed: ${e}`, "error");
}
}
function renderStructured(value: unknown): string { function renderStructured(value: unknown): string {
return JSON.stringify(value, (_key, item) => { return JSON.stringify(value, (_key, item) => {
if (typeof item === "bigint") { if (typeof item === "bigint") {
@ -70,13 +246,16 @@ function setKeyStatus(msg: string) {
} }
function bytesToHex(bytes: Uint8Array): string { function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
"",
);
} }
function hexToBytes(value: string): Uint8Array { function hexToBytes(value: string): Uint8Array {
const hex = value.replace(/[^0-9a-fA-F]/g, ""); const hex = value.replace(/[^0-9a-fA-F]/g, "");
if (hex.length === 0) throw new Error("host public key is required"); if (hex.length === 0) throw new Error("host public key is required");
if (hex.length % 2 !== 0) throw new Error("host public key hex has an odd length"); if (hex.length % 2 !== 0)
throw new Error("host public key hex has an odd length");
const bytes = new Uint8Array(hex.length / 2); const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i += 1) { for (let i = 0; i < bytes.length; i += 1) {
@ -87,7 +266,10 @@ function hexToBytes(value: string): Uint8Array {
function saveHostPublicKey() { function saveHostPublicKey() {
try { try {
localStorage.setItem(HOST_PUBLIC_KEY_KEY, bytesToHex(hexToBytes(HOST_PUBLIC_KEY.value))); localStorage.setItem(
HOST_PUBLIC_KEY_KEY,
bytesToHex(hexToBytes(HOST_PUBLIC_KEY.value)),
);
} catch { } catch {
localStorage.removeItem(HOST_PUBLIC_KEY_KEY); localStorage.removeItem(HOST_PUBLIC_KEY_KEY);
} }
@ -102,13 +284,15 @@ function loadKeys() {
if (!raw) { if (!raw) {
CLIENT_CREDENTIALS.value = ""; CLIENT_CREDENTIALS.value = "";
setKeyStatus("No saved SDK credentials. The next connection will generate and store a reusable keyring."); setKeyStatus(
"No saved SDK credentials. The next connection will generate and store a reusable keyring.",
);
return; return;
} }
const data = JSON.parse(raw) as SavedKeys; const data = JSON.parse(raw) as SavedKeys;
clientId = data.clientId ? BigInt(data.clientId) : null; clientId = data.clientId ? BigInt(data.clientId) : null;
const keyringLength = (data.keyring ?? data.keyringBytes ?? []).length; const keyringLength = (data.keyring ?? []).length;
CLIENT_CREDENTIALS.value = renderStructured({ CLIENT_CREDENTIALS.value = renderStructured({
clientId: data.clientId, clientId: data.clientId,
keyringBytes: keyringLength, keyringBytes: keyringLength,
@ -127,11 +311,13 @@ function loadKeys() {
async function loadHostPublicKey() { async function loadHostPublicKey() {
try { try {
const response = await fetch("/host_public_key_bundle.hex", { cache: "no-store" }); const response = await fetch("/host_public_key_bundle.hex", {
cache: "no-store",
});
if (!response.ok) return; if (!response.ok) return;
const hostPublicKey = (await response.text()).trim(); const hostPublicKey = (await response.text()).trim();
if (!hostPublicKey) return; if (!hostPublicKey || !/^[0-9a-f]+$/i.test(hostPublicKey)) return;
HOST_PUBLIC_KEY.value = hostPublicKey; HOST_PUBLIC_KEY.value = hostPublicKey;
saveHostPublicKey(); saveHostPublicKey();
@ -143,11 +329,14 @@ async function loadHostPublicKey() {
async function loadDevCertHash() { async function loadDevCertHash() {
try { try {
const response = await fetch("/mtp_dev_cert_hash.txt", { cache: "no-store" }); const response = await fetch(`/mtp_dev_cert_hash.txt?t=${Date.now()}`, {
cache: "no-store",
});
if (!response.ok) return; if (!response.ok) return;
devCertHash = (await response.text()).trim(); const hash = (await response.text()).trim();
if (devCertHash) { if (/^[0-9a-f]{64}$/i.test(hash)) {
devCertHash = hash;
log(`Loaded WebTransport certificate hash: ${devCertHash}`); log(`Loaded WebTransport certificate hash: ${devCertHash}`);
} }
} catch { } catch {
@ -157,32 +346,23 @@ async function loadDevCertHash() {
async function initWasm() { async function initWasm() {
log("Loading WASM module..."); log("Loading WASM module...");
await MTPClient.create({ url: SERVER_URL.value, storage: credentialStorage, credentialsStorageKey: CREDENTIALS_KEY }); await MTPClient.create({
url: SERVER_URL.value,
storage: credentialStorage,
credentialsStorageKey: CREDENTIALS_KEY,
});
const supported = MTPClient.isSupported(); const supported = MTPClient.isSupported();
log(`WASM loaded. WebTransport supported: ${supported}`); log(`WASM loaded. WebTransport supported: ${supported}`);
CONNECT.disabled = !supported; CONNECT.disabled = !supported;
CONNECT_UNAUTHENTICATED.disabled = !supported;
} }
async function connect() { async function createClient() {
STATUS.textContent = "";
if (!MTPClient.isSupported()) {
log("WebTransport is not supported in this browser.", "error");
return;
}
const hostPk = hexToBytes(HOST_PUBLIC_KEY.value); const hostPk = hexToBytes(HOST_PUBLIC_KEY.value);
saveHostPublicKey();
await loadDevCertHash(); await loadDevCertHash();
const serverUrl = SERVER_URL.value.trim(); const serverUrl = SERVER_URL.value.trim();
const serverCertificateHashes = devCertHash ? [`sha-256:${devCertHash}`] : undefined; const serverCertificateHashes = devCertHash ? [devCertHash] : undefined;
if (serverCertificateHashes) {
log(`Pinning WebTransport certificate hash: ${serverCertificateHashes[0]}`);
} else {
log("No WebTransport certificate hash loaded; relying on browser trust store.", "state");
}
try {
const client = await MTPClient.create({ const client = await MTPClient.create({
url: serverUrl, url: serverUrl,
hostPublicKey: hostPk, hostPublicKey: hostPk,
@ -191,48 +371,372 @@ async function connect() {
serverCertificateHashes, serverCertificateHashes,
pings: { intervalMs: 30_000 }, pings: { intervalMs: 30_000 },
logger(event) { logger(event) {
log(renderLoggerEvent(event), event.hint === "error" ? "error" : event.type === "state" ? "state" : ""); log(
renderLoggerEvent(event),
event.hint === "error"
? "error"
: event.type === "state"
? "state"
: "",
);
}, },
}); });
return client;
}
async function connect() {
STATUS.textContent = "";
PIPE_STATUS.textContent = "";
if (!MTPClient.isSupported()) {
log("WebTransport is not supported in this browser.", "error");
return;
}
saveHostPublicKey();
await loadDevCertHash();
const serverUrl = SERVER_URL.value.trim();
const serverCertificateHashes = devCertHash ? [devCertHash] : undefined;
if (serverCertificateHashes) {
log(`Pinning WebTransport certificate hash: ${serverCertificateHashes[0]}`);
} else {
log(
"No WebTransport certificate hash loaded; relying on browser trust store.",
"state",
);
}
try {
const client = await createClient();
activeClient = client;
client.subscribe("Pong", (frame: ParsedFrame) => { client.subscribe("Pong", (frame: ParsedFrame) => {
log(`Subscribed Pong: ${formatParsedFrame(frame)}`, "received"); log(`Subscribed Pong: ${formatParsedFrame(frame)}`, "received");
}); });
const existingClientId = client.credentials?.clientId; const activeClientId = await client.auth();
const activeClientId = existingClientId == null
? await client.register()
: (await client.connect(), BigInt(existingClientId));
clientId = activeClientId; clientId = activeClientId;
loadKeys(); loadKeys();
log(`Connected as client ${activeClientId}`); log(`Connected as authenticated client ${activeClientId}`);
log("\nSending typed Ping..."); log("\nSending typed Ping...");
await client.send("Ping", { await client.send(
"Ping",
{
Description: "MTP web client send ping", Description: "MTP web client send ping",
Timestamp: BigInt(Date.now()), Timestamp: BigInt(Date.now()),
}, { sender: activeClientId }); },
{ sender: activeClientId },
);
log("Typed Ping sent."); log("Typed Ping sent.");
log("\nRequesting Pong by Ping frame id..."); log("\nRequesting Pong by Ping frame id...");
const response = await client.request("Ping", { const response = await client.request(
"Ping",
{
Description: "MTP web client request ping", Description: "MTP web client request ping",
Timestamp: BigInt(Date.now()), Timestamp: BigInt(Date.now()),
}, { sender: activeClientId, responseType: "Pong" }); },
{ sender: activeClientId, responseType: "Pong" },
);
log(`Request response: ${formatParsedFrame(response)}`, "received"); log(`Request response: ${formatParsedFrame(response)}`, "received");
log("\nClient running. Waiting for incoming messages..."); log("\nClient running. Waiting for incoming messages...");
STREAM_MIC.disabled = false;
log("\nPipe demo ready. Click 'Stream Microphone' to start.", "pipe");
updateMetrics();
} catch (error) { } catch (error) {
log(`[error] ${error}`, "error"); log(`[error] ${error}`, "error");
} }
} }
async function connectUnauthenticated() {
STATUS.textContent = "";
PIPE_STATUS.textContent = "";
if (!MTPClient.isSupported()) {
log("WebTransport is not supported in this browser.", "error");
return;
}
saveHostPublicKey();
try {
const client = await createClient();
activeClient = client;
const storedIdentity = client.credentials?.clientId;
await client.connectUnauthenticated();
clientId = storedIdentity ?? clientId;
loadKeys();
log(
`Connected over an unauthenticated transport (guest connection). Stored protection identity ${storedIdentity == null ? "not registered" : `${storedIdentity} retained`}.`,
);
log(
"The explicit connectUnauthenticated() path did not delete or replace stored credentials.",
"state",
);
STREAM_MIC.disabled = false;
log("\nPipe demo ready. Click 'Stream Microphone' to start.", "pipe");
updateMetrics();
} catch (error) {
log(`[error] ${error}`, "error");
}
}
async function startMicStreaming() {
if (!activeClient) {
pipeLog("No active client connection.", "error");
return;
}
try {
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (e) {
pipeLog(`Microphone access denied: ${e}`, "error");
return;
}
STREAM_MIC.disabled = true;
STOP_MIC.disabled = false;
setPipeState("creating", { pipeId: null, description: "mic-audio" });
pipeLog("Microphone acquired. Creating pipe ...");
pipeLog(
"Microphone monitoring is off; playback will use the server loopback.",
);
if (!hasPipeRequestHandler) {
activeClient.setOnPipeRequest(async (request) => {
pipeLog(
`Incoming return pipe: id=${request.pipeId} desc=${request.description}`,
);
try {
const reader = await activeClient!.acceptPipe(request.pipeId);
pendingPipeReaders.push(reader);
pipeLog(
`Pipe accepted. Streaming return pipe (pipe id=${getPipeId(reader) ?? "unknown"}) ...`,
);
readLoopbackPipe(reader);
} catch (e) {
pipeLog(`Failed to accept return pipe: ${e}`, "error");
}
});
hasPipeRequestHandler = true;
}
const handle = await activeClient.createPipe("mic-audio");
const pipeId = getPipeId(handle);
setPipeState("waiting-for-accept", { pipeId, description: "mic-audio" });
pipeLog(
`Pipe created (id=${pipeId ?? "unknown"}). Waiting for server to accept ...`,
);
const writer = await handle.wait();
if (!writer) {
setPipeState("denied", { pipeId, description: "mic-audio" });
pipeLog("Pipe denied by server.", "error");
stopMicStreaming();
return;
}
activePipeWriter = writer;
const streamGeneration = ++micStreamGeneration;
setPipeState("streaming", {
pipeId: getPipeId(writer) ?? pipeId,
description: "mic-audio",
});
pipeLog(
`Pipe accepted. Streaming microphone (pipe id=${getPipeId(writer) ?? pipeId ?? "unknown"}) ...`,
);
// Stream microphone audio via MediaRecorder
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
? "audio/webm;codecs=opus"
: "audio/webm";
const recorder = new MediaRecorder(micStream, { mimeType });
mediaRecorder = recorder;
recorder.ondataavailable = async (event) => {
// A final chunk can be queued before recorder.stop(). Do not use the
// captured writer unless this is still the current active stream.
if (
event.data.size === 0 ||
!activeClient ||
micStreamGeneration !== streamGeneration ||
mediaRecorder !== recorder ||
activePipeWriter !== writer
) {
return;
}
pipeSendCount++;
const chunkNum = pipeSendCount;
try {
lastPipeSendStartedAt = performance.now();
const buffer = await event.data.arrayBuffer();
const data = new Uint8Array(buffer);
// arrayBuffer() yields, so shutdown may have happened meanwhile.
if (
micStreamGeneration !== streamGeneration ||
mediaRecorder !== recorder ||
activePipeWriter !== writer
) {
return;
}
await writer.write(data);
currentPipePingMs = performance.now() - lastPipeSendStartedAt;
updateMetrics();
} catch (e) {
pipeLog(` [chunk ${chunkNum}] send error: ${e}`, "error");
}
};
recorder.start(200); // emit data every 200ms
updateMetrics();
pipeLog("Streaming started (200ms chunks).");
}
async function readLoopbackPipe(reader: MTPPipeReader) {
const startTime = performance.now();
let totalBytes = 0;
let chunkCount = 0;
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
? "audio/webm;codecs=opus"
: "audio/webm";
try {
loopbackBlobParts = [];
loopbackMimeType = mimeType;
pipeLog("Loopback: accumulating chunks...");
while (true) {
const data = await reader.read();
if (data == null) break; // EOF
totalBytes += data.length;
chunkCount++;
loopbackBlobParts.push(data.slice());
}
} catch (e) {
pipeLog(` Return pipe read error: ${e}`, "error");
return;
}
const elapsed = performance.now() - startTime;
pipeLog(
` Return pipe complete: ${chunkCount} chunks, ${totalBytes} bytes, ` +
`delay=${elapsed.toFixed(1)}ms`,
);
setPipeState("loopback-ready", { pingMs: elapsed });
// Decode and play the complete recording
if (loopbackBlobParts.length > 0) {
try {
const blob = new Blob(loopbackBlobParts, { type: loopbackMimeType });
const arrayBuffer = await blob.arrayBuffer();
if (!loopbackAudioContext) {
loopbackAudioContext = new AudioContext();
}
const audioBuffer =
await loopbackAudioContext.decodeAudioData(arrayBuffer);
const source = loopbackAudioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(loopbackAudioContext.destination);
source.start();
loopbackPlaybackCount += 1;
updateMetrics();
pipeLog(
`Loopback playback started (${audioBuffer.duration.toFixed(2)}s).`,
);
} catch (e) {
pipeLog(`Loopback decode failed: ${e}`, "error");
}
}
// Clean up the reader from the pending list
const idx = pendingPipeReaders.indexOf(reader);
if (idx >= 0) pendingPipeReaders.splice(idx, 1);
}
// ===== CRITICAL FIX: stopMicStreaming must capture the final chunk =====
async function stopMicStreaming() {
micStreamGeneration++;
const recorder = mediaRecorder;
mediaRecorder = null;
const writer = activePipeWriter;
activePipeWriter = null;
// STOPPING STRATEGY:
// 1. Request a final dataavailable event by calling requestData() if needed,
// then stop(). The final event contains the WebM trailer.
// 2. Wait for that final event to be processed (it writes through the pipe).
// 3. Only THEN close the pipe writer.
if (recorder) {
// Create a promise that resolves when the final dataavailable fires
const finalChunkPromise = new Promise<void>((resolve) => {
const originalHandler = recorder.ondataavailable;
recorder.ondataavailable = async (event) => {
// Call the original handler first so the chunk gets written to the pipe
if (originalHandler) {
await originalHandler.call(recorder, event);
}
// The final chunk from stop() has a 'type' but no special marker.
// MediaRecorder state will be 'inactive' after the final event.
if (recorder.state === "inactive") {
resolve();
}
};
});
if (recorder.state !== "inactive") {
recorder.stop();
}
// Wait up to 1 second for the final chunk to be captured and written
await Promise.race([
finalChunkPromise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error("final chunk timeout")), 1000),
),
]).catch(() => {
pipeLog("Warning: final chunk may not have been captured", "error");
});
}
if (writer) {
try {
await writer.close();
} catch (e) {
pipeLog(`Pipe close error: ${e}`, "error");
}
}
if (micStream) {
micStream.getTracks().forEach((track) => track.stop());
micStream = null;
}
pendingPipeReaders = [];
setPipeState("stopped", {
pipeId: currentPipeId,
description: currentPipeDescription,
});
STREAM_MIC.disabled = false;
STOP_MIC.disabled = true;
pipeLog("Microphone streaming stopped.");
}
GENERATE_KEYPAIR.addEventListener("click", () => { GENERATE_KEYPAIR.addEventListener("click", () => {
try { try {
clientId = null; clientId = null;
localStorage.removeItem(CREDENTIALS_KEY); localStorage.removeItem(CREDENTIALS_KEY);
CLIENT_CREDENTIALS.value = ""; CLIENT_CREDENTIALS.value = "";
setKeyStatus("Cleared saved credentials. The next connection will generate a new reusable keyring."); setKeyStatus(
"Cleared saved credentials. The next connection will generate a new reusable keyring.",
);
log("Cleared saved SDK credentials."); log("Cleared saved SDK credentials.");
} catch (e) { } catch (e) {
log(`Credential reset failed: ${e}`, "error"); log(`Credential reset failed: ${e}`, "error");
@ -251,6 +755,13 @@ CONNECT.addEventListener("click", () => {
}); });
}); });
CONNECT_UNAUTHENTICATED.addEventListener("click", () => {
connectUnauthenticated().catch((e) => {
log(`Unauthenticated connection failed: ${e}`, "error");
console.error(e);
});
});
CLEAR_KEYS.addEventListener("click", () => { CLEAR_KEYS.addEventListener("click", () => {
clientId = null; clientId = null;
CLIENT_CREDENTIALS.value = ""; CLIENT_CREDENTIALS.value = "";
@ -260,6 +771,17 @@ CLEAR_KEYS.addEventListener("click", () => {
log("Cleared saved SDK credentials and host public key."); log("Cleared saved SDK credentials and host public key.");
}); });
STREAM_MIC.addEventListener("click", () => {
startMicStreaming().catch((e) => {
pipeLog(`Pipe streaming error: ${e}`, "error");
console.error(e);
});
});
STOP_MIC.addEventListener("click", () => {
stopMicStreaming();
});
HOST_PUBLIC_KEY.addEventListener("change", saveHostPublicKey); HOST_PUBLIC_KEY.addEventListener("change", saveHostPublicKey);
initWasm() initWasm()
@ -271,3 +793,5 @@ initWasm()
log(`Fatal error: ${e}`, "error"); log(`Fatal error: ${e}`, "error");
console.error(e); console.error(e);
}); });
updateMetrics();

View file

@ -1,16 +1,41 @@
import { defineConfig } from 'vite'; import { defineConfig, type Plugin } from 'vite';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { mtp } from 'mtp/vite'; import { mtp } from 'mtp/vite';
const devCertDir = path.resolve(__dirname, '../dev-cert'); const exampleDir = path.resolve(__dirname, '..');
const devCertDir = path.join(exampleDir, 'dev-cert');
const certPath = process.env.MTP_DEV_CERT ?? path.join(devCertDir, 'cert.pem'); const certPath = process.env.MTP_DEV_CERT ?? path.join(devCertDir, 'cert.pem');
const keyPath = process.env.MTP_DEV_KEY ?? path.join(devCertDir, 'key.pem'); const keyPath = process.env.MTP_DEV_KEY ?? path.join(devCertDir, 'key.pem');
const hasDevCert = fs.existsSync(certPath) && fs.existsSync(keyPath); const hasDevCert = fs.existsSync(certPath) && fs.existsSync(keyPath);
const devFiles: Record<string, string> = {
'/host_public_key_bundle.hex': path.join(exampleDir, 'host_public_key_bundle.hex'),
'/mtp_dev_cert_hash.txt': path.join(devCertDir, 'sha256.txt'),
};
function devFileServe(): Plugin {
return {
name: 'dev-file-serve',
configureServer(server) {
server.middlewares.use((req, res, next) => {
const target = devFiles[req.url?.split('?')[0] ?? ''];
if (!target) return next();
fs.readFile(target, (err, data) => {
if (err) return next();
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Cache-Control', 'no-store');
res.end(data);
});
});
},
};
}
export default defineConfig({ export default defineConfig({
plugins: [mtp({ typeMaps: '../../example/type-maps.yaml' })], plugins: [mtp({ typeMaps: '../../example/type-maps.yaml' }), devFileServe()],
server: { server: {
https: hasDevCert https: hasDevCert
? { ? {

22
files/Cargo.toml Normal file
View file

@ -0,0 +1,22 @@
[package]
name = "mtp-files"
version = "0.3.0"
edition = "2024"
[dependencies]
# Only the plain key types (`Keyring`, `PublicKeyBundle`, `CryptoError`) are
# needed here; those are always compiled, so no crypto features are required.
mtp-crypto = { version = "0.3.0", path = "../crypto", default-features = false, features = ["chacha20poly1305", "hkdf", "password-kdf"] }
rand = "0.10.2"
thiserror = "2"
zeroize = "1.9"
[features]
# Plain private-key files are only needed by migration tooling and tests.
raw = []
[dev-dependencies]
# Enable suite implementations for bundle-loading tests without adding them to
# the normal files-library dependency surface.
mtp-crypto = { version = "0.3.0", path = "../crypto", features = ["mlkem-tls"] }

460
files/src/lib.rs Normal file
View file

@ -0,0 +1,460 @@
/*
* On-disk storage for methanium key material.
*
* `.mk` files hold a passphrase-protected Keyring and are written atomically
* with owner-only permissions (0600) on Unix. `.mpkb` files hold a
* PublicKeyBundle (public keys only) and are safe to share. Each file opens
* with a 4-byte magic that doubles as a type tag, so a bundle never loads as a
* keyring, followed by a version byte.
*/
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use mtp_crypto::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305};
use rand::RngExt;
use thiserror::Error;
use zeroize::Zeroizing;
pub use mtp_crypto::{CryptoError, Keyring, PublicKeyBundle};
/// File extension for a stored [`Keyring`].
pub const KEYRING_EXTENSION: &str = "mk";
/// File extension for a stored [`PublicKeyBundle`].
pub const BUNDLE_EXTENSION: &str = "mpkb";
/* Container layout: magic (4 bytes) || version (1 byte) || payload. */
const KEYRING_MAGIC: [u8; 4] = *b"MTMK"; /* Methanium Keyring */
const BUNDLE_MAGIC: [u8; 4] = *b"MPKB"; /* Methanium Public Key Bundle */
const RAW_FORMAT_VERSION: u8 = 1;
const PROTECTED_FORMAT_VERSION: u8 = 3;
const BUNDLE_FORMAT_VERSION: u8 = 1;
const HEADER_LEN: usize = 4 + 1;
const SALT_LEN: usize = 32;
const KDF_ID_ARGON2ID: u8 = 1;
const ARGON2_MEMORY_KIB: u32 = 19 * 1024;
const ARGON2_ITERATIONS: u32 = 2;
const ARGON2_LANES: u32 = 1;
const PROTECTED_PARAMS_LEN: usize = 1 + 4 + 4 + 4 + SALT_LEN;
#[derive(Error, Debug)]
pub enum FileError {
#[error("io error: {0}")]
Io(#[from] io::Error),
#[error("crypto error: {0}")]
Crypto(#[from] CryptoError),
#[error("not a valid methanium {expected} file (bad magic)")]
BadMagic { expected: &'static str },
#[error("unsupported {kind} format version {found}")]
UnsupportedVersion { kind: &'static str, found: u8 },
#[error("file is truncated: {0} bytes, need at least {HEADER_LEN}")]
Truncated(usize),
#[error("passphrase must not be empty")]
EmptyPassphrase,
#[error(
"keyring is stored in the unprotected raw format; use load_keyring_raw only for trusted development or migration"
)]
UnprotectedKeyring,
#[error("keyring is passphrase-protected and cannot be loaded as raw")]
ProtectedKeyring,
}
fn encode(magic: [u8; 4], version: u8, payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(HEADER_LEN + payload.len());
out.extend_from_slice(&magic);
out.push(version);
out.extend_from_slice(payload);
out
}
fn decode<'a>(
bytes: &'a [u8],
magic: [u8; 4],
kind: &'static str,
) -> Result<(u8, &'a [u8]), FileError> {
if bytes.len() < HEADER_LEN {
return Err(FileError::Truncated(bytes.len()));
}
if bytes[..4] != magic {
return Err(FileError::BadMagic { expected: kind });
}
Ok((bytes[4], &bytes[HEADER_LEN..]))
}
/* The temporary secret file is owner-only from the instant it is created. */
#[cfg(unix)]
fn create_secret_file(path: &Path) -> io::Result<fs::File> {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
}
#[cfg(not(unix))]
fn create_secret_file(path: &Path) -> io::Result<fs::File> {
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
}
fn temporary_path(path: &Path, attempt: u64) -> io::Result<PathBuf> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let name = path
.file_name()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?;
let mut temporary_name = name.to_os_string();
temporary_name.push(format!(
".tmp-{}-{}-{attempt}",
std::process::id(),
TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
));
Ok(parent.join(temporary_name))
}
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[cfg(unix)]
fn sync_parent_directory(path: &Path) -> io::Result<()> {
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
fs::File::open(parent)?.sync_all()
}
#[cfg(not(unix))]
fn sync_parent_directory(_path: &Path) -> io::Result<()> {
Ok(())
}
fn write_secret_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
use std::io::Write;
let (temporary, mut file) = (0..100)
.find_map(|attempt| {
let temporary = temporary_path(path, attempt).ok()?;
match create_secret_file(&temporary) {
Ok(file) => Some(Ok((temporary, file))),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => None,
Err(error) => Some(Err(error)),
}
})
.transpose()?
.ok_or_else(|| {
io::Error::new(io::ErrorKind::AlreadyExists, "no temporary name available")
})?;
if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
drop(file);
let _ = fs::remove_file(&temporary);
return Err(error);
}
drop(file);
if let Err(error) = fs::rename(&temporary, path) {
let _ = fs::remove_file(&temporary);
return Err(error);
}
sync_parent_directory(path)
}
fn derive_key(
passphrase: &[u8],
salt: &[u8],
memory_kib: u32,
iterations: u32,
lanes: u32,
) -> Result<Zeroizing<[u8; 32]>, FileError> {
if salt.len() != SALT_LEN {
return Err(FileError::Crypto(CryptoError::KdfError));
}
Ok(Zeroizing::new(mtp_crypto::derive_password_key(
passphrase, salt, memory_kib, iterations, lanes,
)?))
}
fn protected_header_aad(parameters: &[u8]) -> Vec<u8> {
let mut aad = Vec::with_capacity(HEADER_LEN + parameters.len());
aad.extend_from_slice(&KEYRING_MAGIC);
aad.push(PROTECTED_FORMAT_VERSION);
aad.extend_from_slice(parameters);
aad
}
/// Save a keyring encrypted with XChaCha20-Poly1305 under Argon2id.
pub fn save_keyring(
keyring: &Keyring,
path: impl AsRef<Path>,
passphrase: &[u8],
) -> Result<(), FileError> {
if passphrase.is_empty() {
return Err(FileError::EmptyPassphrase);
}
let mut salt = [0u8; SALT_LEN];
rand::rng().fill(&mut salt);
let key = derive_key(
passphrase,
&salt,
ARGON2_MEMORY_KIB,
ARGON2_ITERATIONS,
ARGON2_LANES,
)?;
let mut parameters = Vec::with_capacity(PROTECTED_PARAMS_LEN);
parameters.push(KDF_ID_ARGON2ID);
parameters.extend_from_slice(&ARGON2_MEMORY_KIB.to_be_bytes());
parameters.extend_from_slice(&ARGON2_ITERATIONS.to_be_bytes());
parameters.extend_from_slice(&ARGON2_LANES.to_be_bytes());
parameters.extend_from_slice(&salt);
let cipher = ChaCha20Poly1305::new(*key);
let plaintext = keyring.try_to_bytes()?;
let encrypted = cipher.encrypt(&plaintext, &protected_header_aad(&parameters))?;
let mut payload = Vec::with_capacity(PROTECTED_PARAMS_LEN + encrypted.len());
payload.extend_from_slice(&parameters);
payload.extend_from_slice(&encrypted);
let bytes = encode(KEYRING_MAGIC, PROTECTED_FORMAT_VERSION, &payload);
write_secret_atomic(path.as_ref(), &bytes)?;
Ok(())
}
pub fn load_keyring(path: impl AsRef<Path>, passphrase: &[u8]) -> Result<Keyring, FileError> {
if passphrase.is_empty() {
return Err(FileError::EmptyPassphrase);
}
let bytes = fs::read(path)?;
let (version, payload) = decode(&bytes, KEYRING_MAGIC, "keyring")?;
if version == RAW_FORMAT_VERSION {
return Err(FileError::UnprotectedKeyring);
}
if version != PROTECTED_FORMAT_VERSION {
return Err(FileError::UnsupportedVersion {
kind: "keyring",
found: version,
});
}
if payload.len() < PROTECTED_PARAMS_LEN {
return Err(FileError::Truncated(bytes.len()));
}
if payload[0] != KDF_ID_ARGON2ID {
return Err(FileError::UnsupportedVersion {
kind: "keyring KDF",
found: payload[0],
});
}
let memory_kib = u32::from_be_bytes(payload[1..5].try_into().unwrap());
let iterations = u32::from_be_bytes(payload[5..9].try_into().unwrap());
let lanes = u32::from_be_bytes(payload[9..13].try_into().unwrap());
let salt = &payload[13..PROTECTED_PARAMS_LEN];
let encrypted = payload
.get(PROTECTED_PARAMS_LEN..)
.ok_or(FileError::Truncated(bytes.len()))?;
let key = derive_key(passphrase, salt, memory_kib, iterations, lanes)?;
let cipher = ChaCha20Poly1305::new(*key);
let plaintext = Zeroizing::new(cipher.decrypt(
encrypted,
&protected_header_aad(&payload[..PROTECTED_PARAMS_LEN]),
)?);
Ok(Keyring::from_bytes(&plaintext)?)
}
/// Explicitly save the legacy plaintext format for tests and development.
#[cfg(any(test, feature = "raw"))]
pub fn save_keyring_raw(keyring: &Keyring, path: impl AsRef<Path>) -> Result<(), FileError> {
let payload = keyring.try_to_bytes()?;
let bytes = Zeroizing::new(encode(KEYRING_MAGIC, RAW_FORMAT_VERSION, &payload));
write_secret_atomic(path.as_ref(), &bytes)?;
Ok(())
}
/// Explicitly load the legacy plaintext format for tests and development.
#[cfg(any(test, feature = "raw"))]
pub fn load_keyring_raw(path: impl AsRef<Path>) -> Result<Keyring, FileError> {
let bytes = Zeroizing::new(fs::read(path)?);
let (version, payload) = decode(&bytes, KEYRING_MAGIC, "keyring")?;
if version == PROTECTED_FORMAT_VERSION {
return Err(FileError::ProtectedKeyring);
}
if version != RAW_FORMAT_VERSION {
return Err(FileError::UnsupportedVersion {
kind: "keyring",
found: version,
});
}
Ok(Keyring::from_bytes(payload)?)
}
pub fn save_public_key_bundle(
bundle: &PublicKeyBundle,
path: impl AsRef<Path>,
) -> Result<(), FileError> {
let bundle_bytes = bundle.try_as_bytes()?;
let bytes = encode(BUNDLE_MAGIC, BUNDLE_FORMAT_VERSION, &bundle_bytes);
fs::write(path, bytes)?;
Ok(())
}
pub fn load_public_key_bundle(path: impl AsRef<Path>) -> Result<PublicKeyBundle, FileError> {
let bytes = fs::read(path)?;
let (version, payload) = decode(&bytes, BUNDLE_MAGIC, "public key bundle")?;
if version != BUNDLE_FORMAT_VERSION {
return Err(FileError::UnsupportedVersion {
kind: "public key bundle",
found: version,
});
}
Ok(PublicKeyBundle::from_bytes_validated(payload)?)
}
#[cfg(test)]
mod tests {
use super::*;
use mtp_crypto::keypair::{
KEM_PUBLIC_KEY_LEN, KemPrivateKey, KemPublicKey, SIG_CL_PUBLIC_KEY_LEN,
SIG_PQ_PUBLIC_KEY_LEN, SignaturePqPrivateKey, SignaturePqPublicKey, SignaturePrivateKey,
SignaturePublicKey,
};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
fn temp_path(ext: &str) -> PathBuf {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let mut path = std::env::temp_dir();
path.push(format!("mtp-files-test-{}-{n}.{ext}", std::process::id()));
path
}
fn sample_keyring() -> Keyring {
Keyring::new(
KemPublicKey::new(vec![1u8; KEM_PUBLIC_KEY_LEN]),
KemPrivateKey::new(vec![2u8; 32]),
SignaturePqPublicKey::new(vec![3u8; SIG_PQ_PUBLIC_KEY_LEN]),
SignaturePqPrivateKey::new(vec![4u8; 64]),
SignaturePublicKey::new(vec![5u8; SIG_CL_PUBLIC_KEY_LEN]),
SignaturePrivateKey::new(vec![6u8; 32]),
)
}
#[test]
fn keyring_save_load_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path(KEYRING_EXTENSION);
let keyring = sample_keyring();
save_keyring(&keyring, &path, b"correct horse battery staple")?;
let loaded = load_keyring(&path, b"correct horse battery staple")?;
assert_eq!(keyring.try_to_bytes()?, loaded.try_to_bytes()?);
let _ = fs::remove_file(&path);
Ok(())
}
#[test]
fn bundle_save_load_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path(BUNDLE_EXTENSION);
let bundle = Keyring::generate().public_key_bundle();
save_public_key_bundle(&bundle, &path)?;
let loaded = load_public_key_bundle(&path)?;
assert_eq!(bundle.try_as_bytes()?, loaded.try_as_bytes()?);
let _ = fs::remove_file(&path);
Ok(())
}
#[test]
fn loading_bundle_as_keyring_fails_on_magic() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path(BUNDLE_EXTENSION);
let bundle = Keyring::generate().public_key_bundle();
save_public_key_bundle(&bundle, &path)?;
assert!(matches!(
load_keyring(&path, b"passphrase"),
Err(FileError::BadMagic { .. })
));
let _ = fs::remove_file(&path);
Ok(())
}
#[test]
fn truncated_file_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path(KEYRING_EXTENSION);
fs::write(&path, b"MT")?;
assert!(matches!(
load_keyring(&path, b"passphrase"),
Err(FileError::Truncated(2))
));
let _ = fs::remove_file(&path);
Ok(())
}
#[cfg(unix)]
#[test]
fn keyring_file_is_owner_only() -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::PermissionsExt;
let path = temp_path(KEYRING_EXTENSION);
save_keyring(&sample_keyring(), &path, b"passphrase")?;
let mode = fs::metadata(&path)?.permissions().mode();
assert_eq!(mode & 0o777, 0o600);
let _ = fs::remove_file(&path);
Ok(())
}
#[test]
fn wrong_passphrase_cannot_load_keyring() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path(KEYRING_EXTENSION);
save_keyring(&sample_keyring(), &path, b"right passphrase")?;
assert!(matches!(
load_keyring(&path, b"wrong passphrase"),
Err(FileError::Crypto(CryptoError::DecryptionFailed))
));
let _ = fs::remove_file(&path);
Ok(())
}
#[test]
fn raw_keyring_requires_explicit_api() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path(KEYRING_EXTENSION);
let keyring = sample_keyring();
save_keyring_raw(&keyring, &path)?;
assert!(matches!(
load_keyring(&path, b"passphrase"),
Err(FileError::UnprotectedKeyring)
));
let loaded = load_keyring_raw(&path)?;
assert_eq!(keyring.try_to_bytes()?, loaded.try_to_bytes()?);
let _ = fs::remove_file(&path);
Ok(())
}
#[test]
fn protected_keyring_is_not_plaintext() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path(KEYRING_EXTENSION);
let keyring = sample_keyring();
let serialized = keyring.try_to_bytes()?;
save_keyring(&keyring, &path, b"passphrase")?;
let stored = fs::read(&path)?;
assert!(
!stored
.windows(serialized.len())
.any(|window| window == serialized.as_slice())
);
let _ = fs::remove_file(&path);
Ok(())
}
#[test]
fn protected_header_parameters_are_authenticated() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path(KEYRING_EXTENSION);
save_keyring(&sample_keyring(), &path, b"passphrase")?;
let mut stored = fs::read(&path)?;
// The iteration count begins after the file header, KDF identifier,
// and memory parameter: MTMK || version || KDF || memory.
stored[5 + 1 + 4 + 3] ^= 1;
fs::write(&path, stored)?;
assert!(matches!(
load_keyring(&path, b"passphrase"),
Err(FileError::Crypto(CryptoError::DecryptionFailed))
));
let _ = fs::remove_file(&path);
Ok(())
}
}

View file

@ -1,31 +1,34 @@
{ {
description = "MTP - Methanium Transport Protocol"; description = "MTP - Methanium Transport Protocol";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay.url = "github:oxalica/rust-overlay"; rust-overlay.url = "github:oxalica/rust-overlay";
}; };
outputs = { outputs =
{
self, self,
nixpkgs, nixpkgs,
rust-overlay, rust-overlay,
}: let }:
let
systems = [ systems = [
"aarch64-darwin" "aarch64-darwin"
"aarch64-linux" "aarch64-linux"
"x86_64-darwin" "x86_64-darwin"
"x86_64-linux" "x86_64-linux"
]; ];
eachSystem = f: eachSystem =
nixpkgs.lib.foldl' nixpkgs.lib.recursiveUpdate {} ( f:
map (system: nixpkgs.lib.mapAttrs (_: value: {${system} = value;}) (f system)) systems nixpkgs.lib.foldl' nixpkgs.lib.recursiveUpdate { } (
map (system: nixpkgs.lib.mapAttrs (_: value: { ${system} = value; }) (f system)) systems
); );
in in
eachSystem ( eachSystem (
system: let system:
overlays = [rust-overlay.overlays.default]; let
pkgs = import nixpkgs {inherit system overlays;}; overlays = [ rust-overlay.overlays.default ];
pkgs = import nixpkgs { inherit system overlays; };
rustToolchain = pkgs.rust-bin.stable.latest.default.override { rustToolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = [ extensions = [
@ -33,12 +36,12 @@
"clippy" "clippy"
"rustfmt" "rustfmt"
]; ];
targets = ["wasm32-unknown-unknown"]; targets = [ "wasm32-unknown-unknown" ];
}; };
clippyCheck = pkgs.writeShellApplication { clippyCheck = pkgs.writeShellApplication {
name = "mtp-clippy"; name = "mtp-clippy";
runtimeInputs = [rustToolchain]; runtimeInputs = [ rustToolchain ];
text = '' text = ''
export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}" export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}"
cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings -W unreachable-pub cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings -W unreachable-pub
@ -47,7 +50,7 @@
macheteCheck = pkgs.writeShellApplication { macheteCheck = pkgs.writeShellApplication {
name = "mtp-machete"; name = "mtp-machete";
runtimeInputs = [pkgs.cargo-machete]; runtimeInputs = [ pkgs.cargo-machete ];
text = '' text = ''
cargo machete "$@" cargo machete "$@"
''; '';
@ -55,7 +58,15 @@
buildAll = pkgs.writeShellApplication { buildAll = pkgs.writeShellApplication {
name = "mtp-build-all"; name = "mtp-build-all";
runtimeInputs = [rustToolchain pkgs.cargo-deny pkgs.wasm-pack pkgs.pnpm pkgs.coreutils clippyCheck macheteCheck]; runtimeInputs = [
rustToolchain
pkgs.cargo-deny
pkgs.wasm-pack
pkgs.pnpm
pkgs.coreutils
clippyCheck
macheteCheck
];
text = '' text = ''
export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}" export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}"
@ -63,24 +74,33 @@
cargo fmt --all --check cargo fmt --all --check
cargo b cargo b
cargo test --workspace --exclude mtp-wasm --all-features cargo test --workspace --exclude mtp-wasm --all-features
MTP_TYPE_MAPS="$PWD/example/type-maps.yaml" \
cargo check --manifest-path example/Cargo.toml --workspace --all-targets --all-features cargo check --manifest-path example/Cargo.toml --workspace --all-targets --all-features
mtp-clippy mtp-clippy
mtp-machete mtp-machete
pnpm run dup
pnpm run build pnpm run build
RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack test --node wasm
pnpm run test:e2e
pnpm run test:secrets
pnpm run test:types
pnpm run test:boundary
pnpm --filter mtp-web-client run build pnpm --filter mtp-web-client run build
''; '';
}; };
healthCheck = pkgs.writeShellApplication { healthCheck = pkgs.writeShellApplication {
name = "mtp-health"; name = "mtp-health";
runtimeInputs = [clippyCheck macheteCheck]; runtimeInputs = [
clippyCheck
macheteCheck
];
text = '' text = ''
mtp-clippy mtp-clippy
mtp-machete mtp-machete
''; '';
}; };
in { in
{
devShells = { devShells = {
default = pkgs.mkShell { default = pkgs.mkShell {
name = "mtp-dev"; name = "mtp-dev";

2
host/Cargo.lock generated
View file

@ -4,4 +4,4 @@ version = 4
[[package]] [[package]]
name = "host" name = "host"
version = "0.1.0" version = "0.2.0"

View file

@ -1,15 +1,21 @@
[package] [package]
name = "mtp-host" name = "mtp-host"
version = "0.1.0" version = "0.3.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
mtp-common = { version = "0.1.0", path = "../common" } mtp-common = { version = "0.3.0", path = "../common" }
mtp-codec = { version = "0.1.0", path = "../codec", features = ["registry"] } mtp-codec = { version = "0.3.0", path = "../codec", features = ["registry"] }
mtp-transport = { version = "0.1.0", path = "../transport", features = ["host"] } mtp-transport = { version = "0.3.0", path = "../transport", features = ["host"] }
mtp-crypto = { version = "0.1.0", path = "../crypto", optional = true } mtp-crypto = { version = "0.3.0", path = "../crypto", optional = true }
rand = "0.8" rand = "0.10"
tokio = { version = "1", features = ["time"] } thiserror = "2"
tokio = { version = "1", features = ["macros", "rt", "time", "sync"] }
tracing = "0.1"
wtransport = "0.7"
[features] [features]
crypto = ["dep:mtp-crypto", "mtp-codec/crypto"] crypto = ["dep:mtp-crypto", "mtp-codec/crypto"]
pipes = ["mtp-common/pipes", "mtp-transport/pipes"]

525
host/src/config.rs Normal file
View file

@ -0,0 +1,525 @@
use std::net::IpAddr;
#[cfg(feature = "crypto")]
use std::collections::HashMap;
#[cfg(feature = "crypto")]
use std::collections::HashSet;
#[cfg(feature = "crypto")]
use std::collections::VecDeque;
#[cfg(feature = "crypto")]
use std::pin::Pin;
#[cfg(feature = "crypto")]
use std::sync::{Arc, Mutex};
#[cfg(feature = "crypto")]
use std::time::{Duration as StdDuration, Instant};
#[cfg(feature = "crypto")]
use tokio::time::Duration;
pub use mtp_transport::Policy;
/// Callback that looks up a registered client by ID.
///
/// Called during login to retrieve a client's public key bundle for signature
/// verification, and also during guest ID generation to check whether a random
/// candidate collides with a registered client. When used for collision
/// checking the `description` argument is `None`.
#[cfg(feature = "crypto")]
pub type GetExistingClient = Box<
dyn Fn(
u64,
Option<String>,
)
-> Pin<Box<dyn std::future::Future<Output = Option<mtp_crypto::PublicKeyBundle>> + Send>>
+ Send
+ Sync,
>;
/// Callback that assigns a guest (unauthenticated) client ID.
///
/// Return `Some(id)` to accept the guest with the given full-width `u64` ID, or
/// `None` to reject the connection.
///
/// When set to `None` on `HostConfig`, the built-in generator produces a random
/// full-width non-zero ID that avoids collisions with registered clients and
/// currently connected guests.
#[cfg(feature = "crypto")]
pub type GuestIdGenerator =
Box<dyn Fn() -> Pin<Box<dyn std::future::Future<Output = Option<u64>> + Send>> + Send + Sync>;
#[cfg(feature = "crypto")]
/// Callback that commits a new registration and returns its non-zero ID.
///
/// The host serializes registration commits and remembers successful identity
/// assignments for the lifetime of the host. Applications that need retry
/// recovery across a host restart should also configure [`FindRegisteredClient`]
/// to look up the public identity in persistent storage.
pub type CompleteRegister = Box<
dyn Fn(
mtp_crypto::PublicKeyBundle,
Option<String>,
) -> Pin<Box<dyn std::future::Future<Output = u64> + Send>>
+ Send
+ Sync,
>;
/// Callback that recovers an existing registration by its public identity.
///
/// Returning an ID makes a registration retry idempotent: the host can send
/// the same final response when the original response was lost after the
/// application committed the registration. Returning `None` asks the host to
/// invoke [`CompleteRegister`] for a new registration.
#[cfg(feature = "crypto")]
pub type FindRegisteredClient = Box<
dyn Fn(
mtp_crypto::PublicKeyBundle,
Option<String>,
) -> Pin<Box<dyn std::future::Future<Output = Option<u64>> + Send>>
+ Send
+ Sync,
>;
#[cfg(feature = "crypto")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthenticationPolicy {
ForceAuthentication,
AllowAuthentication,
Unauthenticated,
}
/// Transport-supplied identity used to scope authentication attempt limits.
/// Concrete hosts should populate these fields from the accepted connection;
/// the zero/empty defaults exist only for transport-neutral callers.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AuthenticationContext {
pub peer_network_identity: Option<String>,
pub connection_id: u64,
}
#[cfg(feature = "crypto")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthenticationAttempt {
pub peer_network_identity: Option<String>,
pub connection_id: u64,
pub claimed_client_id: Option<u64>,
pub registration: bool,
}
#[cfg(feature = "crypto")]
#[derive(Debug, thiserror::Error)]
pub enum AuthenticationLimitError {
#[error("authentication limiter storage is unavailable")]
Store,
}
#[cfg(feature = "crypto")]
pub trait AuthenticationAttemptLimiter: Send + Sync {
fn allow(&self, context: &AuthenticationAttempt) -> Result<bool, AuthenticationLimitError>;
}
#[cfg(feature = "crypto")]
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
enum AuthenticationLimitKey {
Peer(String),
Connection(u64),
Client(u64),
Registration,
Global,
}
#[cfg(feature = "crypto")]
#[derive(Debug)]
pub struct InMemoryAuthenticationAttemptLimiter {
max_attempts: usize,
window: StdDuration,
max_keys: usize,
by_peer: bool,
by_connection: bool,
by_client: bool,
by_registration: bool,
attempts: Mutex<HashMap<AuthenticationLimitKey, VecDeque<Instant>>>,
}
#[cfg(feature = "crypto")]
impl InMemoryAuthenticationAttemptLimiter {
pub fn new(max_attempts: usize, window: StdDuration) -> Self {
Self {
max_attempts,
window,
max_keys: 100_000,
by_peer: true,
by_connection: true,
by_client: true,
by_registration: true,
attempts: Mutex::new(HashMap::new()),
}
}
pub fn with_keys(
mut self,
by_peer: bool,
by_connection: bool,
by_client: bool,
by_registration: bool,
) -> Self {
self.by_peer = by_peer;
self.by_connection = by_connection;
self.by_client = by_client;
self.by_registration = by_registration;
self
}
pub fn with_max_keys(mut self, max_keys: usize) -> Self {
self.max_keys = max_keys.max(1);
self
}
fn keys(&self, context: &AuthenticationAttempt) -> Vec<AuthenticationLimitKey> {
let mut keys = Vec::with_capacity(5);
if self.by_peer
&& let Some(peer) = context.peer_network_identity.as_ref()
{
keys.push(AuthenticationLimitKey::Peer(peer.clone()));
}
if self.by_connection && context.connection_id != 0 {
keys.push(AuthenticationLimitKey::Connection(context.connection_id));
}
if self.by_client
&& let Some(client_id) = context.claimed_client_id
{
keys.push(AuthenticationLimitKey::Client(client_id));
}
if self.by_registration && context.registration {
keys.push(AuthenticationLimitKey::Registration);
}
// Keep one global bucket as a backstop when an attacker varies the
// claimed client ID or presents no peer/connection identity.
keys.push(AuthenticationLimitKey::Global);
keys
}
}
#[cfg(feature = "crypto")]
impl AuthenticationAttemptLimiter for InMemoryAuthenticationAttemptLimiter {
fn allow(&self, context: &AuthenticationAttempt) -> Result<bool, AuthenticationLimitError> {
if self.max_attempts == 0 {
return Ok(false);
}
let now = Instant::now();
let cutoff = now.checked_sub(self.window);
let keys = self.keys(context);
let mut attempts = self
.attempts
.lock()
.map_err(|_| AuthenticationLimitError::Store)?;
for key in &keys {
if let Some(history) = attempts.get_mut(key) {
while history
.front()
.is_some_and(|timestamp| cutoff.is_some_and(|cutoff| *timestamp <= cutoff))
{
history.pop_front();
}
if history.len() >= self.max_attempts {
return Ok(false);
}
}
}
for key in keys {
if !attempts.contains_key(&key)
&& attempts.len() >= self.max_keys
&& let Some(oldest) = attempts.keys().next().cloned()
{
attempts.remove(&oldest);
}
attempts.entry(key).or_default().push_back(now);
}
Ok(true)
}
}
pub struct HostConfig {
pub ip: IpAddr,
pub port: u16,
pub tls_fullchain: Vec<u8>,
pub tls_key: Vec<u8>,
pub policy: Policy,
pub send_pongs: bool,
#[cfg(feature = "crypto")]
pub authentication_policy: AuthenticationPolicy,
#[cfg(feature = "crypto")]
authentication_policy_explicit: bool,
#[cfg(feature = "crypto")]
pub auth_timeout: Duration,
#[cfg(feature = "crypto")]
pub require_pq: bool,
#[cfg(feature = "crypto")]
pub host_keyring: mtp_crypto::Keyring,
#[cfg(feature = "crypto")]
pub get_existing_client: GetExistingClient,
#[cfg(feature = "crypto")]
pub(crate) active_guest_ids: Arc<Mutex<HashSet<u64>>>,
#[cfg(feature = "crypto")]
pub(crate) registration_ids: Arc<Mutex<HashMap<Vec<u8>, u64>>>,
#[cfg(feature = "crypto")]
pub(crate) registration_lock: Arc<tokio::sync::Mutex<()>>,
#[cfg(feature = "crypto")]
pub guest_id_generator: Option<GuestIdGenerator>,
#[cfg(feature = "crypto")]
pub complete_register: CompleteRegister,
#[cfg(feature = "crypto")]
pub find_registered_client: Option<FindRegisteredClient>,
#[cfg(feature = "crypto")]
pub auth_limiter: Arc<dyn AuthenticationAttemptLimiter>,
#[cfg(feature = "crypto")]
pub conceal_authentication_identities: bool,
}
impl HostConfig {
pub fn new(ip: IpAddr, port: u16, tls_fullchain: Vec<u8>, tls_key: Vec<u8>) -> Self {
Self {
ip,
port,
tls_fullchain,
tls_key,
policy: Policy::default(),
send_pongs: true,
#[cfg(feature = "crypto")]
authentication_policy: AuthenticationPolicy::Unauthenticated,
#[cfg(feature = "crypto")]
authentication_policy_explicit: false,
#[cfg(feature = "crypto")]
auth_timeout: Duration::from_secs(30),
#[cfg(feature = "crypto")]
require_pq: true,
#[cfg(feature = "crypto")]
host_keyring: mtp_crypto::Keyring::new(
mtp_crypto::KemPublicKey::new(Vec::new()),
mtp_crypto::KemPrivateKey::new(Vec::new()),
mtp_crypto::SignaturePqPublicKey::new(Vec::new()),
mtp_crypto::SignaturePqPrivateKey::new(Vec::new()),
mtp_crypto::SignaturePublicKey::new(Vec::new()),
mtp_crypto::SignaturePrivateKey::new(Vec::new()),
),
#[cfg(feature = "crypto")]
get_existing_client: Box::new(|_, _| Box::pin(async { None })),
#[cfg(feature = "crypto")]
active_guest_ids: Arc::new(Mutex::new(HashSet::new())),
#[cfg(feature = "crypto")]
registration_ids: Arc::new(Mutex::new(HashMap::new())),
#[cfg(feature = "crypto")]
registration_lock: Arc::new(tokio::sync::Mutex::new(())),
#[cfg(feature = "crypto")]
guest_id_generator: None,
#[cfg(feature = "crypto")]
complete_register: Box::new(|_, _| Box::pin(async { 0 })),
#[cfg(feature = "crypto")]
find_registered_client: None,
#[cfg(feature = "crypto")]
auth_limiter: Arc::new(InMemoryAuthenticationAttemptLimiter::new(
32,
StdDuration::from_secs(60),
)),
#[cfg(feature = "crypto")]
conceal_authentication_identities: true,
}
}
pub fn with_policy(mut self, policy: Policy) -> Self {
self.policy = policy;
self
}
pub fn with_pongs(mut self, send_pongs: bool) -> Self {
self.send_pongs = send_pongs;
self
}
#[cfg(feature = "crypto")]
pub fn with_authentication(
mut self,
host_keyring: mtp_crypto::Keyring,
get_existing_client: GetExistingClient,
complete_register: CompleteRegister,
) -> Self {
if !self.authentication_policy_explicit {
self.authentication_policy = AuthenticationPolicy::ForceAuthentication;
}
self.host_keyring = host_keyring;
self.get_existing_client = Box::new(get_existing_client);
self.complete_register = Box::new(complete_register);
self
}
#[cfg(feature = "crypto")]
pub fn with_authentication_policy(mut self, policy: AuthenticationPolicy) -> Self {
self.authentication_policy = policy;
self.authentication_policy_explicit = true;
self
}
#[cfg(feature = "crypto")]
pub fn with_auth_timeout(mut self, timeout: Duration) -> Self {
self.auth_timeout = timeout;
self
}
#[cfg(feature = "crypto")]
pub fn with_require_pq(mut self, require_pq: bool) -> Self {
self.require_pq = require_pq;
self
}
#[cfg(feature = "crypto")]
pub fn with_guest_id_generator(mut self, generator: GuestIdGenerator) -> Self {
self.guest_id_generator = Some(generator);
self
}
/// Configure the lookup used to make registration retries idempotent.
#[cfg(feature = "crypto")]
pub fn with_registration_lookup(mut self, lookup: FindRegisteredClient) -> Self {
self.find_registered_client = Some(lookup);
self
}
#[cfg(feature = "crypto")]
pub fn with_authentication_limiter(
mut self,
limiter: Arc<dyn AuthenticationAttemptLimiter>,
) -> Self {
self.auth_limiter = limiter;
self
}
#[cfg(feature = "crypto")]
pub fn with_authentication_identity_concealment(mut self, conceal: bool) -> Self {
self.conceal_authentication_identities = conceal;
self
}
}
#[cfg(all(test, feature = "crypto"))]
mod tests {
use super::*;
#[test]
fn authentication_attempt_limiter_rejects_repeated_attempts() {
let limiter = InMemoryAuthenticationAttemptLimiter::new(1, StdDuration::from_secs(60))
.with_keys(false, true, false, false);
let attempt = AuthenticationAttempt {
peer_network_identity: None,
connection_id: 9,
claimed_client_id: Some(42),
registration: false,
};
assert!(limiter.allow(&attempt).expect("first attempt decision"));
assert!(!limiter.allow(&attempt).expect("second attempt decision"));
}
#[test]
fn authentication_attempt_limiter_can_scope_registration_separately() {
let limiter = InMemoryAuthenticationAttemptLimiter::new(2, StdDuration::from_secs(60))
.with_keys(false, false, false, true);
let login = AuthenticationAttempt {
peer_network_identity: None,
connection_id: 1,
claimed_client_id: None,
registration: false,
};
let registration = AuthenticationAttempt {
registration: true,
..login.clone()
};
assert!(limiter.allow(&login).expect("login attempt decision"));
assert!(
limiter
.allow(&registration)
.expect("registration attempt decision")
);
assert!(
!limiter
.allow(&registration)
.expect("repeated registration decision")
);
}
fn test_keyring() -> mtp_crypto::Keyring {
mtp_crypto::Keyring::new(
mtp_crypto::KemPublicKey::new(Vec::new()),
mtp_crypto::KemPrivateKey::new(Vec::new()),
mtp_crypto::SignaturePqPublicKey::new(Vec::new()),
mtp_crypto::SignaturePqPrivateKey::new(Vec::new()),
mtp_crypto::SignaturePublicKey::new(Vec::new()),
mtp_crypto::SignaturePrivateKey::new(Vec::new()),
)
}
fn test_get_existing_client() -> GetExistingClient {
Box::new(|_, _| Box::pin(async { None }))
}
fn test_complete_register() -> CompleteRegister {
Box::new(|_, _| Box::pin(async { 1 }))
}
fn test_config() -> HostConfig {
HostConfig::new(
IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
4433,
Vec::new(),
Vec::new(),
)
}
#[test]
fn with_authentication_defaults_to_force_authentication() {
let config = test_config().with_authentication(
test_keyring(),
test_get_existing_client(),
test_complete_register(),
);
assert_eq!(
config.authentication_policy,
AuthenticationPolicy::ForceAuthentication
);
}
#[test]
fn explicit_authentication_policy_before_with_authentication_is_preserved() {
let config = test_config()
.with_authentication_policy(AuthenticationPolicy::AllowAuthentication)
.with_authentication(
test_keyring(),
test_get_existing_client(),
test_complete_register(),
);
assert_eq!(
config.authentication_policy,
AuthenticationPolicy::AllowAuthentication
);
}
#[test]
fn explicit_authentication_policy_after_with_authentication_is_preserved() {
let config = test_config()
.with_authentication(
test_keyring(),
test_get_existing_client(),
test_complete_register(),
)
.with_authentication_policy(AuthenticationPolicy::AllowAuthentication);
assert_eq!(
config.authentication_policy,
AuthenticationPolicy::AllowAuthentication
);
}
}

392
host/src/connection.rs Normal file
View file

@ -0,0 +1,392 @@
#[cfg(feature = "pipes")]
use mtp_codec::{CommunicationType, DataType, DataValue};
use mtp_codec::{CommunicationValue, Version, registry::VersionedCodec};
use mtp_common::CommunicationError;
use std::net::SocketAddr;
#[cfg(feature = "pipes")]
use std::sync::Arc;
#[cfg(feature = "pipes")]
use tokio::sync::{Mutex, mpsc};
#[cfg(feature = "crypto")]
use crate::error::random_client_id;
#[cfg(feature = "pipes")]
use crate::pipe::{
PendingCreationGuard, PipeDispatcher, PipeReceiver, PipeRequest, PipeSender,
is_expired_creation, run_dispatcher,
};
#[cfg(feature = "pipes")]
use mtp_transport::Policy;
mod connection_capability {
pub trait Sealed {}
}
pub trait MtpSenderLike: connection_capability::Sealed + Clone + Send + Sync {}
pub trait MtpReceiverLike: connection_capability::Sealed + Clone + Send + Sync {
fn receive_message(
&self,
) -> impl std::future::Future<Output = Result<CommunicationValue, CommunicationError>> + Send;
}
impl connection_capability::Sealed for mtp_transport::Sender {}
impl MtpSenderLike for mtp_transport::Sender {}
impl connection_capability::Sealed for mtp_transport::Receiver {}
impl MtpReceiverLike for mtp_transport::Receiver {
async fn receive_message(&self) -> Result<CommunicationValue, CommunicationError> {
self.receive().await
}
}
impl<C: mtp_transport::TransportConnection> connection_capability::Sealed
for mtp_transport::GenericSender<C>
{
}
impl<C: mtp_transport::TransportConnection> MtpSenderLike for mtp_transport::GenericSender<C> {}
impl<C: mtp_transport::TransportConnection> connection_capability::Sealed
for mtp_transport::GenericReceiver<C>
{
}
impl<C: mtp_transport::TransportConnection> MtpReceiverLike for mtp_transport::GenericReceiver<C> {
async fn receive_message(&self) -> Result<CommunicationValue, CommunicationError> {
self.receive().await
}
}
pub struct MTPConnection<
S = mtp_transport::Sender,
R = mtp_transport::Receiver,
P = wtransport::RecvStream,
> {
pub version: Version,
pub codec: VersionedCodec,
pub sender: S,
pub receiver: R,
/// The WebTransport request path used to establish this connection.
///
/// Legacy `MTPHost` connections do not have an HTTP router in front of
/// them, so they always use the root path. Alternative hosts can retain
/// the CONNECT request path when constructing an MTP connection.
pub path: String,
/// The address of the peer that established this connection, when exposed
/// by the underlying transport.
pub remote_addr: Option<SocketAddr>,
#[cfg(feature = "pipes")]
pub(crate) app_rx: Mutex<mpsc::Receiver<Result<CommunicationValue, CommunicationError>>>,
#[cfg(feature = "pipes")]
pub(crate) pipe_req_rx: Mutex<mpsc::Receiver<PipeRequest<S, R, P>>>,
#[cfg(feature = "pipes")]
pub(crate) pipe_dispatcher: Arc<PipeDispatcher<P>>,
#[cfg(not(feature = "pipes"))]
pub(crate) _pipe_stream: std::marker::PhantomData<P>,
pub description: Option<String>,
pub(crate) _dispatcher_task: tokio::task::JoinHandle<()>,
/// Keeps an outer server admission permit alive for this MTP session.
/// Native hosts leave it empty; WebTransport hosts use it to make the
/// configured connection limit cover the session lifetime.
pub(crate) _connection_guard: Option<tokio::sync::OwnedSemaphorePermit>,
#[cfg(feature = "crypto")]
pub auth_state: crate::error::AuthState,
#[cfg(feature = "crypto")]
pub client_id: u64,
#[cfg(feature = "crypto")]
pub client_public_key: Option<mtp_crypto::PublicKeyBundle>,
#[cfg(feature = "crypto")]
pub(crate) guest_id_lease: Option<crate::engine::GuestIdLease>,
}
impl<S, R, P> MTPConnection<S, R, P> {
/// Keep an outer server admission permit until this connection is dropped.
pub fn set_connection_guard(&mut self, guard: tokio::sync::OwnedSemaphorePermit) {
self._connection_guard = Some(guard);
}
#[cfg(feature = "crypto")]
pub fn set_guest_id_lease(&mut self, lease: Option<crate::engine::GuestIdLease>) {
self.guest_id_lease = lease;
}
}
#[cfg(feature = "pipes")]
impl<S, R, P> MTPConnection<S, R, P>
where
S: PipeSender,
R: PipeReceiver<P>,
P: tokio::io::AsyncRead + Send + Unpin + 'static,
{
/// Construct an MTP connection from an alternative transport backend.
///
/// Native `MTPHost` users continue to receive the default
/// `MTPConnection<Sender, Receiver>` type. HTTP/3 WebTransport hosts use
/// this constructor with their stream adapters while retaining the shared
/// version, codec, path, and metadata representation.
pub fn from_transport_parts(
version: Version,
codec: VersionedCodec,
sender: S,
receiver: R,
path: String,
description: Option<String>,
) -> Self {
Self::from_transport_parts_with_remote_addr(
version,
codec,
sender,
receiver,
path,
description,
None,
)
}
pub fn from_transport_parts_with_remote_addr(
version: Version,
codec: VersionedCodec,
sender: S,
receiver: R,
path: String,
description: Option<String>,
remote_addr: Option<SocketAddr>,
) -> Self {
let policy = Arc::new(Policy::default());
let receiver_queue_capacity = policy.receiver_queue_capacity.max(1);
let (app_tx, app_rx) = mpsc::channel(receiver_queue_capacity);
let (pipe_req_tx, pipe_req_rx) = mpsc::channel(receiver_queue_capacity);
let dispatcher = Arc::new(PipeDispatcher {
pending_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
expired_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
pending_pipes: Mutex::new(std::collections::HashMap::new()),
policy,
type_map: codec.type_map().clone(),
});
let task = tokio::spawn(run_dispatcher(
receiver.clone(),
sender.clone(),
app_tx,
pipe_req_tx,
dispatcher.clone(),
));
Self {
version,
codec,
sender,
receiver,
path,
remote_addr,
app_rx: Mutex::new(app_rx),
pipe_req_rx: Mutex::new(pipe_req_rx),
pipe_dispatcher: dispatcher,
description,
_dispatcher_task: task,
_connection_guard: None,
#[cfg(feature = "crypto")]
auth_state: crate::error::AuthState::Unauthenticated,
#[cfg(feature = "crypto")]
client_id: random_client_id(),
#[cfg(feature = "crypto")]
client_public_key: None,
#[cfg(feature = "crypto")]
guest_id_lease: None,
}
}
/// Construct an MTP connection with an explicit policy for pipe dispatch.
// The shared transport constructor keeps its argument order aligned with
// `from_transport_parts_with_remote_addr`; policy is required only here.
#[allow(clippy::too_many_arguments)]
pub fn from_transport_parts_with_policy(
version: Version,
codec: VersionedCodec,
sender: S,
receiver: R,
path: String,
description: Option<String>,
remote_addr: Option<SocketAddr>,
policy: Arc<Policy>,
) -> Self {
let receiver_queue_capacity = policy.receiver_queue_capacity.max(1);
let (app_tx, app_rx) = mpsc::channel(receiver_queue_capacity);
let (pipe_req_tx, pipe_req_rx) = mpsc::channel(receiver_queue_capacity);
let dispatcher = Arc::new(PipeDispatcher {
pending_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
expired_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
pending_pipes: Mutex::new(std::collections::HashMap::new()),
policy,
type_map: codec.type_map().clone(),
});
let task = tokio::spawn(run_dispatcher(
receiver.clone(),
sender.clone(),
app_tx,
pipe_req_tx,
dispatcher.clone(),
));
Self {
version,
codec,
sender,
receiver,
path,
remote_addr,
app_rx: Mutex::new(app_rx),
pipe_req_rx: Mutex::new(pipe_req_rx),
pipe_dispatcher: dispatcher,
description,
_dispatcher_task: task,
_connection_guard: None,
#[cfg(feature = "crypto")]
auth_state: crate::error::AuthState::Unauthenticated,
#[cfg(feature = "crypto")]
client_id: random_client_id(),
#[cfg(feature = "crypto")]
client_public_key: None,
#[cfg(feature = "crypto")]
guest_id_lease: None,
}
}
}
#[cfg(not(feature = "pipes"))]
impl<S, R, P> MTPConnection<S, R, P> {
pub fn from_transport_parts(
version: Version,
codec: VersionedCodec,
sender: S,
receiver: R,
path: String,
description: Option<String>,
) -> Self {
Self::from_transport_parts_with_remote_addr(
version,
codec,
sender,
receiver,
path,
description,
None,
)
}
pub fn from_transport_parts_with_remote_addr(
version: Version,
codec: VersionedCodec,
sender: S,
receiver: R,
path: String,
description: Option<String>,
remote_addr: Option<SocketAddr>,
) -> Self {
Self {
version,
codec,
sender,
receiver,
path,
remote_addr,
description,
_pipe_stream: std::marker::PhantomData,
_dispatcher_task: tokio::spawn(async {}),
_connection_guard: None,
#[cfg(feature = "crypto")]
auth_state: crate::error::AuthState::Unauthenticated,
#[cfg(feature = "crypto")]
client_id: random_client_id(),
#[cfg(feature = "crypto")]
client_public_key: None,
#[cfg(feature = "crypto")]
guest_id_lease: None,
}
}
}
#[cfg(not(feature = "pipes"))]
impl<S: MtpSenderLike, R: MtpReceiverLike> MTPConnection<S, R> {
pub async fn receive(&self) -> Result<CommunicationValue, CommunicationError> {
let mut message = self.receiver.receive_message().await?;
message.set_type_map(self.codec.type_map());
Ok(message)
}
}
#[cfg(feature = "pipes")]
impl<S, R, P> MTPConnection<S, R, P>
where
S: PipeSender,
P: tokio::io::AsyncRead + Send + Unpin + 'static,
{
pub async fn receive(&self) -> Result<CommunicationValue, CommunicationError> {
let mut rx = self.app_rx.lock().await;
match rx.recv().await {
Some(Ok(mut message)) => {
message.set_type_map(self.codec.type_map());
Ok(message)
}
Some(Err(error)) => Err(error),
None => Err(CommunicationError::StreamClosed),
}
}
pub async fn create_pipe(
&self,
description: &str,
) -> Result<crate::pipe::PipeHandle<S, P>, mtp_common::PipeError> {
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
let pipe_id = {
let mut pending = self
.pipe_dispatcher
.pending_creations
.lock()
.map_err(|_| mtp_common::PipeError::ConnectionClosed)?;
let pipe_id = loop {
let candidate = rand::random::<u32>();
if candidate != 0
&& !pending.contains_key(&candidate)
&& !is_expired_creation(&self.pipe_dispatcher, candidate)
{
break candidate;
}
};
let token = Arc::new(());
pending.insert(
pipe_id,
crate::pipe::PendingCreation {
token: token.clone(),
sender: response_tx,
},
);
drop(pending);
(pipe_id, token)
};
let (pipe_id, token) = pipe_id;
let mut creation_guard =
PendingCreationGuard::new(self.pipe_dispatcher.clone(), pipe_id, token.clone());
let request = CommunicationValue::new_with_type_map(
CommunicationType::PipeRequest,
self.codec.type_map(),
)
.with_id(pipe_id)
.add_typed_default(DataType::Description, DataValue::Str(description.into()));
if let Err(error) = self.sender.send_pipe_message(&request).await {
return Err(mtp_common::PipeError::from(error));
}
creation_guard.disarm();
Ok(crate::pipe::PipeHandle {
pipe_id,
description: description.to_owned(),
sender: self.sender.clone(),
response_rx,
dispatcher: self.pipe_dispatcher.clone(),
token,
})
}
pub async fn receive_pipe(&self) -> Result<PipeRequest<S, R, P>, CommunicationError> {
self.pipe_req_rx
.lock()
.await
.recv()
.await
.ok_or(CommunicationError::StreamClosed)
}
}

1306
host/src/engine.rs Executable file

File diff suppressed because it is too large Load diff

58
host/src/error.rs Normal file
View file

@ -0,0 +1,58 @@
use mtp_codec::Version;
use mtp_common::CommunicationError;
use std::{error::Error, fmt};
#[cfg(test)]
use mtp_codec::{CommunicationValue, DataType, DataValue};
#[cfg(feature = "crypto")]
pub(crate) fn random_client_id() -> u64 {
rand::random::<u64>()
}
#[cfg(test)]
pub(crate) fn extract_version(msg: &CommunicationValue) -> Option<Version> {
match msg.get_data(DataType::Version) {
Some(DataValue::Str(s)) => Version::parse(s.as_str()),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AcceptError {
Receive(CommunicationError),
MissingVersion,
UnsupportedVersion(Version),
AuthenticationFailed(String),
AuthenticationTimedOut,
Send(CommunicationError),
}
impl fmt::Display for AcceptError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Receive(error) => write!(f, "failed to receive opening message: {error}"),
Self::MissingVersion => write!(
f,
"opening message did not include a valid protocol version"
),
Self::UnsupportedVersion(version) => {
write!(f, "unsupported protocol version: {version}")
}
Self::AuthenticationFailed(reason) => write!(f, "authentication failed: {reason}"),
Self::AuthenticationTimedOut => write!(f, "authentication handshake timed out"),
Self::Send(error) => write!(f, "failed to send handshake message: {error}"),
}
}
}
impl Error for AcceptError {}
#[cfg(feature = "crypto")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthState {
Unauthenticated,
Pending,
Authenticated,
Failed,
}

334
host/src/handshake.rs Normal file
View file

@ -0,0 +1,334 @@
#[cfg(feature = "crypto")]
use mtp_codec::registry::Registry;
#[cfg(not(feature = "crypto"))]
use mtp_codec::{
Version,
registry::{Registry, VersionedCodec},
};
use mtp_transport::{Receiver, Sender};
use std::sync::Arc;
use std::time::Instant;
#[cfg(feature = "pipes")]
use tokio::sync::mpsc;
use crate::config::{AuthenticationContext, HostConfig};
use crate::connection::MTPConnection;
use crate::engine::HandshakeEngine;
use crate::error::AcceptError;
#[cfg(feature = "pipes")]
use crate::pipe::PipeDispatcher;
#[cfg(feature = "pipes")]
use crate::pipe::run_dispatcher;
pub struct MTPHost {
pub(crate) transport: mtp_transport::Host,
pub(crate) context: Arc<HandshakeContext>,
pub(crate) handshakes: tokio::task::JoinSet<Result<Option<MTPConnection>, AcceptError>>,
pub(crate) transport_closed: bool,
}
pub(crate) struct HandshakeContext {
pub(crate) registry: Registry,
pub(crate) config: Arc<HostConfig>,
}
impl MTPHost {
pub async fn new(config: HostConfig) -> Result<Self, mtp_common::CommunicationError> {
let registry = Registry::builtin();
let transport = mtp_transport::host(
config.ip,
config.port,
config.tls_fullchain.clone(),
config.tls_key.clone(),
config.policy,
)
.await?;
Ok(Self {
transport,
context: Arc::new(HandshakeContext {
registry,
config: Arc::new(config),
}),
handshakes: tokio::task::JoinSet::new(),
transport_closed: false,
})
}
pub async fn accept(&mut self) -> Result<Option<MTPConnection>, AcceptError> {
loop {
if self.transport_closed {
return match self.handshakes.join_next().await {
Some(Ok(result)) => result,
Some(Err(error)) => Err(AcceptError::AuthenticationFailed(format!(
"handshake task failed: {error}"
))),
None => Ok(None),
};
}
if self.handshakes.is_empty() {
let incoming_started = Instant::now();
match self.transport.next().await {
Some((sender, receiver)) => {
tracing::debug!(elapsed = ?incoming_started.elapsed(), "host accept loop: dispatch authentication handshake");
let context = self.context.clone();
self.handshakes.spawn(async move {
let handshake_started = Instant::now();
let result = context.accept_pair_timed(sender, receiver).await;
tracing::debug!(elapsed = ?handshake_started.elapsed(), success = result.is_ok(), "host accept loop: authentication handshake finished");
result
});
continue;
}
None => {
self.transport_closed = true;
continue;
}
}
}
tokio::select! {
completed = self.handshakes.join_next() => {
if let Some(completed) = completed {
return completed.unwrap_or_else(|error| {
Err(AcceptError::AuthenticationFailed(format!(
"handshake task failed: {error}"
)))
});
}
}
incoming = self.transport.next() => {
match incoming {
Some((sender, receiver)) => {
tracing::debug!("host accept loop: dispatch authentication handshake");
let context = self.context.clone();
self.handshakes
.spawn(async move {
let handshake_started = Instant::now();
let result = context.accept_pair_timed(sender, receiver).await;
tracing::debug!(elapsed = ?handshake_started.elapsed(), success = result.is_ok(), "host accept loop: authentication handshake finished");
result
});
}
None => self.transport_closed = true,
}
}
}
}
}
pub fn local_addr(&self) -> std::net::SocketAddr {
self.transport.local_addr()
}
pub fn registry(&self) -> &Registry {
&self.context.registry
}
}
impl HandshakeContext {
async fn accept_pair_timed(
&self,
sender: Sender,
receiver: Receiver,
) -> Result<Option<MTPConnection>, AcceptError> {
let engine = HandshakeEngine::new(self.registry.clone(), self.config.clone());
let authentication_context = AuthenticationContext {
peer_network_identity: sender
.handle()
.remote_addr()
.map(|address| address.to_string()),
connection_id: sender.handle().connection_id(),
};
let result = engine
.accept_with_context(&sender, &receiver, authentication_context)
.await?;
#[cfg(feature = "crypto")]
{
Ok(Some(self.connection_from_handshake_result(
sender, receiver, result,
)))
}
#[cfg(not(feature = "crypto"))]
{
Ok(Some(self.connection_from_parts(
sender,
receiver,
result.negotiated_version,
result.codec,
result.description,
)))
}
}
#[cfg(feature = "crypto")]
pub(crate) fn connection_from_handshake_result(
&self,
sender: Sender,
receiver: Receiver,
result: crate::engine::HandshakeResult,
) -> MTPConnection {
let remote_addr = sender.handle().remote_addr();
receiver.set_max_message_size(self.config.policy.max_message_size);
#[cfg(feature = "pipes")]
let type_map = result.codec.type_map().clone();
#[cfg(feature = "pipes")]
{
if self.config.send_pongs {
receiver.respond_to_pings(sender.clone());
}
let receiver_queue_capacity = self.config.policy.receiver_queue_capacity.max(1);
let (app_tx, app_rx) = mpsc::channel(receiver_queue_capacity);
let (pipe_req_tx, pipe_req_rx) = mpsc::channel(receiver_queue_capacity);
let dispatcher = Arc::new(PipeDispatcher {
pending_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
expired_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
pending_pipes: tokio::sync::Mutex::new(std::collections::HashMap::new()),
policy: Arc::new(self.config.policy),
type_map: type_map.clone(),
});
let dispatcher_clone = dispatcher.clone();
let receiver_clone = receiver.clone();
let sender_clone = sender.clone();
let task = tokio::spawn(run_dispatcher(
receiver_clone,
sender_clone,
app_tx,
pipe_req_tx,
dispatcher_clone,
));
MTPConnection {
version: result.negotiated_version,
codec: result.codec,
sender,
receiver,
path: "/".to_string(),
remote_addr,
app_rx: tokio::sync::Mutex::new(app_rx),
pipe_req_rx: tokio::sync::Mutex::new(pipe_req_rx),
pipe_dispatcher: dispatcher,
description: result.description,
_dispatcher_task: task,
_connection_guard: None,
auth_state: result.auth_state,
client_id: result.client_id,
client_public_key: result.client_public_key,
guest_id_lease: result.guest_id_lease,
}
}
#[cfg(not(feature = "pipes"))]
{
if self.config.send_pongs {
receiver.respond_to_pings(sender.clone());
}
let task = tokio::spawn(async {});
MTPConnection {
version: result.negotiated_version,
codec: result.codec,
sender,
receiver,
path: "/".to_string(),
remote_addr,
_pipe_stream: std::marker::PhantomData,
description: result.description,
_dispatcher_task: task,
_connection_guard: None,
auth_state: result.auth_state,
client_id: result.client_id,
client_public_key: result.client_public_key,
guest_id_lease: result.guest_id_lease,
}
}
}
#[cfg(not(feature = "crypto"))]
#[allow(clippy::too_many_arguments)]
pub(crate) fn connection_from_parts(
&self,
sender: Sender,
receiver: Receiver,
version: Version,
codec: VersionedCodec,
description: Option<String>,
) -> MTPConnection {
let remote_addr = sender.handle().remote_addr();
receiver.set_max_message_size(self.config.policy.max_message_size);
#[cfg(feature = "pipes")]
let type_map = codec.type_map().clone();
#[cfg(feature = "pipes")]
{
if self.config.send_pongs {
receiver.respond_to_pings(sender.clone());
}
let receiver_queue_capacity = self.config.policy.receiver_queue_capacity.max(1);
let (app_tx, app_rx) = mpsc::channel(receiver_queue_capacity);
let (pipe_req_tx, pipe_req_rx) = mpsc::channel(receiver_queue_capacity);
let dispatcher = Arc::new(PipeDispatcher {
pending_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
expired_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
pending_pipes: tokio::sync::Mutex::new(std::collections::HashMap::new()),
policy: Arc::new(self.config.policy),
type_map,
});
let dispatcher_clone = dispatcher.clone();
let receiver_clone = receiver.clone();
let sender_clone = sender.clone();
let task = tokio::spawn(run_dispatcher(
receiver_clone,
sender_clone,
app_tx,
pipe_req_tx,
dispatcher_clone,
));
MTPConnection {
version,
codec,
sender,
receiver,
path: "/".to_string(),
remote_addr,
app_rx: tokio::sync::Mutex::new(app_rx),
pipe_req_rx: tokio::sync::Mutex::new(pipe_req_rx),
pipe_dispatcher: dispatcher,
description,
_dispatcher_task: task,
_connection_guard: None,
}
}
#[cfg(not(feature = "pipes"))]
{
if self.config.send_pongs {
receiver.respond_to_pings(sender.clone());
}
let task = tokio::spawn(async {});
MTPConnection {
version,
codec,
sender,
receiver,
path: "/".to_string(),
remote_addr,
_pipe_stream: std::marker::PhantomData,
description,
_dispatcher_task: task,
_connection_guard: None,
}
}
}
}

View file

@ -1,616 +1,78 @@
use mtp_codec::{ pub mod config;
CommunicationValue, DataType, DataValue, Version, pub mod connection;
registry::{Registry, VersionedCodec}, pub mod engine;
pub mod error;
pub mod handshake;
#[cfg(feature = "pipes")]
pub mod pipe;
pub use MTPConnection as Connection;
pub use MTPHost as Host;
pub use config::HostConfig;
pub use config::Policy;
pub use connection::{MTPConnection, MtpReceiverLike, MtpSenderLike};
pub use engine::{HandshakeEngine, HandshakeReceiver, HandshakeResult, HandshakeSender};
pub use error::AcceptError;
pub use handshake::MTPHost;
pub use mtp_transport::Receiver;
pub use mtp_transport::SendMode;
pub use mtp_transport::Sender;
#[cfg(feature = "pipes")]
pub use mtp_common::PipeError;
#[cfg(feature = "pipes")]
pub use mtp_transport::PipeWriter;
#[cfg(feature = "pipes")]
pub use pipe::PipeRequest;
pub use mtp_codec::registry::Registry;
#[cfg(feature = "crypto")]
pub use config::{
AuthenticationAttempt, AuthenticationAttemptLimiter, AuthenticationContext,
AuthenticationLimitError, AuthenticationPolicy, CompleteRegister, FindRegisteredClient,
GetExistingClient, GuestIdGenerator, InMemoryAuthenticationAttemptLimiter,
}; };
use mtp_common::CommunicationError;
use mtp_transport::{Policy, Receiver, Sender};
use std::net::IpAddr;
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
use std::pin::Pin; pub use error::AuthState;
use std::{error::Error, fmt};
#[cfg(feature = "crypto")]
use tokio::time::Duration;
/* ---- async callback type aliases ---- */
#[cfg(feature = "crypto")]
type GetExistingUser = Box<
dyn Fn(
u64,
)
-> Pin<Box<dyn std::future::Future<Output = Option<mtp_crypto::PublicKeyBundle>> + Send>>
+ Send
+ Sync,
>;
#[cfg(feature = "crypto")]
type CompleteRegister = Box<
dyn Fn(mtp_crypto::PublicKeyBundle) -> Pin<Box<dyn std::future::Future<Output = u64> + Send>>
+ Send
+ Sync,
>;
/* Host configuration. */
pub struct HostConfig {
pub ip: IpAddr,
pub port: u16,
pub tls_fullchain: Vec<u8>,
pub tls_key: Vec<u8>,
#[cfg(feature = "crypto")]
pub require_authentication: bool,
#[cfg(feature = "crypto")]
pub auth_timeout: Duration,
#[cfg(feature = "crypto")]
pub host_keyring: mtp_crypto::Keyring,
#[cfg(feature = "crypto")]
pub get_existing_user: GetExistingUser,
#[cfg(feature = "crypto")]
pub complete_register: CompleteRegister,
}
impl HostConfig {
pub fn new(ip: IpAddr, port: u16, tls_fullchain: Vec<u8>, tls_key: Vec<u8>) -> Self {
Self {
ip,
port,
tls_fullchain,
tls_key,
#[cfg(feature = "crypto")]
require_authentication: false,
#[cfg(feature = "crypto")]
auth_timeout: Duration::from_secs(30),
#[cfg(feature = "crypto")]
host_keyring: mtp_crypto::Keyring::new(
mtp_crypto::KemPublicKey::new(Vec::new()),
mtp_crypto::KemPrivateKey::new(Vec::new()),
mtp_crypto::SignaturePqPublicKey::new(Vec::new()),
mtp_crypto::SignaturePqPrivateKey::new(Vec::new()),
mtp_crypto::SignaturePublicKey::new(Vec::new()),
mtp_crypto::SignaturePrivateKey::new(Vec::new()),
),
#[cfg(feature = "crypto")]
get_existing_user: Box::new(|_| Box::pin(async { None })),
#[cfg(feature = "crypto")]
complete_register: Box::new(|_| Box::pin(async { 0 })),
}
}
#[cfg(feature = "crypto")]
pub fn with_authentication(
mut self,
host_keyring: mtp_crypto::Keyring,
get_existing_user: impl Fn(
u64,
) -> Pin<
Box<dyn std::future::Future<Output = Option<mtp_crypto::PublicKeyBundle>> + Send>,
> + Send
+ Sync
+ 'static,
complete_register: impl Fn(
mtp_crypto::PublicKeyBundle,
) -> Pin<Box<dyn std::future::Future<Output = u64> + Send>>
+ Send
+ Sync
+ 'static,
) -> Self {
self.require_authentication = true;
self.host_keyring = host_keyring;
self.get_existing_user = Box::new(get_existing_user);
self.complete_register = Box::new(complete_register);
self
}
#[cfg(feature = "crypto")]
pub fn with_auth_timeout(mut self, timeout: Duration) -> Self {
self.auth_timeout = timeout;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AcceptError {
Receive(CommunicationError),
MissingVersion,
UnsupportedVersion(Version),
AuthenticationFailed(String),
AuthenticationTimedOut,
Send(CommunicationError),
}
impl fmt::Display for AcceptError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Receive(error) => write!(f, "failed to receive opening message: {error}"),
Self::MissingVersion => write!(
f,
"opening message did not include a valid protocol version"
),
Self::UnsupportedVersion(version) => {
write!(f, "unsupported protocol version: {version}")
}
Self::AuthenticationFailed(reason) => write!(f, "authentication failed: {reason}"),
Self::AuthenticationTimedOut => write!(f, "authentication handshake timed out"),
Self::Send(error) => write!(f, "failed to send handshake message: {error}"),
}
}
}
impl Error for AcceptError {}
#[cfg(feature = "crypto")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthState {
Unauthenticated,
Pending,
Authenticated,
Failed,
}
/* A connection that has completed version negotiation. */
pub struct MTPConnection {
pub version: Version,
pub codec: VersionedCodec,
pub sender: Sender,
pub receiver: Receiver,
#[cfg(feature = "crypto")]
pub auth_state: AuthState,
#[cfg(feature = "crypto")]
pub client_id: u64,
#[cfg(feature = "crypto")]
pub client_public_key: Option<mtp_crypto::PublicKeyBundle>,
}
/* High-level MTP host with built-in version negotiation. */
pub struct MTPHost {
transport: mtp_transport::Host,
registry: Registry,
#[cfg(feature = "crypto")]
config: HostConfig,
}
impl MTPHost {
pub async fn new(config: HostConfig) -> Result<Self, CommunicationError> {
let registry = Registry::builtin();
let transport = mtp_transport::host(
config.ip,
config.port,
config.tls_fullchain.clone(),
config.tls_key.clone(),
Policy::default(),
)
.await?;
Ok(Self {
transport,
registry,
#[cfg(feature = "crypto")]
config,
})
}
/*
* Accept an incoming connection, negotiate the protocol version,
* and return a ready-to-use `MTPConnection`.
*
* Returns `Ok(None)` if the listener is closed. Handshake and version
* negotiation failures are returned explicitly.
*/
pub async fn accept(&mut self) -> Result<Option<MTPConnection>, AcceptError> {
let (sender, receiver) = match self.transport.next().await {
Some(pair) => pair,
None => return Ok(None),
};
#[cfg(feature = "crypto")]
if self.config.require_authentication {
let timeout = self.config.auth_timeout;
return match tokio::time::timeout(timeout, self.accept_authenticated(sender, receiver))
.await
{
Ok(result) => result,
Err(_) => Err(AcceptError::AuthenticationTimedOut),
};
}
// Read the first message (always encoded with reserved types).
let first_msg = match receiver.receive().await {
Ok(m) => m,
Err(e) => return Err(AcceptError::Receive(e)),
};
let client_version = match extract_version(&first_msg) {
Some(v) => v,
None => return Err(AcceptError::MissingVersion),
};
let negotiated = match self
.registry
.negotiate(std::slice::from_ref(&client_version))
{
Some(v) => v,
None => return Err(AcceptError::UnsupportedVersion(client_version)),
};
let codec = VersionedCodec::new(self.registry.clone());
Ok(Some(MTPConnection {
version: negotiated,
codec,
sender,
receiver,
#[cfg(feature = "crypto")]
auth_state: AuthState::Unauthenticated,
#[cfg(feature = "crypto")]
client_id: 0,
#[cfg(feature = "crypto")]
client_public_key: None,
}))
}
pub fn local_addr(&self) -> std::net::SocketAddr {
self.transport.local_addr()
}
pub fn registry(&self) -> &Registry {
&self.registry
}
}
#[cfg(feature = "crypto")]
impl MTPHost {
/*
* Mutually-authenticated handshake with a server-issued challenge.
*
* 1. C -> H : Identification { version, id } (or Register { version, public_keys })
* 2. H -> C : Challenge { server_challenge, host_sig }
* 3. C -> H : ChallengeResponse { client_nonce, sig }
* 4. H -> C : IdentificationResponse / RegisterResponse { connected, id, sig }
*
* The client's authenticating signature (step 3) covers `server_challenge`,
* a fresh value generated here in step 2 and kept on this task's stack for
* the lifetime of the connection. It is therefore one-time per connection
* with no shared replay state, and a captured proof cannot be replayed on
* any other connection.
*/
async fn accept_authenticated(
&mut self,
sender: Sender,
receiver: Receiver,
) -> Result<Option<MTPConnection>, AcceptError> {
use mtp_crypto::{
Ed25519Signer, MlDsaSigner, PublicKeyBundle, SignatureScheme, auth, verify_ed25519,
verify_ml_dsa,
};
// Flow-specific state resolved from the client's opening hello.
enum Flow {
Login {
id: u64,
bundle: PublicKeyBundle,
},
Register {
bundle: PublicKeyBundle,
pk_bytes: Vec<u8>,
},
}
let tm = mtp_codec::TypeMap::latest();
let pq_enabled = !self
.config
.host_keyring
.sig_pq_secret_key
.as_bytes()
.is_empty();
// Sign `payload` with the host keys (Ed25519 always, ML-DSA when configured).
let host_sign = |payload: &[u8]| -> Result<(Vec<u8>, Vec<u8>), AcceptError> {
let signer = Ed25519Signer::new(&self.config.host_keyring.sig_cl_secret_key)
.map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?;
let sig = signer
.sign(payload)
.map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?;
let pq_sig = if pq_enabled {
let pq = MlDsaSigner::new(
&self.config.host_keyring.sig_pq_secret_key,
&self.config.host_keyring.sig_pq_public_key,
)
.map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?;
pq.sign(payload)
.map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?
} else {
Vec::new()
};
Ok((sig, pq_sig))
};
// ===== Step 1: receive the client's unsigned hello =====
let hello = match receiver.receive().await {
Ok(m) => m,
Err(e) => {
sender.close();
return Err(AcceptError::Receive(e));
}
};
let version_str = match hello.get_data(DataType::Version) {
DataValue::Str(s) => s.clone(),
_ => {
sender.close();
return Err(AcceptError::MissingVersion);
}
};
let client_version = match Version::parse(&version_str) {
Some(v) => v,
None => {
sender.close();
return Err(AcceptError::MissingVersion);
}
};
let (flow, response_type) =
if hello.get_type() == mtp_codec::CommunicationType::Identification.to_id(&tm) {
let cid = match hello.get_data(DataType::Id) {
DataValue::UnsignedNumber(n) => *n as u64,
_ => {
sender.close();
return Err(AcceptError::AuthenticationFailed(
"missing client id".into(),
));
}
};
let bundle = match (self.config.get_existing_user)(cid).await {
Some(b) => b,
None => {
let rejection = CommunicationValue::new(
mtp_codec::CommunicationType::IdentificationResponse,
)
.add_typed_default(DataType::Connected, DataValue::BoolFalse);
let _ = sender.send(&rejection).await;
sender.close();
return Err(AcceptError::AuthenticationFailed(
"unknown client id".into(),
));
}
};
(
Flow::Login { id: cid, bundle },
mtp_codec::CommunicationType::IdentificationResponse,
)
} else if hello.get_type() == mtp_codec::CommunicationType::Register.to_id(&tm) {
let bundle = match hello.get_data(DataType::PublicKeys) {
DataValue::Bytes(b) => PublicKeyBundle::from_bytes(b).map_err(|_| {
AcceptError::AuthenticationFailed("invalid public key bundle".into())
})?,
_ => {
sender.close();
return Err(AcceptError::AuthenticationFailed(
"missing public keys".into(),
));
}
};
let pk_bytes = bundle.as_bytes();
(
Flow::Register { bundle, pk_bytes },
mtp_codec::CommunicationType::RegisterResponse,
)
} else {
sender.close();
return Err(AcceptError::AuthenticationFailed(
"unexpected authentication message".into(),
));
};
let challenge_id = match &flow {
Flow::Login { id, .. } => *id,
Flow::Register { .. } => 0,
};
// ===== Step 2: issue a fresh, host-signed challenge =====
let server_challenge: u128 = rand::random();
let (chal_sig, chal_pq_sig) =
host_sign(&auth::challenge_payload(challenge_id, server_challenge))?;
let mut challenge_msg = CommunicationValue::new(mtp_codec::CommunicationType::Challenge)
.add_typed_default(
DataType::ServerNonce,
DataValue::UnsignedNumber(server_challenge),
)
.add_typed_default(DataType::Signature, DataValue::Bytes(chal_sig));
if pq_enabled {
challenge_msg = challenge_msg
.add_typed_default(DataType::PqSignature, DataValue::Bytes(chal_pq_sig));
}
if let Err(e) = sender.send(&challenge_msg).await {
sender.close();
return Err(AcceptError::Send(e));
}
// ===== Step 3: receive and verify the client's proof =====
let proof = match receiver.receive().await {
Ok(m) => m,
Err(e) => {
sender.close();
return Err(AcceptError::Receive(e));
}
};
if proof.get_type() != mtp_codec::CommunicationType::ChallengeResponse.to_id(&tm) {
sender.close();
return Err(AcceptError::AuthenticationFailed(
"missing challenge response".into(),
));
}
let client_nonce = match proof.get_data(DataType::ClientNonce) {
DataValue::UnsignedNumber(n) => *n,
_ => {
sender.close();
return Err(AcceptError::AuthenticationFailed(
"missing client nonce".into(),
));
}
};
let sig_bytes = match proof.get_data(DataType::Signature) {
DataValue::Bytes(b) => b.clone(),
_ => {
sender.close();
return Err(AcceptError::AuthenticationFailed(
"missing challenge signature".into(),
));
}
};
let pq_sig_bytes: Vec<u8> = match proof.get_data(DataType::PqSignature) {
DataValue::Bytes(b) => b.clone(),
_ => vec![],
};
let (proof_payload, bundle) = match &flow {
Flow::Login { id, bundle } => (
auth::login_proof_payload(&version_str, *id, server_challenge, client_nonce),
bundle,
),
Flow::Register {
bundle, pk_bytes, ..
} => (
auth::register_proof_payload(
&version_str,
pk_bytes,
server_challenge,
client_nonce,
),
bundle,
),
};
let proof_ok = verify_ed25519(&bundle.sig_cl_public_key, &proof_payload, &sig_bytes)
.is_ok()
&& (pq_sig_bytes.is_empty()
|| verify_ml_dsa(&bundle.sig_pq_public_key, &proof_payload, &pq_sig_bytes).is_ok());
if !proof_ok {
let rejection = CommunicationValue::new(response_type)
.add_typed_default(DataType::Connected, DataValue::BoolFalse);
let _ = sender.send(&rejection).await;
sender.close();
return Err(AcceptError::AuthenticationFailed(
"client proof signature invalid".into(),
));
}
// Proof verified: resolve the assigned id and retain the client's bundle.
let (assigned_id, client_bundle) = match flow {
Flow::Login { id, bundle } => (id, bundle),
Flow::Register { bundle, .. } => {
let new_id = (self.config.complete_register)(bundle.clone()).await;
(new_id, bundle)
}
};
// ===== Step 4: send the host's final confirmation =====
let (host_sig, host_pq_sig) = host_sign(&auth::host_final_payload(
assigned_id,
client_nonce,
server_challenge,
))?;
let mut response = CommunicationValue::new(response_type)
.add_typed_default(DataType::Connected, DataValue::BoolTrue)
.add_typed_default(DataType::Id, DataValue::UnsignedNumber(assigned_id as u128))
.add_typed_default(
DataType::ClientNonce,
DataValue::UnsignedNumber(client_nonce),
)
.add_typed_default(DataType::Signature, DataValue::Bytes(host_sig));
if pq_enabled {
response =
response.add_typed_default(DataType::PqSignature, DataValue::Bytes(host_pq_sig));
}
if let Err(e) = sender.send(&response).await {
sender.close();
return Err(AcceptError::Send(e));
}
if let Err(e) = sender.finish_stream().await {
sender.close();
return Err(AcceptError::Send(e));
}
// ===== Version negotiation =====
let negotiated = match self
.registry
.negotiate(std::slice::from_ref(&client_version))
{
Some(v) => v,
None => {
sender.close();
return Err(AcceptError::UnsupportedVersion(client_version));
}
};
let codec = VersionedCodec::new(self.registry.clone());
Ok(Some(MTPConnection {
version: negotiated,
codec,
sender,
receiver,
auth_state: AuthState::Authenticated,
client_id: assigned_id,
client_public_key: Some(client_bundle),
}))
}
}
/*
* Extract the protocol version from an initial `CommunicationValue`.
*
* The client's first message must contain a `Version` data entry
* (reserved ID 3) mapping to `DataValue::Str("major.minor")`.
*/
fn extract_version(msg: &CommunicationValue) -> Option<Version> {
let value = msg.get_data(DataType::Version);
match value {
DataValue::Str(s) => Version::parse(s.as_str()),
_ => None,
}
}
/* ================================ TESTS ================================ */
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[cfg(not(feature = "pipes"))]
use mtp_codec::registry::VersionedCodec;
use mtp_codec::{CommunicationType, DataType, DataValue};
#[cfg(not(feature = "pipes"))]
#[derive(Clone, Debug, PartialEq, Eq)]
struct AlternateSender;
#[cfg(not(feature = "pipes"))]
#[derive(Clone, Debug, PartialEq, Eq)]
struct AlternateReceiver;
#[test] #[test]
fn version_extraction() { fn version_extraction() {
let tm = mtp_codec::TypeMap::latest(); let tm = mtp_codec::TypeMap::latest();
let msg = mtp_codec::CommunicationValue::from_comm( let msg = mtp_codec::CommunicationValue::from_comm(CommunicationType::Identification, &tm)
mtp_codec::CommunicationType::Identification, .add_typed(DataType::Version, &tm, DataValue::Str("3.0".to_string()));
&tm, let version = error::extract_version(&msg);
) assert_eq!(version, Some(mtp_codec::Version(3, 0)));
.add_data(
DataType::Version.to_id(&tm),
DataValue::Str("2.0".to_string()),
);
let version = extract_version(&msg);
assert_eq!(version, Some(Version(2, 0)));
} }
#[test] #[test]
fn version_extraction_returns_none_for_missing() { fn version_extraction_returns_none_for_missing() {
let tm = mtp_codec::TypeMap::latest(); let tm = mtp_codec::TypeMap::latest();
let msg = mtp_codec::CommunicationValue::from_comm( let msg = mtp_codec::CommunicationValue::from_comm(CommunicationType::Identification, &tm);
mtp_codec::CommunicationType::Identification, assert!(error::extract_version(&msg).is_none());
&tm,
);
assert!(extract_version(&msg).is_none());
} }
#[test] #[test]
fn version_extraction_bad_format() { fn version_extraction_bad_format() {
let tm = mtp_codec::TypeMap::latest(); let tm = mtp_codec::TypeMap::latest();
let msg = mtp_codec::CommunicationValue::from_comm( let msg = mtp_codec::CommunicationValue::from_comm(CommunicationType::Identification, &tm)
mtp_codec::CommunicationType::Identification, .add_typed(DataType::Version, &tm, DataValue::UnsignedNumber(42));
&tm, assert!(error::extract_version(&msg).is_none());
)
.add_data(DataType::Version.to_id(&tm), DataValue::UnsignedNumber(42));
assert!(extract_version(&msg).is_none());
} }
#[cfg(feature = "crypto")] #[cfg(feature = "crypto")]
@ -619,4 +81,34 @@ mod tests {
assert_ne!(AuthState::Unauthenticated, AuthState::Authenticated); assert_ne!(AuthState::Unauthenticated, AuthState::Authenticated);
assert_ne!(AuthState::Pending, AuthState::Authenticated); assert_ne!(AuthState::Pending, AuthState::Authenticated);
} }
#[test]
fn host_config_pongs_default_to_enabled() -> Result<(), Box<dyn std::error::Error>> {
let config = HostConfig::new("127.0.0.1".parse()?, 4433, Vec::new(), Vec::new());
assert!(config.send_pongs);
#[cfg(feature = "crypto")]
assert!(config.require_pq);
assert!(!config.with_pongs(false).send_pongs);
Ok(())
}
#[cfg(not(feature = "pipes"))]
#[tokio::test]
async fn alternative_transports_use_the_shared_connection_type() {
let registry = Registry::builtin();
let version = mtp_codec::Version(3, 0);
let codec = VersionedCodec::for_version(registry, version.clone()).unwrap();
let connection: MTPConnection<AlternateSender, AlternateReceiver> =
MTPConnection::from_transport_parts(
version.clone(),
codec,
AlternateSender,
AlternateReceiver,
"/mtp".into(),
Some("browser".into()),
);
assert_eq!(connection.version, version);
assert_eq!(connection.path, "/mtp");
assert_eq!(connection.description.as_deref(), Some("browser"));
}
} }

536
host/src/pipe.rs Normal file
View file

@ -0,0 +1,536 @@
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap};
use mtp_common::{CommunicationError, PipeError};
use mtp_transport::{PipeReader, PipeWriter, Policy, TransportEvent};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use tokio::sync::{Mutex, mpsc};
/// The sender operations needed by the transport-independent pipe protocol.
pub trait PipeSender: Clone + Send + Sync + 'static {
type Writer: tokio::io::AsyncWrite + Send + Unpin + 'static;
fn send_pipe_message(
&self,
message: &CommunicationValue,
) -> impl std::future::Future<Output = Result<(), CommunicationError>> + Send;
fn open_pipe_stream(
&self,
pipe_id: u32,
description: &str,
) -> impl std::future::Future<Output = Result<PipeWriter<Self::Writer>, CommunicationError>> + Send;
}
/// The receiver operations needed by the transport-independent pipe protocol.
pub trait PipeReceiver<P>: Clone + Send + Sync + 'static
where
P: tokio::io::AsyncRead + Send + Unpin + 'static,
{
fn expect_pipe(&self, pipe_id: u32) -> Result<(), CommunicationError>;
fn cancel_expected_pipe(&self, pipe_id: u32);
fn receive_pipe_event(
&self,
) -> impl std::future::Future<Output = Result<TransportEvent<P>, CommunicationError>> + Send;
}
impl PipeSender for mtp_transport::Sender {
type Writer = wtransport::SendStream;
async fn send_pipe_message(
&self,
message: &CommunicationValue,
) -> Result<(), CommunicationError> {
self.send(message).await
}
async fn open_pipe_stream(
&self,
pipe_id: u32,
description: &str,
) -> Result<PipeWriter<Self::Writer>, CommunicationError> {
self.open_pipe(pipe_id, description).await
}
}
impl PipeReceiver<wtransport::RecvStream> for mtp_transport::Receiver {
fn expect_pipe(&self, pipe_id: u32) -> Result<(), CommunicationError> {
self.expect_pipe(pipe_id)
}
fn cancel_expected_pipe(&self, pipe_id: u32) {
self.cancel_expected_pipe(pipe_id);
}
async fn receive_pipe_event(
&self,
) -> Result<TransportEvent<wtransport::RecvStream>, CommunicationError> {
self.receive_event().await
}
}
impl<C> PipeSender for mtp_transport::GenericSender<C>
where
C: mtp_transport::TransportConnection,
C::SendStream: tokio::io::AsyncWrite + Send + Unpin + 'static,
{
type Writer = C::SendStream;
async fn send_pipe_message(
&self,
message: &CommunicationValue,
) -> Result<(), CommunicationError> {
self.send(message).await
}
async fn open_pipe_stream(
&self,
pipe_id: u32,
description: &str,
) -> Result<PipeWriter<Self::Writer>, CommunicationError> {
self.open_pipe(pipe_id, description).await
}
}
impl<C> PipeReceiver<C::RecvStream> for mtp_transport::GenericReceiver<C>
where
C: mtp_transport::TransportConnection,
C::RecvStream: tokio::io::AsyncRead + Send + Unpin + 'static,
{
fn expect_pipe(&self, pipe_id: u32) -> Result<(), CommunicationError> {
self.expect_pipe(pipe_id)
}
fn cancel_expected_pipe(&self, pipe_id: u32) {
self.cancel_expected_pipe(pipe_id);
}
async fn receive_pipe_event(
&self,
) -> Result<TransportEvent<C::RecvStream>, CommunicationError> {
self.receive_event().await
}
}
pub struct PipeHandle<S: PipeSender, P = wtransport::RecvStream> {
pub(crate) pipe_id: u32,
pub(crate) description: String,
pub(crate) sender: S,
pub(crate) response_rx: tokio::sync::oneshot::Receiver<Result<bool, PipeError>>,
pub(crate) dispatcher: Arc<PipeDispatcher<P>>,
pub(crate) token: Arc<()>,
}
impl<S, P> PipeHandle<S, P>
where
S: PipeSender,
P: tokio::io::AsyncRead + Send + Unpin + 'static,
{
pub fn pipe_id(&self) -> u32 {
self.pipe_id
}
pub fn description(&self) -> &str {
&self.description
}
pub async fn wait(mut self) -> Result<Option<PipeWriter<S::Writer>>, PipeError> {
let response =
tokio::time::timeout(self.dispatcher.policy.read_timeout, &mut self.response_rx).await;
match response {
Ok(Ok(Ok(true))) => self
.sender
.open_pipe_stream(self.pipe_id, &self.description)
.await
.map(Some)
.map_err(PipeError::from),
Ok(Ok(Ok(false))) => Ok(None),
Ok(Ok(Err(error))) => {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
Err(error)
}
Ok(Err(_)) => {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
Err(PipeError::StreamClosed)
}
Err(_) => {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
Err(PipeError::HandshakeTimeout)
}
}
}
}
impl<S, P> Drop for PipeHandle<S, P>
where
S: PipeSender,
{
fn drop(&mut self) {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
}
}
pub struct PipeRequest<S, R, P> {
pub(crate) pipe_id: u32,
pub(crate) description: String,
pub(crate) sender: S,
pub(crate) receiver: R,
pub(crate) dispatcher: Arc<PipeDispatcher<P>>,
}
struct ExpectedPipeGuard<R, P>
where
R: PipeReceiver<P>,
P: tokio::io::AsyncRead + Send + Unpin + 'static,
{
receiver: R,
pipe_id: u32,
armed: bool,
_stream: std::marker::PhantomData<P>,
}
impl<R, P> ExpectedPipeGuard<R, P>
where
R: PipeReceiver<P>,
P: tokio::io::AsyncRead + Send + Unpin + 'static,
{
fn new(receiver: R, pipe_id: u32) -> Self {
Self {
receiver,
pipe_id,
armed: true,
_stream: std::marker::PhantomData,
}
}
fn disarm(&mut self) {
self.armed = false;
}
}
impl<R, P> Drop for ExpectedPipeGuard<R, P>
where
R: PipeReceiver<P>,
P: tokio::io::AsyncRead + Send + Unpin + 'static,
{
fn drop(&mut self) {
if self.armed {
self.receiver.cancel_expected_pipe(self.pipe_id);
}
}
}
impl<S, R, P> PipeRequest<S, R, P>
where
S: PipeSender,
R: PipeReceiver<P>,
P: tokio::io::AsyncRead + Send + Unpin + 'static,
{
pub fn id(&self) -> u32 {
self.pipe_id
}
pub fn description(&self) -> &str {
&self.description
}
pub async fn accept(self) -> Result<PipeReader<P>, PipeError> {
self.receiver
.expect_pipe(self.pipe_id)
.map_err(PipeError::from)?;
let mut expected_pipe = ExpectedPipeGuard::<R, P>::new(self.receiver.clone(), self.pipe_id);
let (pipe_tx, pipe_rx) = tokio::sync::oneshot::channel();
self.dispatcher
.pending_pipes
.lock()
.await
.insert(self.pipe_id, pipe_tx);
let response = CommunicationValue::new_with_type_map(
CommunicationType::PipeResponse,
&self.dispatcher.type_map,
)
.with_id(self.pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolTrue);
if let Err(error) = self.sender.send_pipe_message(&response).await {
self.dispatcher
.pending_pipes
.lock()
.await
.remove(&self.pipe_id);
return Err(PipeError::from(error));
}
match tokio::time::timeout(self.dispatcher.policy.read_timeout, pipe_rx).await {
Ok(Ok(reader)) => {
expected_pipe.disarm();
Ok(reader)
}
Ok(Err(_)) => {
self.dispatcher
.pending_pipes
.lock()
.await
.remove(&self.pipe_id);
Err(PipeError::StreamClosed)
}
Err(_) => {
self.dispatcher
.pending_pipes
.lock()
.await
.remove(&self.pipe_id);
Err(PipeError::HandshakeTimeout)
}
}
}
pub async fn deny(self) -> Result<(), PipeError> {
let response = CommunicationValue::new_with_type_map(
CommunicationType::PipeResponse,
&self.dispatcher.type_map,
)
.with_id(self.pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
self.sender
.send_pipe_message(&response)
.await
.map_err(PipeError::from)
}
}
pub(crate) struct PipeDispatcher<P> {
pub(crate) pending_creations: StdMutex<HashMap<u32, PendingCreation>>,
pub(crate) expired_creations: StdMutex<HashMap<u32, tokio::time::Instant>>,
pub(crate) pending_pipes: Mutex<HashMap<u32, tokio::sync::oneshot::Sender<PipeReader<P>>>>,
pub(crate) policy: Arc<Policy>,
pub(crate) type_map: TypeMap,
}
pub(crate) struct PendingCreation {
pub(crate) token: Arc<()>,
pub(crate) sender: tokio::sync::oneshot::Sender<Result<bool, PipeError>>,
}
pub(crate) struct PendingCreationGuard<P> {
dispatcher: Arc<PipeDispatcher<P>>,
pipe_id: u32,
token: Arc<()>,
armed: bool,
}
impl<P> PendingCreationGuard<P> {
pub(crate) fn new(dispatcher: Arc<PipeDispatcher<P>>, pipe_id: u32, token: Arc<()>) -> Self {
Self {
dispatcher,
pipe_id,
token,
armed: true,
}
}
pub(crate) fn disarm(&mut self) {
self.armed = false;
}
}
impl<P> Drop for PendingCreationGuard<P> {
fn drop(&mut self) {
if self.armed {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
}
}
}
const EXPIRED_CREATION_TOMBSTONE_TTL: tokio::time::Duration = tokio::time::Duration::from_secs(60);
const MAX_EXPIRED_CREATION_TOMBSTONES: usize = 1024;
pub(crate) fn expire_pending_creation<P>(
dispatcher: &PipeDispatcher<P>,
pipe_id: u32,
token: &Arc<()>,
) {
let removed = dispatcher
.pending_creations
.lock()
.ok()
.and_then(|mut pending| {
if pending
.get(&pipe_id)
.is_some_and(|entry| Arc::ptr_eq(&entry.token, token))
{
pending.remove(&pipe_id);
Some(())
} else {
None
}
});
if removed.is_none() {
return;
}
let Ok(mut expired) = dispatcher.expired_creations.lock() else {
return;
};
let now = tokio::time::Instant::now();
expired.retain(|_, expires_at| *expires_at > now);
if expired.len() >= MAX_EXPIRED_CREATION_TOMBSTONES
&& let Some(oldest) = expired
.iter()
.min_by_key(|(_, expires_at)| **expires_at)
.map(|(id, _)| *id)
{
expired.remove(&oldest);
}
expired.insert(pipe_id, now + EXPIRED_CREATION_TOMBSTONE_TTL);
}
fn consume_expired_creation<P>(dispatcher: &PipeDispatcher<P>, pipe_id: u32) -> bool {
let Ok(mut expired) = dispatcher.expired_creations.lock() else {
return false;
};
let now = tokio::time::Instant::now();
expired.retain(|_, expires_at| *expires_at > now);
expired.remove(&pipe_id).is_some()
}
pub(crate) fn is_expired_creation<P>(dispatcher: &PipeDispatcher<P>, pipe_id: u32) -> bool {
let Ok(mut expired) = dispatcher.expired_creations.lock() else {
return true;
};
let now = tokio::time::Instant::now();
expired.retain(|_, expires_at| *expires_at > now);
expired.contains_key(&pipe_id)
}
pub(crate) fn fail_pending_creations<P>(
dispatcher: &PipeDispatcher<P>,
error: &CommunicationError,
) {
let pending = dispatcher
.pending_creations
.lock()
.ok()
.map(|mut pending| std::mem::take(&mut *pending));
if let Some(pending) = pending {
let error = PipeError::from(error.clone());
for (_, pending) in pending {
let _ = pending.sender.send(Err(error.clone()));
}
}
if let Ok(mut expired) = dispatcher.expired_creations.lock() {
expired.clear();
}
}
pub(crate) async fn fail_pending_pipes<P>(dispatcher: &PipeDispatcher<P>) {
dispatcher.pending_pipes.lock().await.clear();
}
pub(crate) async fn run_dispatcher<S, R, P>(
receiver: R,
sender: S,
app_tx: mpsc::Sender<Result<CommunicationValue, CommunicationError>>,
pipe_req_tx: mpsc::Sender<PipeRequest<S, R, P>>,
dispatcher: Arc<PipeDispatcher<P>>,
) where
S: PipeSender,
R: PipeReceiver<P>,
P: tokio::io::AsyncRead + Send + Unpin + 'static,
{
loop {
match receiver.receive_pipe_event().await {
Ok(TransportEvent::Message(message)) => {
if message.is_type(CommunicationType::PipeRequest) {
let Some(pipe_id) = message.id().filter(|id| *id != 0) else {
let error = CommunicationError::Other(
"PipeRequest frame must contain a non-zero id".into(),
);
if app_tx.send(Err(error)).await.is_err() {
break;
}
continue;
};
let request = PipeRequest {
pipe_id,
description: message
.get_str(DataType::Description)
.unwrap_or("")
.to_owned(),
sender: sender.clone(),
receiver: receiver.clone(),
dispatcher: dispatcher.clone(),
};
let _ = pipe_req_tx.send(request).await;
continue;
}
if message.is_type(CommunicationType::PipeResponse) {
let Some(pipe_id) = message.id().filter(|id| *id != 0) else {
let error = CommunicationError::Other(
"PipeResponse frame must contain a non-zero id".into(),
);
if app_tx.send(Err(error)).await.is_err() {
break;
}
continue;
};
let pending = dispatcher
.pending_creations
.lock()
.ok()
.and_then(|mut pending| pending.remove(&pipe_id));
if let Some(entry) = pending {
let _ = entry
.sender
.send(Ok(message.get_bool(DataType::Accepted).unwrap_or(false)));
} else if consume_expired_creation(&dispatcher, pipe_id) {
tracing::debug!(pipe_id, "ignored late pipe creation response");
}
continue;
}
if !matches!(message.id(), Some(id) if id != 0)
&& message
.get_type_name()
.is_some_and(|name| name.ends_with("Response"))
{
let error = CommunicationError::Other(
"response frame must contain a non-zero id".into(),
);
if app_tx.send(Err(error)).await.is_err() {
break;
}
continue;
}
if app_tx.send(Ok(message)).await.is_err() {
break;
}
}
Ok(TransportEvent::Pipe(reader)) => {
let pipe_id = reader.pipe_id();
let mut pending = dispatcher.pending_pipes.lock().await;
if let Some(reply) = pending.remove(&pipe_id) {
let _ = reply.send(reader);
continue;
}
drop(pending);
let request = PipeRequest {
pipe_id,
description: reader.description().to_owned(),
sender: sender.clone(),
receiver: receiver.clone(),
dispatcher: dispatcher.clone(),
};
let _ = pipe_req_tx.send(request).await;
}
Err(error) => {
fail_pending_creations(&dispatcher, &error);
fail_pending_pipes(&dispatcher).await;
if app_tx.send(Err(error)).await.is_err() {
break;
}
break;
}
}
}
}

36
mtp-webserver/Cargo.toml Normal file
View file

@ -0,0 +1,36 @@
[package]
name = "mtp-webserver"
version = "0.3.0"
edition = "2024"
[dependencies]
mtp-common = { version = "0.3.0", path = "../common" }
mtp-codec = { version = "0.3.0", path = "../codec", features = ["registry"] }
mtp-host = { version = "0.3.0", path = "../host" }
mtp-transport = { version = "0.3.0", path = "../transport" }
mtp-crypto = { version = "0.3.0", path = "../crypto" }
bytes = "1"
http = "1"
tokio = { version = "1", features = ["io-util", "macros", "net", "rt", "sync", "time"] }
hyper = { version = "1", features = ["server", "http1", "http2"] }
hyper-util = { version = "0.1", features = ["server", "http1", "http2", "tokio"] }
http-body-util = "0.1"
tokio-rustls = "0.26"
tokio-stream = "0.1"
h3 = "0.0.8"
h3-quinn = { version = "0.0.10", features = ["datagram"] }
h3-webtransport = "0.1.2"
quinn = "0.11"
rustls = "0.23"
tracing = "0.1"
thiserror = "2"
async-trait = "0.1"
[dev-dependencies]
rcgen = "0.14"
hyper = { version = "1", features = ["client", "http2"] }
[features]
default = []
crypto = ["mtp-host/crypto"]
pipes = ["mtp-host/pipes", "mtp-transport/pipes"]

View file

@ -0,0 +1,41 @@
use mtp_common::CommunicationError;
use std::fmt;
/// Unified error type for the webserver transport adapter.
#[derive(Debug)]
pub enum WebServerError {
Transport(CommunicationError),
WebTransport(String),
Tls(String),
Http(String),
PayloadTooLarge,
NotFound(String),
}
impl fmt::Display for WebServerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Transport(e) => write!(f, "transport error: {e}"),
Self::WebTransport(msg) => write!(f, "webtransport error: {msg}"),
Self::Tls(msg) => write!(f, "TLS error: {msg}"),
Self::Http(msg) => write!(f, "HTTP error: {msg}"),
Self::PayloadTooLarge => write!(f, "HTTP request body is too large"),
Self::NotFound(route) => write!(f, "route not found: {route}"),
}
}
}
impl std::error::Error for WebServerError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Transport(e) => Some(e),
_ => None,
}
}
}
impl From<CommunicationError> for WebServerError {
fn from(e: CommunicationError) -> Self {
Self::Transport(e)
}
}

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