diff --git a/.cargo/config.toml b/.cargo/config.toml index edd7563..83fc89c 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,5 +1,5 @@ [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. # Scoped to the wasm32 target so it applies to the wasm crate however cargo is diff --git a/.envrc b/.envrc deleted file mode 100644 index 3550a30..0000000 --- a/.envrc +++ /dev/null @@ -1 +0,0 @@ -use flake diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 970eece..ff522b9 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -9,40 +9,214 @@ env: CARGO_TERM_COLOR: always jobs: - checks: - name: checks - runs-on: nixos + fmt: + name: rustfmt + runs-on: docker steps: - - name: Checkout - uses: https://data.forgejo.org/actions/checkout@v7 + - uses: https://data.forgejo.org/actions/checkout@v4 - - name: Run checks + - name: Install system dependencies + run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl build-essential pkg-config libssl-dev + + - name: Install Rust run: | - nix develop --command bash -c ' - set -euo pipefail + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --component rustfmt - cargo fmt --all --check - cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings -W unreachable-pub - cargo test --workspace --exclude mtp-wasm --all-features - RUSTFLAGS="--cfg web_sys_unstable_apis" cargo build -p mtp-wasm --target wasm32-unknown-unknown - cargo deny check advisories bans sources - cargo machete + - name: Check formatting + run: | + export PATH="$HOME/.cargo/bin:$PATH" + cargo fmt --all --check - pnpm install --frozen-lockfile + clippy: + name: clippy + runs-on: docker + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 - 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" pnpm run build - pnpm --filter mtp-web-client run build + - name: Install system dependencies + run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl build-essential pkg-config libssl-dev - node test/e2ee.mjs - pnpm run test:secrets - pnpm run test:types - pnpm run test:boundary + - name: Install Rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --component clippy - ( - cd example - export MTP_TYPE_MAPS="$PWD/type-maps.yaml" - cargo check --workspace --all-targets --all-features - ) - ' + - name: Run clippy + run: | + export PATH="$HOME/.cargo/bin:$PATH" + export MTP_TYPE_MAPS="$PWD/example/type-maps.yaml" + cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings -W unreachable-pub + + test: + name: test + runs-on: docker + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + + - name: Install system dependencies + run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl build-essential pkg-config libssl-dev + + - name: Install Rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + + - name: Run tests + run: | + export PATH="$HOME/.cargo/bin:$PATH" + export MTP_TYPE_MAPS="$PWD/example/type-maps.yaml" + cargo test --workspace --exclude mtp-wasm --all-features + + wasm: + name: wasm build + runs-on: docker + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + + - name: Install system dependencies + run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl build-essential pkg-config libssl-dev lld + + - name: Install Rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --target wasm32-unknown-unknown + + - name: Build wasm crate + run: | + export PATH="$HOME/.cargo/bin:$PATH" + export MTP_TYPE_MAPS="$PWD/example/type-maps.yaml" + cargo build -p mtp-wasm --target wasm32-unknown-unknown + env: + RUSTFLAGS: --cfg web_sys_unstable_apis + + example: + name: example + runs-on: docker + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + + - name: Install system dependencies + run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl build-essential pkg-config libssl-dev + + - name: Install Rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + + - name: Check example usage workspace + working-directory: example + run: | + export PATH="$HOME/.cargo/bin:$PATH" + export MTP_TYPE_MAPS="$PWD/type-maps.yaml" + cargo check --workspace --all-targets --all-features + + deny: + name: cargo-deny + runs-on: docker + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + + - name: Install system dependencies + run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl build-essential pkg-config libssl-dev + + - name: Install Rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + + - name: Install cargo-deny + run: | + export PATH="$HOME/.cargo/bin:$PATH" + cargo install cargo-deny --locked + + - name: Run cargo-deny + run: | + export PATH="$HOME/.cargo/bin:$PATH" + cargo deny check + + machete: + name: cargo-machete + runs-on: docker + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + + - name: Install system dependencies + run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl build-essential pkg-config libssl-dev + + - name: Install Rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + + - name: Install cargo-machete + run: | + export PATH="$HOME/.cargo/bin:$PATH" + cargo install cargo-machete --locked + + - name: Run cargo-machete + run: | + export PATH="$HOME/.cargo/bin:$PATH" + cargo machete + + duplicates: + name: duplicate code + runs-on: docker + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + + - name: Install system dependencies + run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl unzip nodejs + + - name: Install pnpm + run: | + curl -fsSL https://get.pnpm.io/install.sh | SHELL=/bin/sh sh - + + - name: Install dependencies + run: | + export PATH="$HOME/.local/share/pnpm:$PATH" + pnpm install --frozen-lockfile + + - name: Run duplicate detector + run: | + export PATH="$HOME/.local/share/pnpm:$PATH" + pnpm run dup + + web-client: + name: web client + runs-on: docker + steps: + - uses: https://data.forgejo.org/actions/checkout@v4 + + - name: Install system dependencies + run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl unzip nodejs build-essential pkg-config libssl-dev lld + + - name: Install Rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --target wasm32-unknown-unknown + + - name: Install wasm-pack + run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + + - name: Build wasm package + run: | + export PATH="$HOME/.cargo/bin:$PATH" + export MTP_TYPE_MAPS="$PWD/example/type-maps.yaml" + wasm-pack build wasm --target web + env: + RUSTFLAGS: --cfg web_sys_unstable_apis + + - name: Install pnpm + run: | + curl -fsSL https://get.pnpm.io/install.sh | SHELL=/bin/sh sh - + + - name: Install dependencies + run: | + export PATH="$HOME/.local/share/pnpm:$PATH" + pnpm install --frozen-lockfile + + - name: Build TypeScript package + run: | + export PATH="$HOME/.cargo/bin:$HOME/.local/share/pnpm:$PATH" + export MTP_TYPE_MAPS="$PWD/example/type-maps.yaml" + pnpm run build + env: + RUSTFLAGS: --cfg web_sys_unstable_apis + + - name: Build web client + run: | + export PATH="$HOME/.local/share/pnpm:$PATH" + pnpm --filter mtp-web-client run build diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 5996da0..6c0d4ec 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -16,13 +16,22 @@ on: jobs: release: - runs-on: nixos + runs-on: docker steps: - name: Check out repo - uses: https://data.forgejo.org/actions/checkout@v7 + uses: https://data.forgejo.org/actions/checkout@v4 with: 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 run: bun install diff --git a/.gitignore b/.gitignore index f600624..8ce8e7b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,3 @@ node_modules/ dist/ *.tgz wasm/pkg/ -web_client/ -.direnv diff --git a/Cargo.lock b/Cargo.lock index 8e8f6ba..43b5b29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,18 +37,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures 0.2.17", - "password-hash", -] - [[package]] name = "asn1-rs" version = "0.7.2" @@ -61,7 +49,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.20", + "thiserror 2.0.18", "time", ] @@ -73,7 +61,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", "synstructure", ] @@ -85,26 +73,20 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "async-trait" -version = "0.1.92" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - [[package]] name = "autocfg" version = "1.5.1" @@ -113,9 +95,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -124,15 +106,14 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.44.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", "dunce", "fs_extra", - "pkg-config", ] [[package]] @@ -141,12 +122,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - [[package]] name = "base64ct" version = "1.8.3" @@ -164,18 +139,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest 0.10.7", -] +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "block-buffer" @@ -209,9 +175,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "cast" @@ -221,9 +187,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.4.3" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "jobserver", @@ -239,9 +205,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" @@ -254,17 +220,6 @@ dependencies = [ "cpufeatures 0.2.17", ] -[[package]] -name = "chacha20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - [[package]] name = "chacha20poly1305" version = "0.10.1" @@ -272,7 +227,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20 0.9.1", + "chacha20", "cipher", "poly1305", "zeroize", @@ -304,16 +259,6 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -324,6 +269,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -413,23 +364,8 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "fiat-crypto 0.2.9", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "curve25519-dalek-derive", - "digest 0.11.3", - "fiat-crypto 0.3.0", + "digest 0.10.7", + "fiat-crypto", "rustc_version", "subtle", "zeroize", @@ -443,26 +379,36 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "data-encoding" -version = "2.11.1" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "der" -version = "0.8.1" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + [[package]] name = "der-parser" version = "10.0.0" @@ -491,7 +437,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", - "subtle", ] [[package]] @@ -501,20 +446,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] [[package]] name = "displaydoc" -version = "0.2.7" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] @@ -525,25 +470,24 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "ed25519" -version = "3.0.0" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", - "signature", + "pkcs8 0.10.2", + "signature 2.2.0", ] [[package]] name = "ed25519-dalek" -version = "3.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek 5.0.0", + "curve25519-dalek", "ed25519", "serde", - "sha2", - "signature", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -564,53 +508,17 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "fastbloom" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" -dependencies = [ - "foldhash", - "libm", - "portable-atomic", - "siphasher", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - [[package]] name = "fiat-crypto" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" -[[package]] -name = "fiat-crypto" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" - [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "form_urlencoded" @@ -627,90 +535,35 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "futures" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", - "futures-sink", ] [[package]] name = "futures-core" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-executor" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" - -[[package]] -name = "futures-macro" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-task" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "futures-channel", "futures-core", - "futures-io", - "futures-macro", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] @@ -738,6 +591,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -747,7 +614,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", "wasm-bindgen", ] @@ -762,81 +629,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "h2" -version = "0.4.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "h3" -version = "0.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be" -dependencies = [ - "bytes", - "fastrand", - "futures-util", - "http", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "h3-datagram" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d2c9f77921668673721ae40f17c729fc48b9e38a663858097cea547484fdf0f" -dependencies = [ - "bytes", - "h3", - "pin-project-lite", -] - -[[package]] -name = "h3-quinn" -version = "0.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4" -dependencies = [ - "bytes", - "futures", - "h3", - "h3-datagram", - "quinn", - "tokio", - "tokio-util", -] - -[[package]] -name = "h3-webtransport" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d91a50fd582a5d67b1f756fba3cd9c66367ff4f23e1017c882f664d63b350a7" -dependencies = [ - "bytes", - "futures-util", - "h3", - "h3-datagram", - "http", - "pin-project-lite", - "tokio", - "tracing", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -873,102 +665,21 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877" -[[package]] -name = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "ctutils", "typenum", ] -[[package]] -name = "hyper" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "bytes", - "http", - "http-body", - "hyper", - "pin-project-lite", - "tokio", -] - [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", @@ -980,9 +691,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -993,9 +704,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1007,17 +718,16 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1028,15 +738,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.3.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1093,70 +803,21 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys", - "log", - "simd_cesu8", - "thiserror 2.0.20", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.119", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.119", -] - [[package]] name = "jobserver" -version = "0.1.35" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", @@ -1174,9 +835,9 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd9697dc4a9a62e2da93389f34400b77a28f0287711263cabb203b3ccb9c0e4" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1190,9 +851,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -1202,9 +863,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "litemap" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -1229,15 +890,15 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "memchr" -version = "2.8.3" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "minicov" -version = "0.3.9" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3aa3aa12b448ac225b3102217d1ac5cc717908f02722926524b0599c933c7a0" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" dependencies = [ "cc", "walkdir", @@ -1251,9 +912,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.2.2" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -1266,14 +927,14 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" dependencies = [ - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", "hybrid-array", "module-lattice", - "pkcs8", + "pkcs8 0.11.0", "shake", - "signature", + "signature 3.0.0", ] [[package]] @@ -1315,127 +976,101 @@ dependencies = [ [[package]] name = "mtp" -version = "0.3.0" +version = "0.1.0" dependencies = [ "mtp-client", "mtp-codec", "mtp-common", "mtp-crypto", - "mtp-files", "mtp-host", "mtp-transport", "mtp-type-map", - "mtp-webserver", -] - -[[package]] -name = "mtp-client" -version = "0.3.0" -dependencies = [ - "mtp-codec", - "mtp-common", - "mtp-crypto", - "mtp-host", - "mtp-transport", - "rand", + "rand 0.8.6", "rcgen", "tokio", ] [[package]] -name = "mtp-codec" -version = "0.3.0" +name = "mtp-client" +version = "0.1.0" dependencies = [ - "base64 0.23.1", + "mtp-codec", + "mtp-common", + "mtp-crypto", + "mtp-transport", + "rand 0.8.6", + "tokio", +] + +[[package]] +name = "mtp-codec" +version = "0.1.0" +dependencies = [ + "base64", "byteorder", "mtp-common", "mtp-crypto", "mtp-type-map", - "rand", - "thiserror 2.0.20", + "rand 0.8.6", ] [[package]] name = "mtp-common" -version = "0.3.0" +version = "0.1.0" dependencies = [ "quinn", - "thiserror 2.0.20", + "rustls", + "thiserror 2.0.18", "wtransport", ] [[package]] name = "mtp-crypto" -version = "0.3.0" +version = "0.1.0" dependencies = [ "aes-gcm", - "argon2", - "base64 0.22.1", "chacha20poly1305", "ed25519-dalek", "getrandom 0.4.3", "hkdf", "ml-dsa", "mlkem-tls", - "rand", "rand_core 0.6.4", - "rcgen", - "rustls", "serde", - "sha2", + "sha2 0.11.0", "thiserror 1.0.69", - "time", - "tokio", - "zeroize", -] - -[[package]] -name = "mtp-files" -version = "0.3.0" -dependencies = [ - "mtp-crypto", - "rand", - "thiserror 2.0.20", "zeroize", ] [[package]] name = "mtp-host" -version = "0.3.0" +version = "0.1.0" dependencies = [ "mtp-codec", "mtp-common", "mtp-crypto", "mtp-transport", - "rand", - "thiserror 2.0.20", + "rand 0.8.6", "tokio", - "tracing", - "wtransport", ] [[package]] name = "mtp-transport" -version = "0.3.0" +version = "0.1.0" dependencies = [ - "async-trait", + "log", "mtp-codec", "mtp-common", - "mtp-crypto", - "rand", "rcgen", "rustls", "rustls-native-certs", - "sha2", "tokio", - "tracing", "wtransport", - "zeroize", ] [[package]] name = "mtp-type-map" -version = "0.3.0" +version = "0.1.0" dependencies = [ "serde", "serde_yaml", @@ -1443,11 +1078,10 @@ dependencies = [ [[package]] name = "mtp-wasm" -version = "0.3.0" +version = "0.1.0" dependencies = [ "console_error_panic_hook", "futures-channel", - "futures-util", "getrandom 0.2.17", "getrandom 0.4.3", "hex", @@ -1456,40 +1090,9 @@ dependencies = [ "mtp-common", "mtp-crypto", "mtp-type-map", - "tracing", "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-test", - "wasm-tracing", - "zeroize", -] - -[[package]] -name = "mtp-webserver" -version = "0.3.0" -dependencies = [ - "async-trait", - "bytes", - "h3", - "h3-quinn", - "h3-webtransport", - "http", - "http-body-util", - "hyper", - "hyper-util", - "mtp-codec", - "mtp-common", - "mtp-crypto", - "mtp-host", - "mtp-transport", - "quinn", - "rcgen", - "rustls", - "thiserror 2.0.20", - "tokio", - "tokio-rustls", - "tokio-stream", - "tracing", ] [[package]] @@ -1513,9 +1116,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.8" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", @@ -1529,9 +1132,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.47" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ "num-traits", ] @@ -1548,9 +1151,9 @@ dependencies = [ [[package]] name = "octets" -version = "0.3.6" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "866cb5af6f3aa3c1b44c3c2d79d22165fbb1b102e1b3fb499864bfe34736ec4b" +checksum = "8311fa8ab7a57759b4ff1f851a3048d9ef0effaa0130726426b742d26d8a88e7" [[package]] name = "oid-registry" @@ -1608,32 +1211,21 @@ dependencies = [ "windows-link", ] -[[package]] -name = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "pem" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64 0.22.1", + "base64", "serde_core", ] [[package]] name = "pem-rfc7468" -version = "1.0.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" dependencies = [ "base64ct", ] @@ -1650,22 +1242,26 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", - "spki", + "der 0.8.0", + "spki 0.8.0", ] -[[package]] -name = "pkg-config" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" - [[package]] name = "poly1305" version = "0.8.0" @@ -1689,17 +1285,11 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "portable-atomic" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" - [[package]] name = "potential_utf" -version = "0.1.6" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -1711,10 +1301,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "ppv-lite86" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -1727,14 +1326,13 @@ checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", - "futures-io", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", "rustls", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1742,24 +1340,21 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.17" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "aws-lc-rs", "bytes", - "fastbloom", - "getrandom 0.4.3", + "getrandom 0.3.4", "lru-slab", - "rand", - "rand_pcg", + "rand 0.9.4", "ring", "rustc-hash", "rustls", "rustls-pki-types", - "rustls-platform-verifier", "slab", - "thiserror 2.0.20", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1767,27 +1362,33 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.15" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.47" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1796,13 +1397,43 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.10.2" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ - "chacha20 0.10.2", - "getrandom 0.4.3", - "rand_core 0.10.1", + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1814,26 +1445,26 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - [[package]] name = "rcgen" -version = "0.14.9" +version = "0.14.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" dependencies = [ "aws-lc-rs", "pem", @@ -1869,9 +1500,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.3" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -1893,9 +1524,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "log", @@ -1921,46 +1552,19 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.1" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", ] -[[package]] -name = "rustls-platform-verifier" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" -dependencies = [ - "core-foundation", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - [[package]] name = "rustls-webpki" -version = "0.103.14" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -1970,9 +1574,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.23" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -2035,9 +1639,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -2045,29 +1649,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -2089,6 +1693,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" @@ -2117,19 +1732,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" dependencies = [ "digest 0.11.3", - "keccak 0.2.1", + "keccak 0.2.0", "sponge-cursor", ] -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - [[package]] name = "shlex" version = "2.0.1" @@ -2146,6 +1752,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" @@ -2156,28 +1771,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "simd_cesu8" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "siphasher" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" - [[package]] name = "slab" version = "0.4.12" @@ -2192,14 +1785,24 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.5" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "spki" version = "0.8.0" @@ -2207,7 +1810,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.0", ] [[package]] @@ -2230,20 +1833,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.119" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -2258,7 +1850,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -2272,11 +1864,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.18", ] [[package]] @@ -2287,34 +1879,25 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", -] - -[[package]] -name = "thread_local" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" -dependencies = [ - "cfg-if", + "syn", ] [[package]] name = "time" -version = "0.3.55" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" dependencies = [ "deranged", "num-conv", @@ -2332,9 +1915,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.32" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" dependencies = [ "num-conv", "time-core", @@ -2342,9 +1925,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -2352,9 +1935,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -2367,9 +1950,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -2384,48 +1967,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.2" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "libc", - "pin-project-lite", - "tokio", + "syn", ] [[package]] @@ -2434,7 +1982,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2448,7 +1995,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -2460,23 +2007,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "sharded-slab", - "thread_local", - "tracing-core", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "typenum" version = "1.20.1" @@ -2551,15 +2081,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2567,10 +2088,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasm-bindgen" -version = "0.2.127" +name = "wasip2" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -2581,9 +2111,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -2591,9 +2121,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2601,31 +2131,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.77" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "895a2607575412a4eda1df892084a375ea10dfeadc4d7d2ab87b854e4ddc7ba1" +checksum = "2a0d555ca874445df8d314f94f5c948a4e74e5418f332c89f660a3d8310a96f4" dependencies = [ "async-trait", "cast", @@ -2645,31 +2175,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.77" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288cb0ebe215033bf949ae1fd046726daa4c32a157f24b9dc6ac387a52aa759" +checksum = "94eb68555b95bcea5e8cf4abe280b529049479fa995bfc23734af96a6aedc120" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "wasm-bindgen-test-shared" -version = "0.2.127" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ff1c1b360982e93b6d8ea9c04836f71dba0817a16f91e229cf3a51bdd9d987" - -[[package]] -name = "wasm-tracing" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ab253baf6d3772bbdb37a0966b67d37ab80657ccd1a084b4d7b3de3232375d" -dependencies = [ - "tracing", - "tracing-subscriber", - "wasm-bindgen", -] +checksum = "c31d56021e873866c968588ed85ccdf56db5c426e44afdb4618c39895104b920" [[package]] name = "web-time" @@ -2681,15 +2200,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "winapi-util" version = "0.1.11" @@ -2711,7 +2221,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -2729,14 +2248,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -2745,42 +2281,84 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2788,16 +2366,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "writeable" -version = "0.6.4" +name = "windows_x86_64_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wtransport" -version = "0.7.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4273ce3157a3262a68665f8d3f20a0ac0c5b8a69ffd67f05ae986832ebec036" +checksum = "ea4aacf790813ee1956751491800537f4e04af7557b7b370501ccbfbc85963e4" dependencies = [ "bytes", "pem", @@ -2806,9 +2396,9 @@ dependencies = [ "rustls", "rustls-native-certs", "rustls-pki-types", - "sha2", + "sha2 0.11.0", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.18", "time", "tokio", "tracing", @@ -2819,13 +2409,13 @@ dependencies = [ [[package]] name = "wtransport-proto" -version = "0.7.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aad9059572c7dbd6901ccef37f3b7321678cd708dcf58a64b1921dbeab7bfede" +checksum = "d5867c629e4252f7439d82315923daaf27f4fa442410d51b78ab93ef4c432a11" dependencies = [ "httlib-huffman", "octets", - "thiserror 2.0.20", + "thiserror 2.0.18", "url", ] @@ -2835,7 +2425,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ - "curve25519-dalek 4.1.3", + "curve25519-dalek", "rand_core 0.6.4", "serde", "zeroize", @@ -2856,7 +2446,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror 2.0.20", + "thiserror 2.0.18", "time", ] @@ -2889,10 +2479,30 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -2910,7 +2520,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", "synstructure", ] @@ -2931,14 +2541,14 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "zerotrie" -version = "0.2.5" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -2947,9 +2557,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.7" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -2958,17 +2568,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.5" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] name = "zmij" -version = "1.0.23" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 2c1b1ec..4c67127 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,8 +8,6 @@ members = [ "host", "client", "wasm", - "files", - "mtp-webserver", ] # `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 @@ -28,8 +26,6 @@ default-members = [ "transport", "host", "client", - "files", - "mtp-webserver", ] resolver = "3" @@ -45,26 +41,27 @@ resolver = "3" # ============================================================================= [package] name = "mtp" -version = "0.3.0" +version = "0.1.0" edition = "2024" [dependencies] # --- always-on core --- -mtp-common = { version = "0.3.0", path = "common" } -mtp-type-map = { version = "0.3.0", path = "type-map" } -mtp-codec = { version = "0.3.0", path = "codec" } +mtp-common = { path = "common" } +mtp-type-map = { path = "type-map" } +mtp-codec = { path = "codec" } +mtp-transport = { path = "transport" } + # --- optional, behind features --- -mtp-crypto = { version = "0.3.0", path = "crypto", optional = true, features = [ +mtp-crypto = { path = "crypto", optional = true, features = [ "serde", "mlkem-tls", ] } -mtp-host = { version = "0.3.0", path = "host", 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 } +mtp-host = { path = "host", optional = true } +mtp-client = { path = "client", optional = true } [features] +default = [] + # Serialization serde = ["mtp-crypto/serde"] @@ -75,43 +72,15 @@ crypto = [ "mtp-codec/crypto", "mtp-host?/crypto", "mtp-client?/crypto", - "mtp-webserver?/crypto", ] # MTP server host - version negotiation, Registry, incoming QUIC connections. -host = ["dep:mtp-host", "mtp-codec/registry", "transport"] +host = ["dep:mtp-host", "mtp-codec/registry", "mtp-transport/host"] # MTP client - outgoing QUIC connections to a host. -client = ["dep:mtp-client", "transport"] +client = ["dep:mtp-client"] -# Direct access to the framed QUIC transport. Host/client features enable it -# automatically; this feature is useful for low-level integrations. -transport = ["dep:mtp-transport"] - -# 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"] +[dev-dependencies] +tokio = { version = "1", features = ["full"] } +rcgen = "0.14" +rand = "0.8" diff --git a/README.md b/README.md index d7abaee..a5df4d2 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,160 @@ # Methanium Transport Protocol -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. +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. -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). +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). ## Browser SDK -The JavaScript package is `mtp`. +The JavaScript package is `mtp`: -- `mtp` exports the SDK-first `MTPClient` API and codec helpers. -- `mtp/raw` exports generated WASM bindings for lower-level integrations. -- `mtp/vite` exports the Vite integration that builds app-specific bindings. -- `mtp/type-map` exports generated TypeScript type-name unions. +```typescript +import { MTPClient } from "mtp"; +import { mtp } from "mtp/vite"; +``` -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. +Use `mtp` for the SDK-first API, `mtp/raw` for generated WASM bindings, and `mtp/vite` for the Vite integration. -The SDK exposes crypto helpers and frame codec helpers from the main `mtp` export. The security guide describes the available algorithms and their limitations. +```typescript +// 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 -Add the umbrella crate with the features required by the application: +Add the `mtp` crate with your desired features: ```toml [dependencies] mtp = { path = "..", features = ["client", "crypto"] } ``` -Feature summary: +The `mtp` umbrella crate re-exports all sub-crates behind feature flags: -| Feature | Pulls in | Enables | -| --- | --- | --- | -| `serde` | Crypto serialization support | Serde implementations for crypto key types | -| `crypto` | `mtp::crypto` | AEAD, signatures, KEM, KDF, hashing, and connection authentication support | -| `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` | +| Feature | Pulls in | Enables | +| -------- | --------------------------- | ----------------------------------------- | +| `crypto` | `mtp::crypto` | AEAD, signatures, KEM, KDF, hashing | +| `host` | `mtp::host`, `mtp::codec::registry` | QUIC server, version negotiation | +| `client` | `mtp::client` | QUIC client connections | -The core modules always available from the facade are `codec`, `common`, and -`type_map`. Native `client` and `host` modules re-export the transport policy -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) -guides for configuration and usage. See [Security](./docs/SECURITY.md) for security boundaries. +Core crates (`codec`, `transport`, `common`, `type_map`) are always available. + +```rust +use mtp::codec::{CommunicationValue, DataValue}; +use mtp::type_map::{CommunicationType, DataType, TypeMap}; +use mtp::transport::{Sender, Receiver}; + +#[cfg(feature = "crypto")] +use mtp::crypto::ChaCha20Poly1305; +``` ## Sub-crates -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. +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`. ### Codec -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` and `Encrypted` 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. +The codec crate handles binary encoding and decoding of MTP packets using Communication Types and Data Types resolved through the type-map registry. + +**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 -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). +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. ### Host -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). +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. ### Client -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). +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`. ### Common -Common defines shared errors such as `CodecError` and `CommunicationError`, as well as protocol-level types used by the other crates. +Common defines shared error types (`CodecError`, `CommunicationError`) used across all crates. ### Type Map -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). +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). -### Crypto +### Crypto Stack -`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). - -## Examples - -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. +| Crate | Audited? | Notes | +| ---------------- | -------- | ------------------------------------------------ | +| ml-dsa | No | NIST vectors pass; regression bug fixed Jan 2026 | +| ed25519-dalek | Yes | Used by Signal, Diem | +| chacha20poly1305 | Yes | NCC Group audit, Dec 2019 | +| 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 | diff --git a/client/Cargo.lock b/client/Cargo.lock index a02ac5a..72dff85 100644 --- a/client/Cargo.lock +++ b/client/Cargo.lock @@ -4,4 +4,4 @@ version = 4 [[package]] name = "client" -version = "0.2.0" +version = "0.1.0" diff --git a/client/Cargo.toml b/client/Cargo.toml index 7346f63..df69b8c 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -1,21 +1,15 @@ [package] name = "mtp-client" -version = "0.3.0" +version = "0.1.0" edition = "2024" [dependencies] -mtp-common = { version = "0.3.0", path = "../common" } -mtp-codec = { version = "0.3.0", path = "../codec", features = ["registry"] } -mtp-transport = { version = "0.3.0", path = "../transport" } -mtp-crypto = { version = "0.3.0", path = "../crypto", optional = true } -rand = "0.10.1" -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" +mtp-common = { path = "../common" } +mtp-codec = { path = "../codec" } +mtp-transport = { path = "../transport" } +mtp-crypto = { path = "../crypto", optional = true } +rand = "0.8" +tokio = { version = "1", features = ["time"] } [features] crypto = ["dep:mtp-crypto", "mtp-codec/crypto"] -pipes = ["mtp-common/pipes", "mtp-transport/pipes"] diff --git a/client/src/config.rs b/client/src/config.rs deleted file mode 100644 index 8fb292e..0000000 --- a/client/src/config.rs +++ /dev/null @@ -1,115 +0,0 @@ -use tokio::time::Duration; - -pub use mtp_transport::Policy; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ClientTlsConfig { - SystemRoots, - PinnedPem(Vec), -} - -pub struct ClientConfig { - pub url: String, - pub tls: ClientTlsConfig, - pub client_id: u64, - pub description: Option, - pub policy: Policy, - pub ping_interval: Duration, - pub ping_jitter: Option, - 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) -> 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) -> 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) -> 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) -> 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> { - match &self.tls { - ClientTlsConfig::SystemRoots => None, - ClientTlsConfig::PinnedPem(cert) => Some(cert.clone()), - } - } -} diff --git a/client/src/connection.rs b/client/src/connection.rs deleted file mode 100644 index 16a85db..0000000 --- a/client/src/connection.rs +++ /dev/null @@ -1,307 +0,0 @@ -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, - /// The peer address observed by the underlying QUIC connection. - pub remote_addr: Option, - pub(crate) ping: Option, - pub(crate) app_rx: Mutex>>, - #[cfg(feature = "pipes")] - pub(crate) pipe_req_rx: Mutex>, - pub(crate) pipe_dispatcher: Arc, - 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 { - self.ping.as_ref().and_then(PingSession::get_ping) - } - - pub async fn request( - &self, - request: &CommunicationValue, - expected_response: Option, - ) -> Result { - 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 { - 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 { - 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::(); - 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 { - 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::>( - receiver_queue_capacity, - ); - let (pipe_req_tx, pipe_req_rx) = mpsc::channel::(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::>( - 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, - } - } -} diff --git a/client/src/crypto.rs b/client/src/crypto.rs deleted file mode 100644 index 21a8aa2..0000000 --- a/client/src/crypto.rs +++ /dev/null @@ -1,241 +0,0 @@ -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 { - 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, - client_nonce: u128, - type_map: &TypeMap, -) -> Result { - 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 { - 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) -} diff --git a/client/src/lib.rs b/client/src/lib.rs index 9a4a2f5..a60541c 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -1,191 +1,349 @@ -pub mod config; -pub mod connection; +use mtp_codec::{CommunicationValue, DataType, DataValue, PROTOCOL_VERSION, Version}; +use mtp_common::CommunicationError; +use mtp_transport::{Policy, Receiver, Sender}; #[cfg(feature = "crypto")] -pub mod crypto; -pub mod ping; -pub mod pipe; - -#[cfg(feature = "pipes")] -pub use mtp_common::PipeError; -#[cfg(feature = "pipes")] -pub use mtp_transport::PipeWriter; - -pub use MTPClient as Client; -pub use MTPConnection as Connection; -pub use config::{ClientConfig, ClientTlsConfig, Policy}; -pub use connection::MTPConnection; -pub use mtp_transport::Receiver; -pub use mtp_transport::SendMode; -pub use mtp_transport::Sender; +use tokio::time::Duration; #[cfg(feature = "crypto")] -pub use error::AuthState; -mod error { - #[cfg(feature = "crypto")] - #[derive(Debug, Clone, PartialEq, Eq)] - pub enum AuthState { - Unauthenticated, - Pending, - Authenticated, - Failed, - } -} - -use mtp_codec::{ - CommunicationValue, DataType, DataValue, PROTOCOL_VERSION, Version, - registry::{Registry, VersionedCodec}, -}; -use mtp_common::{CommunicationError, HandshakeOutcome, RejectionReason}; - -use connection::connection_from_parts; - -fn parse_handshake_response( +fn unexpected_response_type_error( + context: &str, + expected_type: mtp_codec::CommunicationTypeId, response: &CommunicationValue, - type_map: &mtp_codec::TypeMap, -) -> Result { - let bad_version = mtp_codec::CommunicationType::ErrorBadVersion.try_to_id(type_map); - if Some(response.get_type()) == bad_version { - let supported_versions = match response.get_data(DataType::Version) { - Some(DataValue::Str(v)) if !v.is_empty() => v.split(',').map(String::from).collect(), - _ => vec![], - }; - return Ok(HandshakeOutcome::Rejected { - reason: RejectionReason::BadVersion { supported_versions }, - }); +) -> CommunicationError { + CommunicationError::AuthenticationFailed(format!( + "unexpected response type during {context}: expected {:?}, got {:?}; parsed {}", + expected_type, + response.get_type(), + response + )) +} + +pub struct ClientConfig { + pub url: String, + pub tls: ClientTlsConfig, + pub client_id: u64, + #[cfg(feature = "crypto")] + pub auth_timeout: Duration, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClientTlsConfig { + SystemRoots, + PinnedPem(Vec), +} + +impl ClientConfig { + pub fn new(url: impl Into) -> Self { + Self { + url: url.into(), + tls: ClientTlsConfig::SystemRoots, + client_id: 0, + #[cfg(feature = "crypto")] + auth_timeout: Duration::from_secs(30), + } } - let expected = mtp_codec::CommunicationType::IdentificationResponse - .try_to_id(type_map) - .ok_or_else(|| { - CommunicationError::Other("IdentificationResponse is absent from the type map".into()) - })?; - if response.get_type() != expected { - let detail = response - .get_str(DataType::ErrorMessage) - .unwrap_or("host rejected the connection") - .to_string(); - return Ok(HandshakeOutcome::Rejected { - reason: RejectionReason::AuthenticationFailed { detail }, - }); + pub fn with_tls(mut self, tls: ClientTlsConfig) -> Self { + self.tls = tls; + self } - match response.get_data(DataType::Connected) { - Some(DataValue::BoolTrue) => { - let version = match response.get_data(DataType::Version) { - Some(DataValue::Str(v)) => v.clone(), - _ => { - return Err(CommunicationError::Other( - "host omitted the negotiated version".into(), - )); - } - }; - let assigned_id = match response.get_data(DataType::Id) { - Some(DataValue::UnsignedNumber(n)) => u64::try_from(*n).map_err(|_| { - CommunicationError::Other("host returned an out-of-range client id".into()) - })?, - _ => { - return Err(CommunicationError::Other( - "host omitted the assigned client id".into(), - )); - } - }; - Ok(HandshakeOutcome::Accepted { - version, - assigned_id, - }) + pub fn with_pinned_pem(self, cert_pem: Vec) -> 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 + } + + #[cfg(feature = "crypto")] + pub fn with_auth_timeout(mut self, timeout: Duration) -> Self { + self.auth_timeout = timeout; + self + } + + fn server_cert(&self) -> Option> { + match &self.tls { + ClientTlsConfig::SystemRoots => None, + ClientTlsConfig::PinnedPem(cert) => Some(cert.clone()), } - Some(DataValue::BoolFalse) => { - let detail = response - .get_str(DataType::ErrorMessage) - .unwrap_or("host rejected the connection") - .to_string(); - Ok(HandshakeOutcome::Rejected { - reason: RejectionReason::AuthenticationFailed { detail }, - }) - } - _ => Err(CommunicationError::Other("invalid response".into())), } } -fn codec_for_version(version: &Version) -> Result { - VersionedCodec::for_version(Registry::builtin(), version.clone()).ok_or_else(|| { - CommunicationError::Other(format!( - "host returned unsupported protocol version {version}" - )) - }) +/* Established MTP connection with a single negotiated version. */ +pub struct MTPConnection { + pub version: Version, + pub sender: Sender, + pub receiver: Receiver, + #[cfg(feature = "crypto")] + pub auth_state: AuthState, + #[cfg(feature = "crypto")] + pub client_id: u64, +} + +impl MTPConnection { + /* + * Send a request frame and wait for the response with the same frame id. + * Any expected response type is validated after the id match. Frames with + * other ids are consumed by this call, so applications that need broad + * routing should put request correlation in a dedicated receive task. + */ + pub async fn request( + &self, + request: &CommunicationValue, + expected_response: Option, + ) -> Result { + let request_id = request.get_id(); + if request_id == 0 { + return Err(CommunicationError::Other( + "request frame must have a non-zero id".into(), + )); + } + + self.sender.send(request).await?; + + let tm = mtp_codec::TypeMap::latest(); + loop { + let response = self.receiver.receive().await?; + if response.get_id() != request_id { + continue; + } + + if let Some(expected) = expected_response { + let expected_type = expected.to_id(&tm); + if response.get_type() != expected_type { + return Err(CommunicationError::Other(format!( + "unexpected response type: expected {:?}, got {:?}; parsed {}", + expected_type, + response.get_type(), + response + ))); + } + } + + return Ok(response); + } + } +} + +#[cfg(feature = "crypto")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthState { + Unauthenticated, + Pending, + Authenticated, + Failed, } pub struct MTPClient; impl MTPClient { + /* + * Connect to an MTP host. + * + * The first message includes the client's protocol version + * (a reserved `Version` data entry) so the host can negotiate. + */ pub async fn connect(config: ClientConfig) -> Result { let (sender, receiver) = - mtp_transport::connect(&config.url, config.server_cert(), config.policy).await?; - let opening_codec = codec_for_version(&PROTOCOL_VERSION)?; - sender.set_type_map(opening_codec.type_map()).await; - receiver.set_type_map(opening_codec.type_map()).await; + mtp_transport::connect(&config.url, config.server_cert(), Policy::default()).await?; let version_str = format!("{}", PROTOCOL_VERSION); - let mut ident = CommunicationValue::new_with_type_map( - mtp_codec::CommunicationType::Identification, - opening_codec.type_map(), - ) - .add_typed_default(DataType::Version, DataValue::Str(version_str)) - .add_typed_default( - DataType::Id, - DataValue::UnsignedNumber(config.client_id.into()), - ); - if let Some(desc) = &config.description { - ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); - } + let ident = CommunicationValue::new(mtp_codec::CommunicationType::Identification) + .add_typed_default(DataType::Version, DataValue::Str(version_str)) + .add_typed_default( + DataType::Id, + DataValue::UnsignedNumber(config.client_id.into()), + ); sender.send(&ident).await?; - let response = receiver.receive().await?; - let outcome = parse_handshake_response(&response, opening_codec.type_map())?; - let (negotiated, assigned_id) = match outcome { - mtp_common::HandshakeOutcome::Accepted { - version, - assigned_id, - } => ( - Version::parse(&version).ok_or_else(|| { - CommunicationError::Other("host returned an invalid negotiated version".into()) - })?, - assigned_id, - ), - mtp_common::HandshakeOutcome::Rejected { reason } => { - sender.close().await; - return Err(CommunicationError::Other(reason.to_string())); - } - }; - #[cfg(not(feature = "crypto"))] - let _ = assigned_id; - if negotiated != PROTOCOL_VERSION { - sender.close().await; - return Err(CommunicationError::Other( - "host selected a protocol version the client did not offer".into(), - )); - } - let codec = codec_for_version(&negotiated)?; - - #[cfg(feature = "crypto")] - let client_id = assigned_id; - #[cfg(feature = "crypto")] - return Ok(connection_from_parts( - config, + Ok(MTPConnection { + version: PROTOCOL_VERSION, sender, receiver, - negotiated, - codec, - error::AuthState::Unauthenticated, - client_id, - ) - .await); - #[cfg(not(feature = "crypto"))] - Ok(connection_from_parts(config, sender, receiver, negotiated, codec).await) + #[cfg(feature = "crypto")] + auth_state: AuthState::Unauthenticated, + #[cfg(feature = "crypto")] + client_id: config.client_id, + }) } } +/* ===== Authentication ===== */ + +/* + * Verify the host's signature over the challenge it issued (step 2). + * + * `id` is the client id for a login, or `0` for a registration (the host binds + * `0` since no id has been assigned yet). The Ed25519 signature is mandatory; + * the ML-DSA signature is checked only when the host included one. + */ +#[cfg(feature = "crypto")] +fn verify_host_challenge( + challenge: &CommunicationValue, + host_pk: &mtp_crypto::PublicKeyBundle, + id: u64, + server_challenge: u128, +) -> Result<(), CommunicationError> { + use mtp_crypto::{auth, verify_ed25519, verify_ml_dsa}; + + let sig = match challenge.get_data(DataType::Signature) { + DataValue::Bytes(b) => b.clone(), + _ => { + return Err(CommunicationError::AuthenticationFailed( + "Missing host challenge signature".into(), + )); + } + }; + let pq_sig = match challenge.get_data(DataType::PqSignature) { + DataValue::Bytes(b) => b.clone(), + _ => vec![], + }; + + let payload = auth::challenge_payload(id, server_challenge); + verify_ed25519(&host_pk.sig_cl_public_key, &payload, &sig).map_err(|_| { + CommunicationError::AuthenticationFailed("Host challenge signature invalid".into()) + })?; + if !pq_sig.is_empty() && verify_ml_dsa(&host_pk.sig_pq_public_key, &payload, &pq_sig).is_err() { + return Err(CommunicationError::AuthenticationFailed( + "Host challenge PQ signature invalid".into(), + )); + } + Ok(()) +} + +/* + * Verify the host's final confirmation (step 4): the echoed `client_nonce` and + * the host signature over the handshake transcript. + */ +#[cfg(feature = "crypto")] +fn verify_host_final( + response: &CommunicationValue, + host_pk: &mtp_crypto::PublicKeyBundle, + id: u64, + client_nonce: u128, + server_challenge: u128, +) -> Result<(), CommunicationError> { + use mtp_crypto::{auth, verify_ed25519, verify_ml_dsa}; + + match response.get_data(DataType::ClientNonce) { + DataValue::UnsignedNumber(n) if *n == client_nonce => {} + _ => { + return Err(CommunicationError::AuthenticationFailed( + "Nonce mismatch".into(), + )); + } + } + + let sig = match response.get_data(DataType::Signature) { + DataValue::Bytes(b) => b.clone(), + _ => { + return Err(CommunicationError::AuthenticationFailed( + "Missing signature".into(), + )); + } + }; + let pq_sig = match response.get_data(DataType::PqSignature) { + DataValue::Bytes(b) => b.clone(), + _ => vec![], + }; + + let payload = auth::host_final_payload(id, client_nonce, server_challenge); + verify_ed25519(&host_pk.sig_cl_public_key, &payload, &sig) + .map_err(|_| CommunicationError::AuthenticationFailed("Host signature invalid".into()))?; + if !pq_sig.is_empty() && verify_ml_dsa(&host_pk.sig_pq_public_key, &payload, &pq_sig).is_err() { + return Err(CommunicationError::AuthenticationFailed( + "Host PQ signature invalid".into(), + )); + } + Ok(()) +} + +/* Interpret the host's `Connected` flag. */ +#[cfg(feature = "crypto")] +fn check_connected( + response: &CommunicationValue, + reject_msg: &str, +) -> Result<(), CommunicationError> { + match response.get_data(DataType::Connected) { + DataValue::BoolTrue => Ok(()), + DataValue::BoolFalse => Err(CommunicationError::AuthenticationFailed(reject_msg.into())), + _ => Err(CommunicationError::AuthenticationFailed( + "Invalid response".into(), + )), + } +} + +#[cfg(feature = "crypto")] +fn signed_challenge_response( + keys: &mtp_crypto::Keyring, + proof_payload: &[u8], + client_nonce: u128, +) -> Result { + use mtp_crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme}; + + let signer = Ed25519Signer::new(&keys.sig_cl_secret_key) + .map_err(|e| CommunicationError::Other(e.to_string()))?; + let signature = signer + .sign(proof_payload) + .map_err(|e| CommunicationError::Other(e.to_string()))?; + + let mut proof = CommunicationValue::new(mtp_codec::CommunicationType::ChallengeResponse) + .add_typed_default( + DataType::ClientNonce, + DataValue::UnsignedNumber(client_nonce), + ) + .add_typed_default(DataType::Signature, DataValue::Bytes(signature)); + + if !keys.sig_pq_secret_key.as_bytes().is_empty() { + let pq_signer = MlDsaSigner::new(&keys.sig_pq_secret_key, &keys.sig_pq_public_key) + .map_err(|e| CommunicationError::Other(e.to_string()))?; + let pq_signature = pq_signer + .sign(proof_payload) + .map_err(|e| CommunicationError::Other(e.to_string()))?; + proof = proof.add_typed_default(DataType::PqSignature, DataValue::Bytes(pq_signature)); + } + + Ok(proof) +} + +#[cfg(feature = "crypto")] +async fn receive_verified_challenge( + receiver: &Receiver, + tm: &mtp_codec::TypeMap, + host_public_key_bundle: &mtp_crypto::PublicKeyBundle, + bound_id: u64, + context: &str, +) -> Result { + let challenge = receiver.receive().await?; + let expected = mtp_codec::CommunicationType::Challenge.to_id(tm); + if challenge.get_type() != expected { + return Err(unexpected_response_type_error( + context, expected, &challenge, + )); + } + + let server_challenge = match challenge.get_data(DataType::ServerNonce) { + DataValue::UnsignedNumber(n) => *n, + _ => { + return Err(CommunicationError::AuthenticationFailed( + "Missing server challenge".into(), + )); + } + }; + + verify_host_challenge( + &challenge, + host_public_key_bundle, + bound_id, + server_challenge, + )?; + + Ok(server_challenge) +} + #[cfg(feature = "crypto")] impl MTPClient { pub async fn auth_connect( @@ -212,158 +370,103 @@ impl MTPClient { keys: &mtp_crypto::Keyring, host_public_key_bundle: &mtp_crypto::PublicKeyBundle, ) -> Result { - use mtp_codec::CommunicationType; + use mtp_crypto::auth; let (sender, receiver) = - mtp_transport::connect(&config.url, config.server_cert(), config.policy).await?; + mtp_transport::connect(&config.url, config.server_cert(), Policy::default()).await?; - let handshake_codec = codec_for_version(&PROTOCOL_VERSION)?; - let tm = handshake_codec.type_map().clone(); - sender.set_type_map(&tm).await; - receiver.set_type_map(&tm).await; + let tm = mtp_codec::TypeMap::latest(); let version_str = format!("{}", PROTOCOL_VERSION); - let public_key_bytes = keys - .public_key_bundle() - .try_as_bytes() - .map_err(|error| CommunicationError::ParseError(error.to_string()))?; - let mut ident = - CommunicationValue::new_with_type_map(CommunicationType::Identification, &tm) - .add_typed_default(DataType::Version, DataValue::Str(version_str.clone())) - .add_typed_default( - DataType::Id, - DataValue::UnsignedNumber(config.client_id as u128), - ) - // This capability marker lets a non-crypto host reject an - // authentication attempt instead of treating it as a plain - // unauthenticated connection. - .add_typed_default(DataType::PublicKeys, DataValue::Bytes(public_key_bytes)); - if let Some(desc) = &config.description { - ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); - } + // 1. Send the unsigned Identification hello (version + claimed id). + let ident = CommunicationValue::new(mtp_codec::CommunicationType::Identification) + .add_typed_default(DataType::Version, DataValue::Str(version_str.clone())) + .add_typed_default( + DataType::Id, + DataValue::UnsignedNumber(config.client_id as u128), + ); if let Err(e) = sender.send(&ident).await { - sender.close().await; + sender.close(); return Err(e); } - let server_challenge = match crypto::receive_verified_challenge( + // 2. Receive and verify the host's challenge. + let server_challenge = match receive_verified_challenge( &receiver, &tm, host_public_key_bundle, config.client_id, "auth_connect challenge", - config.require_pq, - !keys.sig_pq_secret_key.as_bytes().is_empty(), ) .await { Ok(c) => c, Err(e) => { - sender.close().await; + sender.close(); return Err(e); } }; + // 3. Sign the host's challenge and send the proof. let client_nonce: u128 = rand::random(); - let proof_payload = mtp_crypto::auth::login_proof_payload( + let proof_payload = auth::login_proof_payload( &version_str, config.client_id, server_challenge, client_nonce, ); - let proof = - match crypto::signed_challenge_response(keys, proof_payload, client_nonce, &tm).await { - Ok(p) => p, - Err(e) => { - sender.close().await; - return Err(e); - } - }; - if let Err(e) = sender.send(&proof).await { - sender.close().await; - return Err(e); - } - - let response = match receiver.receive().await { - Ok(r) => r, + let proof = match signed_challenge_response(keys, &proof_payload, client_nonce) { + Ok(p) => p, Err(e) => { - sender.close().await; + sender.close(); return Err(e); } }; - let expected_type = CommunicationType::IdentificationResponse - .try_to_id(&tm) - .ok_or_else(|| { - CommunicationError::Other( - "IdentificationResponse is absent from the type map".into(), - ) - })?; + if let Err(e) = sender.send(&proof).await { + sender.close(); + return Err(e); + } + + // 4. Receive and verify the host's final confirmation. + let response = match receiver.receive().await { + Ok(r) => r, + Err(e) => { + sender.close(); + return Err(e); + } + }; + let expected_type = mtp_codec::CommunicationType::IdentificationResponse.to_id(&tm); if response.get_type() != expected_type { - sender.close().await; - return Err(crypto::unexpected_response_type_error( + sender.close(); + return Err(unexpected_response_type_error( "auth_connect", expected_type, &response, )); } - if let Err(e) = crypto::check_connected(&response, "Server rejected authentication") { - sender.close().await; + if let Err(e) = check_connected(&response, "Server rejected authentication") { + sender.close(); return Err(e); } - if let Err(e) = crypto::verify_host_final( + if let Err(e) = verify_host_final( &response, host_public_key_bundle, config.client_id, client_nonce, server_challenge, - config.require_pq, - ) - .await - { - sender.close().await; + ) { + sender.close(); return Err(e); } - let assigned_id = match response.get_data(DataType::Id) { - Some(DataValue::UnsignedNumber(id)) => u64::try_from(*id).map_err(|_| { - CommunicationError::AuthenticationFailed( - "host returned an out-of-range client id".into(), - ) - })?, - _ => { - sender.close().await; - return Err(CommunicationError::AuthenticationFailed( - "host omitted the authenticated client id".into(), - )); - } - }; - if assigned_id != config.client_id { - sender.close().await; - return Err(CommunicationError::AuthenticationFailed( - "host returned a different authenticated client id".into(), - )); - } - - let negotiated = crypto::negotiated_version(&response)?; - if negotiated != PROTOCOL_VERSION { - sender.close().await; - return Err(CommunicationError::AuthenticationFailed( - "host selected a protocol version the client did not offer".into(), - )); - } - let codec = codec_for_version(&negotiated)?; - let client_id = config.client_id; - Ok(connection_from_parts( - config, + Ok(MTPConnection { + version: PROTOCOL_VERSION, sender, receiver, - negotiated, - codec, - error::AuthState::Authenticated, - client_id, - ) - .await) + auth_state: AuthState::Authenticated, + client_id: config.client_id, + }) } pub async fn auth_register( @@ -405,141 +508,108 @@ impl MTPClient { keys: &mtp_crypto::Keyring, host_public_key_bundle: &mtp_crypto::PublicKeyBundle, ) -> Result { - use mtp_codec::CommunicationType; + use mtp_crypto::auth; let (sender, receiver) = - mtp_transport::connect(&config.url, config.server_cert(), config.policy).await?; + mtp_transport::connect(&config.url, config.server_cert(), Policy::default()).await?; - let handshake_codec = codec_for_version(&PROTOCOL_VERSION)?; - let tm = handshake_codec.type_map().clone(); - sender.set_type_map(&tm).await; - receiver.set_type_map(&tm).await; + let tm = mtp_codec::TypeMap::latest(); let version_str = format!("{}", PROTOCOL_VERSION); let pk_bundle = keys.public_key_bundle(); - let pk_bytes = pk_bundle - .try_as_bytes() - .map_err(|error| CommunicationError::ParseError(error.to_string()))?; + let pk_bytes = pk_bundle.as_bytes(); - let mut register = CommunicationValue::new_with_type_map(CommunicationType::Register, &tm) + // 1. Send the unsigned Register hello (version + public-key bundle). + let register = CommunicationValue::new(mtp_codec::CommunicationType::Register) .add_typed_default(DataType::Version, DataValue::Str(version_str.clone())) .add_typed_default(DataType::PublicKeys, DataValue::Bytes(pk_bytes.clone())); - if let Some(desc) = &config.description { - register = - register.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); - } if let Err(e) = sender.send(®ister).await { - sender.close().await; + sender.close(); return Err(e); } - let server_challenge = match crypto::receive_verified_challenge( + // 2. Receive and verify the host's challenge (register binds id = 0). + let server_challenge = match receive_verified_challenge( &receiver, &tm, host_public_key_bundle, 0, "auth_register challenge", - config.require_pq, - !keys.sig_pq_secret_key.as_bytes().is_empty(), ) .await { Ok(c) => c, Err(e) => { - sender.close().await; + sender.close(); return Err(e); } }; + // 3. Sign the host's challenge over the bundle and send the proof. let client_nonce: u128 = rand::random(); - let proof_payload = mtp_crypto::auth::register_proof_payload( - &version_str, - &pk_bytes, - server_challenge, - client_nonce, - ); + let proof_payload = + auth::register_proof_payload(&version_str, &pk_bytes, server_challenge, client_nonce); - let proof = - match crypto::signed_challenge_response(keys, proof_payload, client_nonce, &tm).await { - Ok(p) => p, - Err(e) => { - sender.close().await; - return Err(e); - } - }; + let proof = match signed_challenge_response(keys, &proof_payload, client_nonce) { + Ok(p) => p, + Err(e) => { + sender.close(); + return Err(e); + } + }; if let Err(e) = sender.send(&proof).await { - sender.close().await; + sender.close(); return Err(e); } + // 4. Receive the host's final confirmation; extract the assigned id and + // verify the host signature binds to it. let response = match receiver.receive().await { Ok(r) => r, Err(e) => { - sender.close().await; + sender.close(); return Err(e); } }; - let expected_type = CommunicationType::RegisterResponse - .try_to_id(&tm) - .ok_or_else(|| { - CommunicationError::Other("RegisterResponse is absent from the type map".into()) - })?; + let expected_type = mtp_codec::CommunicationType::RegisterResponse.to_id(&tm); if response.get_type() != expected_type { - sender.close().await; - return Err(crypto::unexpected_response_type_error( + sender.close(); + return Err(unexpected_response_type_error( "auth_register", expected_type, &response, )); } - if let Err(e) = crypto::check_connected(&response, "Server rejected registration") { - sender.close().await; + if let Err(e) = check_connected(&response, "Server rejected registration") { + sender.close(); return Err(e); } let assigned_id = match response.get_data(DataType::Id) { - Some(DataValue::UnsignedNumber(n)) => u64::try_from(*n).map_err(|_| { - CommunicationError::AuthenticationFailed( - "host returned an out-of-range client id".into(), - ) - })?, + DataValue::UnsignedNumber(n) => *n as u64, _ => { - sender.close().await; + sender.close(); return Err(CommunicationError::AuthenticationFailed( "Missing assigned ID".into(), )); } }; - if let Err(e) = crypto::verify_host_final( + if let Err(e) = verify_host_final( &response, host_public_key_bundle, assigned_id, client_nonce, server_challenge, - config.require_pq, - ) - .await - { - sender.close().await; + ) { + sender.close(); return Err(e); } - let negotiated = crypto::negotiated_version(&response)?; - if negotiated != PROTOCOL_VERSION { - sender.close().await; - return Err(CommunicationError::AuthenticationFailed( - "host selected a protocol version the client did not offer".into(), - )); - } - let codec = codec_for_version(&negotiated)?; - Ok(connection_from_parts( - config, + Ok(MTPConnection { + version: PROTOCOL_VERSION, sender, receiver, - negotiated, - codec, - error::AuthState::Authenticated, - assigned_id, - ) - .await) + auth_state: AuthState::Authenticated, + client_id: assigned_id, + }) } } @@ -547,7 +617,6 @@ impl MTPClient { #[cfg(test)] mod tests { use super::*; - use std::time::Duration; #[test] fn test_client_config_url() { @@ -568,116 +637,11 @@ mod tests { assert_eq!(config.client_id, 42); } - #[test] - fn test_ping_config() { - let config = ClientConfig::new("https://localhost:4433") - .with_ping_interval(Duration::from_secs(5)) - .with_max_missed_pings(2) - .with_ping_timestamp(false); - assert_eq!(config.ping_interval, Duration::from_secs(5)); - assert_eq!(config.ping_jitter, None); - assert_eq!(config.max_missed_pings, 2); - assert!(!config.ping_timestamp); - } - - #[test] - fn test_request_timeout_config() { - let config = ClientConfig::new("https://localhost:4433"); - assert_eq!(config.request_timeout, Duration::from_secs(30)); - assert_eq!( - config - .with_request_timeout(Duration::from_secs(5)) - .request_timeout, - Duration::from_secs(5) - ); - } - - #[tokio::test] - async fn test_dispatcher_routes_only_matching_request_id() { - use std::collections::HashMap; - use std::sync::Arc; - use tokio::sync::Mutex; - - let dispatcher = pipe::PipeDispatcher { - pending_requests: Mutex::new(HashMap::new()), - expired_requests: Mutex::new(HashMap::new()), - #[cfg(feature = "pipes")] - type_map: mtp_codec::TypeMap::latest(), - #[cfg(feature = "pipes")] - pending_creations: std::sync::Mutex::new(HashMap::new()), - #[cfg(feature = "pipes")] - expired_creations: std::sync::Mutex::new(HashMap::new()), - #[cfg(feature = "pipes")] - pending_pipes: Mutex::new(HashMap::new()), - #[cfg(feature = "pipes")] - policy: Arc::new(Policy::default()), - }; - let (app_tx, mut app_rx) = tokio::sync::mpsc::channel(2); - let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - dispatcher.pending_requests.lock().await.insert( - 7, - pipe::PendingRequest { - token: Arc::new(()), - sender: response_tx, - }, - ); - - let unrelated = CommunicationValue::new(mtp_codec::CommunicationType::Ping).with_id(8); - assert!(pipe::route_message(unrelated, &app_tx, &dispatcher).await); - assert_eq!(app_rx.recv().await.unwrap().unwrap().id(), Some(8)); - - let response = CommunicationValue::new(mtp_codec::CommunicationType::Pong).with_id(7); - assert!(pipe::route_message(response, &app_tx, &dispatcher).await); - assert_eq!(response_rx.await.unwrap().unwrap().id(), Some(7)); - assert!(app_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn test_expired_request_response_is_consumed() { - use std::collections::HashMap; - use std::sync::Arc; - use tokio::sync::Mutex; - - let dispatcher = pipe::PipeDispatcher { - pending_requests: Mutex::new(HashMap::new()), - expired_requests: Mutex::new(HashMap::new()), - #[cfg(feature = "pipes")] - type_map: mtp_codec::TypeMap::latest(), - #[cfg(feature = "pipes")] - pending_creations: std::sync::Mutex::new(HashMap::new()), - #[cfg(feature = "pipes")] - expired_creations: std::sync::Mutex::new(HashMap::new()), - #[cfg(feature = "pipes")] - pending_pipes: Mutex::new(HashMap::new()), - #[cfg(feature = "pipes")] - policy: Arc::new(Policy::default()), - }; - let token = Arc::new(()); - let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - dispatcher.pending_requests.lock().await.insert( - 9, - pipe::PendingRequest { - token: token.clone(), - sender: response_tx, - }, - ); - pipe::expire_pending_request(&dispatcher, 9, &token).await; - - let (app_tx, mut app_rx) = tokio::sync::mpsc::channel(1); - let response = CommunicationValue::new(mtp_codec::CommunicationType::Pong).with_id(9); - assert!(pipe::route_message(response, &app_tx, &dispatcher).await); - assert!(response_rx.await.is_err()); - assert!(app_rx.try_recv().is_err()); - } - #[cfg(feature = "crypto")] #[test] fn test_auth_state_unauthenticated_is_not_authenticated() { - assert_ne!( - error::AuthState::Unauthenticated, - error::AuthState::Authenticated - ); - assert_ne!(error::AuthState::Pending, error::AuthState::Authenticated); + assert_ne!(AuthState::Unauthenticated, AuthState::Authenticated); + assert_ne!(AuthState::Pending, AuthState::Authenticated); } #[cfg(feature = "crypto")] @@ -685,8 +649,6 @@ mod tests { fn test_auth_timeout_default() { let config = ClientConfig::new("https://localhost:4433"); assert_eq!(config.auth_timeout, Duration::from_secs(30)); - assert!(config.require_pq); - assert!(!config.with_require_pq(false).require_pq); } #[cfg(feature = "crypto")] diff --git a/client/src/ping.rs b/client/src/ping.rs deleted file mode 100644 index 5e917de..0000000 --- a/client/src/ping.rs +++ /dev/null @@ -1,179 +0,0 @@ -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>>, - 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 { - 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 { - 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 { - 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); - } -} diff --git a/client/src/pipe.rs b/client/src/pipe.rs deleted file mode 100644 index 136fe01..0000000 --- a/client/src/pipe.rs +++ /dev/null @@ -1,534 +0,0 @@ -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>, - pub(crate) dispatcher: Arc, - 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, 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, -} - -#[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 { - 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>, -} - -#[cfg(feature = "pipes")] -pub(crate) struct PendingCreation { - pub(crate) token: Arc<()>, - pub(crate) sender: tokio::sync::oneshot::Sender>, -} - -#[cfg(feature = "pipes")] -pub(crate) struct PendingCreationGuard { - dispatcher: Arc, - pipe_id: u32, - token: Arc<()>, - armed: bool, -} - -#[cfg(feature = "pipes")] -impl PendingCreationGuard { - pub(crate) fn new(dispatcher: Arc, 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>, - pub(crate) expired_requests: Mutex>, - #[cfg(feature = "pipes")] - pub(crate) type_map: TypeMap, - #[cfg(feature = "pipes")] - pub(crate) pending_creations: StdMutex>, - #[cfg(feature = "pipes")] - pub(crate) expired_creations: StdMutex>, - #[cfg(feature = "pipes")] - pub(crate) pending_pipes: - Mutex>>, - #[cfg(feature = "pipes")] - pub(crate) policy: Arc, -} - -#[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>, - 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>, - pipe_req_tx: mpsc::Sender, - dispatcher: Arc, -) { - 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>, - dispatcher: Arc, -) { - 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; - } - } - } -} diff --git a/client/tests/ping.rs b/client/tests/ping.rs deleted file mode 100644 index 59dc9ec..0000000 --- a/client/tests/ping.rs +++ /dev/null @@ -1,81 +0,0 @@ -use std::net::{IpAddr, Ipv4Addr}; - -use mtp_client::{ClientConfig, MTPClient}; -use mtp_host::{HostConfig, MTPHost}; - -async fn generate_self_signed_cert() -> Result<(Vec, Vec), Box> { - 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), Box> { - 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> { - 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(()) -} diff --git a/codec/Cargo.lock b/codec/Cargo.lock index a898cf4..e987889 100644 --- a/codec/Cargo.lock +++ b/codec/Cargo.lock @@ -2,268 +2,33 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common 0.1.7", - "generic-array", -] - -[[package]] -name = "asn1-rs" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror 2.0.20", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "aws-lc-rs" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" -dependencies = [ - "aws-lc-sys", - "untrusted 0.7.1", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bit-vec" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" -dependencies = [ - "serde", -] - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cc" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - [[package]] name = "chacha20" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "chacha20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - -[[package]] -name = "chacha20poly1305" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" -dependencies = [ - "aead", - "chacha20 0.9.1", - "cipher", - "poly1305", - "zeroize", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout", - "zeroize", -] - -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", + "cpufeatures", + "rand_core", ] [[package]] @@ -275,266 +40,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "typenum", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "getrandom 0.4.3", - "hybrid-array", - "rand_core 0.10.1", -] - -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "fiat-crypto 0.2.9", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "curve25519-dalek-derive", - "digest 0.11.3", - "fiat-crypto 0.3.0", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "data-encoding" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" - -[[package]] -name = "der" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "der-parser" -version = "10.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom", - "num-bigint", - "num-traits", - "rusticata-macros", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "const-oid", - "crypto-common 0.2.2", - "ctutils", -] - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "ed25519" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" -dependencies = [ - "curve25519-dalek 5.0.0", - "ed25519", - "serde", - "sha2", - "signature", - "subtle", - "zeroize", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - -[[package]] -name = "fiat-crypto" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" - -[[package]] -name = "find-msvc-tools" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -542,601 +47,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi", - "rand_core 0.10.1", - "wasm-bindgen", + "rand_core", ] -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "hkdf" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - -[[package]] -name = "httlib-huffman" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877" - -[[package]] -name = "hybrid-array" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" -dependencies = [ - "ctutils", - "typenum", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "keccak" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" -dependencies = [ - "cpufeatures 0.2.17", -] - -[[package]] -name = "keccak" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd9697dc4a9a62e2da93389f34400b77a28f0287711263cabb203b3ccb9c0e4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" -version = "0.2.189" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "mio" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "ml-dsa" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" -dependencies = [ - "const-oid", - "crypto-common 0.2.2", - "ctutils", - "hybrid-array", - "module-lattice", - "pkcs8", - "shake", - "signature", -] - -[[package]] -name = "mlkem-rs" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b0965b8b081668ff0398dc5e9dc3f2ebb9e833393f4ab5b9f725ddce11acef8" -dependencies = [ - "rand_core 0.6.4", - "serde", - "sha3", - "subtle", - "zeroize", -] - -[[package]] -name = "mlkem-tls" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77b205d031298adf904d88efd6a57862d8650a4ab754aade19a9b5e87040bf4e" -dependencies = [ - "mlkem-rs", - "rand_core 0.6.4", - "subtle", - "x25519-dalek", - "zeroize", -] - -[[package]] -name = "module-lattice" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" -dependencies = [ - "ctutils", - "hybrid-array", - "num-traits", -] +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "mtp-codec" -version = "0.3.0" +version = "0.1.0" dependencies = [ - "base64 0.23.1", + "base64", "byteorder", - "mtp-common", - "mtp-crypto", - "mtp-type-map", "rand", - "thiserror 2.0.20", -] - -[[package]] -name = "mtp-common" -version = "0.3.0" -dependencies = [ - "quinn", - "rustls", - "thiserror 2.0.20", - "wtransport", -] - -[[package]] -name = "mtp-crypto" -version = "0.3.0" -dependencies = [ - "base64 0.22.1", - "chacha20poly1305", - "ed25519-dalek", - "getrandom 0.4.3", - "hkdf", - "ml-dsa", - "mlkem-tls", - "rand", - "rand_core 0.6.4", - "rustls", - "sha2", - "thiserror 1.0.69", - "tokio", - "zeroize", -] - -[[package]] -name = "mtp-type-map" -version = "0.3.0" -dependencies = [ - "serde", - "serde_yaml", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "octets" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "866cb5af6f3aa3c1b44c3c2d79d22165fbb1b102e1b3fb499864bfe34736ec4b" - -[[package]] -name = "oid-registry" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" -dependencies = [ - "asn1-rs", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64 0.22.1", - "serde_core", -] - -[[package]] -name = "pem-rfc7468" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" -dependencies = [ - "base64ct", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkcs8" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "poly1305" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" -dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quinn" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.20", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.20", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", ] [[package]] @@ -1147,22 +75,13 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.10.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "chacha20 0.10.2", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", + "chacha20", + "getrandom", + "rand_core", ] [[package]] @@ -1170,881 +89,3 @@ name = "rand_core" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - -[[package]] -name = "rcgen" -version = "0.14.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" -dependencies = [ - "aws-lc-rs", - "rustls-pki-types", - "time", - "x509-parser", - "yasna", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted 0.9.0", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom", -] - -[[package]] -name = "rustls" -version = "0.23.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" -dependencies = [ - "aws-lc-rs", - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted 0.9.0", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[package]] -name = "sha3" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" -dependencies = [ - "digest 0.10.7", - "keccak 0.1.6", -] - -[[package]] -name = "shake" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" -dependencies = [ - "digest 0.11.3", - "keccak 0.2.1", - "sponge-cursor", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signature" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" -dependencies = [ - "digest 0.11.3", - "rand_core 0.10.1", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "spki" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "sponge-cursor" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl 2.0.20", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasm-bindgen" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "wtransport" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4273ce3157a3262a68665f8d3f20a0ac0c5b8a69ffd67f05ae986832ebec036" -dependencies = [ - "bytes", - "pem", - "quinn", - "rcgen", - "rustls", - "rustls-native-certs", - "rustls-pki-types", - "sha2", - "socket2", - "thiserror 2.0.20", - "time", - "tokio", - "tracing", - "url", - "wtransport-proto", - "x509-parser", -] - -[[package]] -name = "wtransport-proto" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aad9059572c7dbd6901ccef37f3b7321678cd708dcf58a64b1921dbeab7bfede" -dependencies = [ - "httlib-huffman", - "octets", - "thiserror 2.0.20", - "url", -] - -[[package]] -name = "x25519-dalek" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" -dependencies = [ - "curve25519-dalek 4.1.3", - "rand_core 0.6.4", - "serde", - "zeroize", -] - -[[package]] -name = "x509-parser" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" -dependencies = [ - "asn1-rs", - "aws-lc-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom", - "oid-registry", - "rusticata-macros", - "thiserror 2.0.20", - "time", -] - -[[package]] -name = "yasna" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" -dependencies = [ - "bit-vec", - "time", -] - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] diff --git a/codec/Cargo.toml b/codec/Cargo.toml index e75211d..6dc27bd 100644 --- a/codec/Cargo.toml +++ b/codec/Cargo.toml @@ -1,18 +1,17 @@ [package] name = "mtp-codec" -version = "0.3.0" +version = "0.1.0" edition = "2024" [dependencies] -mtp-type-map = { version = "0.3.0", path = "../type-map" } -mtp-common = { version = "0.3.0", path = "../common" } -mtp-crypto = { version = "0.3.0", path = "../crypto", optional = true } -base64 = "0.23" +mtp-type-map = { path = "../type-map" } +mtp-common = { path = "../common" } +mtp-crypto = { path = "../crypto", optional = true } +base64 = "0.22" byteorder = "1.5" -rand = { version = "0.10.1", features = ["std", "std_rng"] } -thiserror = "2.0.18" +rand = { version = "0.8", features = ["std", "std_rng"] } [features] +default = [] registry = ["mtp-type-map/registry"] crypto = ["dep:mtp-crypto", "mtp-crypto/mlkem-tls"] -pipes = ["mtp-type-map/pipes"] diff --git a/codec/src/communication_value.rs b/codec/src/communication_value.rs index 1e48422..96acccc 100644 --- a/codec/src/communication_value.rs +++ b/codec/src/communication_value.rs @@ -1,108 +1,97 @@ use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use std::collections::BTreeMap; use std::fmt; -use std::io::Cursor; +use std::io::{Cursor, Read}; -use crate::data_value::{DataKind, DataValue, DecodeError, DecodeLimits, EncodeLimits}; +use crate::data_value::{DataKind, DataValue}; use crate::rand_u32; use mtp_common::CodecError; use mtp_type_map::{ CommunicationType, CommunicationTypeId, DataType, DataTypeId, PROTOCOL_VERSION, TypeMap, + communication_type_name, data_type_name, }; -const FLAG_HAS_ID: u8 = 0b0000_0001; -const FLAG_HAS_SENDER: u8 = 0b0000_0010; -const FLAG_HAS_RECEIVER: u8 = 0b0000_0100; -const FLAG_KNOWN: u8 = FLAG_HAS_ID | FLAG_HAS_SENDER | FLAG_HAS_RECEIVER; +#[cfg(feature = "crypto")] +use mtp_crypto::{PublicKeyBundle, SigAlgorithm, SignatureScheme}; + +const FLAG_HAS_SENDER: u8 = 0b0000_0001; +const FLAG_HAS_RECEIVER: u8 = 0b0000_0010; +const FLAG_HAS_ID: u8 = 0b0000_0100; +const FLAG_ENCRYPTED: u8 = 0b0000_1000; +const FLAG_SIGNED: u8 = 0b0001_0000; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommunicationValue { - id: Option, + id: u32, comm_type: CommunicationTypeId, - sender: Option, - receiver: Option, - payload: DataValue, + sender: u64, + receiver: u64, + data: BTreeMap, type_map: Option, - mapping_error: Option, + #[cfg(feature = "crypto")] + frame_signature: Option<(u8, Vec)>, } impl CommunicationValue { #[must_use] pub fn new(comm_type: CommunicationType) -> Self { - Self::new_with_type_map(comm_type, &TypeMap::new(PROTOCOL_VERSION)) - } - - /// Construct a frame using an explicitly negotiated type map. - /// - /// The type map is local codec context rather than wire data, so callers - /// that build a frame for a non-latest negotiated version must retain it - /// on the `CommunicationValue` as well as using it to resolve the fields. - #[must_use] - pub fn new_with_type_map(comm_type: CommunicationType, type_map: &TypeMap) -> Self { - let id = comm_type.try_to_id(type_map); + let tm = TypeMap::new(PROTOCOL_VERSION); + let id = comm_type.to_id(&tm); Self { - id: Some(rand_u32()), - comm_type: id.unwrap_or(CommunicationTypeId(0)), - sender: None, - receiver: None, - payload: DataValue::Container(Vec::new()), - type_map: Some(type_map.clone()), - mapping_error: id - .is_none() - .then(|| CodecError::UnknownCommunicationType(comm_type.name().to_string())), + id: rand_u32(), + comm_type: id, + sender: 0, + receiver: 0, + data: BTreeMap::new(), + type_map: Some(tm), + #[cfg(feature = "crypto")] + frame_signature: None, } } #[cfg(feature = "registry")] #[must_use] - pub fn from_comm(comm_type: CommunicationType, type_map: &TypeMap) -> Self { - Self::new_with_type_map(comm_type, type_map) + pub fn from_comm(comm_type: CommunicationType, tm: &TypeMap) -> Self { + let id = comm_type.to_id(tm); + Self { + id: rand_u32(), + comm_type: id, + sender: 0, + receiver: 0, + data: BTreeMap::new(), + type_map: Some(tm.clone()), + #[cfg(feature = "crypto")] + frame_signature: None, + } } #[must_use] - pub fn with_id(mut self, id: u32) -> Self { - self.id = Some(id); + pub fn with_id(mut self, p0: u32) -> Self { + self.id = p0; self } - #[must_use] - pub fn without_id(mut self) -> Self { - self.id = None; - self - } - - pub fn id(&self) -> Option { + pub fn get_id(&self) -> u32 { self.id } #[must_use] pub fn with_sender(mut self, sender: u64) -> Self { - self.sender = Some(sender); + self.sender = sender; self } - #[must_use] - pub fn without_sender(mut self) -> Self { - self.sender = None; - self - } - - pub fn sender(&self) -> Option { + pub fn get_sender(&self) -> u64 { self.sender } #[must_use] pub fn with_receiver(mut self, receiver: u64) -> Self { - self.receiver = Some(receiver); + self.receiver = receiver; self } - #[must_use] - pub fn without_receiver(mut self) -> Self { - self.receiver = None; - self - } - - pub fn receiver(&self) -> Option { + pub fn get_receiver(&self) -> u64 { self.receiver } @@ -110,529 +99,632 @@ impl CommunicationValue { self.comm_type } - pub fn type_map(&self) -> Option<&TypeMap> { - self.type_map.as_ref() - } - - pub fn set_type_map(&mut self, type_map: &TypeMap) { - self.type_map = Some(type_map.clone()); - } - - /// Add a field to a clear container payload. - pub fn add_data(mut self, data_type: DataTypeId, value: DataValue) -> Result { - self.insert_data(data_type, value)?; - Ok(self) + #[must_use] + pub fn add_data(mut self, data: DataTypeId, value: DataValue) -> Self { + self.data.insert(data, value); + self } #[cfg(feature = "registry")] #[must_use] - pub fn add_typed(mut self, data: DataType, type_map: &TypeMap, value: DataValue) -> Self { - match data.try_to_id(type_map) { - Some(id) => { - self.insert_data_or_record_error(id, value); - } - None if self.mapping_error.is_none() => { - self.mapping_error = Some(CodecError::UnknownDataType(data.name().to_string())); - } - None => {} - } + pub fn add_typed(mut self, data: DataType, tm: &TypeMap, value: DataValue) -> Self { + self.data.insert(data.to_id(tm), value); self } #[must_use] pub fn add_typed_default(mut self, data: DataType, value: DataValue) -> Self { - let type_map = self.type_map.clone().unwrap_or_else(TypeMap::latest); - match data.try_to_id(&type_map) { - Some(id) => { - self.insert_data_or_record_error(id, value); - } - None if self.mapping_error.is_none() => { - self.mapping_error = Some(CodecError::UnknownDataType(data.name().to_string())); - } - None => {} - } + let tm = self.type_map.clone().unwrap_or_else(TypeMap::latest); + self.data.insert(data.to_id(&tm), value); self } - fn insert_data(&mut self, data_type: DataTypeId, value: DataValue) -> Result<(), CodecError> { - let entries = self - .payload - .container_entries_mut() - .ok_or(CodecError::InvalidEncoding)?; - if let Some((_, existing)) = entries.iter_mut().find(|(id, _)| *id == data_type) { - *existing = value; - } else { - entries.push((data_type, value)); - } - Ok(()) - } - - fn insert_data_or_record_error(&mut self, data_type: DataTypeId, value: DataValue) { - if self.insert_data(data_type, value).is_err() { - self.mapping_error - .get_or_insert(CodecError::InvalidEncoding); + pub fn get_data(&self, data_type: DataType) -> &DataValue { + let tm_owned; + let tm = match &self.type_map { + Some(tm) => tm, + None => { + tm_owned = TypeMap::latest(); + &tm_owned + } + }; + match tm.data_id_enum(data_type) { + Some(raw_id) => self + .data + .get(&DataTypeId(raw_id)) + .unwrap_or(&DataValue::Null), + None => &DataValue::Null, } } - pub fn get_data(&self, data_type: DataType) -> Option<&DataValue> { - let type_map = self.type_map.clone().unwrap_or_else(TypeMap::latest); - let id = type_map.data_id_enum(data_type)?; - self.payload.get_field(DataTypeId(id)) + pub fn get_data_opt(&self, data_type: DataType) -> Option<&DataValue> { + let tm_owned; + let tm = match &self.type_map { + Some(tm) => tm, + None => { + tm_owned = TypeMap::latest(); + &tm_owned + } + }; + let raw_id = tm.data_id_enum(data_type)?; + self.data.get(&DataTypeId(raw_id)) } pub fn has_data(&self, data_type: DataType) -> Option { - self.get_data(data_type).map(DataValue::kind) + self.get_data_opt(data_type).map(|v| v.kind()) } pub fn get_comm_type_enum(&self) -> Option { - let type_map = self.type_map.clone().unwrap_or_else(TypeMap::latest); - type_map.comm_enum_id(self.comm_type.0) + let tm_owned; + let tm = match &self.type_map { + Some(tm) => tm, + None => { + tm_owned = TypeMap::latest(); + &tm_owned + } + }; + tm.comm_enum_id(self.comm_type.0) } - /// Return clear container entries. Protected or scalar payloads return - /// `None` instead of being mistaken for an empty container. - pub fn data(&self) -> Option<&[(DataTypeId, DataValue)]> { - self.payload.container_entries() - } - - pub fn payload(&self) -> &DataValue { - &self.payload - } - - pub fn into_payload(self) -> DataValue { - self.payload - } - - #[must_use] - pub fn with_payload(mut self, payload: DataValue) -> Self { - self.payload = payload; - self + pub fn data(&self) -> &BTreeMap { + &self.data } pub fn data_len(&self) -> usize { - self.payload - .container_entries() - .map_or(0, |entries| entries.len()) + self.data.len() } - pub fn payload_len(&self) -> usize { - self.payload - .container_entries() - .map_or(1, |entries| entries.len()) - } + // ── type checks ────────────────────────────────────────────────────────── pub fn is_type(&self, comm_type: CommunicationType) -> bool { self.get_comm_type_enum() == Some(comm_type) } pub fn get_type_name(&self) -> Option<&'static str> { - self.type_map - .as_ref() - .and_then(|type_map| type_map.communication_type_name(self.comm_type.0)) + communication_type_name(self.comm_type.0) } - pub fn set_data(&mut self, data: DataType, value: DataValue) { - let type_map = self.type_map.clone().unwrap_or_else(TypeMap::latest); - match data.try_to_id(&type_map) { - Some(id) => { - self.insert_data_or_record_error(id, value); - } - None if self.mapping_error.is_none() => { - self.mapping_error = Some(CodecError::UnknownDataType(data.name().to_string())); - } - None => {} + // ── mutation ───────────────────────────────────────────────────────────── + + pub fn set_data(&mut self, data_type: DataType, value: DataValue) { + let tm = self.type_map.clone().unwrap_or_else(TypeMap::latest); + if let Some(raw_id) = tm.data_id_enum(data_type) { + self.data.insert(DataTypeId(raw_id), value); } } #[must_use] - pub fn with_data(mut self, data: DataType, value: DataValue) -> Self { - self.set_data(data, value); + pub fn with_data(mut self, data_type: DataType, value: DataValue) -> Self { + self.set_data(data_type, value); self } - pub fn remove_data(&mut self, data: DataType) -> Option { - let type_map = self.type_map.clone().unwrap_or_else(TypeMap::latest); - let id = DataTypeId(type_map.data_id_enum(data)?); - let entries = self.payload.container_entries_mut()?; - let index = entries.iter().position(|(entry_id, _)| *entry_id == id)?; - Some(entries.remove(index).1) + pub fn remove_data(&mut self, data_type: DataType) -> Option { + let tm_owned; + let tm = match &self.type_map { + Some(tm) => tm, + None => { + tm_owned = TypeMap::latest(); + &tm_owned + } + }; + let raw_id = tm.data_id_enum(data_type)?; + self.data.remove(&DataTypeId(raw_id)) } #[must_use] pub fn reply_to(&self, comm_type: CommunicationType) -> Self { - let type_map = self - .type_map - .as_ref() - .cloned() - .unwrap_or_else(TypeMap::latest); - let mut response = Self::new_with_type_map(comm_type, &type_map); - response.sender = self.receiver; - response.receiver = self.sender; - response + Self::new(comm_type) + .with_sender(self.receiver) + .with_receiver(self.sender) } - /// Merge clear container fields after confirming both values use the same - /// negotiated type map. - pub fn try_merge(&mut self, other: &Self) -> Result<(), CodecError> { - if let Some(error) = &self.mapping_error { - return Err(error.clone()); + pub fn merge(&mut self, other: &CommunicationValue) { + for (id, value) in &other.data { + self.data.insert(*id, value.clone()); } - let left = self.type_map().ok_or(CodecError::MissingTypeMap)?; - let right = other.type_map().ok_or(CodecError::MissingTypeMap)?; - if left.version != right.version { - return Err(CodecError::TypeMapMismatch { - expected: left.version.to_string(), - actual: right.version.to_string(), + } + + // ── typed iteration ────────────────────────────────────────────────────── + + pub fn iter_typed_data(&self) -> impl Iterator, &DataValue)> + '_ { + let tm = self.type_map.clone().unwrap_or_else(TypeMap::latest); + self.data + .iter() + .map(move |(id, val)| (tm.data_enum_id(id.0), val)) + } + + // ── typed field accessors ───────────────────────────────────────────────── + + pub fn get_bool(&self, data_type: DataType) -> Option { + self.get_data_opt(data_type)?.as_bool() + } + + pub fn get_str(&self, data_type: DataType) -> Option<&str> { + self.get_data_opt(data_type)?.as_str() + } + + pub fn get_u128(&self, data_type: DataType) -> Option { + self.get_data_opt(data_type)?.as_unsigned_number() + } + + pub fn get_i128(&self, data_type: DataType) -> Option { + self.get_data_opt(data_type)?.as_signed_number() + } + + pub fn get_float(&self, data_type: DataType) -> Option<(u8, u32)> { + self.get_data_opt(data_type)?.as_float() + } + + pub fn get_bytes(&self, data_type: DataType) -> Option<&[u8]> { + self.get_data_opt(data_type)?.as_bytes_slice() + } + + pub fn get_array(&self, data_type: DataType) -> Option<&[DataValue]> { + self.get_data_opt(data_type)?.as_array_slice() + } +} + +impl CommunicationValue { + /* + * Frame format (strict new format): + * [4 bytes u32 total_length] // number of bytes after this field + * [2 bytes u16 communication_type] + * [1 byte flags] + * [optional 4 bytes id] // if flags bit2 set + * [optional 6 bytes sender] // if flags bit0 set + * [optional 6 bytes receiver] // if flags bit1 set + * [optional 1 byte signature type] // if flags bit4 set; Type defines length of signature + * [optional signature] // if flags bit4 set + * [data container bytes...] + * + * Flags: + * bit0 => has sender + * bit1 => has receiver + * bit2 => has id + * bit3 => is data encrypted If so data bytes will be an encrypted container + * bit4 => is communication value signed + */ + /* + * Build the canonical metadata header and data payload shared by both + * `to_bytes` and `build_signed_payload`. Keeping a single source here + * guarantees the serialized frame and the signed-over bytes stay in sync. + * + * Returns `(metadata, data_bytes)` where + * metadata = comm_type || flags || id? || sender? || receiver? + * + * `force_signed` forces the `FLAG_SIGNED` bit on regardless of whether a + * signature is currently attached. The signed-payload path passes `true` so + * that the bytes signed by `sign_frame` (before the signature is stored) and + * the bytes verified by `verify_frame` (after it is stored) are identical. + */ + fn build_metadata_and_data( + &self, + force_signed: bool, + ) -> Result<(Vec, Vec), CodecError> { + let has_sender = self.sender != 0; + let has_receiver = self.receiver != 0; + let has_id = self.id != 0; + + #[cfg(feature = "crypto")] + let is_encrypted = self.data.len() == 1 + && self.data.values().any(|v| { + matches!( + v, + DataValue::EncryptedContainer(_) | DataValue::SignedEncryptedContainer(_) + ) }); - } - if let Some(error) = &other.mapping_error { - return Err(error.clone()); - } - let other_entries = other - .payload - .container_entries() - .ok_or(CodecError::InvalidEncoding)?; - for (id, value) in other_entries { - self.insert_data(*id, value.clone())?; - } - Ok(()) - } + #[cfg(not(feature = "crypto"))] + let is_encrypted = false; - // Migrate to `try_merge` so a map mismatch cannot be silently recorded in - // a frame that is later sent over the wire. - #[deprecated(note = "migrate to try_merge to handle negotiated type-map mismatches")] - pub fn merge(&mut self, other: &Self) { - if let Err(error) = self.try_merge(other) { - self.mapping_error.get_or_insert(error); - } - } + #[cfg(feature = "crypto")] + let has_frame_sig = self.frame_signature.is_some(); + #[cfg(not(feature = "crypto"))] + let has_frame_sig = false; - pub fn iter_typed_data(&self) -> Box, &DataValue)> + '_> { - let type_map = self.type_map.clone().unwrap_or_else(TypeMap::latest); - match &self.payload { - DataValue::Container(entries) => Box::new( - entries - .iter() - .map(move |(id, value)| (type_map.data_enum_id(id.0), value)), - ), - _ => Box::new(std::iter::empty()), + let mut flags: u8 = 0; + if has_sender { + flags |= FLAG_HAS_SENDER; + } + if has_receiver { + flags |= FLAG_HAS_RECEIVER; + } + if has_id { + flags |= FLAG_HAS_ID; + } + if is_encrypted { + flags |= FLAG_ENCRYPTED; + } + if has_frame_sig || force_signed { + flags |= FLAG_SIGNED; } - } - pub fn get_bool(&self, data: DataType) -> Option { - self.get_data(data)?.as_bool() - } - pub fn get_str(&self, data: DataType) -> Option<&str> { - self.get_data(data)?.as_str() - } - pub fn get_u128(&self, data: DataType) -> Option { - self.get_data(data)?.as_unsigned_number() - } - pub fn get_i128(&self, data: DataType) -> Option { - self.get_data(data)?.as_signed_number() - } - pub fn get_float(&self, data: DataType) -> Option { - self.get_data(data)?.as_float() - } - pub fn get_bytes(&self, data: DataType) -> Option<&[u8]> { - self.get_data(data)?.as_bytes_slice() - } - pub fn get_array(&self, data: DataType) -> Option<&[DataValue]> { - self.get_data(data)?.as_array_slice() + let mut metadata = Vec::new(); + let _ = metadata.write_u16::(self.comm_type.0); + metadata.push(flags); + + if has_id { + let _ = metadata.write_u32::(self.id); + } + + if has_sender { + let sender_be = self.sender.to_be_bytes(); + metadata.extend_from_slice(&sender_be[2..]); + } + + if has_receiver { + let receiver_be = self.receiver.to_be_bytes(); + metadata.extend_from_slice(&receiver_be[2..]); + } + + #[cfg(feature = "crypto")] + let data_bytes = if is_encrypted { + self.data + .values() + .find_map(|v| match v { + DataValue::EncryptedContainer(ct) => Some(ct.clone()), + DataValue::SignedEncryptedContainer(ct) => Some(ct.clone()), + _ => None, + }) + .unwrap_or_default() + } else { + DataValue::container_from_map(&self.data).to_bytes()? + }; + + #[cfg(not(feature = "crypto"))] + let data_bytes = DataValue::container_from_map(&self.data).to_bytes()?; + + Ok((metadata, data_bytes)) } pub fn to_bytes(&self) -> Result, CodecError> { - self.to_bytes_with_limits(EncodeLimits::default()) - } + let (metadata, data_bytes) = self.build_metadata_and_data(false)?; - pub fn to_bytes_with_limits(&self, limits: EncodeLimits) -> Result, CodecError> { - if let Some(error) = &self.mapping_error { - return Err(error.clone()); - } - let header_len = self.frame_header_len(); - let payload_limit = limits - .max_output_size - .checked_sub(header_len) - .ok_or(CodecError::TooManyEntries)?; - let payload = self.payload.to_bytes_with_limits(EncodeLimits { - max_output_size: payload_limit, - ..limits - })?; - let mut body = Vec::new(); - body.write_u16::(self.comm_type.0) - .map_err(|_| CodecError::InvalidEncoding)?; - let mut flags = 0; - if self.id.is_some() { - flags |= FLAG_HAS_ID; - } - if self.sender.is_some() { - flags |= FLAG_HAS_SENDER; - } - if self.receiver.is_some() { - flags |= FLAG_HAS_RECEIVER; - } - body.push(flags); - if let Some(id) = self.id { - body.write_u32::(id) - .map_err(|_| CodecError::InvalidEncoding)?; - } - if let Some(sender) = self.sender { - body.write_u64::(sender) - .map_err(|_| CodecError::InvalidEncoding)?; - } - if let Some(receiver) = self.receiver { - body.write_u64::(receiver) - .map_err(|_| CodecError::InvalidEncoding)?; - } - body.extend_from_slice(&payload); - let length = u32::try_from(body.len()).map_err(|_| CodecError::TooManyEntries)?; - let total_len = 4usize - .checked_add(body.len()) - .ok_or(CodecError::TooManyEntries)?; - if total_len > limits.max_output_size { - return Err(CodecError::TooManyEntries); - } - let mut out = Vec::with_capacity(total_len); - out.write_u32::(length) - .map_err(|_| CodecError::InvalidEncoding)?; - out.extend_from_slice(&body); - Ok(out) - } + let mut payload = Vec::new(); + payload.extend_from_slice(&metadata); - fn frame_header_len(&self) -> usize { - 4 + 2 - + 1 - + self.id.is_some() as usize * 4 - + self.sender.is_some() as usize * 8 - + self.receiver.is_some() as usize * 8 + #[cfg(feature = "crypto")] + if let Some((alg, sig)) = &self.frame_signature { + // algorithm and signature are computed by sign_frame() and stored. + // The frame bytes are built by using the pre-computed signature. + payload.push(*alg); + payload.extend_from_slice(sig); + } + + payload.extend_from_slice(&data_bytes); + + let len = u32::try_from(payload.len()).map_err(|_| CodecError::TooManyEntries)?; + let mut frame = Vec::with_capacity(4 + payload.len()); + frame + .write_u32::(len) + .map_err(|_| CodecError::InvalidEncoding)?; + frame.extend_from_slice(&payload); + + Ok(frame) } pub fn from_bytes(bytes: &[u8]) -> Result { - Self::from_bytes_with_limits(bytes, DecodeLimits::default()) - } - - pub fn from_bytes_with_limits(bytes: &[u8], limits: DecodeLimits) -> Result { - Self::try_from_bytes_with_limits(bytes, limits).map_err(|_| CodecError::InvalidEncoding) - } - - pub fn try_from_bytes(bytes: &[u8]) -> Result { - Self::try_from_bytes_with_limits(bytes, DecodeLimits::default()) - } - - pub fn try_from_bytes_with_limits( - bytes: &[u8], - limits: DecodeLimits, - ) -> Result { let mut cursor = Cursor::new(bytes); - let length = cursor + + let total_len = cursor .read_u32::() - .map_err(|_| DecodeError::MalformedEncoding)? as usize; - let end = 4usize - .checked_add(length) - .ok_or(DecodeError::MalformedEncoding)?; - if end != bytes.len() { - return Err(DecodeError::MalformedEncoding); + .map_err(|_| CodecError::InvalidEncoding)? as usize; + if bytes.len() < 4 + total_len { + return Err(CodecError::InvalidEncoding); } - let comm_type = CommunicationTypeId( + + let frame_end = 4 + total_len; + + let comm_type_num = cursor + .read_u16::() + .map_err(|_| CodecError::InvalidEncoding)?; + let comm_type = CommunicationTypeId(comm_type_num); + + let flags = cursor.read_u8().map_err(|_| CodecError::InvalidEncoding)?; + let has_sender = (flags & FLAG_HAS_SENDER) != 0; + let has_receiver = (flags & FLAG_HAS_RECEIVER) != 0; + let has_id = (flags & FLAG_HAS_ID) != 0; + let is_encrypted = (flags & FLAG_ENCRYPTED) != 0; + let is_signed = (flags & FLAG_SIGNED) != 0; + + #[cfg(not(feature = "crypto"))] + if is_signed || is_encrypted { + return Err(CodecError::InvalidEncoding); + } + + let id = if has_id { cursor - .read_u16::() - .map_err(|_| DecodeError::MalformedEncoding)?, - ); - let flags = cursor - .read_u8() - .map_err(|_| DecodeError::MalformedEncoding)?; - if flags & !FLAG_KNOWN != 0 { - return Err(DecodeError::MalformedEncoding); + .read_u32::() + .map_err(|_| CodecError::InvalidEncoding)? + } else { + 0 + }; + + let sender = if has_sender { + let mut buf = [0u8; 8]; + cursor + .read_exact(&mut buf[2..]) + .map_err(|_| CodecError::InvalidEncoding)?; + u64::from_be_bytes(buf) + } else { + 0 + }; + + let receiver = if has_receiver { + let mut buf = [0u8; 8]; + cursor + .read_exact(&mut buf[2..]) + .map_err(|_| CodecError::InvalidEncoding)?; + u64::from_be_bytes(buf) + } else { + 0 + }; + + #[cfg(feature = "crypto")] + let frame_signature = if is_signed { + let alg = cursor.read_u8().map_err(|_| CodecError::InvalidEncoding)?; + let sig_len = SigAlgorithm::length(alg).ok_or(CodecError::InvalidEncoding)?; + let mut sig = vec![0u8; sig_len]; + cursor + .read_exact(&mut sig) + .map_err(|_| CodecError::InvalidEncoding)?; + Some((alg, sig)) + } else { + None + }; + + let pos = cursor.position() as usize; + if pos > frame_end { + return Err(CodecError::InvalidEncoding); } - let id = if flags & FLAG_HAS_ID != 0 { - Some( - cursor - .read_u32::() - .map_err(|_| DecodeError::MalformedEncoding)?, - ) + + let data_bytes = &bytes[pos..frame_end]; + + #[cfg(feature = "crypto")] + let data = if is_encrypted { + let mut map = BTreeMap::new(); + map.insert( + DataType::Version.to_id(&TypeMap::latest()), + DataValue::EncryptedContainer(data_bytes.to_vec()), + ); + map } else { - None + let data_value = + DataValue::from_bytes(data_bytes).ok_or(CodecError::InvalidEncoding)?; + data_value.as_map().ok_or(CodecError::InvalidEncoding)? }; - let sender = if flags & FLAG_HAS_SENDER != 0 { - Some( - cursor - .read_u64::() - .map_err(|_| DecodeError::MalformedEncoding)?, - ) - } else { - None + + #[cfg(not(feature = "crypto"))] + let data = { + let data_value = + DataValue::from_bytes(data_bytes).ok_or(CodecError::InvalidEncoding)?; + data_value.as_map().ok_or(CodecError::InvalidEncoding)? }; - let receiver = if flags & FLAG_HAS_RECEIVER != 0 { - Some( - cursor - .read_u64::() - .map_err(|_| DecodeError::MalformedEncoding)?, - ) - } else { - None - }; - let payload = DataValue::read_from_with_diagnostics(&mut cursor, limits)?; - if cursor.position() as usize != end { - return Err(DecodeError::MalformedEncoding); - } + Ok(Self { id, comm_type, sender, receiver, - payload, - type_map: Some(TypeMap::new(PROTOCOL_VERSION)), - mapping_error: None, + data, + type_map: None, + #[cfg(feature = "crypto")] + frame_signature, }) } - pub fn from_bytes_with(bytes: &[u8], type_map: &TypeMap) -> Result { - Self::try_from_bytes_with(bytes, type_map).map_err(|_| CodecError::InvalidEncoding) + pub fn from_bytes_with(bytes: &[u8], tm: &TypeMap) -> Result { + let mut val = Self::from_bytes(bytes)?; + val.type_map = Some(tm.clone()); + Ok(val) } - pub fn try_from_bytes_with(bytes: &[u8], type_map: &TypeMap) -> Result { - Self::try_from_bytes_with_type_map_and_limits(bytes, type_map, DecodeLimits::default()) + /* + * Sign the frame. Computes a signature over the canonical form: + * comm_type || flags || id? || sender? || receiver? || data_bytes + * + * After calling this, `to_bytes()` will embed the algorithm and + * signature before the data payload. + */ + #[cfg(feature = "crypto")] + pub fn sign_frame(&mut self, algorithm: u8, signer: &impl SignatureScheme) -> Option<()> { + let signed_payload = self.build_signed_payload().ok()?; + let sig = signer.sign(&signed_payload).ok()?; + self.frame_signature = Some((algorithm, sig)); + Some(()) } - pub fn try_from_bytes_with_type_map_and_limits( - bytes: &[u8], - type_map: &TypeMap, - limits: DecodeLimits, - ) -> Result { - let mut value = Self::try_from_bytes_with_limits(bytes, limits)?; - value.set_type_map(type_map); - Ok(value) + /* + * Verify the frame signature. Reconstructs the signed payload from + * current state and checks it against the stored signature. + */ + #[cfg(feature = "crypto")] + pub fn verify_frame(&self, verifier: &impl SignatureScheme) -> Result<(), CodecError> { + let (_algorithm, sig) = self + .frame_signature + .as_ref() + .ok_or(CodecError::InvalidEncoding)?; + + let signed_payload = self.build_signed_payload()?; + verifier + .verify(&signed_payload, sig) + .map_err(|_| CodecError::InvalidEncoding) } - #[cfg(feature = "registry")] - pub fn migrate(&self, target: &TypeMap) -> Result { - self.migrate_with_limits(target, EncodeLimits::default()) + /* + * Reconstruct the signed payload that the frame signature covers: + * comm_type || flags || id? || sender? || receiver? || data_bytes + */ + #[cfg(feature = "crypto")] + fn build_signed_payload(&self) -> Result, CodecError> { + // Force FLAG_SIGNED on so the signed bytes match whether or not the + // signature has been attached yet (sign_frame runs before storing it). + let (metadata, data_bytes) = self.build_metadata_and_data(true)?; + Ok([metadata, data_bytes].concat()) } - /// Migrate a clear frame while bounding the recursive traversal used to - /// translate its type IDs. - #[cfg(feature = "registry")] - pub fn migrate_with_limits( - &self, - target: &TypeMap, - limits: EncodeLimits, - ) -> Result { - if let Some(error) = &self.mapping_error { - return Err(error.clone()); + #[cfg(feature = "crypto")] + pub fn get_frame_signature(&self) -> Option<&(u8, Vec)> { + self.frame_signature.as_ref() + } + + /* + * Verify the frame signature using a `PublicKeyBundle`. Dispatches to + * Ed25519, ML-DSA-65, or both (DUAL) based on the stored algorithm byte. + * Returns `false` if the frame has no signature or verification fails. + */ + #[cfg(feature = "crypto")] + pub fn validate_signature(&self, pk: &PublicKeyBundle) -> bool { + let Some((alg, _)) = &self.frame_signature else { + return false; + }; + struct Ed25519Verifier<'a>(&'a mtp_crypto::SignaturePublicKey); + impl SignatureScheme for Ed25519Verifier<'_> { + fn sign(&self, _: &[u8]) -> Result, mtp_crypto::CryptoError> { + Err(mtp_crypto::CryptoError::SigningFailed) + } + fn verify(&self, msg: &[u8], sig: &[u8]) -> Result<(), mtp_crypto::CryptoError> { + mtp_crypto::verify_ed25519(self.0, msg, sig) + } } - let source = self.type_map.as_ref().ok_or(CodecError::InvalidEncoding)?; - let comm_name = source - .communication_type_name(self.comm_type.0) + struct MlDsaVerifier<'a>(&'a mtp_crypto::SignaturePqPublicKey); + impl SignatureScheme for MlDsaVerifier<'_> { + fn sign(&self, _: &[u8]) -> Result, mtp_crypto::CryptoError> { + Err(mtp_crypto::CryptoError::SigningFailed) + } + fn verify(&self, msg: &[u8], sig: &[u8]) -> Result<(), mtp_crypto::CryptoError> { + mtp_crypto::verify_ml_dsa(self.0, msg, sig) + } + } + match *alg { + SigAlgorithm::ED25519 => self + .verify_frame(&Ed25519Verifier(&pk.sig_cl_public_key)) + .is_ok(), + SigAlgorithm::ML_DSA_65 => self + .verify_frame(&MlDsaVerifier(&pk.sig_pq_public_key)) + .is_ok(), + SigAlgorithm::DUAL => { + // For DUAL, verify_frame passes the full combined sig to the verifier. + // We wrap a verifier that splits and checks both halves. + struct DualVerifier<'a>( + &'a mtp_crypto::SignaturePublicKey, + &'a mtp_crypto::SignaturePqPublicKey, + ); + impl SignatureScheme for DualVerifier<'_> { + fn sign(&self, _: &[u8]) -> Result, mtp_crypto::CryptoError> { + Err(mtp_crypto::CryptoError::SigningFailed) + } + fn verify( + &self, + msg: &[u8], + sig: &[u8], + ) -> Result<(), mtp_crypto::CryptoError> { + const ED_LEN: usize = 64; + if sig.len() < ED_LEN { + return Err(mtp_crypto::CryptoError::InvalidSignature); + } + mtp_crypto::verify_ed25519(self.0, msg, &sig[..ED_LEN])?; + mtp_crypto::verify_ml_dsa(self.1, msg, &sig[ED_LEN..]) + } + } + self.verify_frame(&DualVerifier(&pk.sig_cl_public_key, &pk.sig_pq_public_key)) + .is_ok() + } + _ => false, + } + } + + #[cfg(feature = "registry")] + pub fn migrate(&self, target_tm: &TypeMap) -> Result { + let comm_name = communication_type_name(self.comm_type.0) .ok_or_else(|| CodecError::UnknownCommunicationType(self.comm_type.0.to_string()))?; - let comm = CommunicationType::from_name(comm_name) + let comm_variant = CommunicationType::from_name(comm_name) .ok_or_else(|| CodecError::UnknownCommunicationType(comm_name.to_string()))?; - let comm_type = CommunicationTypeId( - target - .comm_id_enum(comm) + let new_comm_id = CommunicationTypeId( + target_tm + .comm_id_enum(comm_variant) .ok_or_else(|| CodecError::UnknownCommunicationType(comm_name.to_string()))?, ); - let mut context = MigrationContext::new(limits); - let payload = migrate_data_value(&self.payload, source, target, &mut context)?; + + let mut new_data = BTreeMap::new(); + for (&old_id, value) in &self.data { + let name = data_type_name(old_id.0) + .ok_or_else(|| CodecError::UnknownDataType(old_id.0.to_string()))?; + let variant = DataType::from_name(name) + .ok_or_else(|| CodecError::UnknownDataType(name.to_string()))?; + let new_id = DataTypeId( + target_tm + .data_id_enum(variant) + .ok_or_else(|| CodecError::UnknownDataType(name.to_string()))?, + ); + new_data.insert(new_id, value.clone()); + } + Ok(Self { id: self.id, - comm_type, + comm_type: new_comm_id, sender: self.sender, receiver: self.receiver, - payload, - type_map: Some(target.clone()), - mapping_error: None, + data: new_data, + type_map: Some(target_tm.clone()), + #[cfg(feature = "crypto")] + frame_signature: self.frame_signature.clone(), }) } } -#[cfg(feature = "registry")] -struct MigrationContext { - limits: EncodeLimits, - depth: usize, - values: usize, -} - -#[cfg(feature = "registry")] -impl MigrationContext { - fn new(limits: EncodeLimits) -> Self { - Self { - limits, - depth: 0, - values: 0, - } - } - - fn value(&mut self) -> Result<(), CodecError> { - self.values = self - .values - .checked_add(1) - .ok_or(CodecError::TooManyEntries)?; - if self.values > self.limits.max_values { - return Err(CodecError::TooManyEntries); - } - Ok(()) - } - - fn enter(&mut self) -> Result<(), CodecError> { - self.depth = self - .depth - .checked_add(1) - .ok_or(CodecError::TooManyEntries)?; - if self.depth > self.limits.max_depth { - return Err(CodecError::TooManyEntries); - } - Ok(()) - } - - fn leave(&mut self) { - self.depth = self.depth.saturating_sub(1); - } -} - -#[cfg(feature = "registry")] -fn migrate_data_value( - value: &DataValue, - source: &TypeMap, - target: &TypeMap, - context: &mut MigrationContext, -) -> Result { - context.value()?; - match value { +fn fmt_data_value(val: &DataValue, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match val { DataValue::Container(entries) => { - context.enter()?; - let count = u16::try_from(entries.len()).map_err(|_| CodecError::TooManyEntries)?; - let mut migrated = Vec::with_capacity(usize::from(count)); - for (old_id, value) in entries { - let name = source - .data_type_name(old_id.0) - .ok_or_else(|| CodecError::UnknownDataType(old_id.0.to_string()))?; - let data = DataType::from_name(name) - .ok_or_else(|| CodecError::UnknownDataType(name.to_string()))?; - let new_id = DataTypeId( - target - .data_id_enum(data) - .ok_or_else(|| CodecError::UnknownDataType(name.to_string()))?, - ); - migrated.push((new_id, migrate_data_value(value, source, target, context)?)); + write!(f, "{{")?; + for (i, (key, value)) in entries.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + let name = data_type_name(key.0).unwrap_or("?"); + write!(f, "{}: ", name)?; + fmt_data_value(value, f)?; } - context.leave(); - Ok(DataValue::Container(migrated)) + write!(f, "}}") } - DataValue::Array(values) => { - context.enter()?; - let mut migrated = Vec::with_capacity(values.len()); - for value in values { - migrated.push(migrate_data_value(value, source, target, context)?); + DataValue::Array(arr) => { + write!(f, "[")?; + for (i, value) in arr.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + fmt_data_value(value, f)?; } - context.leave(); - Ok(DataValue::Array(migrated)) + write!(f, "]") } #[cfg(feature = "crypto")] - DataValue::Signed(_) | DataValue::Encrypted(_) => Err(CodecError::InvalidEncoding), - scalar => Ok(scalar.clone()), + DataValue::EncryptedContainer(_) => write!(f, "(Secure)"), + DataValue::Bytes(_) => write!(f, "(Binary)"), + other => write!(f, "{}", other), } } +#[cfg(debug_assertions)] +const BOLD_BLUE: &str = "\x1b[1;34m"; +#[cfg(not(debug_assertions))] +const BOLD_BLUE: &str = ""; +#[cfg(debug_assertions)] +const GREEN: &str = "\x1b[32m"; +#[cfg(not(debug_assertions))] +const GREEN: &str = ""; +#[cfg(debug_assertions)] +const YELLOW: &str = "\x1b[33m"; +#[cfg(not(debug_assertions))] +const YELLOW: &str = ""; +#[cfg(debug_assertions)] +const ORANGE: &str = "\x1b[38;5;208m"; +#[cfg(not(debug_assertions))] +const ORANGE: &str = ""; +#[cfg(debug_assertions)] +const RESET: &str = "\x1b[0m"; +#[cfg(not(debug_assertions))] +const RESET: &str = ""; + impl fmt::Display for CommunicationValue { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let version = self @@ -640,297 +732,183 @@ impl fmt::Display for CommunicationValue { .as_ref() .map(|tm| &tm.version) .unwrap_or(&PROTOCOL_VERSION); - write!(f, "V{}, ", version)?; - if let Some(id) = self.id { - write!(f, "ID:{id:X}, ")?; + + write!(f, "V{}{}{}", BOLD_BLUE, version, RESET)?; + + if self.id != 0 { + write!(f, ", ID:{}{:X}{}", GREEN, self.id, RESET)?; } - if let Some(sender) = self.sender { - write!(f, "S:{sender:X}, ")?; + if self.sender != 0 { + write!(f, ", S:{}{:X}{}", YELLOW, self.sender, RESET)?; } - if let Some(receiver) = self.receiver { - write!(f, "R:{receiver:X}, ")?; + if self.receiver != 0 { + write!(f, ", R:{}{:X}{}", ORANGE, self.receiver, RESET)?; } + let name = self .get_comm_type_enum() - .map(|value| value.name()) - .unwrap_or("?"); - write!(f, "{name}: ")?; - fmt_data_value( - &self.payload, - &self.type_map.clone().unwrap_or_else(TypeMap::latest), - f, - ) - } -} + .map(|t| t.name()) + .unwrap_or_else(|| communication_type_name(self.comm_type.0).unwrap_or("?")); + write!(f, ", {}: ", name)?; -fn fmt_data_value( - value: &DataValue, - type_map: &TypeMap, - f: &mut fmt::Formatter<'_>, -) -> fmt::Result { - match value { - DataValue::Container(entries) => { - f.write_str("{")?; - for (index, (id, value)) in entries.iter().enumerate() { - if index > 0 { - f.write_str(", ")?; - } - write!(f, "{}: ", type_map.data_type_name(id.0).unwrap_or("?"))?; - fmt_data_value(value, type_map, f)?; + let tm = self.type_map.clone().unwrap_or_else(TypeMap::latest); + write!(f, "{{")?; + for (i, (raw_id, value)) in self.data.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; } - f.write_str("}") + let dname = tm + .data_enum_id(raw_id.0) + .map(|t| t.name()) + .or_else(|| data_type_name(raw_id.0)) + .unwrap_or("?"); + write!(f, "{}: ", dname)?; + fmt_data_value(value, f)?; } - DataValue::Array(values) => { - f.write_str("[")?; - for (index, value) in values.iter().enumerate() { - if index > 0 { - f.write_str(", ")?; - } - fmt_data_value(value, type_map, f)?; - } - f.write_str("]") - } - #[cfg(feature = "crypto")] - DataValue::Encrypted(_) => f.write_str("(Encrypted)"), - #[cfg(feature = "crypto")] - DataValue::Signed(_) => f.write_str("(Signed)"), - other => write!(f, "{other}"), + write!(f, "}}") } } +/* ================================ TESTS ================================ */ #[cfg(test)] mod tests { use super::*; + use crate::data_value::DataValue; - #[test] - fn canonical_flags_and_eight_byte_ids_roundtrip() { - const SENDER_ID: u64 = 0x0102_0304_0506_0708; - const RECEIVER_ID: u64 = 0x1112_1314_1516_1718; - - let cases = [ - ( - CommunicationValue::new(CommunicationType::Ping) - .with_id(0x0102_0304) - .without_sender() - .without_receiver(), - 0x01, - ), - ( - CommunicationValue::new(CommunicationType::Ping) - .without_id() - .with_sender(SENDER_ID) - .without_receiver(), - 0x02, - ), - ( - CommunicationValue::new(CommunicationType::Ping) - .without_id() - .without_sender() - .with_receiver(RECEIVER_ID), - 0x04, - ), - ( - CommunicationValue::new(CommunicationType::Ping) - .with_id(0) - .with_sender(SENDER_ID) - .with_receiver(RECEIVER_ID), - 0x07, - ), - ]; - - for (value, expected_flags) in cases { - let bytes = value.to_bytes().unwrap(); - assert_eq!(bytes[6], expected_flags); - if expected_flags == FLAG_HAS_SENDER { - assert_eq!(&bytes[7..15], &SENDER_ID.to_be_bytes()); - } - if expected_flags == FLAG_HAS_RECEIVER { - assert_eq!(&bytes[7..15], &RECEIVER_ID.to_be_bytes()); - } - assert_eq!(CommunicationValue::from_bytes(&bytes).unwrap(), value); - } + fn roundtrip(cv: CommunicationValue) -> CommunicationValue { + let bytes = cv.to_bytes().expect("encode failed"); + let decoded = CommunicationValue::from_bytes(&bytes).expect("failed to deserialize"); + let bytes2 = decoded.to_bytes().expect("encode failed"); + assert_eq!(bytes, bytes2); + decoded } #[test] - fn absent_and_zero_are_distinct() { - let absent = CommunicationValue::new(CommunicationType::Ping).without_id(); - let zero = CommunicationValue::new(CommunicationType::Ping).with_id(0); - assert!(absent.id().is_none()); - assert_eq!(zero.id(), Some(0)); - assert_ne!(absent.to_bytes().unwrap(), zero.to_bytes().unwrap()); + fn test_flags_and_order_without_optional() { + let cv = CommunicationValue::new(CommunicationType::ErrorParsing).with_id(0); + let bytes = cv.to_bytes().expect("encode failed"); + + // [u32 len][u16 type][flags]... + assert!(bytes.len() >= 7); + let mut c = Cursor::new(bytes.as_slice()); + let total_len = c.read_u32::().expect("read len"); + assert_eq!(total_len as usize + 4, bytes.len()); + + let typ = c.read_u16::().expect("read type"); + assert_eq!(typ, 12); + + let flags = c.read_u8().expect("read flags"); + assert_eq!(flags & 0b0000_0111, 0); } #[test] - fn reserved_flags_are_rejected() { - let bytes = CommunicationValue::new(CommunicationType::Ping) - .to_bytes() - .unwrap(); - for unknown_flag in [0x08, 0x10, 0x20, 0x40, 0x80] { - let mut invalid = bytes.clone(); - invalid[6] |= unknown_flag; - assert_eq!( - CommunicationValue::from_bytes(&invalid), - Err(CodecError::InvalidEncoding), - "flag bit {unknown_flag:#04x} must be rejected" + fn test_flags_and_order_with_all_optional() { + let cv = CommunicationValue::new(CommunicationType::ErrorBadVersion) + .with_id(0xAABBCCDD) + .with_sender(0x0000_1122_3344_5566) + .with_receiver(0x0000_6677_8899_AABB); + + let bytes = cv.to_bytes().expect("encode failed"); + let mut c = Cursor::new(bytes.as_slice()); + + let total_len = c.read_u32::().expect("len"); + assert_eq!(total_len as usize + 4, bytes.len()); + + let typ = c.read_u16::().expect("read type"); + assert_eq!(typ, 13); + + let flags = c.read_u8().expect("read flags"); + assert_eq!(flags & 0b0000_0111, 0b0000_0111); + + let id = c.read_u32::().expect("id"); + assert_eq!(id, 0xAABBCCDD); + + let mut sender6 = [0u8; 6]; + c.read_exact(&mut sender6).expect("sender"); + assert_eq!(sender6, [0x11, 0x22, 0x33, 0x44, 0x55, 0x66]); + + let mut receiver6 = [0u8; 6]; + c.read_exact(&mut receiver6).expect("receiver"); + assert_eq!(receiver6, [0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB]); + } + + #[test] + fn test_roundtrip_complex() { + let tm = TypeMap::latest(); + let cv = CommunicationValue::new(CommunicationType::Disconnect) + .with_id(1234) + .with_sender(111) + .with_receiver(222) + .add_typed_default(DataType::Id, DataValue::Str("alice".to_string())) + .add_typed_default(DataType::ClientNonce, DataValue::SignedNumber(42)) + .add_typed_default(DataType::ServerNonce, DataValue::BoolTrue) + .add_typed_default( + DataType::PublicKeys, + DataValue::Array(vec![DataValue::SignedNumber(1), DataValue::SignedNumber(2)]), ); - } - } - #[test] - fn protected_or_scalar_payload_is_not_treated_as_data() { - let frame = CommunicationValue::new(CommunicationType::Ping) - .with_payload(DataValue::Bytes(vec![1])); - assert!(frame.data().is_none()); - assert_eq!(frame.get_data(DataType::Version), None); - } - - #[test] - fn replies_retain_the_request_type_map() { - let type_map = TypeMap::new(mtp_type_map::Version::new(3, 0)); - let request = CommunicationValue::new_with_type_map(CommunicationType::Ping, &type_map) - .with_sender(7) - .with_receiver(9); - let reply = request.reply_to(CommunicationType::Pong); + let decoded = roundtrip(cv.clone()); + assert_eq!(decoded.get_id(), 1234); + assert_eq!(decoded.get_sender(), 111); + assert_eq!(decoded.get_receiver(), 222); + assert_eq!(decoded.get_type(), CommunicationType::Disconnect.to_id(&tm)); assert_eq!( - reply.type_map().map(|map| &map.version), - Some(&type_map.version) + decoded.get_data(DataType::Id), + &DataValue::Str("alice".to_string()) ); - assert_eq!(reply.sender(), Some(9)); - assert_eq!(reply.receiver(), Some(7)); - } - - #[test] - fn try_merge_rejects_frames_from_different_type_maps() { - let left_map = TypeMap::new(mtp_type_map::Version::new(3, 0)); - let right_map = TypeMap::new(mtp_type_map::Version::new(4, 0)); - let mut left = CommunicationValue::new_with_type_map(CommunicationType::Ping, &left_map); - let right = CommunicationValue::new_with_type_map(CommunicationType::Ping, &right_map); - assert_eq!( - left.try_merge(&right), - Err(CodecError::TypeMapMismatch { - expected: "3.0".into(), - actual: "4.0".into(), - }) + decoded.get_data(DataType::ClientNonce), + &DataValue::SignedNumber(42) ); - assert_eq!(left.data_len(), 0); } #[test] - fn generic_payload_roundtrips_without_becoming_a_container() { - let payload = DataValue::Array(vec![ - DataValue::Str("arbitrary".into()), - DataValue::UnsignedNumber(7), - ]); - let encoded = CommunicationValue::new(CommunicationType::Ping) - .with_payload(payload.clone()) - .to_bytes() - .unwrap(); - - let decoded = CommunicationValue::from_bytes(&encoded).unwrap(); - assert_eq!(decoded.payload(), &payload); - assert_eq!(decoded.into_payload(), payload); - } - - #[test] - fn add_data_rejects_a_non_container_payload() { - let type_map = TypeMap::latest(); - let data_type = DataType::Version.try_to_id(&type_map).unwrap(); - let result = CommunicationValue::new(CommunicationType::Ping) - .with_payload(DataValue::Null) - .add_data(data_type, DataValue::Str("1".into())); - - assert_eq!(result, Err(CodecError::InvalidEncoding)); - } - - #[test] - fn trailing_value_after_payload_is_rejected() { - let mut encoded = CommunicationValue::new(CommunicationType::Ping) - .with_payload(DataValue::Null) - .to_bytes() - .unwrap(); - encoded.push(DataValue::BoolTrue.to_bytes().unwrap()[0]); - let body_len = u32::try_from(encoded.len() - 4).unwrap(); - encoded[..4].copy_from_slice(&body_len.to_be_bytes()); - - assert_eq!( - CommunicationValue::from_bytes(&encoded), - Err(CodecError::InvalidEncoding) - ); + fn test_corrupted_length_returns_none() { + let mut bad = vec![0u8; 8]; + // total_length claims more than available + bad[0..4].copy_from_slice(&(1000u32.to_be_bytes())); + assert!(CommunicationValue::from_bytes(&bad).is_err()); } #[cfg(feature = "crypto")] #[test] - fn sealed_sender_is_a_frame_construction_rule() -> Result<(), Box> { - use crate::data_value::ProtectionPurpose; - use mtp_crypto::{Ed25519Signer, Keyring}; + fn test_sign_verify_frame_roundtrip() { + use mtp_crypto::{Ed25519Signer, SigAlgorithm}; - const SENDER_ID: u64 = 0x0102_0304_0506_0708; - const RECEIVER_ID: u64 = 0x1112_1314_1516_1718; + let (signer, sk, _pk) = Ed25519Signer::generate(); - let (signer, _, signer_public_key) = Ed25519Signer::generate(); - let recipient = Keyring::generate(); - let clear_payload = DataValue::Container(vec![( - DataTypeId(32), - DataValue::Str("sealed content".into()), - )]); - let protected_payload = clear_payload - .clone() - .sign(SENDER_ID, ProtectionPurpose::from(1), &signer)? - .encrypt_for( - std::slice::from_ref(&recipient.public_key_bundle()), - ProtectionPurpose::from(2), - )?; + let mut cv = CommunicationValue::new(CommunicationType::Ping) + .with_id(7) + .with_sender(1) + .with_receiver(2) + .add_typed_default(DataType::PqSignature, DataValue::UnsignedNumber(42)); - let frame = CommunicationValue::new(CommunicationType::Ping) - .without_sender() - .with_receiver(RECEIVER_ID) - .with_payload(protected_payload); - assert!(frame.sender().is_none()); - assert!(frame.receiver().is_some()); - assert!(matches!(frame.payload(), DataValue::Encrypted(_))); + assert!(cv.sign_frame(SigAlgorithm::ED25519, &signer).is_some()); - let frame_id = frame.id(); - let encoded = frame.to_bytes()?; + // Same in-memory value verifies (FLAG_SIGNED forced on both sides). + let verifier = Ed25519Signer::new(&sk).unwrap(); + assert!(cv.verify_frame(&verifier).is_ok()); - // Payload protection does not introduce frame flags. The header only - // advertises the transport ID and visible next-hop receiver. - assert_eq!(encoded[6], FLAG_HAS_ID | FLAG_HAS_RECEIVER); - assert_eq!(&encoded[11..19], &RECEIVER_ID.to_be_bytes()); - assert_eq!(encoded[19], 0x0A); + // Survives a wire round-trip. + let bytes = cv.to_bytes().expect("encode failed"); + let decoded = CommunicationValue::from_bytes(&bytes).expect("decode failed"); + assert!(decoded.verify_frame(&verifier).is_ok()); + } - let decoded = CommunicationValue::from_bytes(&encoded)?; - assert_eq!(decoded.id(), frame_id); - assert_eq!(decoded.sender(), None); - assert_eq!(decoded.receiver(), Some(RECEIVER_ID)); - assert!(matches!(decoded.payload(), DataValue::Encrypted(_))); + #[cfg(feature = "crypto")] + #[test] + fn test_verify_frame_wrong_key_fails() { + use mtp_crypto::{Ed25519Signer, SigAlgorithm}; - let signed = decoded - .payload() - .decrypt(&recipient, ProtectionPurpose::from(2))?; - let DataValue::Signed(signed_value) = &signed else { - return Err("expected signed value inside encrypted payload".into()); - }; - assert_eq!(signed_value.signer_id, SENDER_ID); + let (signer, _, _) = Ed25519Signer::generate(); + let (_, other_sk, _) = Ed25519Signer::generate(); - let mut signer_public_keys = recipient.public_key_bundle(); - signer_public_keys.sig_cl_public_key = signer_public_key; - signed.verify_with_policy( - SENDER_ID, - &signer_public_keys, - ProtectionPurpose::from(1), - crate::ProtectionPolicy::any_supported(), - )?; - assert_eq!( - signed.into_verified_with_policy( - SENDER_ID, - &signer_public_keys, - ProtectionPurpose::from(1), - crate::ProtectionPolicy::any_supported(), - )?, - clear_payload - ); - Ok(()) + let mut cv = CommunicationValue::new(CommunicationType::Ping) + .add_typed_default(DataType::PqSignature, DataValue::UnsignedNumber(42)); + assert!(cv.sign_frame(SigAlgorithm::ED25519, &signer).is_some()); + + let wrong = Ed25519Signer::new(&other_sk).unwrap(); + assert!(cv.verify_frame(&wrong).is_err()); } } diff --git a/codec/src/data_value.rs b/codec/src/data_value.rs index 6eb7c1b..8b79d75 100644 --- a/codec/src/data_value.rs +++ b/codec/src/data_value.rs @@ -1,1853 +1,988 @@ use base64::Engine; use base64::engine::general_purpose; -use byteorder::{BigEndian, ReadBytesExt}; -use std::collections::{BTreeMap, BTreeSet}; +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use std::collections::BTreeMap; use std::fmt; use std::hash::{Hash, Hasher}; use std::io::Cursor; -use std::mem::size_of; use mtp_common::CodecError; use mtp_type_map::DataTypeId; -#[cfg(feature = "crypto")] -use mtp_crypto::{ - EncryptionType, Keyring, PublicKeyBundle, RecipientEntry, SigAlgorithm, SignatureScheme, -}; +#[cfg(test)] +use mtp_type_map::{DataType, TypeMap}; -/// Protocol context authenticated by every signed [`DataValue`]. -/// -/// This is intentionally not serialized: it separates MTP data-value -/// signatures from signatures generated for every other MTP purpose. #[cfg(feature = "crypto")] -const SIGN_DOMAIN: &[u8] = b"MTP-DATA-SIGN-1"; +use mtp_crypto::{EncryptionType, Keyring, PublicKeyBundle, SigAlgorithm, SignatureScheme}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum DataKind { Bool, + SignedNumber, UnsignedNumber, Float, + Str, Bytes, - /// Arrays may contain heterogeneous recursive values. - Array, + Array(Box), + Container, + #[cfg(feature = "crypto")] - Encrypted, + EncryptedContainer, #[cfg(feature = "crypto")] - Signed, + SignedContainer, + #[cfg(feature = "crypto")] + SignedEncryptedContainer, + Null, } -/// Resource limits applied while decoding recursive `DataValue` structures. -/// -/// The wire format deliberately uses recursive values, so decoding must not -/// let attacker-controlled nesting or allocation sizes become process-wide -/// limits. These are conservative defaults for transported frames; callers -/// handling a different trust boundary can opt into stricter limits with -/// [`DataValue::from_bytes_with_limits`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct DecodeLimits { - /// Maximum number of nested `Array`, `Container`, and `Signed` wrappers. - pub max_depth: usize, - /// Maximum number of `DataValue` nodes in one decoded value. - pub max_values: usize, - /// Maximum size of one string, binary blob, signed wrapper, or encrypted - /// envelope body. - pub max_blob_size: usize, - /// Maximum number of recipients in one encrypted envelope. - pub max_recipients: usize, - /// Maximum aggregate memory allocated for owned decoder output. - pub max_allocated_bytes: usize, -} - -impl Default for DecodeLimits { - fn default() -> Self { - Self { - max_depth: 64, - max_values: 65_536, - max_blob_size: 16 * 1024 * 1024, - max_recipients: 64, - max_allocated_bytes: 64 * 1024 * 1024, - } - } -} - -/// Conservative multiplier used when deriving decoder allocation capacity -/// from an admitted transport frame. A frame can result in owned wrapper, -/// recipient, ciphertext, and value allocations, so this is intentionally -/// larger than the number of bytes on the wire. -pub const DEFAULT_TRANSPORT_ALLOCATION_FACTOR: u64 = 4; - -impl DecodeLimits { - /// Derive codec allocation limits from the transport's admitted complete - /// frame size. This keeps a larger explicitly configured transport policy - /// from being rejected by an unrelated hard-coded blob bound while still - /// preserving recursive and recipient-count limits. - pub fn for_transport_message_size(max_message_size: u64) -> Self { - Self::for_transport_message_size_with_allocation_factor( - max_message_size, - DEFAULT_TRANSPORT_ALLOCATION_FACTOR, - ) - } - - /// Derive transport limits with an explicit allocation multiplier. - /// - /// The multiplier is a deployment knob for transports whose crypto or - /// framing implementation has a different copy profile. A zero value is - /// treated as one so the allocation budget never becomes accidentally - /// unbounded by arithmetic underflow or unusably small by configuration. - pub fn for_transport_message_size_with_allocation_factor( - max_message_size: u64, - allocation_factor: u64, - ) -> Self { - let max_blob_size = usize::try_from(max_message_size.saturating_sub(4)) - .unwrap_or(usize::MAX) - .min(u32::MAX as usize); - let allocation_factor = allocation_factor.max(1); - let max_allocated_bytes = - usize::try_from(max_message_size.saturating_mul(allocation_factor)) - .unwrap_or(usize::MAX); - Self { - max_blob_size, - max_allocated_bytes, - ..Self::default() - } - } -} - -/// Resource limits applied while encoding recursive `DataValue` structures. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct EncodeLimits { - /// Maximum number of nested `Array`, `Container`, and `Signed` wrappers. - pub max_depth: usize, - /// Maximum number of `DataValue` nodes in one encoded value. - pub max_values: usize, - /// Maximum serialized size of the encoded value. - pub max_output_size: usize, -} - -impl Default for EncodeLimits { - fn default() -> Self { - Self { - max_depth: 64, - max_values: 65_536, - max_output_size: 16 * 1024 * 1024, - } - } -} - -impl EncodeLimits { - /// Derive encoder limits from the transport's admitted complete frame size. - pub fn for_transport_message_size(max_message_size: u64) -> Self { - Self { - max_output_size: usize::try_from(max_message_size).unwrap_or(usize::MAX), - ..Self::default() - } - } -} - -#[derive(Debug, Clone, Copy)] -struct DecodeContext { - limits: DecodeLimits, - depth: usize, - values: usize, - allocated_bytes: usize, -} - -/// Structured failures returned by the diagnostic decoder. -#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] -pub enum DecodeError { - #[error("malformed encoding")] - MalformedEncoding, - #[error("decoder nesting depth limit exceeded")] - DepthLimit, - #[error("decoder value-count limit exceeded")] - ValueCountLimit, - #[error("decoder blob-size limit exceeded")] - BlobLimit, - #[error("decoder allocation limit exceeded")] - AllocationLimit, - #[error("decoder recipient-count limit exceeded")] - RecipientLimit, - #[error("duplicate container field")] - DuplicateField, -} - -// Keep the name used by the original internal diagnostics in this module. -type DecodeFailure = DecodeError; - -#[derive(Debug, Clone, Copy)] -struct EncodeContext { - limits: EncodeLimits, - depth: usize, - values: usize, -} - -impl DecodeContext { - fn new(limits: DecodeLimits) -> Self { - Self { - limits, - depth: 0, - values: 0, - allocated_bytes: 0, - } - } - - fn allocate(&mut self, bytes: usize) -> Result<(), DecodeFailure> { - self.allocated_bytes = self - .allocated_bytes - .checked_add(bytes) - .ok_or(DecodeFailure::AllocationLimit)?; - if self.allocated_bytes > self.limits.max_allocated_bytes { - return Err(DecodeFailure::AllocationLimit); - } - Ok(()) - } - - fn value(&mut self) -> Result<(), DecodeFailure> { - self.values = self - .values - .checked_add(1) - .ok_or(DecodeFailure::ValueCountLimit)?; - (self.values <= self.limits.max_values) - .then_some(()) - .ok_or(DecodeFailure::ValueCountLimit) - } - - fn enter(&mut self) -> Result<(), DecodeFailure> { - self.depth = self.depth.checked_add(1).ok_or(DecodeFailure::DepthLimit)?; - (self.depth <= self.limits.max_depth) - .then_some(()) - .ok_or(DecodeFailure::DepthLimit) - } - - fn leave(&mut self) { - self.depth = self.depth.saturating_sub(1); - } -} - -impl EncodeContext { - fn new(limits: EncodeLimits) -> Self { - Self { - limits, - depth: 0, - values: 0, - } - } - - fn value(&mut self) -> Result<(), CodecError> { - self.values = self - .values - .checked_add(1) - .ok_or(CodecError::TooManyEntries)?; - if self.values > self.limits.max_values { - return Err(CodecError::TooManyEntries); - } - Ok(()) - } - - fn enter(&mut self) -> Result<(), CodecError> { - self.depth = self - .depth - .checked_add(1) - .ok_or(CodecError::TooManyEntries)?; - if self.depth > self.limits.max_depth { - return Err(CodecError::TooManyEntries); - } - Ok(()) - } - - fn leave(&mut self) { - self.depth = self.depth.saturating_sub(1); - } - - fn check_output(&self, current: usize, additional: usize) -> Result<(), CodecError> { - let next = current - .checked_add(additional) - .ok_or(CodecError::TooManyEntries)?; - if next > self.limits.max_output_size { - return Err(CodecError::TooManyEntries); - } - Ok(()) - } -} - impl fmt::Display for DataKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Bool => f.write_str("Bool"), - Self::SignedNumber => f.write_str("SignedNumber"), - Self::UnsignedNumber => f.write_str("UnsignedNumber"), - Self::Float => f.write_str("Float"), - Self::Str => f.write_str("Str"), - Self::Bytes => f.write_str("Bytes"), - Self::Array => f.write_str("Array"), - Self::Container => f.write_str("Container"), + DataKind::Bool => f.write_str("Bool"), + DataKind::SignedNumber => f.write_str("SignedNumber"), + DataKind::UnsignedNumber => f.write_str("UnsignedNumber"), + DataKind::Float => f.write_str("Float"), + DataKind::Str => f.write_str("Str"), + DataKind::Bytes => f.write_str("Bytes"), + DataKind::Array(inner) => write!(f, "Array<{}>", inner), + DataKind::Container => f.write_str("Container"), #[cfg(feature = "crypto")] - Self::Encrypted => f.write_str("Encrypted"), + DataKind::EncryptedContainer => f.write_str("EncryptedContainer"), #[cfg(feature = "crypto")] - Self::Signed => f.write_str("Signed"), - Self::Null => f.write_str("Null"), + DataKind::SignedContainer => f.write_str("SignedContainer"), + #[cfg(feature = "crypto")] + DataKind::SignedEncryptedContainer => f.write_str("SignedEncryptedContainer"), + DataKind::Null => f.write_str("Null"), } } } -#[cfg(feature = "crypto")] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct ProtectionPurpose(pub u8); - -#[cfg(feature = "crypto")] -impl From for ProtectionPurpose { - fn from(value: u8) -> Self { - Self(value) - } -} - -#[cfg(feature = "crypto")] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct ApplicationProtectionPurpose(u8); - -#[cfg(feature = "crypto")] -#[derive(Debug, thiserror::Error, Clone, Copy, PartialEq, Eq)] -pub enum ProtectionPurposeError { - #[error("protection purpose 0x{0:02x} is reserved for MTP")] - Reserved(u8), -} - -/// MTP-owned protection-purpose registry. -/// -/// Applications may still use [`ProtectionPurpose::from`] for their own -/// domain-separated values, but protocol code should use this enum so the -/// reserved values are defined in one place. -#[cfg(feature = "crypto")] -#[repr(u8)] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum MtpProtectionPurpose { - RelayMetadataEncryption = 0x30, - RelayContentSignature = 0x31, - RelayContentEncryption = 0x32, - RelayMetadataSignature = 0x33, - PipeSessionSignature = 0x50, - PipeSessionEncryption = 0x51, -} - -#[cfg(feature = "crypto")] -impl MtpProtectionPurpose { - pub const fn value(self) -> u8 { - self as u8 - } - - pub const fn is_reserved(value: u8) -> bool { - value == Self::RelayMetadataEncryption as u8 - || value == Self::RelayContentSignature as u8 - || value == Self::RelayContentEncryption as u8 - || value == Self::RelayMetadataSignature as u8 - || value == Self::PipeSessionSignature as u8 - || value == Self::PipeSessionEncryption as u8 - } -} - -#[cfg(feature = "crypto")] -impl ApplicationProtectionPurpose { - pub fn new(value: u8) -> Result { - if MtpProtectionPurpose::is_reserved(value) { - return Err(ProtectionPurposeError::Reserved(value)); - } - Ok(Self(value)) - } - - pub const fn value(self) -> u8 { - self.0 - } -} - -#[cfg(feature = "crypto")] -impl TryFrom for ApplicationProtectionPurpose { - type Error = ProtectionPurposeError; - - fn try_from(value: u8) -> Result { - Self::new(value) - } -} - -#[cfg(feature = "crypto")] -impl From for ProtectionPurpose { - fn from(value: ApplicationProtectionPurpose) -> Self { - Self(value.value()) - } -} - -#[cfg(feature = "crypto")] -impl From for ProtectionPurpose { - fn from(value: MtpProtectionPurpose) -> Self { - Self(value.value()) - } -} - -/// Signature algorithms a receiver is willing to accept for a protected -/// value. The policy is deliberately supplied by the receiver; accepting -/// the algorithm selected by an untrusted wrapper is not a security policy. -#[cfg(feature = "crypto")] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum SignaturePolicy { - /// Accept a classical Ed25519 signature only. - Ed25519, - /// Require the hybrid Ed25519 + ML-DSA signature. - Dual, - /// Accept any signature algorithm supported by this build. - AnySupported, -} - -#[cfg(feature = "crypto")] -impl SignaturePolicy { - pub const fn accepts(self, algorithm: u8) -> bool { - match self { - Self::Ed25519 => algorithm == SigAlgorithm::ED25519, - Self::Dual => algorithm == SigAlgorithm::DUAL, - Self::AnySupported => matches!( - algorithm, - SigAlgorithm::ED25519 | SigAlgorithm::ML_DSA_65 | SigAlgorithm::DUAL - ), - } - } -} - -/// Receiver-side protection policy. This is a struct so additional -/// authenticated-value requirements can be added without continually -/// changing every verification function signature. -#[cfg(feature = "crypto")] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct ProtectionPolicy { - pub signature: SignaturePolicy, -} - -#[cfg(feature = "crypto")] -impl Default for ProtectionPolicy { - fn default() -> Self { - // Boundary verification should not silently widen when a new - // signature suite is compiled in. Callers that intentionally need - // the historical permissive behavior must opt into - // `ProtectionPolicy::any_supported()` explicitly. - Self::ed25519() - } -} - -#[cfg(feature = "crypto")] -impl ProtectionPolicy { - pub const fn ed25519() -> Self { - Self { - signature: SignaturePolicy::Ed25519, - } - } - - pub const fn dual() -> Self { - Self { - signature: SignaturePolicy::Dual, - } - } - - /// Explicit compatibility profile for callers that must accept every - /// signature suite compiled into the current build. - pub const fn any_supported() -> Self { - Self { - signature: SignaturePolicy::AnySupported, - } - } -} - -#[cfg(feature = "crypto")] -impl From for ProtectionPolicy { - fn from(signature: SignaturePolicy) -> Self { - Self { signature } - } -} - -#[cfg(feature = "crypto")] -#[derive(Debug, thiserror::Error)] -pub enum ProtectionError { - #[error("value is not encrypted")] - NotEncrypted, - #[error("value is not signed")] - NotSigned, - #[error("malformed protected value")] - Malformed, - #[error("no matching recipient")] - NoMatchingRecipient, - #[error("invalid signature")] - InvalidSignature, - #[error("signature algorithm {actual} does not satisfy the receiver policy {expected:?}")] - SignaturePolicyMismatch { - expected: SignaturePolicy, - actual: u8, - }, - #[error("protection purpose mismatch: expected {expected}, got {actual}")] - PurposeMismatch { expected: u8, actual: u8 }, - #[error("signer ID mismatch: expected {expected}, got {actual}")] - SignerIdMismatch { expected: u64, actual: u64 }, - #[error("no verification key for signer ID {0}")] - SignerKeyNotFound(u64), - #[error("protected resource limit exceeded: {0}")] - ResourceLimit(&'static str), - #[error("codec error: {0}")] - Codec(#[from] CodecError), - #[error("crypto error: {0}")] - Crypto(#[from] mtp_crypto::CryptoError), -} - -#[cfg(feature = "crypto")] -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SignedValue { - pub algorithm: u8, - pub purpose: u8, - pub signer_id: u64, - pub signature: Vec, - pub value: Box, -} - -#[cfg(feature = "crypto")] -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EncryptedValue { - pub encryption_type: EncryptionType, - pub purpose: u8, - pub recipients: Vec, - pub ciphertext: Vec, -} - -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Eq)] pub enum DataValue { BoolTrue, BoolFalse, Bool(bool), + SignedNumber(i128), UnsignedNumber(u128), - Float(f64), + Float(u8, u32), + Str(String), Bytes(Vec), Array(Vec), + /* + * Container format: + * [2 bytes u16 entry_count] // number of entries + * [1 byte kind] // DataValue kind marker + * [if kind == BOOL_TRUE or BOOL_FALSE:] + * [2 bytes u16 key] // DataTypeId discriminant + * [else:] + * [4 bytes u32 payload_len] // length of the value payload + * [2 bytes u16 key] // DataTypeId discriminant + * [payload_len bytes payload] // value data (interpreted based on kind) + */ Container(Vec<(DataTypeId, DataValue)>), + + /* + * Container format: + * [4 bytes u32 entry_count] // length of the container + * [binary data] + * -> After decryption, the container is parsed as a regular container + */ #[cfg(feature = "crypto")] - Encrypted(EncryptedValue), + EncryptedContainer(Vec), + + /* + * Container format: + * [4 bytes u32 entry_count] // length of the container + * [binary data] + * -> Can be turned into Container + * -> Can be used with a public key to verify integrity + */ #[cfg(feature = "crypto")] - Signed(SignedValue), + SignedContainer(Vec), + + /* + * Container format: + * [4 bytes u32 entry_count] // length of the container + * [binary data] + * -> After decryption, the container is parsed as a signed container + */ + #[cfg(feature = "crypto")] + SignedEncryptedContainer(Vec), + Null, } impl DataValue { + /* + * Container format: + * [2 bytes u16 entry_count] // number of entries + * [1 byte kind] // DataValue kind marker + * [if kind == BOOL_TRUE or BOOL_FALSE:] + * [2 bytes u16 key] // DataTypeId discriminant + * [else:] + * [4 bytes u32 payload_len] // length of the value payload + * [2 bytes u16 key] // DataTypeId discriminant + * [payload_len bytes payload] // value data (interpreted based on kind) + * + * Array format (same as container but no keys): + * [2 bytes u16 entry_count] + * for each entry: + * [1 byte kind] + * [if kind == BOOL_TRUE or BOOL_FALSE:] + * (no payload) + * [else:] + * [4 bytes u32 payload_len] + * [payload_len bytes payload] + * + * Kind markers: + * 0x01 => BoolTrue + * 0x02 => BoolFalse + * 0x03 => Signed Number (i128, 16 bytes big-endian) + * 0x04 => Unsigned Number (u128, 16 bytes big-endian) + * 0x05 => Float (1 byte exponent, 4 bytes mantissa) + * 0x06 => Str (UTF-8 bytes) + * 0x07 => Bytes + * 0x08 => Array + * 0x09 => Container + * 0x0A => EncryptedContainer (1 byte EncryptionType + KEM ciphertext + AEAD payload) + * 0x0B => SignedContainer (1 byte SigAlgorithm + signature + serialized container) + * 0x0C => SignedEncryptedContainer (encrypted blob that decrypts to a SignedContainer) + * 0xFF => Null + */ const KIND_BOOL_TRUE: u8 = 0x01; const KIND_BOOL_FALSE: u8 = 0x02; + const KIND_SIGNED_NUMBER: u8 = 0x03; const KIND_UNSIGNED_NUMBER: u8 = 0x04; const KIND_FLOAT: u8 = 0x05; + const KIND_STR: u8 = 0x06; const KIND_BYTES: u8 = 0x07; const KIND_ARRAY: u8 = 0x08; + const KIND_CONTAINER: u8 = 0x09; #[cfg(feature = "crypto")] - const KIND_ENCRYPTED: u8 = 0x0A; + const KIND_ENCRYPTED_CONTAINER: u8 = 0x0A; #[cfg(feature = "crypto")] - const KIND_SIGNED: u8 = 0x0B; + const KIND_SIGNED_CONTAINER: u8 = 0x0B; + #[cfg(feature = "crypto")] + const KIND_SIGNED_ENCRYPTED_CONTAINER: u8 = 0x0C; + const KIND_NULL: u8 = 0xFF; - pub fn container_from_map(map: &BTreeMap) -> Self { - Self::Container(map.iter().map(|(id, value)| (*id, value.clone())).collect()) + /* + * Smallest possible encoded entry, used to cap pre-reservation when + * decoding containers/arrays so a small frame cannot force a huge + * allocation from an attacker-controlled count. A bool/null entry in a + * container is 3 bytes (1 kind + 2 key); a bare value in an array is 1 + * byte, so 1 is the safe lower bound shared by both. + */ + const MIN_ENTRY_BYTES: usize = 1; + + pub fn container_from_map(map: &BTreeMap) -> DataValue { + let mut container = Vec::new(); + for (key, value) in map { + container.push((*key, value.clone())); + } + DataValue::Container(container) } pub fn kind(&self) -> DataKind { match self { - Self::BoolTrue | Self::BoolFalse | Self::Bool(_) => DataKind::Bool, - Self::SignedNumber(_) => DataKind::SignedNumber, - Self::UnsignedNumber(_) => DataKind::UnsignedNumber, - Self::Float(_) => DataKind::Float, - Self::Str(_) => DataKind::Str, - Self::Bytes(_) => DataKind::Bytes, - Self::Array(_) => DataKind::Array, - Self::Container(_) => DataKind::Container, + DataValue::Bool(_) | DataValue::BoolTrue | DataValue::BoolFalse => DataKind::Bool, + DataValue::SignedNumber(_) => DataKind::SignedNumber, + DataValue::UnsignedNumber(_) => DataKind::UnsignedNumber, + DataValue::Float(_, _) => DataKind::Float, + DataValue::Str(_) => DataKind::Str, + DataValue::Array(a) => { + if let Some(first) = a.first() { + DataKind::Array(Box::new(first.kind())) + } else { + DataKind::Array(Box::new(DataKind::Null)) + } + } + DataValue::Bytes(_) => DataKind::Bytes, + DataValue::Container(_) => DataKind::Container, #[cfg(feature = "crypto")] - Self::Encrypted(_) => DataKind::Encrypted, + DataValue::EncryptedContainer(_) => DataKind::EncryptedContainer, #[cfg(feature = "crypto")] - Self::Signed(_) => DataKind::Signed, - Self::Null => DataKind::Null, + DataValue::SignedContainer(_) => DataKind::SignedContainer, + #[cfg(feature = "crypto")] + DataValue::SignedEncryptedContainer(_) => DataKind::SignedEncryptedContainer, + DataValue::Null => DataKind::Null, } } pub fn as_bool(&self) -> Option { match self { - Self::BoolTrue => Some(true), - Self::BoolFalse => Some(false), - Self::Bool(value) => Some(*value), + DataValue::BoolTrue => Some(true), + DataValue::BoolFalse => Some(false), + DataValue::Bool(v) => Some(*v), _ => None, } } pub fn as_str(&self) -> Option<&str> { match self { - Self::Str(value) => Some(value), + DataValue::Str(s) => Some(s), _ => None, } } pub fn as_string(&self) -> Option { - self.as_str().map(str::to_owned) + self.as_str().map(|s| s.to_string()) } pub fn as_signed_number(&self) -> Option { match self { - Self::SignedNumber(value) => Some(*value), + DataValue::SignedNumber(n) => Some(*n), _ => None, } } pub fn as_unsigned_number(&self) -> Option { match self { - Self::UnsignedNumber(value) => Some(*value), + DataValue::UnsignedNumber(n) => Some(*n), _ => None, } } - pub fn as_float(&self) -> Option { + pub fn as_float(&self) -> Option<(u8, u32)> { match self { - Self::Float(value) => Some(*value), + DataValue::Float(a, b) => Some((*a, *b)), _ => None, } } pub fn as_array(&self) -> Option> { match self { - Self::Array(value) => Some(value.clone()), - _ => None, - } - } - - pub fn as_array_slice(&self) -> Option<&[DataValue]> { - match self { - Self::Array(value) => Some(value), + DataValue::Array(a) => Some(a.clone()), _ => None, } } pub fn as_bytes(&self) -> Option> { match self { - Self::Bytes(value) => Some(value.clone()), - _ => None, - } - } - - pub fn as_bytes_slice(&self) -> Option<&[u8]> { - match self { - Self::Bytes(value) => Some(value), + DataValue::Bytes(b) => Some(b.clone()), _ => None, } } pub fn as_container(&self) -> Option> { - self.container_entries().map(<[_]>::to_vec) - } - - pub fn container_entries(&self) -> Option<&[(DataTypeId, DataValue)]> { match self { - Self::Container(entries) => Some(entries), + DataValue::Container(c) => Some(c.clone()), _ => None, } } - pub fn container_entries_mut(&mut self) -> Option<&mut Vec<(DataTypeId, DataValue)>> { - match self { - Self::Container(entries) => Some(entries), - _ => None, - } - } - - pub fn as_container_map(&self) -> Option> { - self.container_entries() - .map(|entries| entries.iter().cloned().collect()) - } - pub fn as_number(&self) -> Option { match self { - Self::SignedNumber(value) => Some(*value), - Self::UnsignedNumber(value) => i128::try_from(*value).ok(), + DataValue::SignedNumber(n) => Some(*n), + DataValue::UnsignedNumber(n) => Some(*n as i128), _ => None, } } pub fn is_null(&self) -> bool { - matches!(self, Self::Null) + matches!(self, DataValue::Null) } pub fn is_truthy(&self) -> bool { match self { - Self::BoolTrue | Self::Bool(true) => true, - Self::BoolFalse | Self::Bool(false) | Self::Null => false, - Self::SignedNumber(0) | Self::UnsignedNumber(0) => false, + DataValue::BoolTrue | DataValue::Bool(true) => true, + DataValue::BoolFalse | DataValue::Bool(false) | DataValue::Null => false, + DataValue::UnsignedNumber(0) | DataValue::SignedNumber(0) => false, _ => true, } } pub fn get_field(&self, key: DataTypeId) -> Option<&DataValue> { match self { - Self::Container(entries) => entries.iter().find(|(id, _)| *id == key).map(|(_, v)| v), + DataValue::Container(entries) => { + entries.iter().find(|(k, _)| *k == key).map(|(_, v)| v) + } _ => None, } } - pub fn as_map(&self) -> Option> { - self.as_container_map() + pub fn as_container_map(&self) -> Option> { + match self { + DataValue::Container(entries) => Some(entries.iter().cloned().collect()), + _ => None, + } + } + + pub fn as_bytes_slice(&self) -> Option<&[u8]> { + match self { + DataValue::Bytes(b) => Some(b), + _ => None, + } + } + + pub fn as_array_slice(&self) -> Option<&[DataValue]> { + match self { + DataValue::Array(a) => Some(a), + _ => None, + } } pub fn type_name(&self) -> &'static str { match self { - Self::BoolTrue | Self::BoolFalse | Self::Bool(_) => "Bool", - Self::SignedNumber(_) => "SignedNumber", - Self::UnsignedNumber(_) => "UnsignedNumber", - Self::Float(_) => "Float", - Self::Str(_) => "Str", - Self::Bytes(_) => "Bytes", - Self::Array(_) => "Array", - Self::Container(_) => "Container", + DataValue::Bool(_) | DataValue::BoolTrue | DataValue::BoolFalse => "Bool", + DataValue::SignedNumber(_) => "SignedNumber", + DataValue::UnsignedNumber(_) => "UnsignedNumber", + DataValue::Float(_, _) => "Float", + DataValue::Str(_) => "Str", + DataValue::Bytes(_) => "Bytes", + DataValue::Array(_) => "Array", + DataValue::Container(_) => "Container", #[cfg(feature = "crypto")] - Self::Encrypted(_) => "Encrypted", + DataValue::EncryptedContainer(_) => "EncryptedContainer", #[cfg(feature = "crypto")] - Self::Signed(_) => "Signed", - Self::Null => "Null", + DataValue::SignedContainer(_) => "SignedContainer", + #[cfg(feature = "crypto")] + DataValue::SignedEncryptedContainer(_) => "SignedEncryptedContainer", + DataValue::Null => "Null", } } #[cfg(feature = "crypto")] - pub fn as_encrypted(&self) -> Option<&EncryptedValue> { + pub fn as_encrypted_container(&self) -> Option> { match self { - Self::Encrypted(value) => Some(value), + DataValue::EncryptedContainer(c) => Some(c.clone()), _ => None, } } #[cfg(feature = "crypto")] - pub fn as_signed(&self) -> Option<&SignedValue> { + pub fn as_signed_container(&self) -> Option> { match self { - Self::Signed(value) => Some(value), + DataValue::SignedContainer(b) => Some(b.clone()), _ => None, } } #[cfg(feature = "crypto")] - pub fn sign( - self, - signer_id: u64, - purpose: ProtectionPurpose, - signer: &(impl SignatureScheme + ?Sized), - ) -> Result { - self.sign_with_limits(signer_id, purpose, signer, EncodeLimits::default()) - } - - /// Sign after bounding the recursive serialization used to construct the - /// authenticated bytes. - #[cfg(feature = "crypto")] - pub fn sign_with_limits( - self, - signer_id: u64, - purpose: ProtectionPurpose, - signer: &(impl SignatureScheme + ?Sized), - limits: EncodeLimits, - ) -> Result { - let inner = self.to_bytes_with_limits(limits)?; - let algorithm = signer.algorithm(); - let signing_bytes = signed_message(algorithm, purpose.0, signer_id, &inner); - let signature = signer.sign(&signing_bytes)?; - validate_signature(algorithm, &signature)?; - Ok(Self::Signed(SignedValue { - algorithm, - purpose: purpose.0, - signer_id, - signature, - value: Box::new(self), - })) - } - - #[cfg(feature = "crypto")] - #[deprecated(note = "migrate to verify_with_policy with an explicit ProtectionPolicy")] - /// Verify with the compatibility policy that accepts any supported suite. - /// Protocol boundaries should prefer [`Self::verify_with_policy`]. - pub fn verify( - &self, - expected_signer_id: u64, - public_keys: &PublicKeyBundle, - expected_purpose: ProtectionPurpose, - ) -> Result<(), ProtectionError> { - self.verify_with_policy( - expected_signer_id, - public_keys, - expected_purpose, - ProtectionPolicy::any_supported(), - ) - } - - #[cfg(feature = "crypto")] - pub fn verify_with_policy( - &self, - expected_signer_id: u64, - public_keys: &PublicKeyBundle, - expected_purpose: ProtectionPurpose, - policy: ProtectionPolicy, - ) -> Result<(), ProtectionError> { + pub fn as_signed_encrypted_container(&self) -> Option> { match self { - Self::Signed(value) => { - value.verify_with_policy(expected_signer_id, public_keys, expected_purpose, policy) + DataValue::SignedEncryptedContainer(c) => Some(c.clone()), + _ => None, + } + } + + /* + * Decrypt an `EncryptedContainer` in-place, replacing it with the + * deserialized `Container`. The algorithm (and which keypair to use) is read + * from the blob's leading `EncryptionType` byte; the matching key is taken + * from `keyring`. Returns `None` if decryption or deserialization fails. + */ + #[cfg(feature = "crypto")] + pub fn decrypt_into_container(&mut self, keyring: &Keyring, aad: &[u8]) -> Option<()> { + let data = self.as_encrypted_container()?; + let plaintext = mtp_crypto::decrypt_with(&data, keyring, aad).ok()?; + let dv = DataValue::from_bytes(&plaintext)?; + match dv { + DataValue::Container(entries) => { + *self = DataValue::Container(entries); + Some(()) } - _ => Err(ProtectionError::NotSigned), + _ => None, } } + /* + * Encrypt a `Container` into an `EncryptedContainer` in-place. + * `enc_type` selects the algorithm and `recipient` provides the public key + * encapsulated to. The resulting blob is self-describing: its leading byte + * is `enc_type`, so `decrypt_into_container` needs only a `Keyring`. + * Returns `None` if the value is not a `Container` or encryption fails. + */ #[cfg(feature = "crypto")] - #[deprecated(note = "migrate to verify_with_resolver_policy with an explicit ProtectionPolicy")] - pub fn verify_with( - &self, - resolve: F, - expected_purpose: ProtectionPurpose, - ) -> Result<(), ProtectionError> - where - F: FnOnce(u64) -> Option, - { - self.verify_with_resolver_policy( - resolve, - expected_purpose, - ProtectionPolicy::any_supported(), - ) + pub fn encrypt_container( + &mut self, + enc_type: EncryptionType, + recipient: &PublicKeyBundle, + aad: &[u8], + ) -> Option<()> { + let entries = self.as_container()?; + let plaintext = DataValue::Container(entries).to_bytes().ok()?; + let ct = mtp_crypto::encrypt_for(enc_type, recipient, &plaintext, aad).ok()?; + *self = DataValue::EncryptedContainer(ct); + Some(()) } + /* + * Sign a `Container` in-place, replacing it with a `SignedContainer`. + * The wire blob is: [1 byte alg] [N bytes sig] [serialized container bytes]. + * The signature covers only the serialized container bytes (not the alg byte). + * Returns `None` if the value is not a `Container` or signing fails. + */ #[cfg(feature = "crypto")] - pub fn verify_with_resolver_policy( - &self, - resolve: F, - expected_purpose: ProtectionPurpose, - policy: ProtectionPolicy, - ) -> Result<(), ProtectionError> - where - F: FnOnce(u64) -> Option, - { - let signed = match self { - Self::Signed(value) => value, - _ => return Err(ProtectionError::NotSigned), + pub fn sign_container(&mut self, algorithm: u8, signer: &impl SignatureScheme) -> Option<()> { + let entries = self.as_container()?; + let container_bytes = Self::encode_container(&entries).ok()?; + + let sig = signer.sign(&container_bytes).ok()?; + + let mut blob = Vec::with_capacity(1 + sig.len() + container_bytes.len()); + blob.push(algorithm); + blob.extend_from_slice(&sig); + blob.extend_from_slice(&container_bytes); + + *self = DataValue::SignedContainer(blob); + Some(()) + } + + /* + * Verify a `SignedContainer` in-place, replacing it with the deserialized + * `Container` on success. Returns `None` if verification fails or the + * blob is malformed. + */ + #[cfg(feature = "crypto")] + pub fn verify_into_container(&mut self, verifier: &impl SignatureScheme) -> Option<()> { + let blob = self.as_signed_container()?; + if blob.len() < 1 + 64 + 2 { + return None; + } + + let algorithm = blob[0]; + let sig_len = SigAlgorithm::length(algorithm)?; + if blob.len() < 1 + sig_len + 2 { + return None; + } + + let signature = &blob[1..1 + sig_len]; + let container_bytes = &blob[1 + sig_len..]; + + verifier.verify(container_bytes, signature).ok()?; + + let entries = DataValue::from_bytes(container_bytes)?.as_container()?; + *self = DataValue::Container(entries); + Some(()) + } + + /* + * Verify a `SignedContainer` without mutating self. Dispatches to + * Ed25519, ML-DSA-65, or both (DUAL) based on the algorithm byte + * embedded in the blob. Returns `false` for any other variant. + */ + #[cfg(feature = "crypto")] + pub fn validate_signature(&self, pk: &PublicKeyBundle) -> bool { + let blob = match self { + DataValue::SignedContainer(b) => b, + _ => return false, }; - let signer_id = signed.signer_id; - let public_keys = - resolve(signer_id).ok_or(ProtectionError::SignerKeyNotFound(signer_id))?; - signed.verify_with_policy(signer_id, &public_keys, expected_purpose, policy) - } - - #[cfg(feature = "crypto")] - #[deprecated(note = "migrate to into_verified_with_policy with an explicit ProtectionPolicy")] - /// Consume a signed value using the compatibility policy that accepts any - /// supported suite. Protocol boundaries should prefer the policy-aware - /// counterpart. - pub fn into_verified( - self, - expected_signer_id: u64, - public_keys: &PublicKeyBundle, - expected_purpose: ProtectionPurpose, - ) -> Result { - self.into_verified_with_policy( - expected_signer_id, - public_keys, - expected_purpose, - ProtectionPolicy::any_supported(), - ) - } - - #[cfg(feature = "crypto")] - pub fn into_verified_with_policy( - self, - expected_signer_id: u64, - public_keys: &PublicKeyBundle, - expected_purpose: ProtectionPurpose, - policy: ProtectionPolicy, - ) -> Result { - match self { - Self::Signed(value) => value.into_verified_with_policy( - expected_signer_id, - public_keys, - expected_purpose, - policy, - ), - _ => Err(ProtectionError::NotSigned), + if blob.is_empty() { + return false; } - } - - #[cfg(feature = "crypto")] - pub fn encrypt_for( - self, - recipients: &[PublicKeyBundle], - purpose: ProtectionPurpose, - ) -> Result { - self.encrypt_for_with_limits(recipients, purpose, EncodeLimits::default()) - } - - /// Encrypt after bounding the recursive serialization of the plaintext. - #[cfg(feature = "crypto")] - pub fn encrypt_for_with_limits( - self, - recipients: &[PublicKeyBundle], - purpose: ProtectionPurpose, - limits: EncodeLimits, - ) -> Result { - let plaintext = self.to_bytes_with_limits(limits)?; - let message = mtp_crypto::encrypt_multi_for( - EncryptionType::MlKemChaCha20Poly1305, - purpose.0, - &plaintext, - recipients, - )?; - Ok(Self::Encrypted(EncryptedValue { - encryption_type: message.encryption_type, - purpose: purpose.0, - recipients: message.recipients, - ciphertext: message.ciphertext, - })) - } - - #[cfg(feature = "crypto")] - pub fn decrypt( - &self, - keyring: &Keyring, - expected_purpose: ProtectionPurpose, - ) -> Result { - self.decrypt_with_limits(keyring, expected_purpose, DecodeLimits::default()) - } - - // Migrate to `decrypt_with_limits` or - // `decrypt_with_keyrings_and_limits` at a protocol boundary so the - // receive policy is not replaced by an intermediate default. - #[deprecated(note = "migrate to decrypt_with_keyrings_and_limits with explicit DecodeLimits")] - #[cfg(feature = "crypto")] - pub fn decrypt_with_keyrings( - &self, - keyrings: &[&Keyring], - expected_purpose: ProtectionPurpose, - ) -> Result { - self.decrypt_with_keyrings_and_limits(keyrings, expected_purpose, DecodeLimits::default()) - } - - /// Try a local key history without exposing recipient-key identifiers on - /// the wire, parsing each successful plaintext with the supplied policy. - #[cfg(feature = "crypto")] - pub fn decrypt_with_keyrings_and_limits( - &self, - keyrings: &[&Keyring], - expected_purpose: ProtectionPurpose, - limits: DecodeLimits, - ) -> Result { - if keyrings.is_empty() { - return Err(ProtectionError::NoMatchingRecipient); - } - let ciphertext_len = match self { - Self::Encrypted(value) => value.ciphertext.len(), - _ => return Err(ProtectionError::NotEncrypted), + let alg = blob[0]; + let sig_len = match SigAlgorithm::length(alg) { + Some(n) => n, + None => return false, }; - let mut remaining_allocations = limits.max_allocated_bytes; - for keyring in keyrings { - if ciphertext_len > remaining_allocations { - return Err(ProtectionError::ResourceLimit("decryption attempts")); + if blob.len() < 1 + sig_len + 2 { + return false; + } + let signature = &blob[1..1 + sig_len]; + let container_bytes = &blob[1 + sig_len..]; + match alg { + SigAlgorithm::ED25519 => { + mtp_crypto::verify_ed25519(&pk.sig_cl_public_key, container_bytes, signature) + .is_ok() } - let attempt_limits = DecodeLimits { - max_allocated_bytes: remaining_allocations, - ..limits - }; - match self.decrypt_with_limits(keyring, expected_purpose, attempt_limits) { - Ok(value) => return Ok(value), - Err(ProtectionError::NoMatchingRecipient) => { - /* A failed attempt may have allocated a plaintext buffer - as large as the ciphertext. Reserve that upper bound - before trying the next historical key. */ - remaining_allocations -= ciphertext_len; + SigAlgorithm::ML_DSA_65 => { + mtp_crypto::verify_ml_dsa(&pk.sig_pq_public_key, container_bytes, signature).is_ok() + } + SigAlgorithm::DUAL => { + const ED_LEN: usize = 64; + if signature.len() < ED_LEN { + return false; } - Err(error) => return Err(error), + let ed_ok = mtp_crypto::verify_ed25519( + &pk.sig_cl_public_key, + container_bytes, + &signature[..ED_LEN], + ) + .is_ok(); + let ml_ok = mtp_crypto::verify_ml_dsa( + &pk.sig_pq_public_key, + container_bytes, + &signature[ED_LEN..], + ) + .is_ok(); + ed_ok && ml_ok } + _ => false, } - Err(ProtectionError::NoMatchingRecipient) } - /// Decrypt an envelope and parse its plaintext with caller-supplied - /// recursive/resource limits. + /* + * Encrypt a `Container` into a `SignedEncryptedContainer` in-place. + * The container is first signed (with `algorithm`/`signer`), then the signed + * blob is encrypted with `enc_type` to `recipient`. The result is an opaque + * ciphertext that decrypts to a `SignedContainer`. + */ #[cfg(feature = "crypto")] - pub fn decrypt_with_limits( - &self, + pub fn sign_and_encrypt_container( + &mut self, + algorithm: u8, + signer: &impl SignatureScheme, + enc_type: EncryptionType, + recipient: &PublicKeyBundle, + aad: &[u8], + ) -> Option<()> { + self.sign_container(algorithm, signer)?; + let blob = self.as_signed_container()?; + let ct = mtp_crypto::encrypt_for(enc_type, recipient, &blob, aad).ok()?; + *self = DataValue::SignedEncryptedContainer(ct); + Some(()) + } + + /* + * Decrypt a `SignedEncryptedContainer` in-place, replacing it with a + * `SignedContainer`. The algorithm and keypair are resolved from the blob's + * leading byte and `keyring`. Does NOT verify; call `verify_into_container` + * next. + */ + #[cfg(feature = "crypto")] + pub fn decrypt_signed_encrypted_container( + &mut self, keyring: &Keyring, - expected_purpose: ProtectionPurpose, - limits: DecodeLimits, - ) -> Result { - let value = match self { - Self::Encrypted(value) => value, - _ => return Err(ProtectionError::NotEncrypted), - }; - if value.ciphertext.len() > limits.max_allocated_bytes { - return Err(ProtectionError::ResourceLimit("decrypted plaintext")); - } - let plaintext = mtp_crypto::decrypt_multi_for_parts_with_limit( - value.encryption_type, - value.purpose, - &value.recipients, - &value.ciphertext, - expected_purpose.0, - keyring, - limits.max_allocated_bytes, - ) - .map_err(protection_error_from_decryption)?; - if plaintext.len() > limits.max_allocated_bytes { - return Err(ProtectionError::ResourceLimit("decrypted plaintext")); + aad: &[u8], + ) -> Option<()> { + let data = self.as_signed_encrypted_container()?; + let plaintext = mtp_crypto::decrypt_with(&data, keyring, aad).ok()?; + *self = DataValue::SignedContainer(plaintext); + Some(()) + } + + pub fn as_map(&self) -> Option> { + match self { + DataValue::Container(c) => { + let mut out = BTreeMap::new(); + for (k, v) in c { + out.insert(*k, v.clone()); + } + Some(out) + } + _ => None, } - let mut decode_limits = limits; - decode_limits.max_allocated_bytes -= plaintext.len(); - Self::try_from_bytes_with_limits(&plaintext, decode_limits).map_err(|error| match error { - DecodeError::DepthLimit => ProtectionError::ResourceLimit("decrypted value depth"), - DecodeError::ValueCountLimit => ProtectionError::ResourceLimit("decrypted value count"), - DecodeError::BlobLimit => ProtectionError::ResourceLimit("decrypted blob"), - DecodeError::AllocationLimit => { - ProtectionError::ResourceLimit("decrypted value allocation") - } - DecodeError::RecipientLimit => ProtectionError::ResourceLimit("decrypted recipients"), - DecodeError::MalformedEncoding | DecodeError::DuplicateField => { - ProtectionError::Malformed - } - }) } - /// Encode with the compatibility resource policy. - /// - /// New protocol boundaries should pass an explicit [`EncodeLimits`] value - /// derived from their admission policy. pub fn to_bytes(&self) -> Result, CodecError> { - self.to_bytes_with_limits(EncodeLimits::default()) - } - - pub fn to_bytes_with_limits(&self, limits: EncodeLimits) -> Result, CodecError> { - let mut out = Vec::new(); - self.write_to_with_limits(&mut out, limits)?; - Ok(out) - } - - /// Encode into an existing output buffer while enforcing depth, node, and - /// serialized-size limits before recursive output is produced. - pub fn write_to(&self, out: &mut Vec) -> Result<(), CodecError> { - self.write_to_with_limits(out, EncodeLimits::default()) - } - - pub fn write_to_with_limits( - &self, - out: &mut Vec, - limits: EncodeLimits, - ) -> Result<(), CodecError> { - let mut sizing = EncodeContext::new(limits); - let size = self.encoded_len_with_context(&mut sizing)?; - sizing.check_output(out.len(), size)?; - - let mut context = EncodeContext::new(limits); - self.write_to_with_context(out, &mut context) - } - - fn encoded_len_with_context(&self, context: &mut EncodeContext) -> Result { - context.value()?; - let size = match self { - Self::BoolTrue | Self::BoolFalse | Self::Bool(_) | Self::Null => 1, - Self::SignedNumber(_) | Self::UnsignedNumber(_) => 1 + 16, - Self::Float(_) => 1 + 8, - Self::Str(value) => checked_add(1, blob_len(value.as_bytes())?)?, - Self::Bytes(value) => checked_add(1, blob_len(value)?)?, - Self::Array(values) => { - context.enter()?; - let _ = checked_count(values.len())?; - let mut size = 1 + 2; - for value in values { - size = checked_add(size, value.encoded_len_with_context(context)?)?; - } - context.leave(); - size - } - Self::Container(entries) => { - ensure_unique_container_fields(entries)?; - context.enter()?; - let _ = checked_count(entries.len())?; - let mut size = 1 + 2; - for (_, value) in entries { - size = checked_add( - checked_add(size, 2)?, - value.encoded_len_with_context(context)?, - )?; - } - context.leave(); - size - } - #[cfg(feature = "crypto")] - Self::Signed(value) => { - context.enter()?; - let signature_len = - SigAlgorithm::length(value.algorithm).ok_or(CodecError::InvalidEncoding)?; - if value.signature.len() != signature_len { - return Err(CodecError::InvalidEncoding); - } - let inner_len = value.value.encoded_len_with_context(context)?; - context.leave(); - let wrapper_len = checked_add(1 + 1 + 8 + signature_len, inner_len)?; - checked_add(1, checked_add(4, wrapper_len)?)? - } - #[cfg(feature = "crypto")] - Self::Encrypted(value) => { - let envelope_len = encrypted_envelope_len(value)?; - checked_add(1, checked_add(4, envelope_len)?)? - } - }; - Ok(size) - } - - fn write_to_with_context( - &self, - out: &mut Vec, - context: &mut EncodeContext, - ) -> Result<(), CodecError> { - context.value()?; - append_bytes(out, context, &[Self::kind_marker(self)])?; match self { - Self::BoolTrue | Self::BoolFalse | Self::Bool(_) | Self::Null => {} - Self::SignedNumber(value) => append_bytes(out, context, &value.to_be_bytes())?, - Self::UnsignedNumber(value) => append_bytes(out, context, &value.to_be_bytes())?, - Self::Float(value) => append_bytes(out, context, &value.to_bits().to_be_bytes())?, - Self::Str(value) => write_blob_with_context(out, context, value.as_bytes())?, - Self::Bytes(value) => write_blob_with_context(out, context, value)?, - Self::Array(values) => { - context.enter()?; - append_bytes(out, context, &checked_count(values.len())?.to_be_bytes())?; - for value in values { - value.write_to_with_context(out, context)?; - } - context.leave(); + DataValue::Container(entries) => Self::encode_container(entries), + DataValue::Array(arr) => Self::encode_array(arr), + _ => { + let mut out = Vec::new(); + Self::write_value_payload(&mut out, self)?; + Ok(out) } - Self::Container(entries) => { - ensure_unique_container_fields(entries)?; - context.enter()?; - append_bytes(out, context, &checked_count(entries.len())?.to_be_bytes())?; - for (id, value) in entries { - append_bytes(out, context, &id.0.to_be_bytes())?; - value.write_to_with_context(out, context)?; - } - context.leave(); - } - #[cfg(feature = "crypto")] - Self::Signed(value) => { - context.enter()?; - let signature_len = - SigAlgorithm::length(value.algorithm).ok_or(CodecError::InvalidEncoding)?; - if value.signature.len() != signature_len { - return Err(CodecError::InvalidEncoding); - } - let inner_len = value - .value - .encoded_len_with_context(&mut EncodeContext::new(context.limits))?; - let wrapper_len = checked_add(1 + 1 + 8 + signature_len, inner_len)?; - let wrapper_len = - u32::try_from(wrapper_len).map_err(|_| CodecError::InvalidEncoding)?; - append_bytes(out, context, &wrapper_len.to_be_bytes())?; - append_bytes(out, context, &[value.algorithm, value.purpose])?; - append_bytes(out, context, &value.signer_id.to_be_bytes())?; - append_bytes(out, context, &value.signature)?; - value.value.write_to_with_context(out, context)?; - context.leave(); - } - #[cfg(feature = "crypto")] - Self::Encrypted(value) => write_encrypted_with_context(out, context, value)?, } - Ok(()) } pub fn from_bytes(bytes: &[u8]) -> Option { - Self::from_bytes_with_limits(bytes, DecodeLimits::default()) - } - - pub fn from_bytes_with_limits(bytes: &[u8], limits: DecodeLimits) -> Option { - Self::try_from_bytes_with_limits(bytes, limits).ok() - } - - pub fn try_from_bytes(bytes: &[u8]) -> Result { - Self::try_from_bytes_with_limits(bytes, DecodeLimits::default()) - } - - pub fn try_from_bytes_with_limits( - bytes: &[u8], - limits: DecodeLimits, - ) -> Result { let mut cursor = Cursor::new(bytes); - let value = Self::read_from_with_diagnostics(&mut cursor, limits)?; + let value = Self::read_value(&mut cursor, true)?; if cursor.position() as usize != bytes.len() { - return Err(DecodeError::MalformedEncoding); + return None; } - Ok(value) - } - - pub fn read_from(cursor: &mut Cursor<&[u8]>) -> Result { - Self::read_from_with_limits(cursor, DecodeLimits::default()) - } - - pub fn read_from_with_limits( - cursor: &mut Cursor<&[u8]>, - limits: DecodeLimits, - ) -> Result { - Self::read_from_with_diagnostics(cursor, limits).map_err(|_| CodecError::InvalidEncoding) - } - - pub fn read_from_with_diagnostics( - cursor: &mut Cursor<&[u8]>, - limits: DecodeLimits, - ) -> Result { - let mut context = DecodeContext::new(limits); - Self::read_value(cursor, &mut context) + Some(value) } pub fn to_base64(&self) -> Result { Ok(general_purpose::STANDARD.encode(self.to_bytes()?)) } - pub fn from_base64(value: &str) -> Option { - general_purpose::STANDARD - .decode(value) - .ok() - .and_then(|bytes| Self::from_bytes(&bytes)) + pub fn from_base64(base64_str: &str) -> Option { + let bytes = general_purpose::STANDARD.decode(base64_str).ok()?; + Self::from_bytes(&bytes) } - fn kind_marker(value: &Self) -> u8 { + fn encode_container(entries: &[(DataTypeId, DataValue)]) -> Result, CodecError> { + let mut out = Vec::new(); + let count = u16::try_from(entries.len()).map_err(|_| CodecError::TooManyEntries)?; + out.write_u16::(count) + .map_err(|_| CodecError::InvalidEncoding)?; + + for (key, value) in entries { + Self::write_container_entry(&mut out, *key, value)?; + } + Ok(out) + } + + fn write_container_entry( + buf: &mut Vec, + key: DataTypeId, + value: &DataValue, + ) -> Result<(), CodecError> { + let kind = Self::kind_marker(value); + buf.push(kind); + + if Self::kind_has_no_payload(kind) { + buf.write_u16::(key.0) + .map_err(|_| CodecError::InvalidEncoding)?; + return Ok(()); + } + + let mut payload = Vec::new(); + Self::write_value_payload(&mut payload, value)?; + + let len = u32::try_from(payload.len()).map_err(|_| CodecError::TooManyEntries)?; + buf.write_u32::(len) + .map_err(|_| CodecError::InvalidEncoding)?; + buf.write_u16::(key.0) + .map_err(|_| CodecError::InvalidEncoding)?; + buf.extend_from_slice(&payload); + Ok(()) + } + + fn encode_array(arr: &[DataValue]) -> Result, CodecError> { + let mut out = Vec::new(); + let count = u16::try_from(arr.len()).map_err(|_| CodecError::TooManyEntries)?; + out.write_u16::(count) + .map_err(|_| CodecError::InvalidEncoding)?; + + for value in arr { + Self::write_array_entry(&mut out, value)?; + } + + Ok(out) + } + + fn write_array_entry(buf: &mut Vec, value: &DataValue) -> Result<(), CodecError> { + let kind = Self::kind_marker(value); + buf.push(kind); + + if Self::kind_has_no_payload(kind) { + return Ok(()); + } + + let mut payload = Vec::new(); + Self::write_value_payload(&mut payload, value)?; + + let len = u32::try_from(payload.len()).map_err(|_| CodecError::TooManyEntries)?; + buf.write_u32::(len) + .map_err(|_| CodecError::InvalidEncoding)?; + buf.extend_from_slice(&payload); + Ok(()) + } + + fn write_value_payload(buf: &mut Vec, value: &DataValue) -> Result<(), CodecError> { match value { - Self::BoolTrue => Self::KIND_BOOL_TRUE, - Self::BoolFalse => Self::KIND_BOOL_FALSE, - Self::Bool(value) => { - if *value { + DataValue::BoolTrue => Ok(()), + DataValue::BoolFalse => Ok(()), + #[allow(clippy::if_same_then_else)] + DataValue::Bool(v) => { + // Kept intentionally: the kind marker already encodes the boolean, + // so both arms carry no payload. Retained for clear compatibility. + if *v { Ok(()) } else { Ok(()) } + } + DataValue::SignedNumber(n) => { + buf.write_i128::(*n) + .map_err(|_| CodecError::InvalidEncoding)?; + Ok(()) + } + DataValue::UnsignedNumber(n) => { + buf.write_u128::(*n) + .map_err(|_| CodecError::InvalidEncoding)?; + Ok(()) + } + DataValue::Float(a, b) => { + buf.write_u8(*a).map_err(|_| CodecError::InvalidEncoding)?; + buf.write_u32::(*b) + .map_err(|_| CodecError::InvalidEncoding)?; + Ok(()) + } + DataValue::Str(s) => { + buf.extend_from_slice(s.as_bytes()); + Ok(()) + } + DataValue::Array(arr) => { + let bytes = Self::encode_array(arr)?; + buf.extend_from_slice(&bytes); + Ok(()) + } + DataValue::Bytes(b) => { + buf.extend_from_slice(b); + Ok(()) + } + DataValue::Container(entries) => { + let bytes = Self::encode_container(entries)?; + buf.extend_from_slice(&bytes); + Ok(()) + } + #[cfg(feature = "crypto")] + DataValue::EncryptedContainer(data) => { + buf.extend_from_slice(data); + Ok(()) + } + #[cfg(feature = "crypto")] + DataValue::SignedContainer(data) => { + buf.extend_from_slice(data); + Ok(()) + } + #[cfg(feature = "crypto")] + DataValue::SignedEncryptedContainer(data) => { + buf.extend_from_slice(data); + Ok(()) + } + + DataValue::Null => Ok(()), + } + } + + fn read_value(cursor: &mut Cursor<&[u8]>, top_level: bool) -> Option { + if top_level { + let start = cursor.position() as usize; + let remaining = cursor.get_ref().len().checked_sub(start)?; + if remaining < 2 { + return None; + } + + let snapshot = cursor.clone(); + if let Some(container) = Self::try_read_container(cursor) { + return Some(container); + } + *cursor = snapshot; + + let array = Self::read_array(cursor)?; + return Some(array); + } + + let kind = cursor.read_u8().ok()?; + Self::read_value_by_kind(cursor, kind, None) + } + + fn try_read_container(cursor: &mut Cursor<&[u8]>) -> Option { + let count = cursor.read_u16::().ok()? as usize; + let remaining = cursor + .get_ref() + .len() + .saturating_sub(cursor.position() as usize); + let mut entries = Vec::with_capacity(count.min(remaining / Self::MIN_ENTRY_BYTES)); + + for _ in 0..count { + let kind = cursor.read_u8().ok()?; + + if Self::kind_has_no_payload(kind) { + let key = DataTypeId(cursor.read_u16::().ok()?); + let value = Self::read_payloadless_value(kind)?; + entries.push((key, value)); + continue; + } + + let len = cursor.read_u32::().ok()? as usize; + let key = DataTypeId(cursor.read_u16::().ok()?); + + let payload = Self::read_payload_slice(cursor, len)?; + let mut inner = Cursor::new(payload); + let value = Self::read_value_by_kind(&mut inner, kind, Some(len))?; + if inner.position() as usize != len { + return None; + } + entries.push((key, value)); + } + + Some(DataValue::Container(entries)) + } + + fn read_array(cursor: &mut Cursor<&[u8]>) -> Option { + let count = cursor.read_u16::().ok()? as usize; + let remaining = cursor + .get_ref() + .len() + .saturating_sub(cursor.position() as usize); + let mut out = Vec::with_capacity(count.min(remaining / Self::MIN_ENTRY_BYTES)); + + for _ in 0..count { + let kind = cursor.read_u8().ok()?; + + if Self::kind_has_no_payload(kind) { + let value = Self::read_payloadless_value(kind)?; + out.push(value); + continue; + } + + let len = cursor.read_u32::().ok()? as usize; + let payload = Self::read_payload_slice(cursor, len)?; + let mut inner = Cursor::new(payload); + let value = Self::read_value_by_kind(&mut inner, kind, Some(len))?; + if inner.position() as usize != len { + return None; + } + out.push(value); + } + + Some(DataValue::Array(out)) + } + + fn read_value_by_kind( + cursor: &mut Cursor<&[u8]>, + kind: u8, + payload_len: Option, + ) -> Option { + match kind { + Self::KIND_BOOL_TRUE => Some(DataValue::BoolTrue), + Self::KIND_BOOL_FALSE => Some(DataValue::BoolFalse), + Self::KIND_SIGNED_NUMBER => Some(DataValue::SignedNumber( + cursor.read_i128::().ok()?, + )), + Self::KIND_UNSIGNED_NUMBER => Some(DataValue::UnsignedNumber( + cursor.read_u128::().ok()?, + )), + Self::KIND_FLOAT => { + let a = cursor.read_u8().ok()?; + let b = cursor.read_u32::().ok()?; + Some(DataValue::Float(a, b)) + } + Self::KIND_STR => { + let s = std::str::from_utf8(Self::read_payload_slice(cursor, payload_len?)?) + .ok()? + .to_string(); + Some(DataValue::Str(s)) + } + Self::KIND_BYTES => Some(DataValue::Bytes(Self::read_blob_payload( + cursor, + payload_len?, + )?)), + Self::KIND_ARRAY => { + let len = payload_len?; + let mut inner = Cursor::new(Self::read_payload_slice(cursor, len)?); + let arr = Self::read_array(&mut inner)?; + if inner.position() as usize != len { + return None; + } + Some(arr) + } + Self::KIND_CONTAINER => { + let len = payload_len?; + let mut inner = Cursor::new(Self::read_payload_slice(cursor, len)?); + let c = Self::try_read_container(&mut inner)?; + if inner.position() as usize != len { + return None; + } + Some(c) + } + #[cfg(feature = "crypto")] + Self::KIND_ENCRYPTED_CONTAINER => Some(DataValue::EncryptedContainer( + Self::read_blob_payload(cursor, payload_len?)?, + )), + #[cfg(feature = "crypto")] + Self::KIND_SIGNED_CONTAINER => Some(DataValue::SignedContainer( + Self::read_blob_payload(cursor, payload_len?)?, + )), + #[cfg(feature = "crypto")] + Self::KIND_SIGNED_ENCRYPTED_CONTAINER => Some(DataValue::SignedEncryptedContainer( + Self::read_blob_payload(cursor, payload_len?)?, + )), + Self::KIND_NULL => Some(DataValue::Null), + #[cfg(not(feature = "crypto"))] + 0x0A | 0x0B | 0x0C => None, + _ => None, + } + } + + fn kind_marker(value: &DataValue) -> u8 { + match value { + DataValue::BoolTrue => Self::KIND_BOOL_TRUE, + DataValue::BoolFalse => Self::KIND_BOOL_FALSE, + DataValue::Bool(v) => { + if *v { Self::KIND_BOOL_TRUE } else { Self::KIND_BOOL_FALSE } } - Self::SignedNumber(_) => Self::KIND_SIGNED_NUMBER, - Self::UnsignedNumber(_) => Self::KIND_UNSIGNED_NUMBER, - Self::Float(_) => Self::KIND_FLOAT, - Self::Str(_) => Self::KIND_STR, - Self::Bytes(_) => Self::KIND_BYTES, - Self::Array(_) => Self::KIND_ARRAY, - Self::Container(_) => Self::KIND_CONTAINER, + DataValue::SignedNumber(_) => Self::KIND_SIGNED_NUMBER, + DataValue::UnsignedNumber(_) => Self::KIND_UNSIGNED_NUMBER, + DataValue::Float(_, _) => Self::KIND_FLOAT, + DataValue::Str(_) => Self::KIND_STR, + DataValue::Array(_) => Self::KIND_ARRAY, + DataValue::Bytes(_) => Self::KIND_BYTES, + DataValue::Container(_) => Self::KIND_CONTAINER, #[cfg(feature = "crypto")] - Self::Encrypted(_) => Self::KIND_ENCRYPTED, + DataValue::EncryptedContainer(_) => Self::KIND_ENCRYPTED_CONTAINER, #[cfg(feature = "crypto")] - Self::Signed(_) => Self::KIND_SIGNED, - Self::Null => Self::KIND_NULL, - } - } - - fn read_value( - cursor: &mut Cursor<&[u8]>, - context: &mut DecodeContext, - ) -> Result { - context.value()?; - match cursor - .read_u8() - .map_err(|_| DecodeFailure::MalformedEncoding)? - { - Self::KIND_BOOL_TRUE => Ok(Self::BoolTrue), - Self::KIND_BOOL_FALSE => Ok(Self::BoolFalse), - Self::KIND_SIGNED_NUMBER => Ok(Self::SignedNumber( - cursor - .read_i128::() - .map_err(|_| DecodeFailure::MalformedEncoding)?, - )), - Self::KIND_UNSIGNED_NUMBER => Ok(Self::UnsignedNumber( - cursor - .read_u128::() - .map_err(|_| DecodeFailure::MalformedEncoding)?, - )), - Self::KIND_FLOAT => Ok(Self::Float( - cursor - .read_f64::() - .map_err(|_| DecodeFailure::MalformedEncoding)?, - )), - Self::KIND_STR => { - let bytes = read_blob_owned(cursor, context)?; - Ok(Self::Str( - String::from_utf8(bytes).map_err(|_| DecodeFailure::MalformedEncoding)?, - )) - } - Self::KIND_BYTES => Ok(Self::Bytes(read_blob_owned(cursor, context)?)), - Self::KIND_ARRAY => { - context.enter()?; - let count = cursor - .read_u16::() - .map_err(|_| DecodeFailure::MalformedEncoding)? - as usize; - context.allocate( - count - .checked_mul(size_of::()) - .ok_or(DecodeFailure::AllocationLimit)?, - )?; - let mut values = Vec::with_capacity(count); - for _ in 0..count { - values.push(Self::read_value(cursor, context)?); - } - context.leave(); - Ok(Self::Array(values)) - } - Self::KIND_CONTAINER => { - context.enter()?; - let count = cursor - .read_u16::() - .map_err(|_| DecodeFailure::MalformedEncoding)? - as usize; - context.allocate( - count - .checked_mul(size_of::<(DataTypeId, DataValue)>()) - .ok_or(DecodeFailure::AllocationLimit)?, - )?; - let mut values = Vec::with_capacity(count); - /* DataTypeId is a u16, so a fixed bitset gives duplicate - detection a predictable allocation instead of hidden - per-node BTreeSet allocations. */ - let seen_words = (usize::from(u16::MAX) + 1) / 64; - context.allocate( - seen_words - .checked_mul(size_of::()) - .ok_or(DecodeFailure::AllocationLimit)?, - )?; - let mut seen = vec![0_u64; seen_words]; - for _ in 0..count { - let id = DataTypeId( - cursor - .read_u16::() - .map_err(|_| DecodeFailure::MalformedEncoding)?, - ); - let index = usize::from(id.0); - let word = index / 64; - let bit = 1_u64 << (index % 64); - if seen[word] & bit != 0 { - return Err(DecodeFailure::DuplicateField); - } - seen[word] |= bit; - values.push((id, Self::read_value(cursor, context)?)); - } - context.leave(); - Ok(Self::Container(values)) - } + DataValue::SignedContainer(_) => Self::KIND_SIGNED_CONTAINER, #[cfg(feature = "crypto")] - Self::KIND_SIGNED => { - context.enter()?; - let wrapper = read_blob_slice(cursor, context.limits.max_blob_size)?; - let mut inner = Cursor::new(wrapper); - let algorithm = inner - .read_u8() - .map_err(|_| DecodeFailure::MalformedEncoding)?; - let purpose = inner - .read_u8() - .map_err(|_| DecodeFailure::MalformedEncoding)?; - let signer_id = inner - .read_u64::() - .map_err(|_| DecodeFailure::MalformedEncoding)?; - let signature_len = - SigAlgorithm::length(algorithm).ok_or(DecodeFailure::MalformedEncoding)?; - context.allocate(signature_len)?; - let signature = read_slice(&mut inner, signature_len) - .ok_or(DecodeFailure::MalformedEncoding)? - .to_vec(); - let value = Self::read_value(&mut inner, context)?; - if inner.position() as usize != wrapper.len() { - return Err(DecodeFailure::MalformedEncoding); - } - context.allocate(size_of::())?; - context.leave(); - Ok(Self::Signed(SignedValue { - algorithm, - purpose, - signer_id, - signature, - value: Box::new(value), - })) - } - #[cfg(feature = "crypto")] - Self::KIND_ENCRYPTED => { - let envelope = read_blob_slice(cursor, context.limits.max_blob_size)?; - let message = mtp_crypto::MultiEncryptedMessageRef::from_bytes(envelope) - .map_err(|_| DecodeFailure::MalformedEncoding)?; - if message.recipient_count() > context.limits.max_recipients { - return Err(DecodeFailure::RecipientLimit); - } - let kem_len = message.encryption_type().kem_ciphertext_len(); - let wrapped_len = message.encryption_type().wrapped_key_len(); - let entry_size = size_of::() - .checked_add(kem_len) - .and_then(|size| size.checked_add(wrapped_len)) - .ok_or(DecodeFailure::AllocationLimit)?; - let owned_size = message - .recipient_count() - .checked_mul(entry_size) - .and_then(|size| size.checked_add(message.ciphertext().len())) - .ok_or(DecodeFailure::AllocationLimit)?; - context.allocate(owned_size)?; - let mut recipients = Vec::with_capacity(message.recipient_count()); - for index in 0..message.recipient_count() { - let (kem_ciphertext, encrypted_key) = message - .recipient(index) - .ok_or(DecodeFailure::MalformedEncoding)?; - recipients.push(mtp_crypto::RecipientEntry { - kem_ciphertext: kem_ciphertext.to_vec(), - encrypted_key: encrypted_key.to_vec(), - }); - } - Ok(Self::Encrypted(EncryptedValue { - encryption_type: message.encryption_type(), - purpose: message.purpose(), - recipients, - ciphertext: message.ciphertext().to_vec(), - })) - } - Self::KIND_NULL => Ok(Self::Null), - // 0x0C was the old SignedEncryptedContainer kind and is reserved. - _ => Err(DecodeFailure::MalformedEncoding), + DataValue::SignedEncryptedContainer(_) => Self::KIND_SIGNED_ENCRYPTED_CONTAINER, + DataValue::Null => Self::KIND_NULL, } } -} -#[cfg(feature = "crypto")] -impl SignedValue { - #[deprecated(note = "migrate to verify_with_policy with an explicit ProtectionPolicy")] - pub fn verify( - &self, - expected_signer_id: u64, - public_keys: &PublicKeyBundle, - expected_purpose: ProtectionPurpose, - ) -> Result<(), ProtectionError> { - self.verify_with_policy( - expected_signer_id, - public_keys, - expected_purpose, - ProtectionPolicy::any_supported(), - ) + fn kind_has_no_payload(kind: u8) -> bool { + kind == Self::KIND_BOOL_TRUE || kind == Self::KIND_BOOL_FALSE || kind == Self::KIND_NULL } - pub fn verify_with_policy( - &self, - expected_signer_id: u64, - public_keys: &PublicKeyBundle, - expected_purpose: ProtectionPurpose, - policy: ProtectionPolicy, - ) -> Result<(), ProtectionError> { - self.verify_with_policy_and_limits( - expected_signer_id, - public_keys, - expected_purpose, - policy, - EncodeLimits::default(), - ) - } - - /// Verify a signed value while bounding the serialization used to - /// reconstruct its authenticated bytes. - pub fn verify_with_policy_and_limits( - &self, - expected_signer_id: u64, - public_keys: &PublicKeyBundle, - expected_purpose: ProtectionPurpose, - policy: ProtectionPolicy, - limits: EncodeLimits, - ) -> Result<(), ProtectionError> { - if self.signer_id != expected_signer_id { - return Err(ProtectionError::SignerIdMismatch { - expected: expected_signer_id, - actual: self.signer_id, - }); + fn read_payloadless_value(kind: u8) -> Option { + match kind { + Self::KIND_BOOL_TRUE => Some(DataValue::BoolTrue), + Self::KIND_BOOL_FALSE => Some(DataValue::BoolFalse), + Self::KIND_NULL => Some(DataValue::Null), + _ => None, } - if self.purpose != expected_purpose.0 { - return Err(ProtectionError::PurposeMismatch { - expected: expected_purpose.0, - actual: self.purpose, - }); + } + + fn read_payload_slice<'a>(cursor: &mut Cursor<&'a [u8]>, len: usize) -> Option<&'a [u8]> { + let start = cursor.position() as usize; + let end = start.checked_add(len)?; + if end > cursor.get_ref().len() { + return None; } - if !policy.signature.accepts(self.algorithm) { - return Err(ProtectionError::SignaturePolicyMismatch { - expected: policy.signature, - actual: self.algorithm, - }); - } - validate_signature(self.algorithm, &self.signature)?; - let inner = self.value.to_bytes_with_limits(limits).map_err(|error| { - if matches!(error, CodecError::TooManyEntries) { - ProtectionError::ResourceLimit("signed value encoding") - } else { - ProtectionError::Codec(error) - } - })?; - let message = signed_message(self.algorithm, self.purpose, self.signer_id, &inner); - let result = match self.algorithm { - SigAlgorithm::ED25519 => mtp_crypto::verify_ed25519( - &public_keys.sig_cl_public_key, - &message, - &self.signature, - ), - SigAlgorithm::ML_DSA_65 => { - mtp_crypto::verify_ml_dsa(&public_keys.sig_pq_public_key, &message, &self.signature) - } - SigAlgorithm::DUAL => { - let ed_len = SigAlgorithm::length(SigAlgorithm::ED25519).unwrap(); - if self.signature.len() - != ed_len + SigAlgorithm::length(SigAlgorithm::ML_DSA_65).unwrap() - { - return Err(ProtectionError::Malformed); - } - mtp_crypto::verify_ed25519( - &public_keys.sig_cl_public_key, - &message, - &self.signature[..ed_len], - ) - .and_then(|_| { - mtp_crypto::verify_ml_dsa( - &public_keys.sig_pq_public_key, - &message, - &self.signature[ed_len..], - ) - }) - } - _ => return Err(ProtectionError::Malformed), - }; - result.map_err(|_| ProtectionError::InvalidSignature) + cursor.set_position(end as u64); + Some(&cursor.get_ref()[start..end]) } - #[deprecated(note = "migrate to into_verified_with_policy with an explicit ProtectionPolicy")] - /// Verify this signed wrapper and return its inner value. - pub fn into_verified( - self, - expected_signer_id: u64, - public_keys: &PublicKeyBundle, - expected_purpose: ProtectionPurpose, - ) -> Result { - self.into_verified_with_policy( - expected_signer_id, - public_keys, - expected_purpose, - ProtectionPolicy::any_supported(), - ) + fn read_blob_payload(cursor: &mut Cursor<&[u8]>, len: usize) -> Option> { + Some(Self::read_payload_slice(cursor, len)?.to_vec()) } - - /// Verify this signed wrapper with an explicit receiver policy and return - /// its inner value. - pub fn into_verified_with_policy( - self, - expected_signer_id: u64, - public_keys: &PublicKeyBundle, - expected_purpose: ProtectionPurpose, - policy: ProtectionPolicy, - ) -> Result { - self.verify_with_policy(expected_signer_id, public_keys, expected_purpose, policy)?; - Ok(*self.value) - } - - #[deprecated(note = "migrate to verify_with_resolver_policy with an explicit ProtectionPolicy")] - pub fn verify_with( - &self, - resolve: F, - expected_purpose: ProtectionPurpose, - ) -> Result<(), ProtectionError> - where - F: FnOnce(u64) -> Option, - { - self.verify_with_resolver_policy( - resolve, - expected_purpose, - ProtectionPolicy::any_supported(), - ) - } - - pub fn verify_with_resolver_policy( - &self, - resolve: F, - expected_purpose: ProtectionPurpose, - policy: ProtectionPolicy, - ) -> Result<(), ProtectionError> - where - F: FnOnce(u64) -> Option, - { - let public_keys = - resolve(self.signer_id).ok_or(ProtectionError::SignerKeyNotFound(self.signer_id))?; - self.verify_with_policy(self.signer_id, &public_keys, expected_purpose, policy) - } - - /// Verify against a local signing-key history without exposing a key - /// identifier in the signed wire value. The first trusted key that - /// verifies is accepted. - pub fn verify_with_key_history( - &self, - expected_signer_id: u64, - public_keys: &[PublicKeyBundle], - expected_purpose: ProtectionPurpose, - policy: ProtectionPolicy, - ) -> Result<(), ProtectionError> { - self.verify_with_key_history_index_and_limits( - expected_signer_id, - public_keys, - expected_purpose, - policy, - EncodeLimits::default(), - ) - .map(|_| ()) - } - - /// Verify against a signing-key history with a bounded authenticated-byte - /// reconstruction policy. - pub fn verify_with_key_history_and_limits( - &self, - expected_signer_id: u64, - public_keys: &[PublicKeyBundle], - expected_purpose: ProtectionPurpose, - policy: ProtectionPolicy, - limits: EncodeLimits, - ) -> Result<(), ProtectionError> { - self.verify_with_key_history_index_and_limits( - expected_signer_id, - public_keys, - expected_purpose, - policy, - limits, - ) - .map(|_| ()) - } - - /// Verify against a local signing-key history and return the index of the - /// trusted key that authenticated the value. - pub fn verify_with_key_history_index( - &self, - expected_signer_id: u64, - public_keys: &[PublicKeyBundle], - expected_purpose: ProtectionPurpose, - policy: ProtectionPolicy, - ) -> Result { - self.verify_with_key_history_index_and_limits( - expected_signer_id, - public_keys, - expected_purpose, - policy, - EncodeLimits::default(), - ) - } - - /// Verify against a signing-key history and bound every authenticated - /// value serialization attempt. - pub fn verify_with_key_history_index_and_limits( - &self, - expected_signer_id: u64, - public_keys: &[PublicKeyBundle], - expected_purpose: ProtectionPurpose, - policy: ProtectionPolicy, - limits: EncodeLimits, - ) -> Result { - let mut last_error = None; - for (index, public_key) in public_keys.iter().enumerate() { - match self.verify_with_policy_and_limits( - expected_signer_id, - public_key, - expected_purpose, - policy, - limits, - ) { - Ok(()) => return Ok(index), - Err(error @ ProtectionError::InvalidSignature) => last_error = Some(error), - Err(error @ ProtectionError::Crypto(_)) => last_error = Some(error), - Err(error) => return Err(error), - } - } - Err(last_error.unwrap_or(ProtectionError::SignerKeyNotFound(expected_signer_id))) - } -} - -#[cfg(feature = "crypto")] -fn signed_message(algorithm: u8, purpose: u8, signer_id: u64, inner: &[u8]) -> Vec { - let mut message = Vec::with_capacity(SIGN_DOMAIN.len() + 10 + inner.len()); - message.extend_from_slice(SIGN_DOMAIN); - message.push(algorithm); - message.push(purpose); - message.extend_from_slice(&signer_id.to_be_bytes()); - message.extend_from_slice(inner); - message -} - -#[cfg(feature = "crypto")] -fn validate_signature(algorithm: u8, signature: &[u8]) -> Result<(), ProtectionError> { - let expected = SigAlgorithm::length(algorithm).ok_or(ProtectionError::Malformed)?; - if signature.len() == expected { - Ok(()) - } else { - Err(ProtectionError::Malformed) - } -} - -#[cfg(feature = "crypto")] -fn protection_error_from_decryption(error: mtp_crypto::CryptoError) -> ProtectionError { - match error { - mtp_crypto::CryptoError::MalformedEnvelope => ProtectionError::Malformed, - mtp_crypto::CryptoError::NoMatchingRecipient => ProtectionError::NoMatchingRecipient, - mtp_crypto::CryptoError::AllocationLimit => { - ProtectionError::ResourceLimit("decrypted plaintext") - } - other => ProtectionError::Crypto(other), - } -} - -fn checked_count(count: usize) -> Result { - u16::try_from(count).map_err(|_| CodecError::TooManyEntries) -} - -fn checked_add(left: usize, right: usize) -> Result { - left.checked_add(right).ok_or(CodecError::TooManyEntries) -} - -fn blob_len(bytes: &[u8]) -> Result { - let _ = u32::try_from(bytes.len()).map_err(|_| CodecError::InvalidEncoding)?; - checked_add(4, bytes.len()) -} - -fn append_bytes( - out: &mut Vec, - context: &EncodeContext, - bytes: &[u8], -) -> Result<(), CodecError> { - context.check_output(out.len(), bytes.len())?; - out.extend_from_slice(bytes); - Ok(()) -} - -fn ensure_unique_container_fields(entries: &[(DataTypeId, DataValue)]) -> Result<(), CodecError> { - let mut seen = BTreeSet::new(); - if entries.iter().all(|(id, _)| seen.insert(*id)) { - Ok(()) - } else { - Err(CodecError::InvalidEncoding) - } -} - -fn write_blob_with_context( - out: &mut Vec, - context: &EncodeContext, - bytes: &[u8], -) -> Result<(), CodecError> { - let len = u32::try_from(bytes.len()).map_err(|_| CodecError::InvalidEncoding)?; - append_bytes(out, context, &len.to_be_bytes())?; - append_bytes(out, context, bytes) -} - -#[cfg(feature = "crypto")] -fn encrypted_envelope_len(value: &EncryptedValue) -> Result { - let kem_len = value.encryption_type.kem_ciphertext_len(); - let wrapped_len = value.encryption_type.wrapped_key_len(); - if value.recipients.is_empty() - || value.recipients.len() > mtp_crypto::MAX_RECIPIENTS - || value.ciphertext.len() < value.encryption_type.minimum_ciphertext_len() - || value.recipients.iter().any(|recipient| { - recipient.kem_ciphertext.len() != kem_len - || recipient.encrypted_key.len() != wrapped_len - }) - { - return Err(CodecError::InvalidEncoding); - } - let _ = checked_count(value.recipients.len())?; - let entry_len = kem_len - .checked_add(wrapped_len) - .ok_or(CodecError::TooManyEntries)?; - let entries_len = value - .recipients - .len() - .checked_mul(entry_len) - .ok_or(CodecError::TooManyEntries)?; - checked_add(4, checked_add(entries_len, value.ciphertext.len())?) -} - -#[cfg(feature = "crypto")] -fn write_encrypted_with_context( - out: &mut Vec, - context: &EncodeContext, - value: &EncryptedValue, -) -> Result<(), CodecError> { - let envelope_len = encrypted_envelope_len(value)?; - let envelope_len = u32::try_from(envelope_len).map_err(|_| CodecError::InvalidEncoding)?; - append_bytes(out, context, &envelope_len.to_be_bytes())?; - append_bytes( - out, - context, - &[value.encryption_type.to_byte(), value.purpose], - )?; - let count = checked_count(value.recipients.len())?; - append_bytes(out, context, &count.to_be_bytes())?; - for recipient in &value.recipients { - append_bytes(out, context, &recipient.kem_ciphertext)?; - append_bytes(out, context, &recipient.encrypted_key)?; - } - append_bytes(out, context, &value.ciphertext) -} - -fn read_blob_slice<'a>( - cursor: &mut Cursor<&'a [u8]>, - max_size: usize, -) -> Result<&'a [u8], DecodeFailure> { - let len = cursor - .read_u32::() - .map_err(|_| DecodeFailure::MalformedEncoding)? as usize; - if len > max_size { - return Err(DecodeFailure::BlobLimit); - } - read_slice(cursor, len).ok_or(DecodeFailure::MalformedEncoding) -} - -fn read_blob_owned( - cursor: &mut Cursor<&[u8]>, - context: &mut DecodeContext, -) -> Result, DecodeFailure> { - let bytes = read_blob_slice(cursor, context.limits.max_blob_size)?; - context.allocate(bytes.len())?; - Ok(bytes.to_vec()) -} - -fn read_slice<'a>(cursor: &mut Cursor<&'a [u8]>, len: usize) -> Option<&'a [u8]> { - let start = cursor.position() as usize; - let end = start.checked_add(len)?; - if end > cursor.get_ref().len() { - return None; - } - cursor.set_position(end as u64); - Some(&cursor.get_ref()[start..end]) } impl fmt::Display for DataValue { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::BoolTrue => f.write_str("true"), - Self::BoolFalse => f.write_str("false"), - Self::Bool(value) => write!(f, "{value}"), - Self::SignedNumber(value) => write!(f, "{value}"), - Self::UnsignedNumber(value) => write!(f, "{value}"), - Self::Float(value) => write!(f, "{value}"), - Self::Str(value) => write!(f, "\"{value}\""), - Self::Bytes(_) => f.write_str("(Binary)"), - Self::Array(values) => { - f.write_str("[")?; - for (index, value) in values.iter().enumerate() { - if index > 0 { - f.write_str(", ")?; + DataValue::BoolTrue => write!(f, "true"), + DataValue::BoolFalse => write!(f, "false"), + DataValue::Bool(v) => write!(f, "{}", v), + DataValue::SignedNumber(n) => write!(f, "{}", n), + DataValue::UnsignedNumber(n) => write!(f, "{}", n), + DataValue::Float(exp, mant) => write!(f, "{}e{}", mant, exp), + DataValue::Str(s) => write!(f, "\"{}\"", s), + DataValue::Container(entries) => { + write!(f, "{{")?; + for (i, (key, value)) in entries.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; } - write!(f, "{value}")?; + write!(f, "{}: {}", key.0, value)?; } - f.write_str("]") + write!(f, "}}") } - Self::Container(entries) => { - f.write_str("{")?; - for (index, (id, value)) in entries.iter().enumerate() { - if index > 0 { - f.write_str(", ")?; + DataValue::Array(arr) => { + write!(f, "[")?; + for (i, value) in arr.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; } - write!(f, "{}: {value}", id.0)?; + write!(f, "{}", value)?; } - f.write_str("}") + write!(f, "]") } + DataValue::Bytes(_) => write!(f, "(Binary)"), #[cfg(feature = "crypto")] - Self::Encrypted(_) => f.write_str("(Encrypted)"), + DataValue::EncryptedContainer(_) => write!(f, "(Secure)"), #[cfg(feature = "crypto")] - Self::Signed(_) => f.write_str("(Signed)"), - Self::Null => f.write_str("null"), + DataValue::SignedContainer(_) => write!(f, "(Signed)"), + #[cfg(feature = "crypto")] + DataValue::SignedEncryptedContainer(_) => write!(f, "(SignedSecure)"), + DataValue::Null => write!(f, "null"), } } } @@ -1856,76 +991,115 @@ impl PartialEq for DataValue { fn eq(&self, other: &Self) -> bool { use DataValue::*; match (self, other) { - (BoolTrue, BoolTrue) - | (BoolFalse, BoolFalse) - | (BoolTrue, Bool(true)) - | (Bool(true), BoolTrue) - | (BoolFalse, Bool(false)) - | (Bool(false), BoolFalse) => true, + (BoolTrue, BoolTrue) | (BoolFalse, BoolFalse) => true, + (BoolTrue, Bool(true)) | (Bool(true), BoolTrue) => true, + (BoolFalse, Bool(false)) | (Bool(false), BoolFalse) => true, (Bool(a), Bool(b)) => a == b, (SignedNumber(a), SignedNumber(b)) => a == b, (UnsignedNumber(a), UnsignedNumber(b)) => a == b, - (Float(a), Float(b)) => a.to_bits() == b.to_bits(), + (Float(a, b), Float(c, d)) => a == c && b == d, (Str(a), Str(b)) => a == b, - (Bytes(a), Bytes(b)) => a == b, (Array(a), Array(b)) => a == b, + (Bytes(a), Bytes(b)) => a == b, (Container(a), Container(b)) => a == b, #[cfg(feature = "crypto")] - (Encrypted(a), Encrypted(b)) => a == b, + (EncryptedContainer(a), EncryptedContainer(b)) => a == b, #[cfg(feature = "crypto")] - (Signed(a), Signed(b)) => a == b, + (SignedContainer(a), SignedContainer(b)) => a == b, + #[cfg(feature = "crypto")] + (SignedEncryptedContainer(a), SignedEncryptedContainer(b)) => a == b, (Null, Null) => true, _ => false, } } } -impl Eq for DataValue {} - impl Hash for DataValue { fn hash(&self, state: &mut H) { + use DataValue::*; + // Use the wire kind marker as the per-variant discriminant. It is unique + // per kind and maps BoolTrue/Bool(true) (and BoolFalse/Bool(false)) to the + // same marker, keeping the hash consistent with the Eq bool equivalence. Self::kind_marker(self).hash(state); match self { - Self::BoolTrue | Self::BoolFalse | Self::Bool(_) | Self::Null => {} - Self::SignedNumber(value) => value.hash(state), - Self::UnsignedNumber(value) => value.hash(state), - Self::Float(value) => value.to_bits().hash(state), - Self::Str(value) => value.hash(state), - Self::Bytes(value) => value.hash(state), - Self::Array(value) => value.hash(state), - Self::Container(value) => value.hash(state), + BoolTrue | BoolFalse | Bool(_) | Null => {} + SignedNumber(n) => n.hash(state), + UnsignedNumber(n) => n.hash(state), + Float(n, m) => { + n.hash(state); + m.hash(state); + } + Str(s) => s.hash(state), + Array(a) => a.hash(state), + Bytes(a) => a.hash(state), + Container(c) => c.hash(state), #[cfg(feature = "crypto")] - Self::Encrypted(value) => value.to_bytes().hash(state), + EncryptedContainer(c) => c.hash(state), #[cfg(feature = "crypto")] - Self::Signed(value) => value.to_bytes().hash(state), + SignedContainer(c) => c.hash(state), + #[cfg(feature = "crypto")] + SignedEncryptedContainer(c) => c.hash(state), } } } -#[cfg(feature = "crypto")] -impl EncryptedValue { - fn to_bytes(&self) -> Vec { - mtp_crypto::MultiEncryptedMessage { - encryption_type: self.encryption_type, - purpose: self.purpose, - recipients: self.recipients.clone(), - ciphertext: self.ciphertext.clone(), +/* ================================ FROM / TRY-FROM ================================ */ + +impl From for DataValue { + fn from(v: bool) -> Self { + if v { + DataValue::BoolTrue + } else { + DataValue::BoolFalse } - .to_bytes() - .unwrap_or_default() } } -#[cfg(feature = "crypto")] -impl SignedValue { - fn to_bytes(&self) -> Vec { - let mut out = Vec::new(); - out.push(self.algorithm); - out.push(self.purpose); - out.extend_from_slice(&self.signer_id.to_be_bytes()); - out.extend_from_slice(&self.signature); - let _ = self.value.write_to(&mut out); - out +impl From<&str> for DataValue { + fn from(s: &str) -> Self { + DataValue::Str(s.to_string()) + } +} + +impl From for DataValue { + fn from(s: String) -> Self { + DataValue::Str(s) + } +} + +impl From for DataValue { + fn from(n: i64) -> Self { + DataValue::SignedNumber(n as i128) + } +} + +impl From for DataValue { + fn from(n: i128) -> Self { + DataValue::SignedNumber(n) + } +} + +impl From for DataValue { + fn from(n: u64) -> Self { + DataValue::UnsignedNumber(n as u128) + } +} + +impl From for DataValue { + fn from(n: u128) -> Self { + DataValue::UnsignedNumber(n) + } +} + +impl From> for DataValue { + fn from(b: Vec) -> Self { + DataValue::Bytes(b) + } +} + +impl From<&[u8]> for DataValue { + fn from(b: &[u8]) -> Self { + DataValue::Bytes(b.to_vec()) } } @@ -1943,79 +1117,21 @@ impl fmt::Display for DataValueTypeMismatch { impl std::error::Error for DataValueTypeMismatch {} -impl From for DataValue { - fn from(value: bool) -> Self { - if value { - Self::BoolTrue - } else { - Self::BoolFalse - } - } -} - -impl From<&str> for DataValue { - fn from(value: &str) -> Self { - Self::Str(value.to_owned()) - } -} - -impl From for DataValue { - fn from(value: String) -> Self { - Self::Str(value) - } -} - -impl From for DataValue { - fn from(value: i64) -> Self { - Self::SignedNumber(value as i128) - } -} - -impl From for DataValue { - fn from(value: i128) -> Self { - Self::SignedNumber(value) - } -} - -impl From for DataValue { - fn from(value: u64) -> Self { - Self::UnsignedNumber(value as u128) - } -} - -impl From for DataValue { - fn from(value: u128) -> Self { - Self::UnsignedNumber(value) - } -} - -impl From> for DataValue { - fn from(value: Vec) -> Self { - Self::Bytes(value) - } -} - -impl From<&[u8]> for DataValue { - fn from(value: &[u8]) -> Self { - Self::Bytes(value.to_vec()) - } -} - impl TryFrom for bool { type Error = DataValueTypeMismatch; - fn try_from(value: DataValue) -> Result { - value.as_bool().ok_or(DataValueTypeMismatch { + fn try_from(v: DataValue) -> Result { + v.as_bool().ok_or(DataValueTypeMismatch { expected: "Bool", - got: value.type_name(), + got: v.type_name(), }) } } impl TryFrom for String { type Error = DataValueTypeMismatch; - fn try_from(value: DataValue) -> Result { - match value { - DataValue::Str(value) => Ok(value), + fn try_from(v: DataValue) -> Result { + match v { + DataValue::Str(s) => Ok(s), other => Err(DataValueTypeMismatch { expected: "Str", got: other.type_name(), @@ -2026,51 +1142,51 @@ impl TryFrom for String { impl TryFrom for i128 { type Error = DataValueTypeMismatch; - fn try_from(value: DataValue) -> Result { - value.as_signed_number().ok_or(DataValueTypeMismatch { + fn try_from(v: DataValue) -> Result { + v.as_signed_number().ok_or(DataValueTypeMismatch { expected: "SignedNumber", - got: value.type_name(), + got: v.type_name(), }) } } impl TryFrom for i64 { type Error = DataValueTypeMismatch; - fn try_from(value: DataValue) -> Result { - let value = i128::try_from(value)?; - i64::try_from(value).map_err(|_| DataValueTypeMismatch { - expected: "i64", - got: "SignedNumber", - }) + fn try_from(v: DataValue) -> Result { + let n = v.as_signed_number().ok_or(DataValueTypeMismatch { + expected: "SignedNumber", + got: v.type_name(), + })?; + Ok(n as i64) } } impl TryFrom for u128 { type Error = DataValueTypeMismatch; - fn try_from(value: DataValue) -> Result { - value.as_unsigned_number().ok_or(DataValueTypeMismatch { + fn try_from(v: DataValue) -> Result { + v.as_unsigned_number().ok_or(DataValueTypeMismatch { expected: "UnsignedNumber", - got: value.type_name(), + got: v.type_name(), }) } } impl TryFrom for u64 { type Error = DataValueTypeMismatch; - fn try_from(value: DataValue) -> Result { - let value = u128::try_from(value)?; - u64::try_from(value).map_err(|_| DataValueTypeMismatch { - expected: "u64", - got: "UnsignedNumber", - }) + fn try_from(v: DataValue) -> Result { + let n = v.as_unsigned_number().ok_or(DataValueTypeMismatch { + expected: "UnsignedNumber", + got: v.type_name(), + })?; + Ok(n as u64) } } impl TryFrom for Vec { type Error = DataValueTypeMismatch; - fn try_from(value: DataValue) -> Result { - match value { - DataValue::Bytes(value) => Ok(value), + fn try_from(v: DataValue) -> Result { + match v { + DataValue::Bytes(b) => Ok(b), other => Err(DataValueTypeMismatch { expected: "Bytes", got: other.type_name(), @@ -2079,914 +1195,590 @@ impl TryFrom for Vec { } } +/* ================================ TESTS ================================ */ #[cfg(test)] mod tests { use super::*; + fn container_roundtrip(values: Vec<(DataTypeId, DataValue)>) { + let dv = DataValue::Container(values.clone()); + let bytes = dv.to_bytes().expect("encode failed"); + let decoded = DataValue::from_bytes(&bytes).expect("roundtrip failed"); + assert_eq!(dv, decoded, "container roundtrip mismatch"); + } + + fn array_roundtrip(values: Vec) { + let dv = DataValue::Array(values.clone()); + let bytes = dv.to_bytes().expect("encode failed"); + let decoded = DataValue::from_bytes(&bytes).expect("roundtrip failed"); + assert_eq!(dv, decoded, "array roundtrip mismatch"); + } + #[test] - fn canonical_data_value_vectors() { - let vectors = [ - (DataValue::BoolTrue, vec![0x01]), - (DataValue::BoolFalse, vec![0x02]), + fn test_bool_in_container() { + let tm = TypeMap::latest(); + container_roundtrip(vec![ + (DataType::Id.to_id(&tm), DataValue::BoolTrue), + (DataType::ClientNonce.to_id(&tm), DataValue::BoolFalse), + ]); + } + + #[test] + fn test_bool_true_eq() { + assert_eq!(DataValue::BoolTrue, DataValue::Bool(true)); + assert_eq!(DataValue::BoolFalse, DataValue::Bool(false)); + assert_ne!(DataValue::BoolTrue, DataValue::Bool(false)); + } + + #[test] + fn test_bool_as_bool() { + assert_eq!(DataValue::BoolTrue.as_bool(), Some(true)); + assert_eq!(DataValue::BoolFalse.as_bool(), Some(false)); + assert_eq!(DataValue::Bool(true).as_bool(), Some(true)); + assert_eq!(DataValue::Null.as_bool(), None); + } + + #[test] + fn test_signed_number_in_container() { + let tm = TypeMap::latest(); + container_roundtrip(vec![ + (DataType::Version.to_id(&tm), DataValue::SignedNumber(0)), + (DataType::Id.to_id(&tm), DataValue::SignedNumber(42)), ( - DataValue::Str("Hello".into()), - vec![0x06, 0, 0, 0, 5, b'H', b'e', b'l', b'l', b'o'], - ), - (DataValue::Bytes(vec![1, 2]), vec![0x07, 0, 0, 0, 2, 1, 2]), - ( - DataValue::Array(vec![ - DataValue::BoolTrue, - DataValue::Str("A".into()), - DataValue::Bytes(vec![0xFF]), - ]), - vec![ - 0x08, 0, 3, // array kind and value count - 0x01, // true - 0x06, 0, 0, 0, 1, b'A', // string - 0x07, 0, 0, 0, 1, 0xFF, // bytes - ], + DataType::ClientNonce.to_id(&tm), + DataValue::SignedNumber(-42), ), ( - DataValue::Container(vec![ - (DataTypeId(9), DataValue::Str("Hello".into())), - (DataTypeId(10), DataValue::BoolTrue), - ]), - vec![ - 0x09, 0, 2, // container kind and entry count - 0, 9, // field ID - 0x06, 0, 0, 0, 5, b'H', b'e', b'l', b'l', b'o', // string - 0, 10, // field ID - 0x01, // true has no payload or entry length - ], + DataType::ServerNonce.to_id(&tm), + DataValue::SignedNumber(i128::MAX), ), - ]; - - for (value, expected) in vectors { - assert_eq!(value.to_bytes().unwrap(), expected); - assert_eq!(DataValue::from_bytes(&expected), Some(value)); - } + ( + DataType::PublicKeys.to_id(&tm), + DataValue::SignedNumber(i128::MIN), + ), + ]); } #[test] - fn decode_limits_bound_recursive_values_and_blobs() { - let nested = DataValue::Array(vec![DataValue::Array(vec![DataValue::BoolTrue])]); - let bytes = nested.to_bytes().expect("nested value should encode"); - let mut limits = DecodeLimits { - max_depth: 1, - ..DecodeLimits::default() - }; - assert!(DataValue::from_bytes_with_limits(&bytes, limits).is_none()); - - let many = DataValue::Array(vec![DataValue::BoolTrue, DataValue::BoolFalse]); - let bytes = many.to_bytes().expect("array should encode"); - limits = DecodeLimits::default(); - limits.max_values = 2; - assert!(DataValue::from_bytes_with_limits(&bytes, limits).is_none()); - - let blob = DataValue::Bytes(vec![1, 2, 3]); - let bytes = blob.to_bytes().expect("blob should encode"); - limits = DecodeLimits::default(); - limits.max_blob_size = 2; - assert!(DataValue::from_bytes_with_limits(&bytes, limits).is_none()); + fn test_unsigned_number_in_container() { + let tm = TypeMap::latest(); + container_roundtrip(vec![ + (DataType::Version.to_id(&tm), DataValue::UnsignedNumber(0)), + (DataType::Id.to_id(&tm), DataValue::UnsignedNumber(42)), + ( + DataType::ClientNonce.to_id(&tm), + DataValue::UnsignedNumber(u128::MAX), + ), + ]); } #[test] - fn decoder_diagnostics_distinguish_policy_rejections() { - let nested = DataValue::Array(vec![DataValue::Array(vec![DataValue::BoolTrue])]); - let nested_bytes = nested.to_bytes().expect("nested value should encode"); - let mut limits = DecodeLimits { - max_depth: 1, - ..DecodeLimits::default() - }; - let mut cursor = Cursor::new(nested_bytes.as_slice()); - assert_eq!( - DataValue::read_from_with_diagnostics(&mut cursor, limits), - Err(DecodeFailure::DepthLimit) - ); - - let many = DataValue::Array(vec![DataValue::BoolTrue, DataValue::BoolFalse]); - let many_bytes = many.to_bytes().expect("array should encode"); - limits = DecodeLimits { - max_values: 2, - ..DecodeLimits::default() - }; - let mut cursor = Cursor::new(many_bytes.as_slice()); - assert_eq!( - DataValue::read_from_with_diagnostics(&mut cursor, limits), - Err(DecodeFailure::ValueCountLimit) - ); - - let allocation_blob = DataValue::Bytes(vec![1, 2, 3]); - let allocation_blob_bytes = allocation_blob.to_bytes().expect("blob should encode"); - limits = DecodeLimits { - max_blob_size: 2, - ..DecodeLimits::default() - }; - let mut cursor = Cursor::new(allocation_blob_bytes.as_slice()); - assert_eq!( - DataValue::read_from_with_diagnostics(&mut cursor, limits), - Err(DecodeFailure::BlobLimit) - ); - - let duplicate = [0x09, 0, 2, 0, 1, 0x01, 0, 1, 0x02]; - let mut cursor = Cursor::new(duplicate.as_slice()); - assert_eq!( - DataValue::read_from_with_diagnostics(&mut cursor, DecodeLimits::default()), - Err(DecodeFailure::DuplicateField) - ); - - let mut cursor = Cursor::new([0xFE].as_slice()); - assert_eq!( - DataValue::read_from_with_diagnostics(&mut cursor, DecodeLimits::default()), - Err(DecodeFailure::MalformedEncoding) - ); - - let blob = DataValue::Bytes(vec![1, 2, 3]); - let blob_bytes = blob.to_bytes().expect("blob should encode"); - let limits = DecodeLimits { - max_allocated_bytes: 2, - ..DecodeLimits::default() - }; - let mut cursor = Cursor::new(blob_bytes.as_slice()); - assert_eq!( - DataValue::read_from_with_diagnostics(&mut cursor, limits), - Err(DecodeFailure::AllocationLimit) - ); - } - - #[cfg(feature = "crypto")] - #[test] - fn nested_signed_values_hit_cumulative_allocation_limit() - -> Result<(), Box> { - use mtp_crypto::Ed25519Signer; - - let (signer, _, _) = Ed25519Signer::generate(); - let mut value = DataValue::Null; - for _ in 0..3 { - value = value.sign(7, ProtectionPurpose::from(1), &signer)?; - } - let bytes = value.to_bytes()?; - let limits = DecodeLimits { - max_allocated_bytes: 100, - ..DecodeLimits::default() - }; - let mut cursor = Cursor::new(bytes.as_slice()); - - assert_eq!( - DataValue::read_from_with_diagnostics(&mut cursor, limits), - Err(DecodeFailure::AllocationLimit) - ); - Ok(()) + fn test_float_in_container() { + let tm = TypeMap::latest(); + container_roundtrip(vec![ + (DataType::Version.to_id(&tm), DataValue::Float(0, 0)), + (DataType::Id.to_id(&tm), DataValue::Float(2, 12345)), + ( + DataType::ClientNonce.to_id(&tm), + DataValue::Float(255, 4294967295), + ), + ]); } #[test] - fn encoder_limits_bound_nodes_depth_and_output() { - let value = DataValue::Array(vec![DataValue::BoolTrue]); + fn test_str_in_container() { + let tm = TypeMap::latest(); + container_roundtrip(vec![ + (DataType::Version.to_id(&tm), DataValue::Str(String::new())), + (DataType::Id.to_id(&tm), DataValue::Str("hello".to_string())), + ( + DataType::ClientNonce.to_id(&tm), + DataValue::Str("a".repeat(1000)), + ), + ]); + } - let limits = EncodeLimits { - max_values: 1, - ..EncodeLimits::default() - }; + #[test] + fn test_bytes_in_container() { + let tm = TypeMap::latest(); + container_roundtrip(vec![ + (DataType::Version.to_id(&tm), DataValue::Bytes(vec![])), + ( + DataType::Id.to_id(&tm), + DataValue::Bytes(vec![0x00, 0xFF, 0xAB]), + ), + ( + DataType::ClientNonce.to_id(&tm), + DataValue::Bytes(vec![0x42; 100]), + ), + ]); + } + + #[test] + fn test_null_in_container() { + let tm = TypeMap::latest(); + container_roundtrip(vec![(DataType::Version.to_id(&tm), DataValue::Null)]); + } + + #[test] + fn test_array_non_empty_roundtrip() { + array_roundtrip(vec![ + DataValue::BoolTrue, + DataValue::SignedNumber(42), + DataValue::Str("hello".to_string()), + DataValue::Null, + ]); + } + + #[test] + fn test_array_nested_roundtrip() { + array_roundtrip(vec![ + DataValue::Array(vec![DataValue::BoolTrue, DataValue::BoolFalse]), + DataValue::Array(vec![DataValue::SignedNumber(1), DataValue::SignedNumber(2)]), + ]); + } + + #[test] + fn test_container_empty_roundtrip() { + container_roundtrip(vec![]); + } + + #[test] + fn test_container_mixed_roundtrip() { + let tm = TypeMap::latest(); + container_roundtrip(vec![ + (DataType::Version.to_id(&tm), DataValue::BoolTrue), + (DataType::Id.to_id(&tm), DataValue::SignedNumber(-100)), + ( + DataType::ClientNonce.to_id(&tm), + DataValue::Str("test".to_string()), + ), + ( + DataType::ServerNonce.to_id(&tm), + DataValue::UnsignedNumber(u128::MAX), + ), + (DataType::PublicKeys.to_id(&tm), DataValue::Null), + ]); + } + + #[test] + fn test_container_nested_roundtrip() { + let tm = TypeMap::latest(); + container_roundtrip(vec![ + ( + DataType::Version.to_id(&tm), + DataValue::Container(vec![(DataType::Error.to_id(&tm), DataValue::BoolTrue)]), + ), + ( + DataType::Id.to_id(&tm), + DataValue::Array(vec![DataValue::SignedNumber(1), DataValue::SignedNumber(2)]), + ), + ]); + } + + #[test] + fn test_container_base64_roundtrip() { + let tm = TypeMap::latest(); + let dv = DataValue::Container(vec![( + DataType::Description.to_id(&tm), + DataValue::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF]), + )]); + let b64 = dv.to_base64().expect("encode failed"); + let decoded = DataValue::from_base64(&b64).expect("base64 roundtrip failed"); + assert_eq!(dv, decoded); + } + + #[test] + fn test_kind_classification() { + assert_eq!(DataValue::BoolTrue.kind(), DataKind::Bool); + assert_eq!(DataValue::Bool(false).kind(), DataKind::Bool); + assert_eq!(DataValue::SignedNumber(0).kind(), DataKind::SignedNumber); assert_eq!( - value.to_bytes_with_limits(limits), - Err(CodecError::TooManyEntries) + DataValue::UnsignedNumber(0).kind(), + DataKind::UnsignedNumber ); - - let limits = EncodeLimits { - max_depth: 0, - ..EncodeLimits::default() - }; + assert_eq!(DataValue::Float(0, 0).kind(), DataKind::Float); + assert_eq!(DataValue::Str(String::new()).kind(), DataKind::Str); + assert_eq!(DataValue::Bytes(vec![]).kind(), DataKind::Bytes); assert_eq!( - value.to_bytes_with_limits(limits), - Err(CodecError::TooManyEntries) + DataValue::Array(vec![]).kind(), + DataKind::Array(Box::new(DataKind::Null)) ); + assert_eq!(DataValue::Container(vec![]).kind(), DataKind::Container); + assert_eq!(DataValue::Null.kind(), DataKind::Null); + } - let limits = EncodeLimits { - max_output_size: 1, - ..EncodeLimits::default() - }; + #[test] + fn test_as_accessors() { + let tm = TypeMap::latest(); + let dv = DataValue::Container(vec![ + ( + DataType::Version.to_id(&tm), + DataValue::Str("alice".to_string()), + ), + (DataType::Id.to_id(&tm), DataValue::SignedNumber(42)), + ( + DataType::ClientNonce.to_id(&tm), + DataValue::Bytes(vec![0x01, 0x02]), + ), + ( + DataType::ServerNonce.to_id(&tm), + DataValue::Array(vec![DataValue::BoolTrue]), + ), + ]); + + let map = dv.as_map().expect("should be a container"); assert_eq!( - value.to_bytes_with_limits(limits), - Err(CodecError::TooManyEntries) + map.get(&DataType::Version.to_id(&tm)) + .and_then(|v| v.as_str()), + Some("alice") + ); + assert_eq!( + map.get(&DataType::Id.to_id(&tm)) + .and_then(|v| v.as_signed_number()), + Some(42) + ); + assert_eq!( + map.get(&DataType::ClientNonce.to_id(&tm)) + .and_then(|v| v.as_bytes()), + Some(vec![0x01, 0x02]) + ); + assert_eq!( + map.get(&DataType::ServerNonce.to_id(&tm)) + .and_then(|v| v.as_array()), + Some(vec![DataValue::BoolTrue]) ); } #[test] - fn transport_decode_limits_follow_admitted_frame_size() { - let limits = DecodeLimits::for_transport_message_size(1024); - assert_eq!(limits.max_blob_size, 1020); - assert_eq!( - limits.max_allocated_bytes, - 1024 * DEFAULT_TRANSPORT_ALLOCATION_FACTOR as usize - ); - assert_eq!(limits.max_depth, DecodeLimits::default().max_depth); - assert_eq!( - limits.max_recipients, - DecodeLimits::default().max_recipients - ); - - let custom = DecodeLimits::for_transport_message_size_with_allocation_factor(1024, 2); - assert_eq!(custom.max_allocated_bytes, 2048); + fn test_as_string() { + let dv = DataValue::Str("hello".to_string()); + assert_eq!(dv.as_string(), Some("hello".to_string())); + assert_eq!(dv.as_str(), Some("hello")); + assert_eq!(DataValue::Null.as_string(), None); } #[test] - fn decoder_allocation_budget_counts_utf8_bytes_and_capacity() { - let value = DataValue::Str("é".into()); - let bytes = value.to_bytes().expect("string should encode"); - - let mut limits = DecodeLimits { - max_allocated_bytes: 1, - ..DecodeLimits::default() - }; - let mut cursor = Cursor::new(bytes.as_slice()); - assert_eq!( - DataValue::read_from_with_diagnostics(&mut cursor, limits), - Err(DecodeFailure::AllocationLimit) - ); - - limits.max_allocated_bytes = "é".len(); - assert_eq!( - DataValue::try_from_bytes_with_limits(&bytes, limits), - Ok(value) - ); - - let array = DataValue::Array(vec![DataValue::BoolTrue]); - let array_bytes = array.to_bytes().expect("array should encode"); - limits.max_allocated_bytes = size_of::() - 1; - let mut cursor = Cursor::new(array_bytes.as_slice()); - assert_eq!( - DataValue::read_from_with_diagnostics(&mut cursor, limits), - Err(DecodeFailure::AllocationLimit) - ); + fn test_as_float() { + assert_eq!(DataValue::Float(3, 14).as_float(), Some((3, 14))); + assert_eq!(DataValue::Null.as_float(), None); } #[test] - fn integer_conversions_reject_narrowing_overflow() { - assert!(i64::try_from(DataValue::SignedNumber(i64::MAX as i128 + 1)).is_err()); - assert!(i64::try_from(DataValue::SignedNumber(i64::MIN as i128 - 1)).is_err()); - assert!(u64::try_from(DataValue::UnsignedNumber(u64::MAX as u128 + 1)).is_err()); + fn test_container_from_map() { + let tm = TypeMap::latest(); + let mut map = BTreeMap::new(); + map.insert(DataType::Version.to_id(&tm), DataValue::BoolTrue); + map.insert(DataType::Id.to_id(&tm), DataValue::SignedNumber(99)); + let dv = DataValue::container_from_map(&map); + let container = dv.as_container().expect("should be container"); + assert_eq!(container.len(), 2); } #[test] - fn duplicate_container_fields_are_rejected() { - let bytes = [0x09, 0, 2, 0, 1, 0x01, 0, 1, 0x02]; + fn test_invalid_short_input() { + assert!(DataValue::from_bytes(&[]).is_none()); + assert!(DataValue::from_bytes(&[0x01]).is_none()); + } + + #[test] + fn test_invalid_kind_rejected() { + let bytes = vec![0x00, 0x01, 0x0D, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x41]; assert!(DataValue::from_bytes(&bytes).is_none()); - - let value = DataValue::Container(vec![ - (DataTypeId(1), DataValue::BoolTrue), - (DataTypeId(1), DataValue::BoolFalse), - ]); - assert_eq!(value.to_bytes(), Err(CodecError::InvalidEncoding)); } #[test] - fn read_from_stops_at_each_self_delimiting_value() { - let bytes = [0x01, 0x03, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 42]; - let mut cursor = Cursor::new(bytes.as_slice()); - - assert_eq!( - DataValue::read_from(&mut cursor).unwrap(), - DataValue::BoolTrue - ); - assert_eq!( - DataValue::read_from(&mut cursor).unwrap(), - DataValue::SignedNumber(42) - ); - assert_eq!(cursor.position() as usize, bytes.len()); + fn test_truncated_container_rejected() { + let tm = TypeMap::latest(); + let dv = DataValue::Container(vec![( + DataType::Version.to_id(&tm), + DataValue::Str("hello".to_string()), + )]); + let bytes = dv.to_bytes().expect("encode failed"); + // Truncate to fewer than 2 bytes so neither container nor array can be read + assert!(DataValue::from_bytes(&bytes[..1]).is_none()); + assert!(DataValue::from_bytes(&bytes[..0]).is_none()); } #[test] - fn removed_signed_encrypted_container_kind_is_rejected() { - assert_eq!(DataValue::from_bytes(&[0x0C]), None); + fn test_oversized_count_does_not_overallocate() { + // A frame declaring 65535 entries but carrying almost no payload must be + // rejected without pre-reserving a Vec for 65535 entries. The capacity is + // capped against remaining bytes, so these decode attempts allocate at + // most a handful of slots before failing. + // Container path: count = 0xFFFF, no entries follow. + assert!(DataValue::from_bytes(&[0xFF, 0xFF]).is_none()); + // Container path with one stray byte after the count. + assert!(DataValue::from_bytes(&[0xFF, 0xFF, 0x01]).is_none()); + // Array path: force the container parse to fail first, then the array + // parse also sees the oversized count. A leading kind byte that is not a + // valid container entry makes try_read_container bail to the array path. + assert!(DataValue::from_bytes(&[0xFF, 0xFF, 0x08, 0xFF, 0xFF]).is_none()); } - #[cfg(feature = "crypto")] #[test] - fn signed_values_have_canonical_layout_and_verify() -> Result<(), Box> { - use mtp_crypto::{Ed25519Signer, Keyring, SigAlgorithm}; + fn test_display_basic() { + assert_eq!(format!("{}", DataValue::BoolTrue), "true"); + assert_eq!(format!("{}", DataValue::BoolFalse), "false"); + assert_eq!(format!("{}", DataValue::Null), "null"); + assert_eq!(format!("{}", DataValue::SignedNumber(42)), "42"); + assert_eq!(format!("{}", DataValue::UnsignedNumber(42)), "42"); + assert_eq!(format!("{}", DataValue::Str("hi".to_string())), "\"hi\""); + assert_eq!(format!("{}", DataValue::Bytes(vec![])), "(Binary)"); + } - let (signer, _, signer_public) = Ed25519Signer::generate(); - let mut public_keys = Keyring::generate().public_key_bundle(); - public_keys.sig_cl_public_key = signer_public; + #[test] + fn test_hash_consistency() { + use std::collections::HashSet; + let mut set = HashSet::new(); + set.insert(DataValue::BoolTrue); + set.insert(DataValue::BoolFalse); + set.insert(DataValue::Null); + set.insert(DataValue::SignedNumber(1)); + set.insert(DataValue::UnsignedNumber(1)); + assert_eq!(set.len(), 5); + set.insert(DataValue::Bool(true)); + assert_eq!(set.len(), 5); + } - let original = DataValue::Container(vec![ - (DataTypeId(20), DataValue::BoolTrue), - (DataTypeId(21), DataValue::Str("signed".into())), - ]); - let inner = original.to_bytes()?; - let signed = original.clone().sign( - 0x0102_0304_0506_0708, - ProtectionPurpose::from(0xA5), - &signer, - )?; - let encoded = signed.to_bytes()?; + #[test] + fn test_float_display() { + let s = format!("{}", DataValue::Float(2, 12345)); + assert_eq!(s, "12345e2"); + } - let wrapper_len = u32::from_be_bytes(encoded[1..5].try_into()?) as usize; - let signature_len = SigAlgorithm::length(SigAlgorithm::ED25519).unwrap(); - assert_eq!(encoded[0], DataValue::KIND_SIGNED); - assert_eq!(wrapper_len, encoded.len() - 5); - assert_eq!(encoded[5], SigAlgorithm::ED25519); - assert_eq!(encoded[6], 0xA5); - assert_eq!(&encoded[7..15], &0x0102_0304_0506_0708u64.to_be_bytes()); - assert_eq!(&encoded[15 + signature_len..], inner); - - let decoded = DataValue::from_bytes(&encoded).ok_or("signed value did not decode")?; - decoded.verify_with_policy( - 0x0102_0304_0506_0708, - &public_keys, - ProtectionPurpose::from(0xA5), - ProtectionPolicy::any_supported(), - )?; - assert!(matches!( - decoded.clone().into_verified_with_policy( - 0x0102_0304_0506_0708, - &public_keys, - ProtectionPurpose::from(0xA5), - ProtectionPolicy::from(SignaturePolicy::Dual), + #[test] + fn test_container_display() { + let tm = TypeMap::latest(); + let dv = DataValue::Container(vec![ + ( + DataType::ServerNonce.to_id(&tm), + DataValue::Str("v2.0".to_string()), ), - Err(ProtectionError::SignaturePolicyMismatch { .. }) - )); - // Verification is non-consuming, so it can safely be repeated. - decoded.verify_with_policy( - 0x0102_0304_0506_0708, - &public_keys, - ProtectionPurpose::from(0xA5), - ProtectionPolicy::any_supported(), - )?; - assert_eq!( - decoded.clone().into_verified_with_policy( - 0x0102_0304_0506_0708, - &public_keys, - ProtectionPurpose::from(0xA5), - ProtectionPolicy::any_supported(), - )?, - original - ); + ( + DataType::PqSignature.to_id(&tm), + DataValue::UnsignedNumber(42), + ), + ]); + let s = format!("{}", dv); + assert!(s.contains("3:")); + assert!(s.contains("6:")); + } - let DataValue::Signed(wrapper) = decoded else { - return Err("expected signed value".into()); - }; + #[test] + fn test_from_primitives() { + assert_eq!(DataValue::from(true), DataValue::BoolTrue); + assert_eq!(DataValue::from(false), DataValue::BoolFalse); assert_eq!( - wrapper.into_verified_with_policy( - 0x0102_0304_0506_0708, - &public_keys, - ProtectionPurpose::from(0xA5), - ProtectionPolicy::any_supported(), - )?, - original + DataValue::from("hello"), + DataValue::Str("hello".to_string()) ); - Ok(()) + assert_eq!( + DataValue::from("hello".to_string()), + DataValue::Str("hello".to_string()) + ); + assert_eq!(DataValue::from(42i64), DataValue::SignedNumber(42)); + assert_eq!(DataValue::from(42i128), DataValue::SignedNumber(42)); + assert_eq!(DataValue::from(42u64), DataValue::UnsignedNumber(42)); + assert_eq!(DataValue::from(42u128), DataValue::UnsignedNumber(42)); + assert_eq!( + DataValue::from(vec![1u8, 2, 3]), + DataValue::Bytes(vec![1, 2, 3]) + ); + assert_eq!( + DataValue::from([1u8, 2, 3].as_ref()), + DataValue::Bytes(vec![1, 2, 3]) + ); + } + + #[test] + fn test_try_from_ok() { + assert!(bool::try_from(DataValue::BoolTrue).unwrap()); + assert!(!bool::try_from(DataValue::BoolFalse).unwrap()); + assert_eq!( + String::try_from(DataValue::Str("hi".to_string())).unwrap(), + "hi" + ); + assert_eq!(i128::try_from(DataValue::SignedNumber(-1)).unwrap(), -1i128); + assert_eq!(i64::try_from(DataValue::SignedNumber(10)).unwrap(), 10i64); + assert_eq!( + u128::try_from(DataValue::UnsignedNumber(99)).unwrap(), + 99u128 + ); + assert_eq!(u64::try_from(DataValue::UnsignedNumber(7)).unwrap(), 7u64); + assert_eq!( + Vec::::try_from(DataValue::Bytes(vec![0xAB])).unwrap(), + vec![0xABu8] + ); + } + + #[test] + fn test_try_from_err() { + assert!(bool::try_from(DataValue::Null).is_err()); + assert!(String::try_from(DataValue::SignedNumber(1)).is_err()); + assert!(i128::try_from(DataValue::BoolTrue).is_err()); + assert!(u128::try_from(DataValue::Str("x".to_string())).is_err()); + assert!(Vec::::try_from(DataValue::Null).is_err()); + } + + #[test] + fn test_array_display() { + let dv = DataValue::Array(vec![DataValue::SignedNumber(1), DataValue::SignedNumber(2)]); + let s = format!("{}", dv); + assert_eq!(s, "[1, 2]"); + } + + /* ===== Crypto container tests ===== */ + + #[cfg(feature = "crypto")] + #[test] + fn test_encrypt_decrypt_container_roundtrip() { + use mtp_crypto::{EncryptionType, Keyring}; + let tm = TypeMap::latest(); + let keyring = Keyring::generate(); + let bundle = keyring.public_key_bundle(); + + let mut dv = DataValue::Container(vec![ + ( + DataType::Version.to_id(&tm), + DataValue::Str("secret".to_string()), + ), + (DataType::Id.to_id(&tm), DataValue::UnsignedNumber(42)), + ]); + + assert!( + dv.encrypt_container(EncryptionType::MlKemChaCha20Poly1305, &bundle, b"aad") + .is_some() + ); + assert!(matches!(dv, DataValue::EncryptedContainer(_))); + + assert!(dv.decrypt_into_container(&keyring, b"aad").is_some()); + assert!(matches!(dv, DataValue::Container(_))); + + let entries = dv.as_container().unwrap(); + assert_eq!(entries.len(), 2); } #[cfg(feature = "crypto")] #[test] - fn reordered_containers_have_distinct_signed_bytes() -> Result<(), Box> { - use mtp_crypto::Ed25519Signer; + fn test_encrypt_container_wrong_key_fails() { + use mtp_crypto::{EncryptionType, Keyring}; + let tm = TypeMap::latest(); + let keyring_a = Keyring::generate(); + let keyring_b = Keyring::generate(); + + let mut dv = DataValue::Container(vec![( + DataType::Version.to_id(&tm), + DataValue::Str("secret".to_string()), + )]); + + assert!( + dv.encrypt_container( + EncryptionType::MlKemChaCha20Poly1305, + &keyring_a.public_key_bundle(), + b"aad" + ) + .is_some() + ); + assert!(dv.decrypt_into_container(&keyring_b, b"aad").is_none()); + } + + #[cfg(feature = "crypto")] + #[test] + fn test_encrypt_container_wrong_aad_fails() { + use mtp_crypto::{EncryptionType, Keyring}; + let tm = TypeMap::latest(); + let keyring = Keyring::generate(); + + let mut dv = DataValue::Container(vec![( + DataType::Version.to_id(&tm), + DataValue::Str("secret".to_string()), + )]); + + assert!( + dv.encrypt_container( + EncryptionType::MlKemChaCha20Poly1305, + &keyring.public_key_bundle(), + b"correct-aad" + ) + .is_some() + ); + assert!(dv.decrypt_into_container(&keyring, b"wrong-aad").is_none()); + } + + #[cfg(feature = "crypto")] + #[test] + fn test_encrypt_non_container_fails() { + use mtp_crypto::{EncryptionType, Keyring}; + let keyring = Keyring::generate(); + + let mut dv = DataValue::Str("not a container".to_string()); + assert!( + dv.encrypt_container( + EncryptionType::MlKemChaCha20Poly1305, + &keyring.public_key_bundle(), + b"aad" + ) + .is_none() + ); + } + + #[cfg(feature = "crypto")] + #[test] + fn test_sign_verify_container_roundtrip() { + use mtp_crypto::{Ed25519Signer, EncryptionType, Keyring, SigAlgorithm}; + let tm = TypeMap::latest(); + + let keyring = Keyring::generate(); + let (signer, sk, _pk) = Ed25519Signer::generate(); + + let mut dv = DataValue::Container(vec![( + DataType::Version.to_id(&tm), + DataValue::Str("signed data".to_string()), + )]); + + assert!( + dv.sign_and_encrypt_container( + SigAlgorithm::ED25519, + &signer, + EncryptionType::MlKemChaCha20Poly1305, + &keyring.public_key_bundle(), + b"aad" + ) + .is_some() + ); + assert!(matches!(dv, DataValue::SignedEncryptedContainer(_))); + + assert!( + dv.decrypt_signed_encrypted_container(&keyring, b"aad") + .is_some() + ); + assert!(matches!(dv, DataValue::SignedContainer(_))); + + let verifier = Ed25519Signer::new(&sk).unwrap(); + assert!(dv.verify_into_container(&verifier).is_some()); + assert!(matches!(dv, DataValue::Container(_))); + + let entries = dv.as_container().unwrap(); + assert_eq!(entries.len(), 1); + } + + #[cfg(feature = "crypto")] + #[test] + fn test_sign_container_wrong_key_fails() { + use mtp_crypto::{Ed25519Signer, SigAlgorithm}; + let tm = TypeMap::latest(); let (signer, _, _) = Ed25519Signer::generate(); - let first = DataValue::Container(vec![ - (DataTypeId(1), DataValue::BoolTrue), - (DataTypeId(2), DataValue::BoolFalse), - ]) - .sign(1, ProtectionPurpose::from(7), &signer)?; - let second = DataValue::Container(vec![ - (DataTypeId(2), DataValue::BoolFalse), - (DataTypeId(1), DataValue::BoolTrue), - ]) - .sign(1, ProtectionPurpose::from(7), &signer)?; + let (_, sk2, _) = Ed25519Signer::generate(); + let wrong_verifier = Ed25519Signer::new(&sk2).unwrap(); - assert_ne!(first.to_bytes()?, second.to_bytes()?); - Ok(()) - } + let mut dv = DataValue::Container(vec![( + DataType::Version.to_id(&tm), + DataValue::Str("signed data".to_string()), + )]); - #[cfg(feature = "crypto")] - #[test] - fn encrypted_decode_limits_bound_owned_entries() -> Result<(), Box> { - use mtp_crypto::Keyring; - - let keyring = Keyring::generate(); - let encrypted = DataValue::Bytes(vec![0xAB; 32]).encrypt_for( - std::slice::from_ref(&keyring.public_key_bundle()), - ProtectionPurpose::from(9), - )?; - let bytes = encrypted.to_bytes()?; - let mut limits = DecodeLimits { - max_allocated_bytes: 1, - ..DecodeLimits::default() - }; - let mut cursor = Cursor::new(bytes.as_slice()); - assert_eq!( - DataValue::read_from_with_diagnostics(&mut cursor, limits), - Err(DecodeFailure::AllocationLimit) - ); - - limits.max_allocated_bytes = usize::MAX; - limits.max_recipients = 0; - let mut cursor = Cursor::new(bytes.as_slice()); - assert_eq!( - DataValue::read_from_with_diagnostics(&mut cursor, limits), - Err(DecodeFailure::RecipientLimit) - ); - Ok(()) - } - - #[cfg(feature = "crypto")] - #[test] - fn signed_value_authenticates_its_metadata_and_inner_value() - -> Result<(), Box> { - use mtp_crypto::{Ed25519Signer, Keyring}; - - let (signer, _, signer_public) = Ed25519Signer::generate(); - let mut public_keys = Keyring::generate().public_key_bundle(); - public_keys.sig_cl_public_key = signer_public; - let signed = - DataValue::Str("original".into()).sign(41, ProtectionPurpose::from(7), &signer)?; - - let DataValue::Signed(mut wrong_purpose) = signed.clone() else { - return Err("expected signed value".into()); - }; - wrong_purpose.purpose ^= 1; - assert!(matches!( - wrong_purpose.verify_with_policy( - 41, - &public_keys, - ProtectionPurpose::from(7), - ProtectionPolicy::any_supported(), - ), - Err(ProtectionError::PurposeMismatch { .. }) - )); - - let DataValue::Signed(mut wrong_signer_id) = signed.clone() else { - return Err("expected signed value".into()); - }; - wrong_signer_id.signer_id ^= 1; - assert!(matches!( - wrong_signer_id.verify_with_policy( - 41, - &public_keys, - ProtectionPurpose::from(7), - ProtectionPolicy::any_supported(), - ), - Err(ProtectionError::SignerIdMismatch { .. }) - )); - - let DataValue::Signed(mut wrong_signature) = signed.clone() else { - return Err("expected signed value".into()); - }; - wrong_signature.signature[0] ^= 1; - assert!(matches!( - wrong_signature.verify_with_policy( - 41, - &public_keys, - ProtectionPurpose::from(7), - ProtectionPolicy::any_supported(), - ), - Err(ProtectionError::InvalidSignature) - )); - - let DataValue::Signed(mut wrong_value) = signed else { - return Err("expected signed value".into()); - }; - *wrong_value.value = DataValue::Str("replacement".into()); - assert!(matches!( - wrong_value.verify_with_policy( - 41, - &public_keys, - ProtectionPurpose::from(7), - ProtectionPolicy::any_supported(), - ), - Err(ProtectionError::InvalidSignature) - )); - Ok(()) - } - - #[cfg(feature = "crypto")] - #[test] - fn signing_rejects_an_unknown_algorithm_or_wrong_signature_size() { - use mtp_crypto::{CryptoError, SigAlgorithm, SignatureScheme}; - - struct InvalidSigner(u8); - - impl SignatureScheme for InvalidSigner { - fn algorithm(&self) -> u8 { - self.0 - } - - fn sign(&self, _: &[u8]) -> Result, CryptoError> { - Ok(vec![0; 63]) - } - - fn verify(&self, _: &[u8], _: &[u8]) -> Result<(), CryptoError> { - Ok(()) - } - } - - assert!(matches!( - DataValue::Null.sign( - 1, - ProtectionPurpose::from(1), - &InvalidSigner(SigAlgorithm::ED25519) - ), - Err(ProtectionError::Malformed) - )); - assert!(matches!( - DataValue::Null.sign(1, ProtectionPurpose::from(1), &InvalidSigner(0xFE)), - Err(ProtectionError::Malformed) - )); - } - - #[cfg(feature = "crypto")] - #[test] - fn signed_then_encrypted_composition_roundtrips() -> Result<(), Box> { - use mtp_crypto::{Ed25519Signer, Keyring}; - - let (signer, _, signer_public) = Ed25519Signer::generate(); - let keyring = Keyring::generate(); - let value = DataValue::Container(vec![(DataTypeId(32), DataValue::Str("secret".into()))]); - let protected = value - .clone() - .sign(7, ProtectionPurpose::from(1), &signer)? - .encrypt_for( - std::slice::from_ref(&keyring.public_key_bundle()), - ProtectionPurpose::from(2), - )?; - - let encoded = protected.to_bytes()?; - assert_eq!(encoded[0], DataValue::KIND_ENCRYPTED); - let decoded = DataValue::from_bytes(&encoded).ok_or("protected value did not decode")?; - let opened = decoded.decrypt(&keyring, ProtectionPurpose::from(2))?; - let mut public_keys = keyring.public_key_bundle(); - public_keys.sig_cl_public_key = signer_public; - opened.verify_with_policy( - 7, - &public_keys, - ProtectionPurpose::from(1), - ProtectionPolicy::any_supported(), - )?; - assert_eq!( - opened.into_verified_with_policy( - 7, - &public_keys, - ProtectionPurpose::from(1), - ProtectionPolicy::any_supported(), - )?, - value - ); - Ok(()) - } - - #[cfg(feature = "crypto")] - #[test] - fn encrypted_then_signed_composition_roundtrips_and_exposes_signer() - -> Result<(), Box> { - use mtp_crypto::{Ed25519Signer, Keyring}; - - let (signer, _, signer_public) = Ed25519Signer::generate(); - let keyring = Keyring::generate(); - let value = DataValue::Container(vec![(DataTypeId(32), DataValue::Str("secret".into()))]); - let protected = value - .clone() - .encrypt_for( - std::slice::from_ref(&keyring.public_key_bundle()), - ProtectionPurpose::from(2), - )? - .sign(7, ProtectionPurpose::from(1), &signer)?; - - let encoded = protected.to_bytes()?; - assert_eq!(encoded[0], DataValue::KIND_SIGNED); - let decoded = DataValue::from_bytes(&encoded).ok_or("protected value did not decode")?; - let mut public_keys = keyring.public_key_bundle(); - public_keys.sig_cl_public_key = signer_public; - - // The signer metadata is available before opening the encrypted value. - let DataValue::Signed(signed) = decoded else { - return Err("expected signed outer wrapper".into()); - }; - assert_eq!(signed.signer_id, 7); - signed.verify_with_policy( - 7, - &public_keys, - ProtectionPurpose::from(1), - ProtectionPolicy::any_supported(), - )?; - let encrypted = signed.into_verified_with_policy( - 7, - &public_keys, - ProtectionPurpose::from(1), - ProtectionPolicy::any_supported(), - )?; - assert!(matches!(encrypted, DataValue::Encrypted(_))); - assert_eq!( - encrypted.decrypt(&keyring, ProtectionPurpose::from(2))?, - value - ); - Ok(()) - } - - #[cfg(feature = "crypto")] - #[test] - fn deeply_nested_protection_composition_roundtrips() -> Result<(), Box> { - use mtp_crypto::{Ed25519Signer, Keyring}; - - const OUTER_SIGNER_ID: u64 = 0x0102_0304_0506_0708; - const INNER_SIGNER_ID: u64 = 0x1112_1314_1516_1718; - - let (signer, _, signer_public) = Ed25519Signer::generate(); - let outer_recipient = Keyring::generate(); - let inner_recipient = Keyring::generate(); - let leaf = - DataValue::Container(vec![(DataTypeId(60), DataValue::Str("deep secret".into()))]); - let nested = leaf - .clone() - .sign(INNER_SIGNER_ID, ProtectionPurpose::from(3), &signer)? - .encrypt_for( - std::slice::from_ref(&inner_recipient.public_key_bundle()), - ProtectionPurpose::from(4), - )?; - let middle = DataValue::Container(vec![(DataTypeId(50), nested)]); - let protected = middle - .clone() - .sign(OUTER_SIGNER_ID, ProtectionPurpose::from(1), &signer)? - .encrypt_for( - std::slice::from_ref(&outer_recipient.public_key_bundle()), - ProtectionPurpose::from(2), - )?; - - let encoded = protected.to_bytes()?; - let decoded = DataValue::from_bytes(&encoded).ok_or("nested value did not decode")?; - assert!(matches!(decoded, DataValue::Encrypted(_))); - - let outer_signed = decoded.decrypt(&outer_recipient, ProtectionPurpose::from(2))?; - let DataValue::Signed(outer_wrapper) = &outer_signed else { - return Err("expected signed value inside outer encryption".into()); - }; - assert_eq!(outer_wrapper.signer_id, OUTER_SIGNER_ID); - - let mut signer_keys = outer_recipient.public_key_bundle(); - signer_keys.sig_cl_public_key = signer_public; - let middle = outer_signed.into_verified_with_policy( - OUTER_SIGNER_ID, - &signer_keys, - ProtectionPurpose::from(1), - ProtectionPolicy::any_supported(), - )?; - let DataValue::Container(entries) = middle else { - return Err("expected container inside outer signature".into()); - }; - let nested = entries - .into_iter() - .find_map(|(id, value)| (id == DataTypeId(50)).then_some(value)) - .ok_or("nested field missing")?; - assert!(matches!(nested, DataValue::Encrypted(_))); - - let inner_signed = nested.decrypt(&inner_recipient, ProtectionPurpose::from(4))?; - let DataValue::Signed(inner_wrapper) = &inner_signed else { - return Err("expected signed value inside nested encryption".into()); - }; - assert_eq!(inner_wrapper.signer_id, INNER_SIGNER_ID); - assert_eq!( - inner_signed.into_verified_with_policy( - INNER_SIGNER_ID, - &signer_keys, - ProtectionPurpose::from(3), - ProtectionPolicy::any_supported(), - )?, - leaf - ); - Ok(()) - } - - #[cfg(feature = "crypto")] - #[test] - fn encrypted_authenticated_purpose_cannot_be_changed() -> Result<(), Box> - { - use mtp_crypto::Keyring; - - let keyring = Keyring::generate(); - let value = DataValue::Bytes(vec![1, 2, 3]).encrypt_for( - std::slice::from_ref(&keyring.public_key_bundle()), - ProtectionPurpose::from(9), - )?; - let DataValue::Encrypted(mut encrypted) = value else { - return Err("expected encrypted value".into()); - }; - encrypted.purpose ^= 1; - assert!( - DataValue::Encrypted(encrypted) - .decrypt(&keyring, ProtectionPurpose::from(9)) - .is_err() - ); - Ok(()) - } - - #[cfg(feature = "crypto")] - #[test] - fn encrypted_values_use_one_authenticated_envelope_for_all_recipients() - -> Result<(), Box> { - use mtp_crypto::Keyring; - - let recipient_a = Keyring::generate(); - let recipient_b = Keyring::generate(); - let recipient_c = Keyring::generate(); - let original = DataValue::Container(vec![ - (DataTypeId(40), DataValue::Str("shared secret".into())), - (DataTypeId(41), DataValue::UnsignedNumber(42)), - ]); - let inner = original.to_bytes()?; - let purpose = ProtectionPurpose::from(0xA5); - let encrypted = original.clone().encrypt_for( - &[ - recipient_a.public_key_bundle(), - recipient_b.public_key_bundle(), - recipient_c.public_key_bundle(), - ], - purpose, - )?; - - let DataValue::Encrypted(value) = &encrypted else { - return Err("expected encrypted value".into()); - }; - let suite = value.encryption_type; - assert_eq!(value.recipients.len(), 3); - assert!( - value - .recipients - .iter() - .all( - |entry| entry.kem_ciphertext.len() == suite.kem_ciphertext_len() - && entry.encrypted_key.len() == suite.wrapped_key_len() - ) - ); - - let encoded = encrypted.to_bytes()?; - let envelope_len = u32::from_be_bytes(encoded[1..5].try_into()?) as usize; - assert_eq!(encoded[0], DataValue::KIND_ENCRYPTED); - assert_eq!(envelope_len, encoded.len() - 5); - assert_eq!(encoded[5], suite.to_byte()); - assert_eq!(encoded[6], purpose.0); - assert_eq!(u16::from_be_bytes(encoded[7..9].try_into()?), 3); - assert_eq!( - envelope_len, - 4 + 3 * (suite.kem_ciphertext_len() + suite.wrapped_key_len()) - + suite.encrypted_len(inner.len()) - ); - - for keyring in [&recipient_a, &recipient_b, &recipient_c] { - assert_eq!(encrypted.decrypt(keyring, purpose)?, original); - } - assert!(matches!( - encrypted.decrypt(&Keyring::generate(), purpose), - Err(ProtectionError::NoMatchingRecipient) - )); - Ok(()) - } - - #[cfg(feature = "crypto")] - #[test] - fn encrypted_recipient_table_is_authenticated() -> Result<(), Box> { - use mtp_crypto::{CryptoError, Keyring}; - - let recipient_a = Keyring::generate(); - let recipient_b = Keyring::generate(); - let encrypted = DataValue::Str("secret".into()).encrypt_for( - &[ - recipient_a.public_key_bundle(), - recipient_b.public_key_bundle(), - ], - ProtectionPurpose::from(1), - )?; - let DataValue::Encrypted(mut value) = encrypted else { - return Err("expected encrypted value".into()); - }; - - // Keep recipient A's wrapped CEK valid. Altering B's table entry must - // still invalidate the payload because that complete table is AAD. - value.recipients[1].encrypted_key[0] ^= 1; - assert!(matches!( - DataValue::Encrypted(value).decrypt(&recipient_a, ProtectionPurpose::from(1)), - Err(ProtectionError::Crypto(CryptoError::DecryptionFailed)) - )); - Ok(()) - } - - #[cfg(feature = "crypto")] - #[test] - fn decryption_rejects_a_trailing_inner_value() -> Result<(), Box> { - use mtp_crypto::{EncryptionType, Keyring}; - - let recipient = Keyring::generate(); - let original = DataValue::BoolTrue; - let mut plaintext = original.to_bytes()?; - plaintext.push(DataValue::KIND_NULL); - let message = mtp_crypto::encrypt_multi_for( - EncryptionType::MlKemChaCha20Poly1305, - 3, - &plaintext, - std::slice::from_ref(&recipient.public_key_bundle()), - )?; - let encrypted = DataValue::Encrypted(EncryptedValue { - encryption_type: message.encryption_type, - purpose: message.purpose, - recipients: message.recipients, - ciphertext: message.ciphertext, - }); - - assert!(matches!( - encrypted.decrypt(&recipient, ProtectionPurpose::from(3)), - Err(ProtectionError::Malformed) - )); - Ok(()) - } - - #[cfg(feature = "crypto")] - #[test] - fn protection_operations_preserve_failure_reasons() -> Result<(), Box> { - use mtp_crypto::{EncryptionType, Keyring}; - - let keyring = Keyring::generate(); - let public_keys = keyring.public_key_bundle(); - - assert!(matches!( - DataValue::Null.verify_with_policy( - 1, - &public_keys, - ProtectionPurpose::from(1), - ProtectionPolicy::any_supported(), - ), - Err(ProtectionError::NotSigned) - )); - assert!(matches!( - DataValue::Null.into_verified_with_policy( - 1, - &public_keys, - ProtectionPurpose::from(1), - ProtectionPolicy::any_supported(), - ), - Err(ProtectionError::NotSigned) - )); - assert!(matches!( - DataValue::Null.decrypt(&keyring, ProtectionPurpose::from(1)), - Err(ProtectionError::NotEncrypted) - )); - assert!(matches!( - DataValue::Null.encrypt_for(&[], ProtectionPurpose::from(1)), - Err(ProtectionError::Crypto( - mtp_crypto::CryptoError::NoRecipients - )) - )); - - let malformed_encrypted = DataValue::Encrypted(EncryptedValue { - encryption_type: EncryptionType::MlKemChaCha20Poly1305, - purpose: 1, - recipients: Vec::new(), - ciphertext: Vec::new(), - }); - assert!(matches!( - malformed_encrypted.decrypt(&keyring, ProtectionPurpose::from(1)), - Err(ProtectionError::Malformed) - )); - - let (signer, _, signer_public) = mtp_crypto::Ed25519Signer::generate(); - let mut signing_keys = keyring.public_key_bundle(); - signing_keys.sig_cl_public_key = signer_public; - - let duplicate_fields = DataValue::Container(vec![ - (DataTypeId(1), DataValue::Null), - (DataTypeId(1), DataValue::Null), - ]); - assert!(matches!( - duplicate_fields.sign(1, ProtectionPurpose::from(1), &signer), - Err(ProtectionError::Codec(CodecError::InvalidEncoding)) - )); - - let signed = DataValue::Null.sign(1, ProtectionPurpose::from(1), &signer)?; - let DataValue::Signed(mut signed) = signed else { - return Err("expected signed value".into()); - }; - signed.signature[0] ^= 1; - assert!(matches!( - DataValue::Signed(signed).verify_with_policy( - 1, - &signing_keys, - ProtectionPurpose::from(1), - ProtectionPolicy::any_supported(), - ), - Err(ProtectionError::InvalidSignature) - )); - - Ok(()) - } - - #[cfg(feature = "crypto")] - #[test] - fn application_purposes_cannot_collide_with_mtp_registry() { - assert!( - ApplicationProtectionPurpose::new( - MtpProtectionPurpose::RelayMetadataEncryption.value() - ) - .is_err() - ); - let application = ApplicationProtectionPurpose::new(0x40).expect("application purpose"); - assert_eq!(ProtectionPurpose::from(application).0, 0x40); + assert!(dv.sign_container(SigAlgorithm::ED25519, &signer).is_some()); + assert!(dv.verify_into_container(&wrong_verifier).is_none()); } } diff --git a/codec/src/lib.rs b/codec/src/lib.rs index fe0af7f..69f15e7 100644 --- a/codec/src/lib.rs +++ b/codec/src/lib.rs @@ -1,57 +1,17 @@ pub mod communication_value; pub mod data_value; -#[cfg(feature = "crypto")] -pub mod protected; -#[cfg(feature = "crypto")] -pub mod relay; pub use communication_value::CommunicationValue; -#[cfg(feature = "crypto")] -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 data_value::{DataKind, DataValue}; +pub use mtp_common::CodecError; pub use mtp_type_map::{ CommunicationType, CommunicationTypeId, DataType, DataTypeId, PROTOCOL_VERSION, TypeMap, - Version, + Version, communication_type_name, data_type_name, }; pub(crate) fn rand_u32() -> u32 { - loop { - let value = rand::random(); - if value != 0 { - return value; - } - } + rand::random() } #[cfg(feature = "registry")] diff --git a/codec/src/protected.rs b/codec/src/protected.rs deleted file mode 100644 index dd4f1c2..0000000 --- a/codec/src/protected.rs +++ /dev/null @@ -1,1613 +0,0 @@ -//! Native codec for direct protected application messages. -//! -//! The protected envelope is an MTP protocol structure. Keeping its schema, -//! version checks, and routing authentication here gives native applications -//! and language bindings one implementation to consume. - -#![cfg(feature = "crypto")] - -use mtp_crypto::{Keyring, PublicKeyBundle, SignatureScheme}; -use mtp_type_map::{CommunicationType, DataType, DataTypeId, TypeMap}; -use std::collections::{HashSet, VecDeque}; - -use crate::{ - CommunicationValue, DataValue, DecodeLimits, EncodeLimits, ProtectionError, ProtectionPolicy, - ProtectionPurpose, -}; - -/// The direct protected-message envelope schema version emitted by this -/// codec. -pub const CURRENT_PROTECTED_VERSION: u64 = 1; - -/// Semantic limits for fields that are retained after a protected message is -/// opened. These are intentionally separate from generic transport blobs. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct ProtectedLimits { - pub max_message_id_bytes: usize, - pub max_metadata_encoded_bytes: usize, - pub max_signer_key_history: usize, - pub max_decryption_key_history: usize, -} - -impl Default for ProtectedLimits { - fn default() -> Self { - Self { - max_message_id_bytes: 256, - max_metadata_encoded_bytes: 1024 * 1024, - max_signer_key_history: 8, - max_decryption_key_history: 8, - } - } -} - -#[derive(Debug, thiserror::Error)] -pub enum ProtectedError { - #[error("value is not an application communication frame")] - NotApplicationFrame, - #[error("protected frame must contain an explicit receiver")] - MissingReceiver, - #[error("protected frame payload is not encrypted")] - PayloadNotEncrypted, - #[error("protected frame payload is not signed")] - PayloadNotSigned, - #[error("protected payload does not contain an MTP envelope")] - MissingEnvelope, - #[error("protected frame has an invalid protected layout: {0}")] - InvalidLayout(&'static str), - #[error("protected message does not declare a protected version")] - MissingProtectedVersion, - #[error("unsupported protected message version {0}")] - UnsupportedProtectedVersion(u64), - #[error("protected message type does not match outer routing")] - MessageTypeMismatch, - #[error("protected final recipient does not match outer routing receiver")] - FinalRecipientMismatch, - #[error("protected frame sender does not match authenticated signer")] - SenderMismatch, - #[error("protected receiver does not match the expected recipient")] - ExpectedReceiverMismatch, - #[error("protected application communication type is reserved: {0}")] - ReservedApplicationType(String), - #[error("protected message was already accepted")] - Replay, - #[error("protected resource limit exceeded: {0}")] - ResourceLimit(&'static str), - #[error("protection error: {0}")] - Protection(#[from] ProtectionError), - #[error("replay guard error: {0}")] - ReplayGuard(#[from] ReplayError), -} - -/// A storage-backed caller hook for authenticated message deduplication. -/// -/// The codec deliberately does not decide where durable state lives. An -/// application can implement this over persistent storage. The key is the -/// authenticated `(signer_id, message_id)` pair, never the transport-visible -/// communication ID. `created_at` is supplied as authenticated retention -/// metadata; implementations must not use it as the replay identity. -pub trait ReplayGuard { - /// Return `true` when the message is new and has been recorded. Return - /// `false` for a message that was already recorded. - fn accept( - &mut self, - signer_id: u64, - message_id: &str, - created_at: u64, - ) -> Result; -} - -#[derive(Debug, thiserror::Error)] -pub enum ReplayError { - #[error("replay store error: {0}")] - Store(String), -} - -/// Small in-memory guard useful for tests and short-lived clients. Production -/// consumers should implement [`ReplayGuard`] over persistent storage. -#[derive(Debug)] -pub struct InMemoryReplayGuard { - accepted: HashSet<(u64, String)>, - order: VecDeque<(u64, String)>, - capacity: usize, -} - -impl Default for InMemoryReplayGuard { - fn default() -> Self { - Self::with_capacity(10_000) - } -} - -impl InMemoryReplayGuard { - pub fn new(capacity: usize) -> Self { - Self::with_capacity(capacity) - } - - pub fn with_capacity(capacity: usize) -> Self { - Self { - accepted: HashSet::new(), - order: VecDeque::new(), - capacity, - } - } - - pub fn len(&self) -> usize { - self.accepted.len() - } - - pub fn is_empty(&self) -> bool { - self.accepted.is_empty() - } -} - -impl ReplayGuard for InMemoryReplayGuard { - fn accept( - &mut self, - signer_id: u64, - message_id: &str, - _created_at: u64, - ) -> Result { - let key = (signer_id, message_id.to_owned()); - if self.accepted.contains(&key) { - return Ok(false); - } - if self.capacity == 0 { - return Ok(false); - } - self.accepted.insert(key.clone()); - self.order.push_back(key); - while self.accepted.len() > self.capacity { - if let Some(oldest) = self.order.pop_front() { - self.accepted.remove(&oldest); - } - } - Ok(true) - } -} - -/// A direct protected-message builder shared by native applications and -/// language bindings. -pub struct ProtectedMessageBuilder<'a> { - message_type: String, - content: DataValue, - signer_id: u64, - final_recipient_id: u64, - message_id: Option, - created_at: Option, - signer: &'a dyn SignatureScheme, - recipients: Vec, - signature_purpose: ProtectionPurpose, - encryption_purpose: ProtectionPurpose, - type_map: Option, - frame_id: Option, - expose_sender: bool, - limits: ProtectedLimits, - encode_limits: EncodeLimits, -} - -impl<'a> ProtectedMessageBuilder<'a> { - pub fn new( - message_type: impl Into, - content: DataValue, - signer_id: u64, - final_recipient_id: u64, - signer: &'a dyn SignatureScheme, - signature_purpose: ProtectionPurpose, - encryption_purpose: ProtectionPurpose, - ) -> Self { - Self { - message_type: message_type.into(), - content, - signer_id, - final_recipient_id, - message_id: None, - created_at: None, - signer, - recipients: Vec::new(), - signature_purpose, - encryption_purpose, - type_map: None, - frame_id: None, - expose_sender: false, - limits: ProtectedLimits::default(), - encode_limits: EncodeLimits::default(), - } - } - - pub fn message_id(mut self, value: impl Into) -> Self { - self.message_id = Some(value.into()); - self - } - - /// Set `CreatedAt` as Unix epoch milliseconds. - pub fn created_at(mut self, value: u64) -> Self { - self.created_at = Some(value); - self - } - - pub fn recipients(mut self, recipients: Vec) -> Self { - self.recipients = recipients; - self - } - - /// Build against an explicitly negotiated type map. - pub fn type_map(mut self, type_map: &TypeMap) -> Self { - self.type_map = Some(type_map.clone()); - self - } - - /// Set the clear outer MTP frame ID. The protected envelope's - /// authenticated `MessageId` remains independent from this transport - /// correlation field. - pub fn frame_id(mut self, value: u32) -> Self { - self.frame_id = Some(value); - self - } - - /// Include the authenticated signer ID in the clear outer frame sender - /// field. The default keeps the outer sender hidden. - pub fn expose_sender(mut self, expose: bool) -> Self { - self.expose_sender = expose; - self - } - - pub fn protected_limits(mut self, limits: ProtectedLimits) -> Self { - self.limits = limits; - self - } - - pub fn encode_limits(mut self, limits: EncodeLimits) -> Self { - self.encode_limits = limits; - self - } - - pub fn build(self) -> Result { - let message_id = self.message_id.ok_or(ProtectedError::InvalidLayout( - "protected builder requires a message ID", - ))?; - let created_at = self.created_at.ok_or(ProtectedError::InvalidLayout( - "protected builder requires a creation timestamp", - ))?; - if self.message_type.is_empty() || message_id.is_empty() { - return Err(ProtectedError::InvalidLayout( - "protected identifiers must be non-empty", - )); - } - if message_id.len() > self.limits.max_message_id_bytes { - return Err(ProtectedError::ResourceLimit("message ID")); - } - if self.recipients.is_empty() { - return Err(ProtectedError::InvalidLayout( - "protected builder requires at least one recipient", - )); - } - - let type_map = self.type_map.unwrap_or_else(TypeMap::latest); - let application_type = validate_application_message_type(&self.message_type, &type_map)?; - let protected_version_id = protected_field_id(DataType::ProtectedVersion, &type_map)?; - let message_type_id = protected_field_id(DataType::MessageType, &type_map)?; - let final_recipient_id = protected_field_id(DataType::FinalRecipientId, &type_map)?; - let message_id_id = protected_field_id(DataType::MessageId, &type_map)?; - let created_at_id = protected_field_id(DataType::CreatedAt, &type_map)?; - let content_id = protected_field_id(DataType::Content, &type_map)?; - - let envelope = DataValue::Container(vec![ - ( - protected_version_id, - DataValue::UnsignedNumber(CURRENT_PROTECTED_VERSION as u128), - ), - (message_type_id, DataValue::Str(self.message_type)), - ( - final_recipient_id, - DataValue::UnsignedNumber(self.final_recipient_id as u128), - ), - (message_id_id, DataValue::Str(message_id)), - (created_at_id, DataValue::UnsignedNumber(created_at as u128)), - (content_id, self.content), - ]); - let signed = envelope.sign_with_limits( - self.signer_id, - self.signature_purpose, - self.signer, - self.encode_limits, - )?; - let encrypted = signed.encrypt_for_with_limits( - &self.recipients, - self.encryption_purpose, - self.encode_limits, - )?; - - let mut frame = CommunicationValue::new_with_type_map(application_type, &type_map) - .with_receiver(self.final_recipient_id) - .with_payload(encrypted); - if let Some(frame_id) = self.frame_id { - frame = frame.with_id(frame_id); - } - if self.expose_sender { - frame = frame.with_sender(self.signer_id); - } - Ok(frame) - } -} - -/// A direct protected message after the encrypted envelope and its signature -/// have been authenticated. -#[derive(Debug, Clone, PartialEq)] -pub struct VerifiedProtectedMessage { - pub protected_version: u64, - pub signer_id: u64, - pub final_recipient_id: u64, - pub message_id: String, - pub created_at: u64, - pub message_type: String, - pub content: DataValue, - pub matched_signer_key_index: usize, -} - -/// Options that control verification of a direct protected message. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct ProtectedOpenOptions { - /// Require the protected frame to be addressed to this receiver when set. - pub expected_receiver_id: Option, - /// Purpose used to verify the protected envelope signature. - pub signature_purpose: ProtectionPurpose, - /// Purpose used to decrypt the protected envelope. - pub encryption_purpose: ProtectionPurpose, - /// Signature algorithms accepted by the receiver. - pub policy: ProtectionPolicy, - /// Recursive and cumulative allocation policy used while opening. - pub decode_limits: DecodeLimits, - /// Bound used when reconstructing signed bytes for verification. - pub encode_limits: EncodeLimits, - /// Semantic limits for retained protected fields and key histories. - pub protected_limits: ProtectedLimits, -} - -impl ProtectedOpenOptions { - pub const fn new( - expected_receiver_id: Option, - signature_purpose: ProtectionPurpose, - encryption_purpose: ProtectionPurpose, - policy: ProtectionPolicy, - ) -> Self { - Self { - expected_receiver_id, - signature_purpose, - encryption_purpose, - policy, - decode_limits: DecodeLimits { - max_depth: 64, - max_values: 65_536, - max_blob_size: 16 * 1024 * 1024, - max_recipients: 64, - max_allocated_bytes: 64 * 1024 * 1024, - }, - encode_limits: EncodeLimits { - max_depth: 64, - max_values: 65_536, - max_output_size: 16 * 1024 * 1024, - }, - protected_limits: ProtectedLimits { - max_message_id_bytes: 256, - max_metadata_encoded_bytes: 1024 * 1024, - max_signer_key_history: 8, - max_decryption_key_history: 8, - }, - } - } - - pub const fn with_limits( - mut self, - decode_limits: DecodeLimits, - protected_limits: ProtectedLimits, - ) -> Self { - self.decode_limits = decode_limits; - self.protected_limits = protected_limits; - self - } - - pub const fn with_encode_limits(mut self, encode_limits: EncodeLimits) -> Self { - self.encode_limits = encode_limits; - self - } -} - -fn protected_field_id( - data_type: DataType, - type_map: &TypeMap, -) -> Result { - data_type - .try_to_id(type_map) - .ok_or(ProtectedError::InvalidLayout( - "reserved protected type is unavailable", - )) -} - -fn validate_application_message_type( - message_type: &str, - type_map: &TypeMap, -) -> Result { - let communication_type = CommunicationType::from_name(message_type).ok_or( - ProtectedError::InvalidLayout("protected message type is unknown"), - )?; - let communication_id = - communication_type - .try_to_id(type_map) - .ok_or(ProtectedError::InvalidLayout( - "protected message type is unavailable", - ))?; - if communication_id.is_reserved() { - return Err(ProtectedError::ReservedApplicationType( - message_type.to_owned(), - )); - } - Ok(communication_type) -} - -fn validate_protected_frame(frame: &CommunicationValue) -> Result { - let type_map = frame.type_map().cloned().unwrap_or_else(TypeMap::latest); - let Some(communication_type) = frame.get_comm_type_enum() else { - return Err(ProtectedError::NotApplicationFrame); - }; - let communication_id = communication_type - .try_to_id(&type_map) - .ok_or(ProtectedError::NotApplicationFrame)?; - if communication_id.is_reserved() { - return Err(ProtectedError::ReservedApplicationType( - communication_type.name().to_owned(), - )); - } - if frame.receiver().is_none() { - return Err(ProtectedError::MissingReceiver); - } - Ok(type_map) -} - -fn field<'a>( - entries: &'a [(DataTypeId, DataValue)], - data_type: DataType, - type_map: &TypeMap, -) -> Result<&'a DataValue, ProtectedError> { - let field_id = protected_field_id(data_type, type_map)?; - entries - .iter() - .find(|(id, _)| *id == field_id) - .map(|(_, value)| value) - .ok_or(ProtectedError::InvalidLayout( - "required protected field is missing", - )) -} - -fn unsigned_field( - entries: &[(DataTypeId, DataValue)], - data_type: DataType, - type_map: &TypeMap, -) -> Result { - field(entries, data_type, type_map)? - .as_unsigned_number() - .ok_or(ProtectedError::InvalidLayout( - "protected field is not unsigned", - )) -} - -fn string_field( - entries: &[(DataTypeId, DataValue)], - data_type: DataType, - type_map: &TypeMap, -) -> Result { - field(entries, data_type, type_map)? - .as_string() - .filter(|value| !value.is_empty()) - .ok_or(ProtectedError::InvalidLayout( - "protected field is not a non-empty string", - )) -} - -fn string_field_ref<'a>( - entries: &'a [(DataTypeId, DataValue)], - data_type: DataType, - type_map: &TypeMap, -) -> Result<&'a str, ProtectedError> { - field(entries, data_type, type_map)? - .as_str() - .filter(|value| !value.is_empty()) - .ok_or(ProtectedError::InvalidLayout( - "protected field is not a non-empty string", - )) -} - -fn protected_version( - entries: &[(DataTypeId, DataValue)], - type_map: &TypeMap, -) -> Result { - let version_id = protected_field_id(DataType::ProtectedVersion, type_map)?; - let version = entries - .iter() - .find(|(id, _)| *id == version_id) - .map(|(_, value)| value) - .ok_or(ProtectedError::MissingProtectedVersion)? - .as_unsigned_number() - .ok_or(ProtectedError::InvalidLayout( - "protected version is not unsigned", - ))?; - u64::try_from(version) - .map_err(|_| ProtectedError::InvalidLayout("protected version is out of range")) -} - -fn decrypt_protected_payload( - frame: &CommunicationValue, - keyrings: &[&Keyring], - encryption_purpose: ProtectionPurpose, - decode_limits: DecodeLimits, - max_decryption_key_history: usize, -) -> Result { - if keyrings.len() > max_decryption_key_history { - return Err(ProtectedError::ResourceLimit("decryption key history")); - } - frame - .payload() - .decrypt_with_keyrings_and_limits(keyrings, encryption_purpose, decode_limits) - .map_err(|error| match error { - ProtectionError::NotEncrypted => ProtectedError::PayloadNotEncrypted, - other => ProtectedError::Protection(other), - }) -} - -/// Return the claimed signer ID after decryption, without verifying its -/// signature. The value is untrusted and may only select the key history that -/// is then bound to the same signer ID during the subsequent open. -#[deprecated(note = "use protected_claimed_signer_id_with_limits; pass the receive DecodeLimits")] -pub fn protected_claimed_signer_id( - frame: &CommunicationValue, - keyrings: &[&Keyring], - encryption_purpose: ProtectionPurpose, -) -> Result { - // Migrate to `protected_claimed_signer_id_with_limits` at receive boundaries. - protected_claimed_signer_id_with_limits( - frame, - keyrings, - encryption_purpose, - DecodeLimits::default(), - ) -} - -pub fn protected_claimed_signer_id_with_limits( - frame: &CommunicationValue, - keyrings: &[&Keyring], - encryption_purpose: ProtectionPurpose, - decode_limits: DecodeLimits, -) -> Result { - protected_claimed_signer_id_with_options( - frame, - keyrings, - encryption_purpose, - decode_limits, - ProtectedLimits::default(), - ) -} - -/// Return the claimed signer ID while applying the complete receive policy. -/// -/// This is deliberately separate from the compatibility decoder above: the -/// claimed ID is used to select a signer-key history, so the decryption-key -/// history bound must be the same bound used by the eventual open operation. -pub fn protected_claimed_signer_id_with_options( - frame: &CommunicationValue, - keyrings: &[&Keyring], - encryption_purpose: ProtectionPurpose, - decode_limits: DecodeLimits, - protected_limits: ProtectedLimits, -) -> Result { - validate_protected_frame(frame)?; - let decrypted = decrypt_protected_payload( - frame, - keyrings, - encryption_purpose, - decode_limits, - protected_limits.max_decryption_key_history, - )?; - let signed = decrypted - .as_signed() - .ok_or(ProtectedError::PayloadNotSigned)?; - Ok(signed.signer_id) -} - -fn open_protected_with_impl( - frame: &CommunicationValue, - keyrings: &[&Keyring], - expected_signer_id: Option, - resolve_signer_keys: F, - options: ProtectedOpenOptions, - replay_guard: Option<&mut dyn ReplayGuard>, -) -> Result -where - F: FnOnce(u64) -> Option>, -{ - validate_protected_frame(frame)?; - let type_map = frame.type_map().cloned().unwrap_or_else(TypeMap::latest); - let decrypted = decrypt_protected_payload( - frame, - keyrings, - options.encryption_purpose, - options.decode_limits, - options.protected_limits.max_decryption_key_history, - )?; - let signed = decrypted - .as_signed() - .ok_or(ProtectedError::PayloadNotSigned)?; - if let Some(expected_signer_id) = expected_signer_id - && signed.signer_id != expected_signer_id - { - return Err(ProtectionError::SignerIdMismatch { - expected: expected_signer_id, - actual: signed.signer_id, - } - .into()); - } - let signer_keys = resolve_signer_keys(signed.signer_id) - .ok_or(ProtectionError::SignerKeyNotFound(signed.signer_id))?; - if signer_keys.len() > options.protected_limits.max_signer_key_history { - return Err(ProtectedError::ResourceLimit("signer key history")); - } - open_decrypted_protected(frame, type_map, signed, &signer_keys, options, replay_guard) -} - -pub fn open_protected_with_checked( - frame: &CommunicationValue, - keyrings: &[&Keyring], - expected_signer_id: Option, - resolve_signer_keys: F, - options: ProtectedOpenOptions, - replay_guard: &mut dyn ReplayGuard, -) -> Result -where - F: FnOnce(u64) -> Option>, -{ - open_protected_with_impl( - frame, - keyrings, - expected_signer_id, - resolve_signer_keys, - options, - Some(replay_guard), - ) -} - -pub fn open_protected_with_without_replay( - frame: &CommunicationValue, - keyrings: &[&Keyring], - expected_signer_id: Option, - resolve_signer_keys: F, - options: ProtectedOpenOptions, -) -> Result -where - F: FnOnce(u64) -> Option>, -{ - open_protected_with_impl( - frame, - keyrings, - expected_signer_id, - resolve_signer_keys, - options, - None, - ) -} - -fn open_protected_with_keys_impl( - frame: &CommunicationValue, - keyrings: &[&Keyring], - expected_signer_id: u64, - signer_public_keys: &[PublicKeyBundle], - options: ProtectedOpenOptions, - replay_guard: Option<&mut dyn ReplayGuard>, -) -> Result { - let type_map = validate_protected_frame(frame)?; - let decrypted = decrypt_protected_payload( - frame, - keyrings, - options.encryption_purpose, - options.decode_limits, - options.protected_limits.max_decryption_key_history, - )?; - let signed = decrypted - .as_signed() - .ok_or(ProtectedError::PayloadNotSigned)?; - if signed.signer_id != expected_signer_id { - return Err(ProtectionError::SignerIdMismatch { - expected: expected_signer_id, - actual: signed.signer_id, - } - .into()); - } - open_decrypted_protected( - frame, - type_map, - signed, - signer_public_keys, - options, - replay_guard, - ) -} - -pub fn open_protected_with_keys_checked( - frame: &CommunicationValue, - keyrings: &[&Keyring], - expected_signer_id: u64, - signer_public_keys: &[PublicKeyBundle], - options: ProtectedOpenOptions, - replay_guard: &mut dyn ReplayGuard, -) -> Result { - open_protected_with_keys_impl( - frame, - keyrings, - expected_signer_id, - signer_public_keys, - options, - Some(replay_guard), - ) -} - -pub fn open_protected_with_keys_without_replay( - frame: &CommunicationValue, - keyrings: &[&Keyring], - expected_signer_id: u64, - signer_public_keys: &[PublicKeyBundle], - options: ProtectedOpenOptions, -) -> Result { - open_protected_with_keys_impl( - frame, - keyrings, - expected_signer_id, - signer_public_keys, - options, - None, - ) -} - -pub fn open_protected_checked( - frame: &CommunicationValue, - keyring: &Keyring, - expected_signer_id: u64, - signer_public_key: &PublicKeyBundle, - options: ProtectedOpenOptions, - replay_guard: &mut dyn ReplayGuard, -) -> Result { - open_protected_with_keys_impl( - frame, - std::slice::from_ref(&keyring), - expected_signer_id, - std::slice::from_ref(signer_public_key), - options, - Some(replay_guard), - ) -} - -pub fn open_protected_without_replay( - frame: &CommunicationValue, - keyring: &Keyring, - expected_signer_id: u64, - signer_public_key: &PublicKeyBundle, - options: ProtectedOpenOptions, -) -> Result { - open_protected_with_keys_impl( - frame, - std::slice::from_ref(&keyring), - expected_signer_id, - std::slice::from_ref(signer_public_key), - options, - None, - ) -} - -fn open_decrypted_protected( - frame: &CommunicationValue, - type_map: TypeMap, - signed: &crate::SignedValue, - signer_public_keys: &[PublicKeyBundle], - options: ProtectedOpenOptions, - mut replay_guard: Option<&mut dyn ReplayGuard>, -) -> Result { - if signer_public_keys.len() > options.protected_limits.max_signer_key_history { - return Err(ProtectedError::ResourceLimit("signer key history")); - } - let matched_signer_key_index = signed.verify_with_key_history_index_and_limits( - signed.signer_id, - signer_public_keys, - options.signature_purpose, - options.policy, - options.encode_limits, - )?; - let receiver_id = frame.receiver().ok_or(ProtectedError::MissingReceiver)?; - if options - .expected_receiver_id - .is_some_and(|expected| expected != receiver_id) - { - return Err(ProtectedError::ExpectedReceiverMismatch); - } - if frame - .sender() - .is_some_and(|sender| sender != signed.signer_id) - { - return Err(ProtectedError::SenderMismatch); - } - /* The authenticated value is already owned by the decoder. Keep this - inspection borrowed so opening a large envelope does not clone it. */ - let envelope = signed - .value - .container_entries() - .ok_or(ProtectedError::MissingEnvelope)?; - let version = protected_version(envelope, &type_map)?; - if version != CURRENT_PROTECTED_VERSION { - return Err(ProtectedError::UnsupportedProtectedVersion(version)); - } - let message_type = string_field(envelope, DataType::MessageType, &type_map)?; - let application_type = validate_application_message_type(&message_type, &type_map)?; - if frame.get_comm_type_enum() != Some(application_type) { - return Err(ProtectedError::MessageTypeMismatch); - } - let final_recipient_id = u64::try_from(unsigned_field( - envelope, - DataType::FinalRecipientId, - &type_map, - )?) - .map_err(|_| ProtectedError::InvalidLayout("final recipient ID is out of range"))?; - if final_recipient_id != receiver_id { - return Err(ProtectedError::FinalRecipientMismatch); - } - let message_id = string_field_ref(envelope, DataType::MessageId, &type_map)?; - if message_id.len() > options.protected_limits.max_message_id_bytes { - return Err(ProtectedError::ResourceLimit("message ID")); - } - let message_id = message_id.to_owned(); - let created_at = u64::try_from(unsigned_field(envelope, DataType::CreatedAt, &type_map)?) - .map_err(|_| ProtectedError::InvalidLayout("created-at value is out of range"))?; - let content = field(envelope, DataType::Content, &type_map)?.clone(); - - if let Some(guard) = replay_guard.as_mut() - && !guard.accept(signed.signer_id, &message_id, created_at)? - { - return Err(ProtectedError::Replay); - } - - Ok(VerifiedProtectedMessage { - protected_version: version, - signer_id: signed.signer_id, - final_recipient_id, - message_id, - created_at, - message_type, - content, - matched_signer_key_index, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use mtp_crypto::{Ed25519Signer, Keyring, PublicKeyBundle}; - use mtp_type_map::{DataType, DataTypeId}; - - const SIGNATURE_PURPOSE: ProtectionPurpose = ProtectionPurpose(0x40); - const ENCRYPTION_PURPOSE: ProtectionPurpose = ProtectionPurpose(0x41); - - fn open_options(expected_receiver_id: Option) -> ProtectedOpenOptions { - ProtectedOpenOptions::new( - expected_receiver_id, - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), - ) - } - - // Keep the existing test cases concise while making the production API - // choice explicit: every call below is routed to either the checked or - // the named without-replay entry point. - fn open_protected( - frame: &CommunicationValue, - keyring: &Keyring, - expected_signer_id: u64, - signer_public_key: &PublicKeyBundle, - options: ProtectedOpenOptions, - replay_guard: Option<&mut dyn ReplayGuard>, - ) -> Result { - match replay_guard { - Some(replay_guard) => super::open_protected_checked( - frame, - keyring, - expected_signer_id, - signer_public_key, - options, - replay_guard, - ), - None => super::open_protected_without_replay( - frame, - keyring, - expected_signer_id, - signer_public_key, - options, - ), - } - } - - fn open_protected_with( - frame: &CommunicationValue, - keyrings: &[&Keyring], - expected_signer_id: Option, - resolve_signer_keys: F, - options: ProtectedOpenOptions, - replay_guard: Option<&mut dyn ReplayGuard>, - ) -> Result - where - F: FnOnce(u64) -> Option>, - { - match replay_guard { - Some(replay_guard) => super::open_protected_with_checked( - frame, - keyrings, - expected_signer_id, - resolve_signer_keys, - options, - replay_guard, - ), - None => super::open_protected_with_without_replay( - frame, - keyrings, - expected_signer_id, - resolve_signer_keys, - options, - ), - } - } - - fn open_protected_with_keys( - frame: &CommunicationValue, - keyrings: &[&Keyring], - expected_signer_id: u64, - signer_public_keys: &[PublicKeyBundle], - options: ProtectedOpenOptions, - replay_guard: Option<&mut dyn ReplayGuard>, - ) -> Result { - match replay_guard { - Some(replay_guard) => super::open_protected_with_keys_checked( - frame, - keyrings, - expected_signer_id, - signer_public_keys, - options, - replay_guard, - ), - None => super::open_protected_with_keys_without_replay( - frame, - keyrings, - expected_signer_id, - signer_public_keys, - options, - ), - } - } - - #[derive(Default)] - struct RecordingReplayGuard { - created_at: Option, - accepted: bool, - calls: usize, - } - - impl ReplayGuard for RecordingReplayGuard { - fn accept( - &mut self, - _signer_id: u64, - _message_id: &str, - created_at: u64, - ) -> Result { - self.calls += 1; - self.created_at = Some(created_at); - if self.accepted { - Ok(false) - } else { - self.accepted = true; - Ok(true) - } - } - } - - #[test] - fn in_memory_replay_guard_is_bounded_and_deduplicates() { - let mut guard = InMemoryReplayGuard::with_capacity(2); - assert!(guard.accept(7, "first", 1).expect("first replay decision")); - assert!( - guard - .accept(7, "second", 2) - .expect("second replay decision") - ); - assert!( - !guard - .accept(7, "first", 3) - .expect("duplicate replay decision") - ); - assert_eq!(guard.len(), 2); - - assert!(guard.accept(7, "third", 4).expect("third replay decision")); - assert_eq!(guard.len(), 2); - assert!( - guard - .accept(7, "first", 5) - .expect("evicted replay decision") - ); - - let mut disabled = InMemoryReplayGuard::with_capacity(0); - assert!( - !disabled - .accept(7, "disabled", 1) - .expect("disabled replay decision") - ); - } - - fn protected_field(data_type: DataType, type_map: &TypeMap) -> DataTypeId { - data_type - .try_to_id(type_map) - .expect("protected type mapping") - } - - fn envelope( - type_map: &TypeMap, - version: Option, - message_type: &str, - final_recipient_id: u64, - message_id: &str, - created_at: u64, - content: DataValue, - ) -> DataValue { - let mut fields = Vec::new(); - if let Some(version) = version { - fields.push(( - protected_field(DataType::ProtectedVersion, type_map), - DataValue::UnsignedNumber(version), - )); - } - fields.extend([ - ( - protected_field(DataType::MessageType, type_map), - DataValue::Str(message_type.into()), - ), - ( - protected_field(DataType::FinalRecipientId, type_map), - DataValue::UnsignedNumber(final_recipient_id as u128), - ), - ( - protected_field(DataType::MessageId, type_map), - DataValue::Str(message_id.into()), - ), - ( - protected_field(DataType::CreatedAt, type_map), - DataValue::UnsignedNumber(created_at as u128), - ), - (protected_field(DataType::Content, type_map), content), - ]); - DataValue::Container(fields) - } - - fn encrypted_frame( - envelope: DataValue, - signer_keyring: &Keyring, - signer_id: u64, - recipient_keyring: &Keyring, - receiver_id: Option, - sender_id: Option, - ) -> CommunicationValue { - let type_map = TypeMap::latest(); - let application_type = CommunicationType::from_name("ProtectedMessage") - .expect("ProtectedMessage communication type"); - let signer = Ed25519Signer::new(&signer_keyring.sig_cl_secret_key).expect("Ed25519 signer"); - let signed = envelope - .sign(signer_id, SIGNATURE_PURPOSE, &signer) - .expect("protected envelope signing"); - let encrypted = signed - .encrypt_for(&[recipient_keyring.public_key_bundle()], ENCRYPTION_PURPOSE) - .expect("protected envelope encryption"); - let mut frame = CommunicationValue::new_with_type_map(application_type, &type_map) - .with_payload(encrypted); - if let Some(receiver_id) = receiver_id { - frame = frame.with_receiver(receiver_id); - } - if let Some(sender_id) = sender_id { - frame = frame.with_sender(sender_id); - } - frame - } - - fn valid_frame( - signer_keyring: &Keyring, - recipient_keyring: &Keyring, - content: DataValue, - ) -> CommunicationValue { - ProtectedMessageBuilder::new( - "ProtectedMessage", - content, - 7, - 42, - &Ed25519Signer::new(&signer_keyring.sig_cl_secret_key).expect("Ed25519 signer"), - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ) - .message_id("protected-test") - .created_at(1_700_000_000_000) - .recipients(vec![recipient_keyring.public_key_bundle()]) - .build() - .expect("protected frame") - } - - #[test] - fn protected_message_round_trips_and_rejects_replay() { - let type_map = TypeMap::latest(); - let Some(application_type) = CommunicationType::from_name("ProtectedMessage") else { - return; - }; - if application_type.try_to_id(&type_map).is_none() { - return; - } - - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let signer = Ed25519Signer::new(&sender.sig_cl_secret_key).expect("Ed25519 signer"); - let frame = ProtectedMessageBuilder::new( - "ProtectedMessage", - DataValue::Str("hello".into()), - 7, - 42, - &signer, - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ) - .message_id("protected-test") - .created_at(1_700_000_000_000) - .recipients(vec![recipient.public_key_bundle()]) - .type_map(&type_map) - .build() - .expect("protected frame"); - - let mut guard = RecordingReplayGuard::default(); - let opened = open_protected( - &frame, - &recipient, - 7, - &sender.public_key_bundle(), - open_options(Some(42)), - Some(&mut guard), - ) - .expect("protected message should open"); - assert_eq!(opened.message_type, "ProtectedMessage"); - assert_eq!(opened.message_id, "protected-test"); - assert_eq!(guard.created_at, Some(1_700_000_000_000)); - assert_eq!(guard.calls, 1); - assert_eq!(opened.content, DataValue::Str("hello".into())); - assert!(matches!( - open_protected( - &frame, - &recipient, - 7, - &sender.public_key_bundle(), - open_options(Some(42)), - Some(&mut guard), - ), - Err(ProtectedError::Replay) - )); - } - - #[test] - fn oversized_message_id_is_rejected_before_replay_guard() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let frame = valid_frame(&sender, &recipient, DataValue::Str("hello".into())); - let mut options = open_options(Some(42)); - options.protected_limits.max_message_id_bytes = 3; - let mut guard = RecordingReplayGuard::default(); - - assert!(matches!( - open_protected_checked( - &frame, - &recipient, - 7, - &sender.public_key_bundle(), - options, - &mut guard, - ), - Err(ProtectedError::ResourceLimit("message ID")) - )); - assert_eq!(guard.calls, 0); - } - - #[test] - fn builder_owns_outer_sender_and_frame_id() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let signer = Ed25519Signer::new(&sender.sig_cl_secret_key).expect("Ed25519 signer"); - let frame = ProtectedMessageBuilder::new( - "ProtectedMessage", - DataValue::Str("hello".into()), - 7, - 42, - &signer, - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ) - .message_id("outer-fields") - .created_at(123) - .frame_id(99) - .expose_sender(true) - .recipients(vec![recipient.public_key_bundle()]) - .build() - .expect("protected frame"); - - assert_eq!(frame.id(), Some(99)); - assert_eq!(frame.sender(), Some(7)); - assert_eq!(frame.receiver(), Some(42)); - open_protected( - &frame, - &recipient, - 7, - &sender.public_key_bundle(), - open_options(Some(42)), - None, - ) - .expect("outer fields should verify"); - } - - #[test] - fn protected_version_is_required_and_versioned() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let type_map = TypeMap::latest(); - let missing = encrypted_frame( - envelope( - &type_map, - None, - "ProtectedMessage", - 42, - "missing-version", - 123, - DataValue::Str("hello".into()), - ), - &sender, - 7, - &recipient, - Some(42), - None, - ); - assert!(matches!( - open_protected( - &missing, - &recipient, - 7, - &sender.public_key_bundle(), - open_options(Some(42)), - None, - ), - Err(ProtectedError::MissingProtectedVersion) - )); - - let unsupported = encrypted_frame( - envelope( - &type_map, - Some((CURRENT_PROTECTED_VERSION + 1) as u128), - "ProtectedMessage", - 42, - "unsupported-version", - 123, - DataValue::Str("hello".into()), - ), - &sender, - 7, - &recipient, - Some(42), - None, - ); - assert!(matches!( - open_protected( - &unsupported, - &recipient, - 7, - &sender.public_key_bundle(), - open_options(Some(42)), - None, - ), - Err(ProtectedError::UnsupportedProtectedVersion(2)) - )); - } - - #[test] - fn protected_builder_rejects_reserved_application_types() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let signer = Ed25519Signer::new(&sender.sig_cl_secret_key).expect("Ed25519 signer"); - assert!(matches!( - ProtectedMessageBuilder::new( - "Ping", - DataValue::Null, - 7, - 42, - &signer, - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ) - .message_id("reserved") - .created_at(123) - .recipients(vec![recipient.public_key_bundle()]) - .build(), - Err(ProtectedError::ReservedApplicationType(type_name)) if type_name == "Ping" - )); - } - - #[test] - fn protected_opening_rejects_type_and_receiver_tampering() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let frame = valid_frame(&sender, &recipient, DataValue::Str("hello".into())); - let alternate_type = CommunicationType::from_name("AlternateMessage"); - let Some(alternate_type) = alternate_type else { - return; - }; - let type_map = TypeMap::latest(); - let payload = frame.payload().clone(); - let changed_type = CommunicationValue::new_with_type_map(alternate_type, &type_map) - .with_receiver(42) - .with_payload(payload.clone()); - assert!(matches!( - open_protected( - &changed_type, - &recipient, - 7, - &sender.public_key_bundle(), - open_options(None), - None, - ), - Err(ProtectedError::MessageTypeMismatch) - )); - - let changed_receiver = frame.clone().with_receiver(43); - assert!(matches!( - open_protected( - &changed_receiver, - &recipient, - 7, - &sender.public_key_bundle(), - open_options(None), - None, - ), - Err(ProtectedError::FinalRecipientMismatch) - )); - - let signed_recipient_mismatch = encrypted_frame( - envelope( - &type_map, - Some(CURRENT_PROTECTED_VERSION as u128), - "ProtectedMessage", - 43, - "signed-recipient-mismatch", - 123, - DataValue::Str("hello".into()), - ), - &sender, - 7, - &recipient, - Some(42), - None, - ); - assert!(matches!( - open_protected( - &signed_recipient_mismatch, - &recipient, - 7, - &sender.public_key_bundle(), - open_options(None), - None, - ), - Err(ProtectedError::FinalRecipientMismatch) - )); - - assert!(matches!( - open_protected( - &frame, - &recipient, - 7, - &sender.public_key_bundle(), - open_options(Some(43)), - None, - ), - Err(ProtectedError::ExpectedReceiverMismatch) - )); - } - - #[test] - fn protected_opening_validates_exposed_sender() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let signer = Ed25519Signer::new(&sender.sig_cl_secret_key).expect("Ed25519 signer"); - let frame = ProtectedMessageBuilder::new( - "ProtectedMessage", - DataValue::Str("hello".into()), - 7, - 42, - &signer, - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ) - .message_id("sender-check") - .created_at(123) - .expose_sender(true) - .recipients(vec![recipient.public_key_bundle()]) - .build() - .expect("protected frame"); - open_protected( - &frame, - &recipient, - 7, - &sender.public_key_bundle(), - open_options(Some(42)), - None, - ) - .expect("matching exposed sender"); - assert!(matches!( - open_protected( - &frame.with_sender(8), - &recipient, - 7, - &sender.public_key_bundle(), - open_options(Some(42)), - None, - ), - Err(ProtectedError::SenderMismatch) - )); - } - - #[test] - fn protected_opening_rejects_unsigned_and_unencrypted_payloads() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let type_map = TypeMap::latest(); - let application_type = CommunicationType::from_name("ProtectedMessage") - .expect("ProtectedMessage communication type"); - let type_map_frame = |payload: DataValue| { - CommunicationValue::new_with_type_map(application_type, &type_map) - .with_receiver(42) - .with_payload(payload) - }; - let signer = Ed25519Signer::new(&sender.sig_cl_secret_key).expect("Ed25519 signer"); - let envelope = envelope( - &type_map, - Some(CURRENT_PROTECTED_VERSION as u128), - "ProtectedMessage", - 42, - "payload-shape", - 123, - DataValue::Str("hello".into()), - ); - let signed = envelope - .clone() - .sign(7, SIGNATURE_PURPOSE, &signer) - .expect("signing"); - assert!(matches!( - open_protected( - &type_map_frame(signed), - &recipient, - 7, - &sender.public_key_bundle(), - open_options(Some(42)), - None, - ), - Err(ProtectedError::PayloadNotEncrypted) - )); - let encrypted_unsigned = DataValue::Str("not signed".into()) - .encrypt_for(&[recipient.public_key_bundle()], ENCRYPTION_PURPOSE) - .expect("encryption"); - assert!(matches!( - open_protected( - &type_map_frame(encrypted_unsigned), - &recipient, - 7, - &sender.public_key_bundle(), - open_options(Some(42)), - None, - ), - Err(ProtectedError::PayloadNotSigned) - )); - } - - #[test] - fn protected_opening_checks_expected_signer_before_resolving_keys() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let frame = valid_frame(&sender, &recipient, DataValue::Str("hello".into())); - let mut resolver_calls = 0; - let result = open_protected_with( - &frame, - &[&recipient], - Some(99), - |_| { - resolver_calls += 1; - Some(vec![sender.public_key_bundle()]) - }, - open_options(Some(42)), - None, - ); - assert!(matches!( - result, - Err(ProtectedError::Protection( - ProtectionError::SignerIdMismatch { - expected: 99, - actual: 7, - } - )) - )); - assert_eq!(resolver_calls, 0); - } - - #[test] - fn protected_opening_accepts_signer_and_recipient_key_history() { - let old_sender = Keyring::generate(); - let current_sender = Keyring::generate(); - let current_recipient = Keyring::generate(); - let old_recipient = Keyring::generate(); - let signer = Ed25519Signer::new(&old_sender.sig_cl_secret_key).expect("Ed25519 signer"); - let frame = ProtectedMessageBuilder::new( - "ProtectedMessage", - DataValue::Str("rotated".into()), - 7, - 42, - &signer, - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - ) - .message_id("history") - .created_at(123) - .recipients(vec![old_recipient.public_key_bundle()]) - .build() - .expect("protected frame"); - - let opened = open_protected_with_keys( - &frame, - &[¤t_recipient, &old_recipient], - 7, - &[ - current_sender.public_key_bundle(), - old_sender.public_key_bundle(), - ], - open_options(Some(42)), - None, - ) - .expect("key history should open"); - assert_eq!(opened.matched_signer_key_index, 1); - assert_eq!(opened.content, DataValue::Str("rotated".into())); - } - - #[test] - fn protected_opening_accepts_arbitrary_application_values() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let frame = valid_frame( - &sender, - &recipient, - DataValue::Array(vec![ - DataValue::Str("value".into()), - DataValue::UnsignedNumber(7), - DataValue::Bytes(vec![1, 2, 3]), - ]), - ); - let opened = open_protected( - &frame, - &recipient, - 7, - &sender.public_key_bundle(), - open_options(Some(42)), - None, - ) - .expect("arbitrary application value should open"); - assert_eq!( - opened.content, - DataValue::Array(vec![ - DataValue::Str("value".into()), - DataValue::UnsignedNumber(7), - DataValue::Bytes(vec![1, 2, 3]), - ]) - ); - } -} diff --git a/codec/src/registry.rs b/codec/src/registry.rs index a7fcded..78757bf 100644 --- a/codec/src/registry.rs +++ b/codec/src/registry.rs @@ -1,8 +1,4 @@ -use mtp_common::CodecError; -use mtp_type_map::{PROTOCOL_VERSION, TypeMap, Version}; - -use crate::CommunicationValue; -use crate::EncodeLimits; +use mtp_type_map::Version; pub use mtp_type_map::Registry; @@ -13,76 +9,11 @@ pub use mtp_type_map::Registry; #[derive(Clone, Debug)] pub struct VersionedCodec { registry: Registry, - type_map: TypeMap, } impl VersionedCodec { pub fn new(registry: Registry) -> Self { - 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 { - 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, 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, 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, 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, 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::from_bytes_with(bytes, &self.type_map) + Self { registry } } pub fn negotiate(&self, client_versions: &[Version]) -> Option { @@ -93,34 +24,3 @@ impl VersionedCodec { &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(), - }) - ); - } -} diff --git a/codec/src/relay.rs b/codec/src/relay.rs deleted file mode 100644 index 8633a75..0000000 --- a/codec/src/relay.rs +++ /dev/null @@ -1,1753 +0,0 @@ -// Relay-specific opening helpers. -// -// The generic [`DataValue`] protection operations remain the primitive API. -// These helpers add the protocol boundary needed by relay participants: -// metadata can be authenticated and returned with an opaque content value, -// while content opening is a separate operation that requires the final -// recipient identity. - -#![cfg(feature = "crypto")] - -use mtp_crypto::{Keyring, PublicKeyBundle, SignatureScheme}; -use mtp_type_map::{CommunicationType, DataType, DataTypeId, TypeMap}; - -use crate::{ - CommunicationValue, DataValue, DecodeLimits, EncodeLimits, MtpProtectionPurpose, - ProtectedLimits, ProtectionError, ProtectionPolicy, ReplayError, ReplayGuard, -}; - -/// The relay metadata schema emitted by [`SealedRelayBuilder`]. -pub const CURRENT_RELAY_VERSION: u64 = 1; - -#[derive(Debug, thiserror::Error)] -pub enum RelayError { - #[error("value is not a Relay communication frame")] - NotRelay, - #[error("sealed relay frame must not expose an outer sender")] - OuterSenderPresent, - #[error("sealed relay frame must contain an explicit next-hop receiver")] - MissingNextHop, - #[error("relay frame has an invalid protected layout: {0}")] - InvalidLayout(&'static str), - #[error("relay frame does not declare a relay version")] - MissingRelayVersion, - #[error("unsupported relay version {0}")] - UnsupportedRelayVersion(u64), - #[error("relay content is addressed to a different final recipient")] - NotFinalRecipient, - #[error("relay message was already accepted")] - Replay, - #[error("relay resource limit exceeded: {0}")] - ResourceLimit(&'static str), - #[error("relay application message type is reserved: {0}")] - ReservedApplicationType(String), - #[error("protection error: {0}")] - Protection(#[from] ProtectionError), - #[error("replay guard error: {0}")] - ReplayGuard(#[from] ReplayError), -} - -/// Metadata authenticated by the signer and decryptable by metadata -/// recipients. -/// -/// `encrypted_content` is intentionally kept as an opaque `DataValue` so a -/// metadata-only relay participant can store or forward it without possessing -/// a content key. -#[derive(Debug, Clone)] -pub struct VerifiedRelayMetadata { - relay_version: u64, - signer_id: u64, - final_recipient_id: u64, - message_id: String, - created_at: u64, - metadata: Option, - encrypted_content: DataValue, - type_map: TypeMap, - matched_signer_key_index: usize, - decode_limits: DecodeLimits, - encode_limits: EncodeLimits, - protected_limits: ProtectedLimits, - // There is intentionally no public constructor. This marker documents - // that the fields originate from a successful authenticated open. - _verified: VerifiedMarker, -} - -#[derive(Debug, Clone, Copy)] -struct VerifiedMarker; - -impl VerifiedRelayMetadata { - /// Return the authenticated MTP relay metadata schema version. - pub fn relay_version(&self) -> u64 { - self.relay_version - } - - pub fn signer_id(&self) -> u64 { - self.signer_id - } - - pub fn final_recipient_id(&self) -> u64 { - self.final_recipient_id - } - - pub fn decode_limits(&self) -> DecodeLimits { - self.decode_limits - } - - pub fn protected_limits(&self) -> ProtectedLimits { - self.protected_limits - } - - pub fn encode_limits(&self) -> EncodeLimits { - self.encode_limits - } - - pub fn message_id(&self) -> &str { - &self.message_id - } - - /// Return the authenticated creation time as Unix epoch milliseconds. - pub fn created_at(&self) -> u64 { - self.created_at - } - - /// Return the authenticated application metadata without interpreting it. - pub fn metadata(&self) -> Option<&DataValue> { - self.metadata.as_ref() - } - - /// Return the authenticated content envelope for forwarding. The value - /// remains opaque to metadata-only relay participants. - pub fn encrypted_content(&self) -> &DataValue { - &self.encrypted_content - } - - /// Return the index of the trusted signing key that verified the - /// authenticated metadata. - pub fn matched_signer_key_index(&self) -> usize { - self.matched_signer_key_index - } -} - -/// Content opened and authenticated for the final recipient. -#[derive(Debug, Clone, PartialEq)] -pub struct VerifiedRelayContent { - pub signer_id: u64, - pub final_recipient_id: u64, - pub message_type: String, - pub content: DataValue, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct RelayOpenOptions { - pub policy: ProtectionPolicy, - pub decode_limits: DecodeLimits, - pub encode_limits: EncodeLimits, - pub protected_limits: ProtectedLimits, -} - -impl RelayOpenOptions { - pub fn new(policy: ProtectionPolicy) -> Self { - Self { - policy, - decode_limits: DecodeLimits::default(), - encode_limits: EncodeLimits::default(), - protected_limits: ProtectedLimits::default(), - } - } - - pub const fn with_limits( - mut self, - decode_limits: DecodeLimits, - protected_limits: ProtectedLimits, - ) -> Self { - self.decode_limits = decode_limits; - self.protected_limits = protected_limits; - self - } - - pub const fn with_encode_limits(mut self, encode_limits: EncodeLimits) -> Self { - self.encode_limits = encode_limits; - self - } -} - -/// Native sealed-relay builder shared by non-WASM applications. -/// -/// The browser SDK and this builder intentionally produce the same reserved -/// metadata layout. Routing recipients are supplied separately from content -/// recipients so a metadata-only relay participant can open metadata without -/// receiving content keys. -pub struct SealedRelayBuilder<'a> { - message_type: String, - content: DataValue, - signer_id: u64, - final_recipient_id: u64, - next_hop_id: u64, - message_id: Option, - created_at: Option, - metadata: Option, - signer: &'a dyn SignatureScheme, - metadata_recipients: Vec, - content_recipients: Vec, - type_map: Option, - limits: ProtectedLimits, - encode_limits: EncodeLimits, -} - -impl<'a> SealedRelayBuilder<'a> { - pub fn new( - message_type: impl Into, - content: DataValue, - signer_id: u64, - final_recipient_id: u64, - next_hop_id: u64, - signer: &'a dyn SignatureScheme, - ) -> Self { - Self { - message_type: message_type.into(), - content, - signer_id, - final_recipient_id, - next_hop_id, - message_id: None, - created_at: None, - metadata: None, - signer, - metadata_recipients: Vec::new(), - content_recipients: Vec::new(), - type_map: None, - limits: ProtectedLimits::default(), - encode_limits: EncodeLimits::default(), - } - } - - pub fn message_id(mut self, value: impl Into) -> Self { - self.message_id = Some(value.into()); - self - } - - pub fn metadata(mut self, value: DataValue) -> Self { - self.metadata = Some(value); - self - } - - /// Set `CreatedAt` as Unix epoch milliseconds. - pub fn created_at(mut self, value: u64) -> Self { - self.created_at = Some(value); - self - } - - pub fn metadata_recipients(mut self, recipients: Vec) -> Self { - self.metadata_recipients = recipients; - self - } - - pub fn content_recipients(mut self, recipients: Vec) -> Self { - self.content_recipients = recipients; - self - } - - pub fn protected_limits(mut self, limits: ProtectedLimits) -> Self { - self.limits = limits; - self - } - - pub fn encode_limits(mut self, limits: EncodeLimits) -> Self { - self.encode_limits = limits; - self - } - - /// Build the reserved relay fields against a negotiated type map. The - /// default is the current map, but native callers handling an older - /// negotiated frame should pass that map explicitly. - pub fn type_map(mut self, type_map: &TypeMap) -> Self { - self.type_map = Some(type_map.clone()); - self - } - - pub fn build(self) -> Result { - let message_id = self.message_id.ok_or(RelayError::InvalidLayout( - "relay builder requires a message ID", - ))?; - let created_at = self.created_at.ok_or(RelayError::InvalidLayout( - "relay builder requires a creation timestamp", - ))?; - if self.message_type.is_empty() || message_id.is_empty() { - return Err(RelayError::InvalidLayout( - "relay builder identifiers must be non-empty", - )); - } - if message_id.len() > self.limits.max_message_id_bytes { - return Err(RelayError::ResourceLimit("message ID")); - } - if self.metadata_recipients.is_empty() || self.content_recipients.is_empty() { - return Err(RelayError::InvalidLayout( - "relay builder requires metadata and content recipients", - )); - } - if let Some(metadata) = self.metadata.as_ref() { - validate_metadata_size(metadata, &self.limits)?; - } - - let type_map = self.type_map.unwrap_or_else(TypeMap::latest); - validate_application_message_type(&self.message_type, &type_map)?; - let message_type_id = relay_field(DataType::MessageType, &type_map)?; - let content_id = relay_field(DataType::Content, &type_map)?; - let message_id_id = relay_field(DataType::MessageId, &type_map)?; - let final_recipient_id = relay_field(DataType::FinalRecipientId, &type_map)?; - let created_at_id = relay_field(DataType::CreatedAt, &type_map)?; - let metadata_id = relay_field(DataType::Metadata, &type_map)?; - let relay_version_id = relay_field(DataType::RelayVersion, &type_map)?; - - let content = DataValue::Container(vec![ - (message_type_id, DataValue::Str(self.message_type)), - (content_id, self.content), - ]); - let signed_content = content.sign_with_limits( - self.signer_id, - MtpProtectionPurpose::RelayContentSignature.into(), - self.signer, - self.encode_limits, - )?; - let encrypted_content = signed_content.encrypt_for_with_limits( - &self.content_recipients, - MtpProtectionPurpose::RelayContentEncryption.into(), - self.encode_limits, - )?; - let mut metadata_fields = vec![ - ( - relay_version_id, - DataValue::UnsignedNumber(CURRENT_RELAY_VERSION as u128), - ), - (message_id_id, DataValue::Str(message_id)), - ( - final_recipient_id, - DataValue::UnsignedNumber(self.final_recipient_id as u128), - ), - (created_at_id, DataValue::UnsignedNumber(created_at as u128)), - (content_id, encrypted_content), - ]; - if let Some(application_metadata) = self.metadata { - metadata_fields.push((metadata_id, application_metadata)); - } - let metadata = DataValue::Container(metadata_fields); - let signed_metadata = metadata.sign_with_limits( - self.signer_id, - MtpProtectionPurpose::RelayMetadataSignature.into(), - self.signer, - self.encode_limits, - )?; - let encrypted_metadata = signed_metadata.encrypt_for_with_limits( - &self.metadata_recipients, - MtpProtectionPurpose::RelayMetadataEncryption.into(), - self.encode_limits, - )?; - Ok( - CommunicationValue::new_with_type_map(CommunicationType::Relay, &type_map) - .without_sender() - .with_receiver(self.next_hop_id) - .with_payload(encrypted_metadata), - ) - } -} - -fn relay_field( - data_type: DataType, - type_map: &TypeMap, -) -> Result { - data_type - .try_to_id(type_map) - .ok_or(RelayError::InvalidLayout( - "reserved relay type is unavailable", - )) -} - -fn validate_application_message_type( - message_type: &str, - type_map: &TypeMap, -) -> Result<(), RelayError> { - let communication_type = CommunicationType::from_name(message_type) - .ok_or(RelayError::InvalidLayout("relay message type is unknown"))?; - let communication_id = - communication_type - .try_to_id(type_map) - .ok_or(RelayError::InvalidLayout( - "relay message type is unavailable", - ))?; - if communication_id.is_reserved() { - return Err(RelayError::ReservedApplicationType(message_type.to_owned())); - } - Ok(()) -} - -fn validate_metadata_size( - metadata: &DataValue, - limits: &ProtectedLimits, -) -> Result<(), RelayError> { - let encoded = metadata - .to_bytes_with_limits(EncodeLimits { - max_output_size: limits.max_metadata_encoded_bytes, - ..EncodeLimits::default() - }) - .map_err(|error| match error { - mtp_common::CodecError::TooManyEntries => RelayError::ResourceLimit("metadata"), - _ => RelayError::InvalidLayout("metadata cannot be encoded"), - })?; - if encoded.len() > limits.max_metadata_encoded_bytes { - return Err(RelayError::ResourceLimit("metadata")); - } - Ok(()) -} - -fn field<'a>( - entries: &'a [(DataTypeId, DataValue)], - data_type: DataType, - type_map: &TypeMap, -) -> Result<&'a DataValue, RelayError> { - let field_id = relay_field(data_type, type_map)?; - entries - .iter() - .find(|(id, _)| *id == field_id) - .map(|(_, value)| value) - .ok_or(RelayError::InvalidLayout("required relay field is missing")) -} - -fn string_field( - entries: &[(DataTypeId, DataValue)], - data_type: DataType, - type_map: &TypeMap, -) -> Result { - field(entries, data_type, type_map)? - .as_string() - .filter(|value| !value.is_empty()) - .ok_or(RelayError::InvalidLayout( - "relay field is not a non-empty string", - )) -} - -fn string_field_ref<'a>( - entries: &'a [(DataTypeId, DataValue)], - data_type: DataType, - type_map: &TypeMap, -) -> Result<&'a str, RelayError> { - field(entries, data_type, type_map)? - .as_str() - .filter(|value| !value.is_empty()) - .ok_or(RelayError::InvalidLayout( - "relay field is not a non-empty string", - )) -} - -fn optional_metadata_field<'a>( - entries: &'a [(DataTypeId, DataValue)], - type_map: &TypeMap, -) -> Result, RelayError> { - let field_id = relay_field(DataType::Metadata, type_map)?; - Ok(entries - .iter() - .find(|(id, _)| *id == field_id) - .map(|(_, value)| value)) -} - -fn unsigned_field( - entries: &[(DataTypeId, DataValue)], - data_type: DataType, - type_map: &TypeMap, -) -> Result { - field(entries, data_type, type_map)? - .as_unsigned_number() - .ok_or(RelayError::InvalidLayout("relay field is not unsigned")) -} - -#[derive(Debug)] -struct RelayMetadataV1 { - final_recipient_id: u64, - message_id: String, - created_at: u64, - metadata: Option, - encrypted_content: DataValue, -} - -fn relay_version( - entries: &[(DataTypeId, DataValue)], - type_map: &TypeMap, -) -> Result { - let version_id = relay_field(DataType::RelayVersion, type_map)?; - let version = entries - .iter() - .find(|(id, _)| *id == version_id) - .map(|(_, value)| value) - .ok_or(RelayError::MissingRelayVersion)? - .as_unsigned_number() - .ok_or(RelayError::InvalidLayout("relay version is not unsigned"))?; - u64::try_from(version).map_err(|_| RelayError::InvalidLayout("relay version is out of range")) -} - -fn parse_relay_v1( - entries: &[(DataTypeId, DataValue)], - type_map: &TypeMap, - limits: &ProtectedLimits, -) -> Result { - let final_recipient_id = u64::try_from(unsigned_field( - entries, - DataType::FinalRecipientId, - type_map, - )?) - .map_err(|_| RelayError::InvalidLayout("final recipient ID is out of range"))?; - let created_at = u64::try_from(unsigned_field(entries, DataType::CreatedAt, type_map)?) - .map_err(|_| RelayError::InvalidLayout("created-at value is out of range"))?; - let encrypted_content = field(entries, DataType::Content, type_map)?.clone(); - if encrypted_content.as_encrypted().is_none() { - return Err(RelayError::InvalidLayout("content is not encrypted")); - } - let message_id = string_field_ref(entries, DataType::MessageId, type_map)?; - if message_id.len() > limits.max_message_id_bytes { - return Err(RelayError::ResourceLimit("message ID")); - } - let metadata = optional_metadata_field(entries, type_map)?; - if let Some(metadata) = metadata { - validate_metadata_size(metadata, limits)?; - } - Ok(RelayMetadataV1 { - final_recipient_id, - message_id: message_id.to_owned(), - created_at, - metadata: metadata.cloned(), - encrypted_content, - }) -} - -pub fn open_relay_metadata_checked( - frame: &CommunicationValue, - keyring: &Keyring, - expected_signer_id: u64, - signer_public_key: &PublicKeyBundle, - options: RelayOpenOptions, - replay_guard: &mut dyn ReplayGuard, -) -> Result { - open_relay_metadata_with_limits_checked( - frame, - std::slice::from_ref(&keyring), - Some(expected_signer_id), - |_| Some(vec![signer_public_key.clone()]), - options, - replay_guard, - ) -} - -pub fn open_relay_metadata_without_replay( - frame: &CommunicationValue, - keyring: &Keyring, - expected_signer_id: u64, - signer_public_key: &PublicKeyBundle, - options: RelayOpenOptions, -) -> Result { - open_relay_metadata_with_limits_without_replay( - frame, - std::slice::from_ref(&keyring), - Some(expected_signer_id), - |_| Some(vec![signer_public_key.clone()]), - options, - ) -} - -fn validate_relay_frame(frame: &CommunicationValue) -> Result<(), RelayError> { - if !frame.is_type(CommunicationType::Relay) { - return Err(RelayError::NotRelay); - } - if frame.sender().is_some() { - return Err(RelayError::OuterSenderPresent); - } - if frame.receiver().is_none() { - return Err(RelayError::MissingNextHop); - } - Ok(()) -} - -/// Return the claimed signer ID from relay metadata without verifying its -/// signature or interpreting the versioned relay schema. The result is -/// untrusted and may only select the key history that is then bound to the -/// same signer ID during [`open_relay_metadata_with_keys`]. -#[deprecated(note = "use relay_metadata_claimed_signer_id_with_limits")] -pub fn relay_metadata_claimed_signer_id( - frame: &CommunicationValue, - keyrings: &[&Keyring], -) -> Result { - // Migrate to `relay_metadata_claimed_signer_id_with_limits` at receive boundaries. - relay_metadata_claimed_signer_id_with_limits(frame, keyrings, DecodeLimits::default()) -} - -pub fn relay_metadata_claimed_signer_id_with_limits( - frame: &CommunicationValue, - keyrings: &[&Keyring], - decode_limits: DecodeLimits, -) -> Result { - relay_metadata_claimed_signer_id_with_options( - frame, - keyrings, - decode_limits, - ProtectedLimits::default(), - ) -} - -/// Return the claimed relay signer ID while applying the complete receive -/// policy, including the caller's decryption-key history bound. -pub fn relay_metadata_claimed_signer_id_with_options( - frame: &CommunicationValue, - keyrings: &[&Keyring], - decode_limits: DecodeLimits, - protected_limits: ProtectedLimits, -) -> Result { - validate_relay_frame(frame)?; - if keyrings.len() > protected_limits.max_decryption_key_history { - return Err(RelayError::ResourceLimit("decryption key history")); - } - - let decrypted = frame.payload().decrypt_with_keyrings_and_limits( - keyrings, - MtpProtectionPurpose::RelayMetadataEncryption.into(), - decode_limits, - )?; - let signed = decrypted - .as_signed() - .ok_or(RelayError::InvalidLayout("metadata is not signed"))?; - Ok(signed.signer_id) -} - -/// Decrypt and verify relay metadata using recipient-key history and a -/// signer-key resolver. The resolver receives a claimed, unverified signer -/// ID used only as a trusted-key lookup key. The ID becomes authenticated -/// only after signature verification. This is the native counterpart of the -/// browser relay API and supports sealed sender plus signing-key rotation. -pub fn open_relay_metadata_with_checked( - frame: &CommunicationValue, - keyrings: &[&Keyring], - expected_signer_id: Option, - resolve_signer_keys: F, - options: RelayOpenOptions, - replay_guard: &mut dyn ReplayGuard, -) -> Result -where - F: FnOnce(u64) -> Option>, -{ - open_relay_metadata_with_limits_checked( - frame, - keyrings, - expected_signer_id, - resolve_signer_keys, - options, - replay_guard, - ) -} - -/// Open relay metadata for message processing with replay protection required -/// by the type system. -pub fn open_relay_metadata_with_limits_checked( - frame: &CommunicationValue, - keyrings: &[&Keyring], - expected_signer_id: Option, - resolve_signer_keys: F, - options: RelayOpenOptions, - replay_guard: &mut dyn ReplayGuard, -) -> Result -where - F: FnOnce(u64) -> Option>, -{ - open_relay_metadata_impl( - frame, - keyrings, - expected_signer_id, - resolve_signer_keys, - options, - Some(replay_guard), - ) -} - -pub fn open_relay_metadata_with_without_replay( - frame: &CommunicationValue, - keyrings: &[&Keyring], - expected_signer_id: Option, - resolve_signer_keys: F, - options: RelayOpenOptions, -) -> Result -where - F: FnOnce(u64) -> Option>, -{ - open_relay_metadata_with_limits_without_replay( - frame, - keyrings, - expected_signer_id, - resolve_signer_keys, - options, - ) -} - -/// Open relay metadata for stored/forensic use without replay protection. -/// The name makes the security trade-off explicit at the call site. -pub fn open_relay_metadata_with_limits_without_replay( - frame: &CommunicationValue, - keyrings: &[&Keyring], - expected_signer_id: Option, - resolve_signer_keys: F, - options: RelayOpenOptions, -) -> Result -where - F: FnOnce(u64) -> Option>, -{ - open_relay_metadata_impl( - frame, - keyrings, - expected_signer_id, - resolve_signer_keys, - options, - None, - ) -} - -fn open_relay_metadata_impl( - frame: &CommunicationValue, - keyrings: &[&Keyring], - expected_signer_id: Option, - resolve_signer_keys: F, - options: RelayOpenOptions, - mut replay_guard: Option<&mut dyn ReplayGuard>, -) -> Result -where - F: FnOnce(u64) -> Option>, -{ - validate_relay_frame(frame)?; - - if keyrings.len() > options.protected_limits.max_decryption_key_history { - return Err(RelayError::ResourceLimit("decryption key history")); - } - - let type_map = frame.type_map().cloned().unwrap_or_else(TypeMap::latest); - let decrypted = frame.payload().decrypt_with_keyrings_and_limits( - keyrings, - MtpProtectionPurpose::RelayMetadataEncryption.into(), - options.decode_limits, - )?; - let signed = decrypted - .as_signed() - .ok_or(RelayError::InvalidLayout("metadata is not signed"))?; - if let Some(expected_signer_id) = expected_signer_id - && signed.signer_id != expected_signer_id - { - return Err(ProtectionError::SignerIdMismatch { - expected: expected_signer_id, - actual: signed.signer_id, - } - .into()); - } - let signer_keys = resolve_signer_keys(signed.signer_id) - .ok_or(ProtectionError::SignerKeyNotFound(signed.signer_id))?; - if signer_keys.len() > options.protected_limits.max_signer_key_history { - return Err(RelayError::ResourceLimit("signer key history")); - } - let matched_signer_key_index = signed.verify_with_key_history_index_and_limits( - signed.signer_id, - &signer_keys, - MtpProtectionPurpose::RelayMetadataSignature.into(), - options.policy, - options.encode_limits, - )?; - /* Verification leaves the signed envelope owned by `decrypted`; inspect - its entries in place to avoid a second attacker-controlled clone. */ - let metadata = signed - .value - .container_entries() - .ok_or(RelayError::InvalidLayout("metadata is not a container"))?; - let relay_version = relay_version(metadata, &type_map)?; - let parsed = match relay_version { - 1 => parse_relay_v1(metadata, &type_map, &options.protected_limits)?, - other => return Err(RelayError::UnsupportedRelayVersion(other)), - }; - if parsed.message_id.len() > options.protected_limits.max_message_id_bytes { - return Err(RelayError::ResourceLimit("message ID")); - } - let result = VerifiedRelayMetadata { - relay_version, - signer_id: signed.signer_id, - final_recipient_id: parsed.final_recipient_id, - message_id: parsed.message_id, - created_at: parsed.created_at, - metadata: parsed.metadata, - encrypted_content: parsed.encrypted_content, - type_map, - matched_signer_key_index, - decode_limits: options.decode_limits, - encode_limits: options.encode_limits, - protected_limits: options.protected_limits, - _verified: VerifiedMarker, - }; - if let Some(guard) = replay_guard.as_mut() - && !guard.accept(result.signer_id, &result.message_id, result.created_at)? - { - return Err(RelayError::Replay); - } - Ok(result) -} - -/// Open and verify content after metadata has been authenticated. -#[deprecated(note = "use open_relay_content_with_limits_without_replay")] -pub fn open_relay_content( - metadata: &VerifiedRelayMetadata, - keyring: &Keyring, - signer_public_key: &PublicKeyBundle, - expected_recipient_id: u64, - policy: ProtectionPolicy, -) -> Result { - open_relay_content_with_limits_without_replay( - metadata, - std::slice::from_ref(&keyring), - std::slice::from_ref(signer_public_key), - Some(expected_recipient_id), - RelayOpenOptions { - policy, - decode_limits: metadata.decode_limits, - encode_limits: metadata.encode_limits, - protected_limits: metadata.protected_limits, - }, - ) -} - -/// Open relay content against trusted signing-key history for the metadata's -/// authenticated signer ID. -#[deprecated(note = "use open_relay_content_with_limits_without_replay")] -pub fn open_relay_content_with_keys( - metadata: &VerifiedRelayMetadata, - keyring: &Keyring, - signer_public_keys: &[PublicKeyBundle], - expected_recipient_id: u64, - policy: ProtectionPolicy, -) -> Result { - open_relay_content_with_limits_without_replay( - metadata, - std::slice::from_ref(&keyring), - signer_public_keys, - Some(expected_recipient_id), - RelayOpenOptions { - policy, - decode_limits: metadata.decode_limits, - encode_limits: metadata.encode_limits, - protected_limits: metadata.protected_limits, - }, - ) -} - -/// Open relay content against recipient-key history and trusted signing-key -/// history. The expected final recipient is optional for callers that only -/// have decryption material and do not have a local identity ID. -#[deprecated(note = "use open_relay_content_with_limits_without_replay")] -pub fn open_relay_content_with_keyrings( - metadata: &VerifiedRelayMetadata, - keyrings: &[&Keyring], - signer_public_keys: &[PublicKeyBundle], - expected_recipient_id: Option, - policy: ProtectionPolicy, -) -> Result { - // Migrate to `open_relay_content_with_limits_without_replay` to keep the decode policy - // explicit across metadata and content opening. - open_relay_content_with_limits_without_replay( - metadata, - keyrings, - signer_public_keys, - expected_recipient_id, - RelayOpenOptions { - policy, - decode_limits: metadata.decode_limits, - encode_limits: metadata.encode_limits, - protected_limits: metadata.protected_limits, - }, - ) -} - -#[deprecated(note = "use open_relay_content_with_limits_without_replay")] -pub fn open_relay_content_with_keyrings_and_limits( - metadata: &VerifiedRelayMetadata, - keyrings: &[&Keyring], - signer_public_keys: &[PublicKeyBundle], - expected_recipient_id: Option, - options: RelayOpenOptions, -) -> Result { - open_relay_content_with_limits_without_replay( - metadata, - keyrings, - signer_public_keys, - expected_recipient_id, - options, - ) -} - -/// Compatibility alias for callers that already hold authenticated relay -/// metadata. New code should use the explicit `_without_replay` name. -#[deprecated(note = "use open_relay_content_with_limits_without_replay")] -pub fn open_relay_content_with_limits( - metadata: &VerifiedRelayMetadata, - keyrings: &[&Keyring], - signer_public_keys: &[PublicKeyBundle], - expected_recipient_id: Option, - options: RelayOpenOptions, -) -> Result { - open_relay_content_with_limits_without_replay( - metadata, - keyrings, - signer_public_keys, - expected_recipient_id, - options, - ) -} - -/// Open relay content after the authenticated metadata operation without -/// making a second replay decision. Replay is consumed by the metadata -/// processing boundary; this explicit name prevents callers from mistaking -/// content opening for an independent replay check. -pub fn open_relay_content_with_limits_without_replay( - metadata: &VerifiedRelayMetadata, - keyrings: &[&Keyring], - signer_public_keys: &[PublicKeyBundle], - expected_recipient_id: Option, - options: RelayOpenOptions, -) -> Result { - let options = RelayOpenOptions { - policy: options.policy, - decode_limits: restrict_decode_limits(options.decode_limits, metadata.decode_limits), - encode_limits: restrict_encode_limits(options.encode_limits, metadata.encode_limits), - protected_limits: restrict_protected_limits( - options.protected_limits, - metadata.protected_limits, - ), - }; - if expected_recipient_id.is_some_and(|id| metadata.final_recipient_id != id) { - return Err(RelayError::NotFinalRecipient); - } - if keyrings.len() > options.protected_limits.max_decryption_key_history { - return Err(RelayError::ResourceLimit("decryption key history")); - } - if signer_public_keys.len() > options.protected_limits.max_signer_key_history { - return Err(RelayError::ResourceLimit("signer key history")); - } - - let type_map = &metadata.type_map; - let decrypted = metadata - .encrypted_content - .decrypt_with_keyrings_and_limits( - keyrings, - MtpProtectionPurpose::RelayContentEncryption.into(), - options.decode_limits, - )?; - let signed = decrypted - .as_signed() - .ok_or(RelayError::InvalidLayout("content is not signed"))?; - if signed.signer_id != metadata.signer_id { - return Err(RelayError::InvalidLayout( - "metadata and content signer IDs differ", - )); - } - // Content is a separately signed value and must not inherit a weaker - // metadata policy. - signed.verify_with_key_history_and_limits( - metadata.signer_id, - signer_public_keys, - MtpProtectionPurpose::RelayContentSignature.into(), - options.policy, - options.encode_limits, - )?; - let content = signed - .value - .container_entries() - .ok_or(RelayError::InvalidLayout("content is not a container"))?; - let message_type = string_field(content, DataType::MessageType, type_map)?; - validate_application_message_type(&message_type, type_map)?; - Ok(VerifiedRelayContent { - signer_id: signed.signer_id, - final_recipient_id: metadata.final_recipient_id, - message_type, - content: field(content, DataType::Content, type_map)?.clone(), - }) -} - -fn restrict_decode_limits(left: DecodeLimits, right: DecodeLimits) -> DecodeLimits { - DecodeLimits { - max_depth: left.max_depth.min(right.max_depth), - max_values: left.max_values.min(right.max_values), - max_blob_size: left.max_blob_size.min(right.max_blob_size), - max_recipients: left.max_recipients.min(right.max_recipients), - max_allocated_bytes: left.max_allocated_bytes.min(right.max_allocated_bytes), - } -} - -fn restrict_encode_limits(left: EncodeLimits, right: EncodeLimits) -> EncodeLimits { - EncodeLimits { - max_depth: left.max_depth.min(right.max_depth), - max_values: left.max_values.min(right.max_values), - max_output_size: left.max_output_size.min(right.max_output_size), - } -} - -fn restrict_protected_limits(left: ProtectedLimits, right: ProtectedLimits) -> ProtectedLimits { - ProtectedLimits { - max_message_id_bytes: left.max_message_id_bytes.min(right.max_message_id_bytes), - max_metadata_encoded_bytes: left - .max_metadata_encoded_bytes - .min(right.max_metadata_encoded_bytes), - max_signer_key_history: left - .max_signer_key_history - .min(right.max_signer_key_history), - max_decryption_key_history: left - .max_decryption_key_history - .min(right.max_decryption_key_history), - } -} - -/// Change only the clear next-hop routing field of a sealed relay frame. -/// The authenticated encrypted payload is cloned byte-for-byte, so a relay -/// cannot alter the final recipient or message metadata while forwarding. -pub fn forward_relay_frame( - frame: &CommunicationValue, - next_hop_receiver_id: u64, -) -> Result { - validate_relay_frame(frame)?; - Ok(frame.clone().with_receiver(next_hop_receiver_id)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::InMemoryReplayGuard; - use mtp_crypto::{Ed25519Signer, Keyring, PublicKeyBundle}; - use mtp_type_map::DataTypeId; - - fn ed_signer(keyring: &Keyring) -> Ed25519Signer { - Ed25519Signer::new(&keyring.sig_cl_secret_key).expect("Ed25519 signer") - } - - fn content_open_options( - metadata: &VerifiedRelayMetadata, - policy: ProtectionPolicy, - ) -> RelayOpenOptions { - RelayOpenOptions { - policy, - decode_limits: metadata.decode_limits, - encode_limits: metadata.encode_limits, - protected_limits: metadata.protected_limits, - } - } - - // Test-only compatibility shims keep older fixture setup readable while - // routing every invocation to an explicit replay choice in production. - fn open_relay_metadata( - frame: &CommunicationValue, - keyring: &Keyring, - expected_signer_id: u64, - signer_public_key: &PublicKeyBundle, - policy: ProtectionPolicy, - ) -> Result { - super::open_relay_metadata_without_replay( - frame, - keyring, - expected_signer_id, - signer_public_key, - RelayOpenOptions::new(policy), - ) - } - - fn open_relay_metadata_with( - frame: &CommunicationValue, - keyrings: &[&Keyring], - expected_signer_id: Option, - resolve_signer_keys: F, - policy: ProtectionPolicy, - replay_guard: Option<&mut dyn ReplayGuard>, - ) -> Result - where - F: Fn(u64) -> Option>, - { - match replay_guard { - Some(replay_guard) => super::open_relay_metadata_with_checked( - frame, - keyrings, - expected_signer_id, - resolve_signer_keys, - RelayOpenOptions::new(policy), - replay_guard, - ), - None => super::open_relay_metadata_with_without_replay( - frame, - keyrings, - expected_signer_id, - resolve_signer_keys, - RelayOpenOptions::new(policy), - ), - } - } - - fn open_relay_metadata_with_keys( - frame: &CommunicationValue, - keyrings: &[&Keyring], - expected_signer_id: u64, - signer_public_keys: &[PublicKeyBundle], - policy: ProtectionPolicy, - ) -> Result { - let signer_public_keys = signer_public_keys.to_vec(); - super::open_relay_metadata_with_limits_without_replay( - frame, - keyrings, - Some(expected_signer_id), - move |_| Some(signer_public_keys), - RelayOpenOptions::new(policy), - ) - } - - fn relay_frame_with_version( - version: Option, - include_v1_fields: bool, - sender: &Keyring, - recipient: &Keyring, - ) -> CommunicationValue { - let signer = ed_signer(sender); - let type_map = TypeMap::latest(); - let mut fields = Vec::new(); - if let Some(version) = version { - fields.push(( - relay_field(DataType::RelayVersion, &type_map).expect("RelayVersion mapping"), - DataValue::UnsignedNumber(version as u128), - )); - } - if include_v1_fields { - let content = DataValue::Container(vec![ - ( - relay_field(DataType::MessageType, &type_map).expect("MessageType mapping"), - DataValue::Str("ProtectedMessage".into()), - ), - ( - relay_field(DataType::Content, &type_map).expect("Content mapping"), - DataValue::Null, - ), - ]) - .sign( - 7, - MtpProtectionPurpose::RelayContentSignature.into(), - &signer, - ) - .expect("sign content") - .encrypt_for( - &[recipient.public_key_bundle()], - MtpProtectionPurpose::RelayContentEncryption.into(), - ) - .expect("encrypt content"); - fields.extend([ - ( - relay_field(DataType::MessageId, &type_map).expect("MessageId mapping"), - DataValue::Str("version-test".into()), - ), - ( - relay_field(DataType::FinalRecipientId, &type_map) - .expect("FinalRecipientId mapping"), - DataValue::UnsignedNumber(42), - ), - ( - relay_field(DataType::CreatedAt, &type_map).expect("CreatedAt mapping"), - DataValue::UnsignedNumber(123), - ), - ( - relay_field(DataType::Metadata, &type_map).expect("Metadata mapping"), - DataValue::Null, - ), - ( - relay_field(DataType::Content, &type_map).expect("Content mapping"), - content, - ), - ]); - } - let payload = DataValue::Container(fields) - .sign( - 7, - MtpProtectionPurpose::RelayMetadataSignature.into(), - &signer, - ) - .expect("sign metadata") - .encrypt_for( - &[recipient.public_key_bundle()], - MtpProtectionPurpose::RelayMetadataEncryption.into(), - ) - .expect("encrypt metadata"); - CommunicationValue::new_with_type_map(CommunicationType::Relay, &type_map) - .without_sender() - .with_receiver(9) - .with_payload(payload) - } - - #[test] - fn relay_version_one_round_trips() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let frame = - relay_frame_with_version(Some(CURRENT_RELAY_VERSION), true, &sender, &recipient); - let metadata = open_relay_metadata( - &frame, - &recipient, - 7, - &sender.public_key_bundle(), - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), - ) - .expect("version 1 metadata"); - assert_eq!(metadata.relay_version(), CURRENT_RELAY_VERSION); - } - - #[test] - fn missing_relay_version_is_rejected() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let frame = relay_frame_with_version(None, true, &sender, &recipient); - let result = open_relay_metadata( - &frame, - &recipient, - 7, - &sender.public_key_bundle(), - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), - ); - assert!(matches!(result, Err(RelayError::MissingRelayVersion))); - } - - #[test] - fn unknown_relay_version_is_rejected_before_v1_fields_are_parsed() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let frame = relay_frame_with_version(Some(99), false, &sender, &recipient); - let result = open_relay_metadata( - &frame, - &recipient, - 7, - &sender.public_key_bundle(), - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), - ); - assert!(matches!( - result, - Err(RelayError::UnsupportedRelayVersion(99)) - )); - } - - #[test] - fn relay_created_at_preserves_exact_milliseconds() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let signer = ed_signer(&sender); - let created_at = 1_720_000_000_123; - let frame = SealedRelayBuilder::new( - "ProtectedMessage", - DataValue::Str("timestamp content".into()), - 11, - 42, - 42, - &signer, - ) - .message_id("relay-created-at-millis") - .created_at(created_at) - .metadata_recipients(vec![recipient.public_key_bundle()]) - .content_recipients(vec![recipient.public_key_bundle()]) - .build() - .expect("relay frame"); - - let type_map = frame.type_map().cloned().unwrap_or_else(TypeMap::latest); - let decrypted = frame - .payload() - .decrypt( - &recipient, - MtpProtectionPurpose::RelayMetadataEncryption.into(), - ) - .expect("metadata decryption"); - let signed = decrypted.as_signed().expect("signed metadata"); - let created_at_id = DataType::CreatedAt - .try_to_id(&type_map) - .expect("CreatedAt mapping"); - - assert_eq!( - signed.value.get_field(created_at_id), - Some(&DataValue::UnsignedNumber(created_at as u128)) - ); - let verified = open_relay_metadata( - &frame, - &recipient, - 11, - &sender.public_key_bundle(), - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), - ) - .expect("verified metadata"); - assert_eq!(verified.created_at(), created_at); - } - - #[test] - fn relay_metadata_policy_and_replay_guard_are_receiver_controls() { - let sender = Keyring::generate(); - let metadata_recipient = Keyring::generate(); - let final_recipient = Keyring::generate(); - let signer = ed_signer(&sender); - let application_metadata = DataValue::Container(vec![ - (DataTypeId(32), DataValue::Str("opaque-field".into())), - (DataTypeId(33), DataValue::UnsignedNumber(7)), - ]); - let frame = SealedRelayBuilder::new( - "ProtectedMessage", - DataValue::Str("hello".into()), - 7, - 42, - 9, - &signer, - ) - .message_id("message-1") - .created_at(123) - .metadata(application_metadata.clone()) - .metadata_recipients(vec![ - metadata_recipient.public_key_bundle(), - final_recipient.public_key_bundle(), - ]) - .content_recipients(vec![final_recipient.public_key_bundle()]) - .build() - .expect("relay frame"); - let frame = - CommunicationValue::from_bytes(&frame.to_bytes().expect("relay frame encoding")) - .expect("relay frame decoding"); - - assert_eq!(frame.sender(), None); - assert_eq!(frame.receiver(), Some(9)); - - let policy = ProtectionPolicy::from(crate::SignaturePolicy::Ed25519); - let mut guard = InMemoryReplayGuard::default(); - let metadata = open_relay_metadata_with( - &frame, - &[&metadata_recipient], - None, - |signer_id| (signer_id == 7).then(|| vec![sender.public_key_bundle()]), - policy, - Some(&mut guard), - ) - .expect("metadata"); - assert_eq!(metadata.signer_id(), 7); - assert_eq!(metadata.relay_version(), CURRENT_RELAY_VERSION); - assert_eq!(metadata.final_recipient_id(), 42); - assert_eq!(metadata.message_id(), "message-1"); - assert_eq!(metadata.created_at(), 123); - assert_eq!(metadata.metadata(), Some(&application_metadata)); - - let mut limited_options = RelayOpenOptions::new(policy); - limited_options.protected_limits.max_message_id_bytes = 1; - let mut limited_guard = InMemoryReplayGuard::default(); - assert!(matches!( - open_relay_metadata_with_limits_checked( - &frame, - &[&metadata_recipient], - None, - |signer_id| (signer_id == 7).then(|| vec![sender.public_key_bundle()]), - limited_options, - &mut limited_guard, - ), - Err(RelayError::ResourceLimit("message ID")) - )); - assert_eq!(limited_guard.len(), 0); - - // A final recipient may be included in the metadata recipient set and - // therefore open both authenticated layers directly. - let final_metadata = open_relay_metadata( - &frame, - &final_recipient, - 7, - &sender.public_key_bundle(), - policy, - ) - .expect("final recipient metadata"); - assert_eq!(final_metadata.metadata(), Some(&application_metadata)); - let final_content = open_relay_content_with_limits_without_replay( - &final_metadata, - &[&final_recipient], - std::slice::from_ref(&sender.public_key_bundle()), - Some(42), - content_open_options(&final_metadata, policy), - ) - .expect("final recipient content"); - assert_eq!(final_content.content, DataValue::Str("hello".into())); - - assert!(matches!( - open_relay_metadata( - &frame, - &metadata_recipient, - 8, - &sender.public_key_bundle(), - policy, - ), - Err(RelayError::Protection(ProtectionError::SignerIdMismatch { - expected: 8, - actual: 7, - })) - )); - - let wrong_signer = Keyring::generate(); - assert!(matches!( - open_relay_metadata( - &frame, - &metadata_recipient, - 7, - &wrong_signer.public_key_bundle(), - policy, - ), - Err(RelayError::Protection(_)) - )); - - assert!(matches!( - open_relay_metadata( - &frame.clone().with_sender(99), - &metadata_recipient, - 7, - &sender.public_key_bundle(), - policy, - ), - Err(RelayError::OuterSenderPresent) - )); - - assert!( - open_relay_content_with_limits_without_replay( - &metadata, - &[&metadata_recipient], - std::slice::from_ref(&sender.public_key_bundle()), - Some(42), - content_open_options(&metadata, policy), - ) - .is_err() - ); - - let content = open_relay_content_with_limits_without_replay( - &metadata, - &[&final_recipient], - std::slice::from_ref(&sender.public_key_bundle()), - Some(42), - content_open_options(&metadata, policy), - ) - .expect("content"); - assert_eq!(content.signer_id, 7); - assert_eq!(content.final_recipient_id, 42); - assert_eq!(content.message_type, "ProtectedMessage"); - assert_eq!(content.content, DataValue::Str("hello".into())); - assert!(matches!( - open_relay_content_with_limits_without_replay( - &metadata, - &[&final_recipient], - std::slice::from_ref(&sender.public_key_bundle()), - Some(43), - content_open_options(&metadata, policy), - ), - Err(RelayError::NotFinalRecipient) - )); - - let replay = open_relay_metadata_with( - &frame, - &[&metadata_recipient], - None, - |signer_id| (signer_id == 7).then(|| vec![sender.public_key_bundle()]), - policy, - Some(&mut guard), - ); - assert!(matches!(replay, Err(RelayError::Replay))); - } - - #[test] - fn relay_metadata_preserves_generic_values_and_absence() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let signer = ed_signer(&sender); - let values = vec![ - DataValue::Null, - DataValue::Bool(true), - DataValue::Str("scalar metadata".into()), - DataValue::Bytes(vec![1, 2, 3]), - DataValue::Array(vec![DataValue::UnsignedNumber(7), DataValue::BoolFalse]), - DataValue::Container(vec![(DataTypeId(32), DataValue::Str("typed".into()))]), - ]; - - for (index, value) in values.into_iter().enumerate() { - let frame = - SealedRelayBuilder::new("ProtectedMessage", DataValue::Null, 7, 42, 9, &signer) - .message_id(format!("metadata-{index}")) - .created_at(123) - .metadata(value.clone()) - .metadata_recipients(vec![recipient.public_key_bundle()]) - .content_recipients(vec![recipient.public_key_bundle()]) - .build() - .expect("relay frame"); - let opened = open_relay_metadata( - &frame, - &recipient, - 7, - &sender.public_key_bundle(), - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), - ) - .expect("relay metadata"); - assert_eq!(opened.metadata(), Some(&value)); - } - - let absent = - SealedRelayBuilder::new("ProtectedMessage", DataValue::Null, 7, 42, 9, &signer) - .message_id("metadata-absent") - .created_at(123) - .metadata_recipients(vec![recipient.public_key_bundle()]) - .content_recipients(vec![recipient.public_key_bundle()]) - .build() - .expect("relay frame"); - let opened = open_relay_metadata( - &absent, - &recipient, - 7, - &sender.public_key_bundle(), - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), - ) - .expect("relay metadata"); - assert_eq!(opened.metadata(), None); - } - - #[test] - fn relay_keyring_boundary_helpers_open_verified_handles() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let signer = ed_signer(&sender); - let frame = SealedRelayBuilder::new( - "ProtectedMessage", - DataValue::Str("hello".into()), - 7, - 42, - 9, - &signer, - ) - .message_id("boundary-message") - .created_at(123) - .metadata_recipients(vec![recipient.public_key_bundle()]) - .content_recipients(vec![recipient.public_key_bundle()]) - .build() - .expect("relay frame"); - - assert_eq!( - relay_metadata_claimed_signer_id_with_limits( - &frame, - &[&recipient], - DecodeLimits::default(), - ) - .expect("signer ID"), - 7 - ); - let metadata = open_relay_metadata_with_keys( - &frame, - &[&recipient], - 7, - &[sender.public_key_bundle()], - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), - ) - .expect("relay metadata"); - let content = open_relay_content_with_limits_without_replay( - &metadata, - &[&recipient], - &[sender.public_key_bundle()], - Some(42), - content_open_options( - &metadata, - ProtectionPolicy::from(crate::SignaturePolicy::Ed25519), - ), - ) - .expect("relay content"); - - assert_eq!(content.signer_id, 7); - assert_eq!(content.final_recipient_id, 42); - assert_eq!(content.message_type, "ProtectedMessage"); - assert_eq!(content.content, DataValue::Str("hello".into())); - } - - #[test] - fn relay_content_opening_accepts_previous_recipient_key_history() { - let sender = Keyring::generate(); - let metadata_recipient = Keyring::generate(); - let current_content_recipient = Keyring::generate(); - let previous_content_recipient = Keyring::generate(); - let signer = ed_signer(&sender); - let frame = SealedRelayBuilder::new( - "ProtectedMessage", - DataValue::Str("opened with a previous recipient key".into()), - 7, - 42, - 9, - &signer, - ) - .message_id("recipient-rotation-1") - .created_at(123) - .metadata_recipients(vec![metadata_recipient.public_key_bundle()]) - .content_recipients(vec![previous_content_recipient.public_key_bundle()]) - .build() - .expect("relay frame"); - let policy = ProtectionPolicy::from(crate::SignaturePolicy::Ed25519); - - let metadata = open_relay_metadata_with( - &frame, - &[&metadata_recipient], - Some(7), - |signer_id| (signer_id == 7).then(|| vec![sender.public_key_bundle()]), - policy, - None, - ) - .expect("relay metadata"); - - let content = open_relay_content_with_limits_without_replay( - &metadata, - &[¤t_content_recipient, &previous_content_recipient], - &[sender.public_key_bundle()], - Some(42), - content_open_options(&metadata, policy), - ) - .expect("previous content recipient key should decrypt"); - - assert_eq!(content.signer_id, 7); - assert_eq!(content.final_recipient_id, 42); - assert_eq!(content.message_type, "ProtectedMessage"); - assert_eq!( - content.content, - DataValue::Str("opened with a previous recipient key".into()) - ); - } - - #[test] - fn dual_policy_rejects_a_valid_ed25519_relay() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let signer = ed_signer(&sender); - let frame = SealedRelayBuilder::new("ProtectedMessage", DataValue::Null, 7, 42, 9, &signer) - .message_id("message-2") - .created_at(123) - .metadata_recipients(vec![recipient.public_key_bundle()]) - .content_recipients(vec![recipient.public_key_bundle()]) - .build() - .expect("relay frame"); - - let result = open_relay_metadata( - &frame, - &recipient, - 7, - &sender.public_key_bundle(), - ProtectionPolicy::from(crate::SignaturePolicy::Dual), - ); - assert!(matches!( - result, - Err(RelayError::Protection( - ProtectionError::SignaturePolicyMismatch { .. } - )) - )); - } - - #[test] - fn forwarding_changes_only_the_outer_next_hop() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let signer = ed_signer(&sender); - let frame = SealedRelayBuilder::new("ProtectedMessage", DataValue::Null, 7, 42, 9, &signer) - .message_id("message-3") - .created_at(123) - .metadata_recipients(vec![recipient.public_key_bundle()]) - .content_recipients(vec![recipient.public_key_bundle()]) - .build() - .expect("relay frame"); - let forwarded = forward_relay_frame(&frame, 10).expect("forward"); - assert_eq!(frame.payload(), forwarded.payload()); - assert_eq!( - frame.payload().to_bytes().expect("original payload bytes"), - forwarded - .payload() - .to_bytes() - .expect("forwarded payload bytes") - ); - assert_eq!(forwarded.receiver(), Some(10)); - assert_eq!(forwarded.sender(), None); - } - - #[test] - fn relay_signer_key_rotation_accepts_previous_key_history() { - let old_signer_keyring = Keyring::generate(); - let current_signer_keyring = Keyring::generate(); - let recipient = Keyring::generate(); - let old_signer = ed_signer(&old_signer_keyring); - let old_signer_public = old_signer_keyring.public_key_bundle(); - let current_signer_public = current_signer_keyring.public_key_bundle(); - let frame = SealedRelayBuilder::new( - "ProtectedMessage", - DataValue::Str("signed with the previous key".into()), - 77, - 42, - 9, - &old_signer, - ) - .message_id("rotation-1") - .created_at(456) - .metadata(DataValue::Str("rotation metadata".into())) - .metadata_recipients(vec![recipient.public_key_bundle()]) - .content_recipients(vec![recipient.public_key_bundle()]) - .build() - .expect("relay frame"); - let policy = ProtectionPolicy::from(crate::SignaturePolicy::Ed25519); - - let metadata = open_relay_metadata_with( - &frame, - &[&recipient], - Some(77), - |signer_id| { - (signer_id == 77) - .then(|| vec![current_signer_public.clone(), old_signer_public.clone()]) - }, - policy, - None, - ) - .expect("metadata signed by a previous key should verify"); - assert_eq!(metadata.matched_signer_key_index(), 1); - let content = open_relay_content_with_limits_without_replay( - &metadata, - &[&recipient], - &[current_signer_public, old_signer_public], - Some(42), - content_open_options(&metadata, policy), - ) - .expect("content signed by a previous key should verify"); - assert_eq!(content.message_type, "ProtectedMessage"); - assert_eq!( - content.content, - DataValue::Str("signed with the previous key".into()) - ); - } - - #[test] - fn builder_preserves_the_negotiated_type_map_context() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let signer = ed_signer(&sender); - let type_map = TypeMap::latest(); - let frame = SealedRelayBuilder::new("ProtectedMessage", DataValue::Null, 7, 42, 9, &signer) - .message_id("message-4") - .created_at(123) - .metadata_recipients(vec![recipient.public_key_bundle()]) - .content_recipients(vec![recipient.public_key_bundle()]) - .type_map(&type_map) - .build() - .expect("relay frame"); - - assert_eq!(frame.type_map(), Some(&type_map)); - } - - #[test] - fn builder_rejects_reserved_application_message_types() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let signer = ed_signer(&sender); - let result = SealedRelayBuilder::new("Ping", DataValue::Null, 7, 42, 9, &signer) - .message_id("reserved-message") - .created_at(123) - .metadata_recipients(vec![recipient.public_key_bundle()]) - .content_recipients(vec![recipient.public_key_bundle()]) - .build(); - - assert!(matches!( - result, - Err(RelayError::ReservedApplicationType(type_name)) if type_name == "Ping" - )); - } -} diff --git a/common/Cargo.lock b/common/Cargo.lock index 8d0f18c..cf2d91b 100644 --- a/common/Cargo.lock +++ b/common/Cargo.lock @@ -3,1465 +3,14 @@ version = 4 [[package]] -name = "asn1-rs" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "aws-lc-rs" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" -dependencies = [ - "aws-lc-sys", - "untrusted 0.7.1", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bit-vec" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" -dependencies = [ - "serde", -] - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cc" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "chacha20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures", - "rand_core", -] - -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "data-encoding" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" - -[[package]] -name = "der-parser" -version = "10.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom", - "num-bigint", - "num-traits", - "rusticata-macros", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", -] - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "find-msvc-tools" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi", - "rand_core", - "wasm-bindgen", -] - -[[package]] -name = "httlib-huffman" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877" - -[[package]] -name = "hybrid-array" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" -dependencies = [ - "typenum", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "mio" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "mtp-common" -version = "0.3.0" -dependencies = [ - "quinn", - "rustls", - "thiserror", - "wtransport", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "octets" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "866cb5af6f3aa3c1b44c3c2d79d22165fbb1b102e1b3fb499864bfe34736ec4b" - -[[package]] -name = "oid-registry" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" -dependencies = [ - "asn1-rs", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quinn" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core", -] - -[[package]] -name = "rcgen" -version = "0.14.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" -dependencies = [ - "aws-lc-rs", - "rustls-pki-types", - "time", - "x509-parser", - "yasna", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted 0.9.0", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom", -] - -[[package]] -name = "rustls" -version = "0.23.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" -dependencies = [ - "aws-lc-rs", - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted 0.9.0", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasm-bindgen" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "wtransport" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4273ce3157a3262a68665f8d3f20a0ac0c5b8a69ffd67f05ae986832ebec036" -dependencies = [ - "bytes", - "pem", - "quinn", - "rcgen", - "rustls", - "rustls-native-certs", - "rustls-pki-types", - "sha2", - "socket2", - "thiserror", - "time", - "tokio", - "tracing", - "url", - "wtransport-proto", - "x509-parser", -] - -[[package]] -name = "wtransport-proto" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aad9059572c7dbd6901ccef37f3b7321678cd708dcf58a64b1921dbeab7bfede" -dependencies = [ - "httlib-huffman", - "octets", - "thiserror", - "url", -] - -[[package]] -name = "x509-parser" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" -dependencies = [ - "asn1-rs", - "aws-lc-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom", - "oid-registry", - "rusticata-macros", - "thiserror", - "time", -] - -[[package]] -name = "yasna" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" -dependencies = [ - "bit-vec", - "time", -] - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] +name = "common" +version = "0.1.0" +quinn = { version = "0.11.9", default-features = false, features = [ + "rustls-aws-lc-rs", + "rustls", +] } +wtransport = { version = "0.7.1", default-features = false, features = [ + "aws-lc-rs", + "quinn", + "self-signed", +] } diff --git a/common/Cargo.toml b/common/Cargo.toml index 5fae68e..fe1a703 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,20 +1,18 @@ [package] name = "mtp-common" -version = "0.3.0" +version = "0.1.0" edition = "2024" [dependencies] thiserror = "2.0.18" -[features] -pipes = [] - [target.'cfg(not(target_arch = "wasm32"))'.dependencies] wtransport = { version = "0.7.1", default-features = false, features = [ "aws-lc-rs", "quinn", "self-signed", ] } +rustls = { version = "0.23.41" } quinn = { version = "0.11.11", default-features = false, features = [ "rustls-aws-lc-rs", "rustls", diff --git a/common/src/lib.rs b/common/src/lib.rs index 71e87f2..71dea88 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -1,32 +1,5 @@ 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::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 { - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| TimeError::BeforeUnixEpoch)?; - duration_to_unix_time_millis(duration) -} - #[derive(Clone, Debug, Error, PartialEq, Eq)] pub enum CodecError { #[error("Unknown version")] @@ -41,10 +14,6 @@ pub enum CodecError { InvalidEncoding, #[error("Too many entries to encode")] TooManyEntries, - #[error("Missing negotiated type map")] - MissingTypeMap, - #[error("Type-map mismatch: expected {expected}, actual {actual}")] - TypeMapMismatch { expected: String, actual: String }, #[error("Crypto failed: {0}")] CryptoFailed(String), #[error("Missing required field: {0}")] @@ -56,24 +25,6 @@ pub enum CodecError { mod tests { 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] fn test_codec_error_display() { let e = CodecError::InvalidEncoding; @@ -99,6 +50,7 @@ mod tests { * wrappers) is available. On WASM only the transport-independent subset is * compiled. */ +#[cfg(not(target_arch = "wasm32"))] #[derive(Debug, Error, Clone)] pub enum CommunicationError { #[error("Use after Closed")] @@ -114,7 +66,6 @@ pub enum CommunicationError { ConnectionLost, #[error("QUIC error: {0}")] - #[cfg(not(target_arch = "wasm32"))] Quinn(#[from] quinn::ConnectionError), #[error("ParseCommunicationValue error")] @@ -133,29 +84,24 @@ pub enum CommunicationError { ParseError(String), #[error("Connection error: {0}")] - #[cfg(not(target_arch = "wasm32"))] ConnectionError(#[from] wtransport::error::ConnectionError), #[error("Connecting error: {0}")] ConnectingError(String), #[error("ReadToEnd error: {0}")] - #[cfg(not(target_arch = "wasm32"))] ReadToEndError(#[from] quinn::ReadToEndError), #[error("Write error: {0}")] - #[cfg(not(target_arch = "wasm32"))] WriteError(#[from] quinn::WriteError), #[error("Closed error: {0}")] - #[cfg(not(target_arch = "wasm32"))] ClosedError(#[from] quinn::ClosedStream), #[error("Message too large")] MessageTooLarge, #[error("ReadExactError: {0}")] - #[cfg(not(target_arch = "wasm32"))] ReadExactError(#[from] quinn::ReadExactError), #[error("Stream Closed")] @@ -164,15 +110,10 @@ pub enum CommunicationError { #[error("Stream Error")] StreamError, - #[error("Stream failed after delivery may have started")] - DeliveryUnknown, - #[error("Stream Error: {0}")] - #[cfg(not(target_arch = "wasm32"))] StreamWriteError(#[from] wtransport::error::StreamWriteError), #[error("Read Exact Error: {0}")] - #[cfg(not(target_arch = "wasm32"))] StreamReadExactError(#[from] wtransport::error::StreamReadExactError), #[error("Crypto Provider Install Error")] @@ -185,40 +126,61 @@ pub enum CommunicationError { Other(String), } -/// How the protocol layer should handle the first frame on a receive stream. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FirstFrameDisposition { - Message, - Pipe(u32), -} +#[cfg(target_arch = "wasm32")] +#[derive(Debug, Error, Clone)] +pub enum CommunicationError { + #[error("Use after Closed")] + UseAfterClosed, -/// Classify a first frame without tying the decision to a WebTransport backend. -/// -/// `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, - pipe_is_expected: bool, -) -> Result { - if !is_pipe_request { - return Ok(FirstFrameDisposition::Message); - } + #[error("Connection closed by local shutdown")] + ClosedLocally, - let pipe_id = pipe_id.filter(|id| *id != 0).ok_or_else(|| { - CommunicationError::Other("PipeRequest frame must contain a non-zero id".into()) - })?; + #[error("Connection closed by peer")] + ClosedByPeer, - if pipe_is_expected { - Ok(FirstFrameDisposition::Pipe(pipe_id)) - } else { - Ok(FirstFrameDisposition::Message) - } + #[error("Connection terminated unexpectedly")] + ConnectionLost, + + #[error("ParseCommunicationValue error")] + 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) ---- +#[cfg(not(target_arch = "wasm32"))] impl PartialEq for CommunicationError { fn eq(&self, other: &Self) -> bool { match (self, other) { @@ -226,31 +188,22 @@ impl PartialEq for CommunicationError { (Self::ClosedLocally, Self::ClosedLocally) => true, (Self::ClosedByPeer, Self::ClosedByPeer) => true, (Self::ConnectionLost, Self::ConnectionLost) => true, - #[cfg(not(target_arch = "wasm32"))] (Self::Quinn(_), Self::Quinn(_)) => true, (Self::ParseCommunicationValue, Self::ParseCommunicationValue) => true, (Self::Encode, Self::Encode) => true, (Self::CertificateParseFailed, Self::CertificateParseFailed) => true, (Self::CertificateLoadFailed, Self::CertificateLoadFailed) => true, (Self::ParseError(a), Self::ParseError(b)) => a == b, - #[cfg(not(target_arch = "wasm32"))] (Self::ConnectionError(_), Self::ConnectionError(_)) => true, (Self::ConnectingError(a), Self::ConnectingError(b)) => a == b, - #[cfg(not(target_arch = "wasm32"))] (Self::ReadToEndError(_), Self::ReadToEndError(_)) => true, - #[cfg(not(target_arch = "wasm32"))] (Self::WriteError(_), Self::WriteError(_)) => true, - #[cfg(not(target_arch = "wasm32"))] (Self::ClosedError(_), Self::ClosedError(_)) => true, (Self::MessageTooLarge, Self::MessageTooLarge) => true, - #[cfg(not(target_arch = "wasm32"))] (Self::ReadExactError(_), Self::ReadExactError(_)) => true, (Self::StreamClosed, Self::StreamClosed) => true, (Self::StreamError, Self::StreamError) => true, - (Self::DeliveryUnknown, Self::DeliveryUnknown) => true, - #[cfg(not(target_arch = "wasm32"))] (Self::StreamWriteError(_), Self::StreamWriteError(_)) => true, - #[cfg(not(target_arch = "wasm32"))] (Self::StreamReadExactError(_), Self::StreamReadExactError(_)) => true, (Self::CryptoProviderInstallFailed, Self::CryptoProviderInstallFailed) => true, (Self::AuthenticationFailed(a), Self::AuthenticationFailed(b)) => a == b, @@ -260,93 +213,36 @@ impl PartialEq for CommunicationError { } } +#[cfg(target_arch = "wasm32")] +impl PartialEq for CommunicationError { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::UseAfterClosed, Self::UseAfterClosed) => true, + (Self::ClosedLocally, Self::ClosedLocally) => true, + (Self::ClosedByPeer, Self::ClosedByPeer) => true, + (Self::ConnectionLost, Self::ConnectionLost) => true, + (Self::ParseCommunicationValue, Self::ParseCommunicationValue) => true, + (Self::Encode, Self::Encode) => true, + (Self::CertificateParseFailed, Self::CertificateParseFailed) => true, + (Self::CertificateLoadFailed, Self::CertificateLoadFailed) => true, + (Self::ParseError(a), Self::ParseError(b)) => a == b, + (Self::ConnectingError(a), Self::ConnectingError(b)) => a == b, + (Self::MessageTooLarge, Self::MessageTooLarge) => true, + (Self::StreamClosed, Self::StreamClosed) => true, + (Self::StreamError, Self::StreamError) => true, + (Self::CryptoProviderInstallFailed, Self::CryptoProviderInstallFailed) => true, + (Self::AuthenticationFailed(a), Self::AuthenticationFailed(b)) => a == b, + (Self::Other(a), Self::Other(b)) => a == b, + _ => false, + } + } +} + +#[cfg(not(target_arch = "wasm32"))] impl Eq for CommunicationError {} -/* ================================ PipeError ================================ */ - -#[cfg(feature = "pipes")] -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PipeError { - Rejected, - HandshakeTimeout, - StreamClosed, - IoError(String), - ConnectionClosed, -} - -#[cfg(feature = "pipes")] -impl std::fmt::Display for PipeError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PipeError::Rejected => write!(f, "pipe request was rejected"), - PipeError::HandshakeTimeout => write!(f, "pipe handshake timed out"), - 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(feature = "pipes")] -impl std::error::Error for PipeError {} - -#[cfg(feature = "pipes")] -impl From 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 }, - 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}"), - } - } -} +#[cfg(target_arch = "wasm32")] +impl Eq for CommunicationError {} /* ================================ TESTS ================================ */ #[cfg(test)] @@ -395,132 +291,3 @@ mod communication_error_tests { 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); - } -} diff --git a/create-web-release.mjs b/create-web-release.mjs deleted file mode 100644 index 690fa5c..0000000 --- a/create-web-release.mjs +++ /dev/null @@ -1,243 +0,0 @@ -#!/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 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; -}); diff --git a/crypto/Cargo.lock b/crypto/Cargo.lock index 5b86012..c708c11 100644 --- a/crypto/Cargo.lock +++ b/crypto/Cargo.lock @@ -3,1408 +3,5 @@ version = 4 [[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common 0.1.7", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - -[[package]] -name = "asn1-rs" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror 2.0.20", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "aws-lc-rs" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bit-vec" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" -dependencies = [ - "serde", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "cc" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chacha20" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "chacha20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - -[[package]] -name = "chacha20poly1305" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" -dependencies = [ - "aead", - "chacha20 0.9.1", - "cipher", - "poly1305", - "zeroize", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout", - "zeroize", -] - -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "typenum", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "getrandom 0.4.3", - "hybrid-array", - "rand_core 0.10.1", -] - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "fiat-crypto 0.2.9", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "curve25519-dalek-derive", - "digest 0.11.3", - "fiat-crypto 0.3.0", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "data-encoding" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" - -[[package]] -name = "der" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "der-parser" -version = "10.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom", - "num-bigint", - "num-traits", - "rusticata-macros", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "const-oid", - "crypto-common 0.2.2", - "ctutils", -] - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "ed25519" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" -dependencies = [ - "curve25519-dalek 5.0.0", - "ed25519", - "serde", - "sha2", - "signature", - "subtle", - "zeroize", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - -[[package]] -name = "fiat-crypto" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" - -[[package]] -name = "find-msvc-tools" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi", - "rand_core 0.10.1", - "wasm-bindgen", -] - -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - -[[package]] -name = "hkdf" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - -[[package]] -name = "hybrid-array" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" -dependencies = [ - "ctutils", - "typenum", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" -dependencies = [ - "cfg-if", - "wasm-bindgen", -] - -[[package]] -name = "keccak" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" -dependencies = [ - "cpufeatures 0.2.17", -] - -[[package]] -name = "keccak" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd9697dc4a9a62e2da93389f34400b77a28f0287711263cabb203b3ccb9c0e4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "ml-dsa" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" -dependencies = [ - "const-oid", - "crypto-common 0.2.2", - "ctutils", - "hybrid-array", - "module-lattice", - "pkcs8", - "shake", - "signature", -] - -[[package]] -name = "mlkem-rs" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b0965b8b081668ff0398dc5e9dc3f2ebb9e833393f4ab5b9f725ddce11acef8" -dependencies = [ - "rand_core 0.6.4", - "serde", - "sha3", - "subtle", - "zeroize", -] - -[[package]] -name = "mlkem-tls" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77b205d031298adf904d88efd6a57862d8650a4ab754aade19a9b5e87040bf4e" -dependencies = [ - "mlkem-rs", - "rand_core 0.6.4", - "subtle", - "x25519-dalek", - "zeroize", -] - -[[package]] -name = "module-lattice" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" -dependencies = [ - "ctutils", - "hybrid-array", - "num-traits", -] - -[[package]] -name = "mtp-crypto" -version = "0.3.0" -dependencies = [ - "aes-gcm", - "base64", - "chacha20poly1305", - "ed25519-dalek", - "getrandom 0.4.3", - "hkdf", - "ml-dsa", - "mlkem-tls", - "rand", - "rand_core 0.6.4", - "rcgen", - "rustls", - "serde", - "sha2", - "thiserror 1.0.69", - "time", - "tokio", - "zeroize", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "oid-registry" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" -dependencies = [ - "asn1-rs", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - -[[package]] -name = "pem-rfc7468" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" -dependencies = [ - "base64ct", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkcs8" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "poly1305" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" -dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20 0.10.2", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rcgen" -version = "0.14.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" -dependencies = [ - "pem", - "ring", - "rustls-pki-types", - "time", - "x509-parser", - "yasna", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys", -] - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom", -] - -[[package]] -name = "rustls" -version = "0.23.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" -dependencies = [ - "aws-lc-rs", - "log", - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[package]] -name = "sha3" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" -dependencies = [ - "digest 0.10.7", - "keccak 0.1.6", -] - -[[package]] -name = "shake" +name = "crypto" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" -dependencies = [ - "digest 0.11.3", - "keccak 0.2.1", - "sponge-cursor", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signature" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" -dependencies = [ - "digest 0.11.3", - "rand_core 0.10.1", -] - -[[package]] -name = "spki" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "sponge-cursor" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl 2.0.20", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "pin-project-lite", - "tokio-macros", -] - -[[package]] -name = "tokio-macros" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasm-bindgen" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "x25519-dalek" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" -dependencies = [ - "curve25519-dalek 4.1.3", - "rand_core 0.6.4", - "serde", - "zeroize", -] - -[[package]] -name = "x509-parser" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" -dependencies = [ - "asn1-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom", - "oid-registry", - "ring", - "rusticata-macros", - "thiserror 2.0.20", - "time", -] - -[[package]] -name = "yasna" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" -dependencies = [ - "bit-vec", - "time", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] diff --git a/crypto/Cargo.toml b/crypto/Cargo.toml index 88a37f4..26031c7 100644 --- a/crypto/Cargo.toml +++ b/crypto/Cargo.toml @@ -1,15 +1,15 @@ [package] name = "mtp-crypto" -version = "0.3.0" +version = "0.1.0" edition = "2024" [package.metadata.cargo-machete] -ignored = ["rand_core"] +ignored = ["getrandom"] [dependencies] chacha20poly1305 = { version = "0.10", optional = true } aes-gcm = { version = "0.10", optional = true } -ed25519-dalek = { version = "3.0", optional = true, features = [ +ed25519-dalek = { version = "2.2", optional = true, features = [ "pkcs8", "pem", ] } @@ -17,31 +17,17 @@ hkdf = { version = "0.13", optional = true } sha2 = { version = "0.11", optional = true } zeroize = { version = "1.9", features = ["derive"] } thiserror = "1" -base64 = "0.22" rand_core = { version = "0.6", features = ["getrandom"] } -rand = "0.10.2" getrandom = "0.4.3" mlkem-tls = { version = "0.2", optional = true } ml-dsa = { version = "0.1.1", optional = true } -argon2 = { version = "0.5", optional = true } 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] -default = ["chacha20poly1305", "ed25519-dalek", "hkdf", "sha2", "ml-dsa", "parallel"] +default = ["chacha20poly1305", "ed25519-dalek", "hkdf", "sha2", "ml-dsa"] # Enabling ml-dsa by default ensures dual-signature support in the handshake # without requiring a separate PQC feature flag in host/client crates. full = ["default", "aes-gcm"] pqc = ["mlkem-tls", "ml-dsa"] serde = ["dep:serde"] wasm = ["getrandom/wasm_js"] -hkdf = ["dep:hkdf", "dep:sha2"] -sha2 = ["dep:sha2"] -tls = ["dep:rcgen", "dep:time"] -parallel = ["dep:tokio"] -password-kdf = ["dep:argon2"] diff --git a/crypto/README.md b/crypto/README.md new file mode 100644 index 0000000..80e8f88 --- /dev/null +++ b/crypto/README.md @@ -0,0 +1,113 @@ +# 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 +``` diff --git a/crypto/src/aead.rs b/crypto/src/aead.rs index af8b942..d708d7f 100644 --- a/crypto/src/aead.rs +++ b/crypto/src/aead.rs @@ -1,19 +1,10 @@ use crate::error::CryptoError; #[cfg(any(feature = "chacha20poly1305", feature = "aes-gcm"))] -use zeroize::Zeroizing; +use rand_core::OsRng; #[cfg(any(feature = "chacha20poly1305", feature = "aes-gcm"))] -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; +use rand_core::RngCore; pub trait AeadEncrypt { fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result, CryptoError>; @@ -36,31 +27,29 @@ fn prepend_nonce(nonce: &[u8], ciphertext: &mut Vec) -> Vec { } #[cfg(feature = "chacha20poly1305")] -pub struct XChaCha20Poly1305 { - key: Zeroizing<[u8; 32]>, +pub struct ChaCha20Poly1305 { + key: [u8; 32], } #[cfg(feature = "chacha20poly1305")] -impl XChaCha20Poly1305 { +impl ChaCha20Poly1305 { pub fn new(key: [u8; 32]) -> Self { - Self { - key: Zeroizing::new(key), - } + Self { key } } } #[cfg(feature = "chacha20poly1305")] -impl AeadEncrypt for XChaCha20Poly1305 { +impl AeadEncrypt for ChaCha20Poly1305 { fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result, CryptoError> { use chacha20poly1305::XChaCha20Poly1305; use chacha20poly1305::XNonce; use chacha20poly1305::aead::{Aead, KeyInit, Payload}; - let key = chacha20poly1305::Key::from_slice(self.key.as_ref()); + let key = chacha20poly1305::Key::from_slice(&self.key); let cipher = XChaCha20Poly1305::new(key); - let mut nonce = [0u8; XCHACHA20POLY1305_NONCE_LEN]; - fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?; + let mut nonce = [0u8; 24]; + OsRng.fill_bytes(&mut nonce); let nonce_ref = XNonce::from_slice(&nonce); let payload = Payload { @@ -77,18 +66,18 @@ impl AeadEncrypt for XChaCha20Poly1305 { } #[cfg(feature = "chacha20poly1305")] -impl AeadDecrypt for XChaCha20Poly1305 { +impl AeadDecrypt for ChaCha20Poly1305 { fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result, CryptoError> { use chacha20poly1305::XChaCha20Poly1305; use chacha20poly1305::XNonce; use chacha20poly1305::aead::{Aead, KeyInit, Payload}; - if ciphertext.len() < XCHACHA20POLY1305_NONCE_LEN + AUTH_TAG_LEN { + if ciphertext.len() < 24 { return Err(CryptoError::InvalidNonceLength); } - let (nonce, ct) = ciphertext.split_at(XCHACHA20POLY1305_NONCE_LEN); - let key = chacha20poly1305::Key::from_slice(self.key.as_ref()); + let (nonce, ct) = ciphertext.split_at(24); + let key = chacha20poly1305::Key::from_slice(&self.key); let cipher = XChaCha20Poly1305::new(key); let nonce_ref = XNonce::from_slice(nonce); @@ -101,28 +90,21 @@ impl AeadDecrypt for XChaCha20Poly1305 { } #[cfg(feature = "chacha20poly1305")] -impl AeadCipher for XChaCha20Poly1305 { +impl AeadCipher for ChaCha20Poly1305 { fn key_size() -> usize { 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")] pub struct Aes256Gcm { - key: Zeroizing<[u8; 32]>, + key: [u8; 32], } #[cfg(feature = "aes-gcm")] impl Aes256Gcm { pub fn new(key: [u8; 32]) -> Self { - Self { - key: Zeroizing::new(key), - } + Self { key } } } @@ -133,11 +115,11 @@ impl AeadEncrypt for Aes256Gcm { use aes_gcm::Nonce; use aes_gcm::aead::{Aead, KeyInit, Payload}; - let key = aes_gcm::Key::::from_slice(self.key.as_ref()); + let key = aes_gcm::Key::::from_slice(&self.key); let cipher = AesGcmInner::new(key); - let mut nonce = [0u8; AES256GCM_NONCE_LEN]; - fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?; + let mut nonce = [0u8; 12]; + OsRng.fill_bytes(&mut nonce); let nonce_ref = Nonce::from_slice(&nonce); let payload = Payload { @@ -160,12 +142,12 @@ impl AeadDecrypt for Aes256Gcm { use aes_gcm::Nonce; use aes_gcm::aead::{Aead, KeyInit, Payload}; - if ciphertext.len() < AES256GCM_NONCE_LEN + AUTH_TAG_LEN { + if ciphertext.len() < 12 { return Err(CryptoError::InvalidNonceLength); } - let (nonce, ct) = ciphertext.split_at(AES256GCM_NONCE_LEN); - let key = aes_gcm::Key::::from_slice(self.key.as_ref()); + let (nonce, ct) = ciphertext.split_at(12); + let key = aes_gcm::Key::::from_slice(&self.key); let cipher = AesGcmInner::new(key); let nonce_ref = Nonce::from_slice(nonce); diff --git a/crypto/src/auth.rs b/crypto/src/auth.rs index 260243b..86186d6 100644 --- a/crypto/src/auth.rs +++ b/crypto/src/auth.rs @@ -11,7 +11,7 @@ * Step 4. Host -> Client : IdentificationResponse { connected, id, host_sig } host_sig over host_final_payload */ -/// Domain-separation tags +/// Domain-separation tags — a distinct leading byte per signed context. pub mod domain { /// Host's signature over the challenge it issues (step 2). pub const CHALLENGE: u8 = 0x10; diff --git a/crypto/src/enc.rs b/crypto/src/enc.rs index 2ba0215..3b7a44b 100644 --- a/crypto/src/enc.rs +++ b/crypto/src/enc.rs @@ -1,15 +1,19 @@ #[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] use crate::error::CryptoError; -#[cfg(feature = "mlkem-tls")] +#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] +use crate::kdf::derive_encryption_key; +#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] use crate::kem::HybridKem; +#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] +use crate::keypair::{Keyring, PublicKeyBundle}; /* - * Algorithm selector for encrypted values. + * Algorithm selector for encrypted containers. * * Mirrors `SigAlgorithm` for signatures: a single marking byte identifies the * key-encapsulation mechanism and the AEAD used to seal a container. The byte - * is stored as the first byte of every encrypted envelope so the decryptor can pick + * is stored as the first byte of every encrypted blob so the decryptor can pick * the matching algorithm (and the matching keypair from a `Keyring`) without * any out-of-band agreement. * @@ -30,7 +34,7 @@ impl EncryptionType { pub const ML_KEM_CHACHA20POLY1305: u8 = 0x01; pub const ML_KEM_AES256_GCM: u8 = 0x02; - /// The marking byte written at the front of an encrypted envelope. + /// The marking byte written at the front of an encrypted blob. pub const fn to_byte(self) -> u8 { match self { Self::MlKemChaCha20Poly1305 => Self::ML_KEM_CHACHA20POLY1305, @@ -46,50 +50,6 @@ impl EncryptionType { _ => 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) - } } /* @@ -99,7 +59,7 @@ impl EncryptionType { */ #[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] #[allow(unused_variables)] -pub fn seal_with_key( +fn aead_seal( enc_type: EncryptionType, key: [u8; 32], plaintext: &[u8], @@ -110,7 +70,7 @@ pub fn seal_with_key( match enc_type { #[cfg(feature = "chacha20poly1305")] EncryptionType::MlKemChaCha20Poly1305 => { - crate::aead::XChaCha20Poly1305::new(key).encrypt(plaintext, aad) + crate::aead::ChaCha20Poly1305::new(key).encrypt(plaintext, aad) } #[cfg(feature = "aes-gcm")] EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).encrypt(plaintext, aad), @@ -126,7 +86,7 @@ pub fn seal_with_key( */ #[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] #[allow(unused_variables)] -pub fn open_with_key( +fn aead_open( enc_type: EncryptionType, key: [u8; 32], ciphertext: &[u8], @@ -137,7 +97,7 @@ pub fn open_with_key( match enc_type { #[cfg(feature = "chacha20poly1305")] EncryptionType::MlKemChaCha20Poly1305 => { - crate::aead::XChaCha20Poly1305::new(key).decrypt(ciphertext, aad) + crate::aead::ChaCha20Poly1305::new(key).decrypt(ciphertext, aad) } #[cfg(feature = "aes-gcm")] EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).decrypt(ciphertext, aad), @@ -146,51 +106,76 @@ pub fn open_with_key( } } +#[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, 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, 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)] mod tests { 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] fn encryption_type_byte_roundtrip() { for t in [ @@ -203,19 +188,59 @@ mod tests { assert_eq!(EncryptionType::from_byte(0xFF), None); } + #[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))] #[test] - fn suite_lengths_are_derived_from_the_selected_primitives() { - assert_eq!( - EncryptionType::MlKemChaCha20Poly1305.wrapped_key_len(), - EncryptionType::CONTENT_ENCRYPTION_KEY_LEN - + crate::aead::XCHACHA20POLY1305_NONCE_LEN - + crate::aead::AUTH_TAG_LEN - ); - assert_eq!( - EncryptionType::MlKemAes256Gcm.wrapped_key_len(), - EncryptionType::CONTENT_ENCRYPTION_KEY_LEN - + crate::aead::AES256GCM_NONCE_LEN - + crate::aead::AUTH_TAG_LEN - ); + fn encrypt_for_roundtrip() { + let kr = Keyring::generate(); + let blob = encrypt_for( + EncryptionType::MlKemChaCha20Poly1305, + &kr.public_key_bundle(), + b"secret payload", + b"aad", + ) + .unwrap(); + assert_eq!(blob[0], EncryptionType::ML_KEM_CHACHA20POLY1305); + + let pt = decrypt_with(&blob, &kr, b"aad").unwrap(); + 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()); } } diff --git a/crypto/src/error.rs b/crypto/src/error.rs index b7b9c48..c1a535f 100644 --- a/crypto/src/error.rs +++ b/crypto/src/error.rs @@ -6,18 +6,8 @@ pub enum CryptoError { EncryptionFailed, #[error("decryption failed")] 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")] InvalidKeyLength, - #[error("public and private key material do not match")] - InvalidKeyMaterial, #[error("invalid nonce length")] InvalidNonceLength, #[error("invalid signature")] @@ -38,8 +28,4 @@ pub enum CryptoError { UnknownAlgorithm, #[error("invalid hex encoding")] InvalidHex, - #[error("invalid base64 encoding")] - InvalidBase64, - #[error("TLS error: {0}")] - Tls(String), } diff --git a/crypto/src/helper.rs b/crypto/src/helper.rs index 6254cd8..fe68178 100644 --- a/crypto/src/helper.rs +++ b/crypto/src/helper.rs @@ -1,471 +1,198 @@ -// Canonical multi-recipient encryption envelopes. - -use crate::enc::EncryptionType; use crate::error::CryptoError; -#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] -use crate::enc::{open_with_key, seal_with_key}; -#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] +#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] +use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305}; +#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] use crate::kdf::derive_encryption_key; -#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] +#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] use crate::kem::HybridKem; -#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] +#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] use crate::keypair::{Keyring, PublicKeyBundle}; -#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] -use rand::Rng; -#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] -use zeroize::Zeroizing; +#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] +use rand_core::RngCore; -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 kem_ciphertext: Vec, pub encrypted_key: Vec, } -/// The envelope body used by `DataValue::Encrypted`. -#[derive(Debug, Clone, PartialEq, Eq)] +/* + * A payload encrypted for multiple recipients. + * + * Any recipient who possesses the corresponding `KemPrivateKey` can decrypt the message. + */ pub struct MultiEncryptedMessage { - pub encryption_type: EncryptionType, - pub purpose: u8, pub recipients: Vec, - /// The AEAD output, including its nonce as defined by the selected suite. + pub nonce: [u8; 24], pub ciphertext: Vec, } -/// Borrowed view of a canonical encrypted envelope. -/// -/// The codec uses this view while validating an attacker-controlled envelope -/// so parsing it does not first create a complete temporary copy of every -/// recipient entry and the ciphertext. -#[derive(Debug, Clone, Copy)] -pub struct MultiEncryptedMessageRef<'a> { - encryption_type: EncryptionType, - purpose: u8, - bytes: &'a [u8], - entries_start: usize, - entry_len: usize, - count: usize, - ciphertext_start: usize, -} +impl MultiEncryptedMessage { + /* + * Serialize into a compact byte vector. + * + * Format: + * - `num_recipients: u16` + * - for each recipient: + * - `kem_ct_len: u16` | `kem_ciphertext` + * - `ek_len: u16` | `encrypted_key` + * - `nonce: 24 bytes` + * - `ciphertext` (remaining) + */ + pub fn to_bytes(&self) -> Vec { + let mut out = Vec::new(); + 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()); + out.extend_from_slice(&r.kem_ciphertext); + out.extend_from_slice(&(r.encrypted_key.len() as u16).to_be_bytes()); + out.extend_from_slice(&r.encrypted_key); + } + out.extend_from_slice(&self.nonce); + out.extend_from_slice(&self.ciphertext); + out + } -impl<'a> MultiEncryptedMessageRef<'a> { - pub fn from_bytes(bytes: &'a [u8]) -> Result { - if bytes.len() < 4 { - return Err(CryptoError::MalformedEnvelope); - } - let encryption_type = - EncryptionType::from_byte(bytes[0]).ok_or(CryptoError::UnknownAlgorithm)?; - 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 - .kem_ciphertext_len() - .checked_add(encryption_type.wrapped_key_len()) - .ok_or(CryptoError::MalformedEnvelope)?; - let entries_len = count - .checked_mul(entry_len) - .ok_or(CryptoError::MalformedEnvelope)?; - let entries_start = 4usize; - let ciphertext_start = entries_start - .checked_add(entries_len) - .ok_or(CryptoError::MalformedEnvelope)?; - let ciphertext_len = bytes - .len() - .checked_sub(ciphertext_start) - .ok_or(CryptoError::MalformedEnvelope)?; - if ciphertext_len < encryption_type.minimum_ciphertext_len() { - return Err(CryptoError::MalformedEnvelope); + /// Deserialize from bytes produced by `to_bytes`. + pub fn from_bytes(bytes: &[u8]) -> Result { + let mut offset = 0; + let read_u16 = |off: &mut usize| -> Result { + let slice = bytes + .get(*off..*off + 2) + .ok_or(CryptoError::DecryptionFailed)?; + let arr: [u8; 2] = slice + .try_into() + .map_err(|_| CryptoError::DecryptionFailed)?; + *off += 2; + Ok(u16::from_be_bytes(arr)) + }; + + let num = read_u16(&mut offset)? as usize; + let mut recipients = Vec::with_capacity(num); + 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 { - encryption_type, - purpose, - bytes, - entries_start, - entry_len, - count, - ciphertext_start, + recipients, + nonce, + ciphertext, }) } - - 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 { - /// Serialize the envelope body without redundant per-recipient lengths. - pub fn to_bytes(&self) -> Result, CryptoError> { - let kem_len = self.encryption_type.kem_ciphertext_len(); - let wrapped_len = self.encryption_type.wrapped_key_len(); - let count = - u16::try_from(self.recipients.len()).map_err(|_| CryptoError::EncryptionFailed)?; - if self.recipients.is_empty() - || self.recipients.len() > MAX_RECIPIENTS - || self.ciphertext.len() < self.encryption_type.minimum_ciphertext_len() - || self - .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 { - 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 { - 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, 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, +/* + * Encrypt `plaintext` for every recipient in `entities`. + * + * Internally generates a fresh content-encryption key, encrypts the payload + * with ChaCha20-Poly1305, then KEM-encapsulates and wraps the key for each + * recipient. The returned `MultiEncryptedMessage` can be decrypted by any + * entity whose keyring contains the corresponding private KEM key. + * + * Requires the `pqc` and `chacha20poly1305` features. + */ +#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] +pub fn encrypt_multi( plaintext: &[u8], + aad: &[u8], entities: &[PublicKeyBundle], ) -> Result { - if entities.is_empty() { - return Err(CryptoError::NoRecipients); - } - if entities.len() > MAX_RECIPIENTS { - return Err(CryptoError::EncryptionFailed); - } + let mut cek = [0u8; 32]; + rand_core::OsRng.fill_bytes(&mut cek); - let mut cek = Zeroizing::new([0u8; 32]); - rand::rng().fill_bytes(cek.as_mut()); + let cipher = ChaCha20Poly1305::new(cek); + let encrypted_payload = cipher.encrypt(plaintext, aad)?; + + 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()); for entity in entities { let enc = HybridKem::encapsulate(&entity.kem_public_key)?; - let wrap_key = Zeroizing::new(derive_encryption_key( + let wrap_key = derive_encryption_key( &enc.shared_secret, - KEY_WRAP_DOMAIN, - &[encryption_type.to_byte(), purpose], - )?); - let aad = wrap_aad(encryption_type, purpose, &enc.ciphertext); - let encrypted_key = seal_with_key(encryption_type, *wrap_key, cek.as_ref(), &aad)?; + b"mtp-multi-key-wrap", + b"multi-recipient", + )?; + + let wrap_cipher = ChaCha20Poly1305::new(wrap_key); + let encrypted_key = wrap_cipher.encrypt(&cek, b"")?; + recipients.push(RecipientEntry { kem_ciphertext: enc.ciphertext, encrypted_key, }); } - let mut message = MultiEncryptedMessage { - encryption_type, - purpose, + Ok(MultiEncryptedMessage { recipients, - ciphertext: Vec::new(), - }; - let aad = payload_aad(&message)?; - message.ciphertext = seal_with_key(encryption_type, *cek, plaintext, &aad)?; - Ok(message) + nonce, + ciphertext, + }) } -/// Decrypt a canonical envelope for a recipient in `keyring`. -#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))] -pub fn decrypt_multi_for( - message: &MultiEncryptedMessage, - purpose: u8, +/* + * Decrypt a `MultiEncryptedMessage` using the recipient's `Keyring`. + * + * Tries each `RecipientEntry` until one succeeds with the given keyring's + * KEM secret key. Returns the original plaintext. + */ +#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] +pub fn decrypt_multi( + msg: &MultiEncryptedMessage, + aad: &[u8], keyring: &Keyring, ) -> Result, CryptoError> { - decrypt_multi_for_parts( - message.encryption_type, - 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, 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, - }; - let wrap_key = Zeroizing::new(derive_encryption_key( - &shared_secret, - KEY_WRAP_DOMAIN, - &[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, + for entry in &msg.recipients { + let ss = match HybridKem::decapsulate(&keyring.kem_secret_key, &entry.kem_ciphertext) { + Ok(s) => s, Err(_) => continue, }; - 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, 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], + let wrap_key = derive_encryption_key(&ss, b"mtp-multi-key-wrap", b"multi-recipient")?; + let wrap_cipher = ChaCha20Poly1305::new(wrap_key); + let cek = match wrap_cipher.decrypt(&entry.encrypted_key, b"") { + Ok(k) => k, + Err(_) => continue, }; - assert!(matches!( - message.to_bytes(), - Err(CryptoError::MalformedEnvelope) - )); + let cek_arr: [u8; 32] = cek.try_into().map_err(|_| CryptoError::DecryptionFailed)?; - 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()); + full_ct.extend_from_slice(&msg.nonce); + full_ct.extend_from_slice(&msg.ciphertext); - #[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))] - #[test] - 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()], - )?; - - assert!(matches!( - 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(()) + let data_cipher = ChaCha20Poly1305::new(cek_arr); + return data_cipher.decrypt(&full_ct, aad); } + Err(CryptoError::DecryptionFailed) } diff --git a/crypto/src/kdf.rs b/crypto/src/kdf.rs index caf90a1..9b6f1a6 100644 --- a/crypto/src/kdf.rs +++ b/crypto/src/kdf.rs @@ -36,29 +36,3 @@ pub fn derive_encryption_key( out.copy_from_slice(&key); 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) -} diff --git a/crypto/src/kem.rs b/crypto/src/kem.rs index 6995000..87ca38a 100644 --- a/crypto/src/kem.rs +++ b/crypto/src/kem.rs @@ -1,10 +1,9 @@ use crate::error::CryptoError; use crate::keypair::{KemPrivateKey, KemPublicKey}; -use zeroize::Zeroizing; pub struct Encapsulated { pub ciphertext: Vec, - pub shared_secret: Zeroizing>, + pub shared_secret: Vec, } #[cfg(feature = "mlkem-tls")] @@ -12,11 +11,6 @@ pub struct HybridKem; #[cfg(feature = "mlkem-tls")] 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) { let (ek, dk) = mlkem_tls::X25519MlKem768::keygen(&mut rand_core::OsRng); ( @@ -31,19 +25,19 @@ impl HybridKem { let (ct, ss) = mlkem_tls::X25519MlKem768::encapsulate(&ek, &mut rand_core::OsRng); Ok(Encapsulated { ciphertext: ct.as_bytes().to_vec(), - shared_secret: Zeroizing::new(ss.as_bytes().to_vec()), + shared_secret: ss.as_bytes().to_vec(), }) } pub fn decapsulate( recipient_sk: &KemPrivateKey, ciphertext: &[u8], - ) -> Result>, CryptoError> { + ) -> Result, CryptoError> { let dk = mlkem_tls::DecapsKey768::try_from(recipient_sk.as_bytes()) .map_err(|_| CryptoError::KemDecapsulationFailed)?; let ct = mlkem_tls::Ciphertext768Hybrid::try_from(ciphertext) .map_err(|_| CryptoError::KemDecapsulationFailed)?; let ss = mlkem_tls::X25519MlKem768::decapsulate(&dk, &ct); - Ok(Zeroizing::new(ss.as_bytes().to_vec())) + Ok(ss.as_bytes().to_vec()) } } diff --git a/crypto/src/keypair.rs b/crypto/src/keypair.rs index d738118..169eaa8 100644 --- a/crypto/src/keypair.rs +++ b/crypto/src/keypair.rs @@ -1,60 +1,177 @@ use std::fmt; - -use base64::Engine; -use base64::engine::general_purpose; -use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; +use zeroize::{Zeroize, ZeroizeOnDrop}; // --- Private key types --- -macro_rules! impl_private_key { - ($name:ident) => { - #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] - #[cfg_attr(feature = "serde", serde(transparent))] - #[derive(Zeroize, ZeroizeOnDrop)] - pub struct $name(Vec); +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct EncryptionPrivateKey(Vec); - impl $name { - pub fn new(bytes: Vec) -> Self { - Self(bytes) - } - pub fn as_bytes(&self) -> &[u8] { - &self.0 - } - } - - impl fmt::Debug for $name { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct(stringify!($name)) - .field("len", &self.0.len()) - .field("data", &"[REDACTED]") - .finish() - } - } - - impl AsRef<[u8]> for $name { - fn as_ref(&self) -> &[u8] { - &self.0 - } - } - - impl From> for $name { - fn from(bytes: Vec) -> Self { - Self(bytes) - } - } - - impl From<&[u8]> for $name { - fn from(bytes: &[u8]) -> Self { - Self(bytes.to_vec()) - } - } - }; +impl EncryptionPrivateKey { + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } } -impl_private_key!(EncryptionPrivateKey); -impl_private_key!(SignaturePrivateKey); -impl_private_key!(KemPrivateKey); -impl_private_key!(SignaturePqPrivateKey); +impl fmt::Debug for EncryptionPrivateKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("EncryptionPrivateKey") + .field("len", &self.0.len()) + .field("data", &"[REDACTED]") + .finish() + } +} + +impl AsRef<[u8]> for EncryptionPrivateKey { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl From> for EncryptionPrivateKey { + fn from(bytes: Vec) -> Self { + Self(bytes) + } +} + +impl From<&[u8]> for EncryptionPrivateKey { + fn from(bytes: &[u8]) -> Self { + Self(bytes.to_vec()) + } +} + +// --- + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct SignaturePrivateKey(Vec); + +impl SignaturePrivateKey { + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Debug for SignaturePrivateKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SignaturePrivateKey") + .field("len", &self.0.len()) + .field("data", &"[REDACTED]") + .finish() + } +} + +impl AsRef<[u8]> for SignaturePrivateKey { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl From> for SignaturePrivateKey { + fn from(bytes: Vec) -> Self { + Self(bytes) + } +} + +impl From<&[u8]> for SignaturePrivateKey { + fn from(bytes: &[u8]) -> Self { + Self(bytes.to_vec()) + } +} + +// --- + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct KemPrivateKey(Vec); + +impl KemPrivateKey { + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Debug for KemPrivateKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("KemPrivateKey") + .field("len", &self.0.len()) + .field("data", &"[REDACTED]") + .finish() + } +} + +impl AsRef<[u8]> for KemPrivateKey { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl From> for KemPrivateKey { + fn from(bytes: Vec) -> Self { + Self(bytes) + } +} + +impl From<&[u8]> for KemPrivateKey { + fn from(bytes: &[u8]) -> Self { + Self(bytes.to_vec()) + } +} + +// --- + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct SignaturePqPrivateKey(Vec); + +impl SignaturePqPrivateKey { + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Debug for SignaturePqPrivateKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SignaturePqPrivateKey") + .field("len", &self.0.len()) + .field("data", &"[REDACTED]") + .finish() + } +} + +impl AsRef<[u8]> for SignaturePqPrivateKey { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl From> for SignaturePqPrivateKey { + fn from(bytes: Vec) -> Self { + Self(bytes) + } +} + +impl From<&[u8]> for SignaturePqPrivateKey { + fn from(bytes: &[u8]) -> Self { + Self(bytes.to_vec()) + } +} // --- Public key types --- @@ -80,74 +197,213 @@ fn hex_to_bytes(s: &str) -> Result, crate::error::CryptoError> { .collect() } -fn bytes_to_base64(bytes: &[u8]) -> String { - general_purpose::STANDARD.encode(bytes) +// --- + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +#[derive(Clone)] +pub struct EncryptionPublicKey(Vec); + +impl EncryptionPublicKey { + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + pub fn to_hex(&self) -> String { + bytes_to_hex(&self.0) + } + pub fn from_hex(s: &str) -> Result { + hex_to_bytes(s).map(Self) + } } -fn base64_to_bytes(s: &str) -> Result, crate::error::CryptoError> { - general_purpose::STANDARD - .decode(s) - .map_err(|_| crate::error::CryptoError::InvalidBase64) +impl fmt::Debug for EncryptionPublicKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "EncryptionPublicKey({})", self.to_hex()) + } } -macro_rules! impl_public_key { - ($name:ident) => { - #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] - #[cfg_attr(feature = "serde", serde(transparent))] - #[derive(Clone)] - pub struct $name(Vec); - - impl $name { - pub fn new(bytes: Vec) -> Self { - Self(bytes) - } - pub fn as_bytes(&self) -> &[u8] { - &self.0 - } - pub fn to_hex(&self) -> String { - bytes_to_hex(&self.0) - } - pub fn from_hex(s: &str) -> Result { - hex_to_bytes(s).map(Self) - } - } - - impl fmt::Debug for $name { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}({})", stringify!($name), self.to_hex()) - } - } - - impl AsRef<[u8]> for $name { - fn as_ref(&self) -> &[u8] { - &self.0 - } - } - - impl From> for $name { - fn from(bytes: Vec) -> Self { - Self(bytes) - } - } - - impl From<&[u8]> for $name { - fn from(bytes: &[u8]) -> Self { - Self(bytes.to_vec()) - } - } - - impl From<&$name> for Vec { - fn from(key: &$name) -> Vec { - key.0.clone() - } - } - }; +impl AsRef<[u8]> for EncryptionPublicKey { + fn as_ref(&self) -> &[u8] { + &self.0 + } } -impl_public_key!(EncryptionPublicKey); -impl_public_key!(SignaturePublicKey); -impl_public_key!(KemPublicKey); -impl_public_key!(SignaturePqPublicKey); +impl From> for EncryptionPublicKey { + fn from(bytes: Vec) -> Self { + Self(bytes) + } +} + +impl From<&[u8]> for EncryptionPublicKey { + fn from(bytes: &[u8]) -> Self { + Self(bytes.to_vec()) + } +} + +impl From<&EncryptionPublicKey> for Vec { + fn from(key: &EncryptionPublicKey) -> Vec { + key.0.clone() + } +} + +// --- + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +#[derive(Clone)] +pub struct SignaturePublicKey(Vec); + +impl SignaturePublicKey { + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + pub fn to_hex(&self) -> String { + bytes_to_hex(&self.0) + } + pub fn from_hex(s: &str) -> Result { + hex_to_bytes(s).map(Self) + } +} + +impl fmt::Debug for SignaturePublicKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "SignaturePublicKey({})", self.to_hex()) + } +} + +impl AsRef<[u8]> for SignaturePublicKey { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl From> for SignaturePublicKey { + fn from(bytes: Vec) -> Self { + Self(bytes) + } +} + +impl From<&[u8]> for SignaturePublicKey { + fn from(bytes: &[u8]) -> Self { + Self(bytes.to_vec()) + } +} + +impl From<&SignaturePublicKey> for Vec { + fn from(key: &SignaturePublicKey) -> Vec { + key.0.clone() + } +} + +// --- + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +#[derive(Clone)] +pub struct KemPublicKey(Vec); + +impl KemPublicKey { + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + pub fn to_hex(&self) -> String { + bytes_to_hex(&self.0) + } + pub fn from_hex(s: &str) -> Result { + hex_to_bytes(s).map(Self) + } +} + +impl fmt::Debug for KemPublicKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "KemPublicKey({})", self.to_hex()) + } +} + +impl AsRef<[u8]> for KemPublicKey { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl From> for KemPublicKey { + fn from(bytes: Vec) -> Self { + Self(bytes) + } +} + +impl From<&[u8]> for KemPublicKey { + fn from(bytes: &[u8]) -> Self { + Self(bytes.to_vec()) + } +} + +impl From<&KemPublicKey> for Vec { + fn from(key: &KemPublicKey) -> Vec { + key.0.clone() + } +} + +// --- + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(transparent))] +#[derive(Clone)] +pub struct SignaturePqPublicKey(Vec); + +impl SignaturePqPublicKey { + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + pub fn to_hex(&self) -> String { + bytes_to_hex(&self.0) + } + pub fn from_hex(s: &str) -> Result { + hex_to_bytes(s).map(Self) + } +} + +impl fmt::Debug for SignaturePqPublicKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "SignaturePqPublicKey({})", self.to_hex()) + } +} + +impl AsRef<[u8]> for SignaturePqPublicKey { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl From> for SignaturePqPublicKey { + fn from(bytes: Vec) -> Self { + Self(bytes) + } +} + +impl From<&[u8]> for SignaturePqPublicKey { + fn from(bytes: &[u8]) -> Self { + Self(bytes.to_vec()) + } +} + +impl From<&SignaturePqPublicKey> for Vec { + fn from(key: &SignaturePqPublicKey) -> Vec { + key.0.clone() + } +} // --- Keyring --- @@ -200,35 +456,6 @@ impl Keyring { } } - /// Generates the independent KEM, classical-signature, and PQ-signature keys concurrently. - #[cfg(all( - feature = "mlkem-tls", - feature = "ml-dsa", - feature = "ed25519-dalek", - feature = "parallel" - ))] - pub async fn generate_parallel() -> Self { - let kem_handle = tokio::task::spawn_blocking(crate::kem::HybridKem::generate_keypair); - let ed_handle = tokio::task::spawn_blocking(crate::sign::Ed25519Signer::generate); - let pq_handle = tokio::task::spawn_blocking(crate::sign::MlDsaSigner::generate); - - let (kem_result, ed_result, pq_result) = tokio::join!(kem_handle, ed_handle, pq_handle); - let (kem_sk, kem_pk) = kem_result.expect("key generation task must not panic"); - let (_ed_signer, sig_cl_sk, sig_cl_pk) = - ed_result.expect("key generation task must not panic"); - let (_pq_signer, sig_pq_sk, sig_pq_pk) = - pq_result.expect("key generation task must not panic"); - - Self { - kem_public_key: kem_pk, - kem_secret_key: kem_sk, - sig_pq_public_key: sig_pq_pk, - sig_pq_secret_key: sig_pq_sk, - sig_cl_public_key: sig_cl_pk, - sig_cl_secret_key: sig_cl_sk, - } - } - pub fn public_key_bundle(&self) -> PublicKeyBundle { PublicKeyBundle { kem_public_key: self.kem_public_key.clone(), @@ -237,91 +464,7 @@ impl Keyring { } } - /// Validate the material required to produce classical signatures. This - /// intentionally permits a browser role-specific keyring without KEM or - /// PQ fields. - pub fn validate_ed25519_signing(&self) -> Result<(), crate::error::CryptoError> { - use crate::error::CryptoError; - if self.sig_cl_secret_key.as_bytes().len() != 32 - || self.sig_cl_public_key.as_bytes().len() != SIG_CL_PUBLIC_KEY_LEN - { - return Err(CryptoError::InvalidKeyLength); - } - #[cfg(feature = "ed25519-dalek")] - { - let signer = crate::sign::Ed25519Signer::new(&self.sig_cl_secret_key)?; - if signer.public_key().as_bytes() != self.sig_cl_public_key.as_bytes() { - return Err(CryptoError::InvalidKeyMaterial); - } - } - Ok(()) - } - - /// Validate material required for a hybrid Ed25519 + ML-DSA signature. - pub fn validate_dual_signing(&self) -> Result<(), crate::error::CryptoError> { - use crate::error::CryptoError; - self.validate_ed25519_signing()?; - if self.sig_pq_secret_key.as_bytes().len() != 32 - || self.sig_pq_public_key.as_bytes().len() != SIG_PQ_PUBLIC_KEY_LEN - { - return Err(CryptoError::InvalidKeyLength); - } - #[cfg(feature = "ml-dsa")] - { - let signer = - crate::sign::MlDsaSigner::new(&self.sig_pq_secret_key, &self.sig_pq_public_key)?; - if signer.public_key().as_bytes() != self.sig_pq_public_key.as_bytes() { - return Err(CryptoError::InvalidKeyMaterial); - } - } - Ok(()) - } - - /// Validate the KEM material required to decrypt envelopes addressed to - /// this keyring. This is intentionally separate from full identity - /// validation because browser and relay roles may use Ed25519-only - /// signing material while still needing a complete encryption key pair. - pub fn validate_encryption(&self) -> Result<(), crate::error::CryptoError> { - use crate::error::CryptoError; - if self.kem_public_key.as_bytes().is_empty() || self.kem_secret_key.as_bytes().is_empty() { - return Err(CryptoError::InvalidKeyLength); - } - #[cfg(feature = "mlkem-tls")] - { - let encapsulated = crate::kem::HybridKem::encapsulate(&self.kem_public_key)?; - let recovered = - crate::kem::HybridKem::decapsulate(&self.kem_secret_key, &encapsulated.ciphertext)?; - if recovered.as_slice() != encapsulated.shared_secret.as_slice() { - return Err(CryptoError::InvalidKeyMaterial); - } - } - Ok(()) - } - - /// Validate a complete identity before using it at a protocol boundary. - /// - /// `Keyring` remains permissive because browser callers may intentionally - /// hold role-specific material. Protocol paths that need encryption and - /// both signing suites should call this method explicitly. - pub fn validate_full(&self) -> Result<(), crate::error::CryptoError> { - use crate::error::CryptoError; - - self.public_key_bundle().validate()?; - self.validate_encryption()?; - if self.sig_pq_secret_key.as_bytes().len() != 32 - || self.sig_cl_secret_key.as_bytes().len() != 32 - { - return Err(CryptoError::InvalidKeyLength); - } - - #[cfg(all(feature = "mlkem-tls", feature = "ml-dsa", feature = "ed25519-dalek"))] - { - self.validate_dual_signing()?; - } - Ok(()) - } - - pub fn try_to_bytes(&self) -> Result>, crate::error::CryptoError> { + pub fn to_bytes(&self) -> Vec { let fields: &[&[u8]] = &[ self.kem_public_key.as_bytes(), self.kem_secret_key.as_bytes(), @@ -330,33 +473,25 @@ impl Keyring { self.sig_cl_public_key.as_bytes(), self.sig_cl_secret_key.as_bytes(), ]; - let mut out = Zeroizing::new(Vec::new()); + let mut out = Vec::new(); for f in fields { - let length = - u16::try_from(f.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?; - out.extend_from_slice(&length.to_be_bytes()); + out.extend_from_slice(&(f.len() as u16).to_be_bytes()); out.extend_from_slice(f); } - Ok(out) - } - - #[deprecated(note = "use try_to_bytes for the primary fallible serializer")] - pub fn to_bytes(&self) -> Result>, crate::error::CryptoError> { - self.try_to_bytes() + out } pub fn from_bytes(bytes: &[u8]) -> Result { use crate::error::CryptoError; let mut offset = 0; let read_key = |offset: &mut usize| -> Result, CryptoError> { - let slice = bytes - .get(*offset..*offset + 2) - .ok_or(CryptoError::InvalidKeyLength)?; - let len = if let Ok(arr) = <[u8; 2]>::try_from(slice) { - u16::from_be_bytes(arr) - } else { - return Err(CryptoError::InvalidKeyLength); - } as usize; + let len = u16::from_be_bytes( + bytes + .get(*offset..*offset + 2) + .ok_or(CryptoError::InvalidKeyLength)? + .try_into() + .expect("slice is 2 bytes, verified above"), + ) as usize; *offset += 2; let key = bytes .get(*offset..*offset + len) @@ -374,39 +509,6 @@ impl Keyring { sig_cl_public_key: SignaturePublicKey::new(read_key(&mut offset)?), sig_cl_secret_key: SignaturePrivateKey::new(read_key(&mut offset)?), }) - .and_then(|keyring| { - if offset == bytes.len() { - Ok(keyring) - } else { - Err(CryptoError::InvalidKeyLength) - } - }) - } - - #[deprecated(note = "use try_to_hex for the primary fallible serializer")] - pub fn to_hex(&self) -> Result { - self.try_to_hex() - } - - pub fn try_to_hex(&self) -> Result { - Ok(bytes_to_hex(&self.try_to_bytes()?)) - } - - pub fn from_hex(s: &str) -> Result { - Self::from_bytes(&hex_to_bytes(s)?) - } - - #[deprecated(note = "use try_to_base64 for the primary fallible serializer")] - pub fn to_base64(&self) -> Result { - self.try_to_base64() - } - - pub fn try_to_base64(&self) -> Result { - Ok(bytes_to_base64(&self.try_to_bytes()?)) - } - - pub fn from_base64(s: &str) -> Result { - Self::from_bytes(&base64_to_bytes(s)?) } } @@ -417,6 +519,12 @@ impl TryFrom<&[u8]> for Keyring { } } +impl From<&Keyring> for Vec { + fn from(keyring: &Keyring) -> Vec { + keyring.to_bytes() + } +} + impl fmt::Debug for Keyring { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Keyring") @@ -460,6 +568,7 @@ impl PublicKeyBundle { } } + #[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa", feature = "mlkem-tls"))] pub fn validate(&self) -> Result<(), crate::error::CryptoError> { use crate::error::CryptoError; if self.sig_cl_public_key.as_bytes().len() != SIG_CL_PUBLIC_KEY_LEN { @@ -471,70 +580,25 @@ impl PublicKeyBundle { if self.kem_public_key.as_bytes().len() != KEM_PUBLIC_KEY_LEN { return Err(CryptoError::InvalidKeyLength); } - #[cfg(feature = "ed25519-dalek")] - { - let bytes: [u8; SIG_CL_PUBLIC_KEY_LEN] = self - .sig_cl_public_key - .as_bytes() - .try_into() - .map_err(|_| CryptoError::InvalidKeyLength)?; - ed25519_dalek::VerifyingKey::from_bytes(&bytes) - .map_err(|_| CryptoError::InvalidKeyMaterial)?; - } - #[cfg(feature = "ml-dsa")] - { - let encoded = ml_dsa::EncodedVerifyingKey::::try_from( - self.sig_pq_public_key.as_bytes(), - ) - .map_err(|_| CryptoError::InvalidKeyMaterial)?; - let _ = ml_dsa::VerifyingKey::::decode(&encoded); - } - #[cfg(feature = "mlkem-tls")] - { - crate::kem::HybridKem::encapsulate(&self.kem_public_key) - .map_err(|_| CryptoError::InvalidKeyMaterial)?; - } Ok(()) } - pub fn try_as_bytes(&self) -> Result, crate::error::CryptoError> { + pub fn as_bytes(&self) -> Vec { let kem = self.kem_public_key.as_bytes(); let pq = self.sig_pq_public_key.as_bytes(); let cl = self.sig_cl_public_key.as_bytes(); - let kem_len = - u16::try_from(kem.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?; - let pq_len = - u16::try_from(pq.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?; - let cl_len = - u16::try_from(cl.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?; + let mut out = Vec::with_capacity(kem.len() + pq.len() + cl.len() + 6); - out.extend_from_slice(&kem_len.to_be_bytes()); + out.extend_from_slice(&(kem.len() as u16).to_be_bytes()); out.extend_from_slice(kem); - out.extend_from_slice(&pq_len.to_be_bytes()); + out.extend_from_slice(&(pq.len() as u16).to_be_bytes()); out.extend_from_slice(pq); - out.extend_from_slice(&cl_len.to_be_bytes()); + out.extend_from_slice(&(cl.len() as u16).to_be_bytes()); out.extend_from_slice(cl); - Ok(out) + out } - #[deprecated(note = "use try_as_bytes for the primary fallible serializer")] - pub fn as_bytes(&self) -> Result, crate::error::CryptoError> { - self.try_as_bytes() - } - - /// Parse a complete suite-compatible public bundle. pub fn from_bytes(bytes: &[u8]) -> Result { - let bundle = Self::from_bytes_unvalidated(bytes)?; - bundle.validate()?; - Ok(bundle) - } - - /// Parse the canonical field layout without requiring all suite fields. - /// - /// This is reserved for explicitly partial development material, such as - /// an Ed25519-only browser keyring. Callers that will encrypt or verify - /// cryptographic protocol values must use [`Self::from_bytes`]. - pub fn from_bytes_unvalidated(bytes: &[u8]) -> Result { use crate::error::CryptoError; let mut offset = 0; @@ -574,11 +638,6 @@ impl PublicKeyBundle { .ok_or(CryptoError::InvalidKeyLength)? .to_vec(), ); - offset += cl_len; - - if offset != bytes.len() { - return Err(CryptoError::InvalidKeyLength); - } Ok(Self { kem_public_key: kem, @@ -586,28 +645,6 @@ impl PublicKeyBundle { sig_cl_public_key: cl, }) } - - /// Parse a complete, suite-compatible public bundle. - pub fn from_bytes_validated(bytes: &[u8]) -> Result { - Self::from_bytes(bytes) - } - - #[deprecated(note = "use try_to_base64 for the primary fallible serializer")] - pub fn to_base64(&self) -> Result { - self.try_to_base64() - } - - pub fn try_to_base64(&self) -> Result { - Ok(bytes_to_base64(&self.try_as_bytes()?)) - } - - pub fn from_base64(s: &str) -> Result { - Self::from_bytes(&base64_to_bytes(s)?) - } - - pub fn from_base64_unvalidated(s: &str) -> Result { - Self::from_bytes_unvalidated(&base64_to_bytes(s)?) - } } impl TryFrom<&[u8]> for PublicKeyBundle { @@ -617,6 +654,12 @@ impl TryFrom<&[u8]> for PublicKeyBundle { } } +impl From<&PublicKeyBundle> for Vec { + fn from(bundle: &PublicKeyBundle) -> Vec { + bundle.as_bytes() + } +} + impl fmt::Debug for PublicKeyBundle { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("PublicKeyBundle") @@ -632,14 +675,14 @@ mod tests { use super::*; #[test] - fn public_key_bundle_roundtrip() -> Result<(), Box> { + fn public_key_bundle_roundtrip() { let kem = KemPublicKey::new(vec![1u8; 32]); let pq = SignaturePqPublicKey::new(vec![2u8; 64]); let cl = SignaturePublicKey::new(vec![3u8; 32]); let bundle = PublicKeyBundle::new(kem, pq, cl); - let bytes = bundle.try_as_bytes()?; - let recovered = PublicKeyBundle::from_bytes_unvalidated(&bytes)?; + let bytes = bundle.as_bytes(); + let recovered = PublicKeyBundle::from_bytes(&bytes).unwrap(); assert_eq!( bundle.kem_public_key.as_bytes(), @@ -653,42 +696,22 @@ mod tests { bundle.sig_cl_public_key.as_bytes(), recovered.sig_cl_public_key.as_bytes() ); - Ok(()) - } - - #[cfg(all(feature = "mlkem-tls", feature = "ml-dsa", feature = "ed25519-dalek"))] - #[test] - fn full_keyring_validation_checks_key_correspondence() { - let keyring = Keyring::generate(); - assert!(keyring.validate_full().is_ok()); - assert!(keyring.validate_encryption().is_ok()); - - let mut invalid = Keyring::generate(); - invalid.sig_cl_public_key = SignaturePublicKey::new(vec![0; SIG_CL_PUBLIC_KEY_LEN]); - assert!(matches!( - invalid.validate_full(), - Err(crate::error::CryptoError::InvalidKeyMaterial) - )); - - invalid.kem_secret_key = KemPrivateKey::new(vec![0]); - assert!(invalid.validate_encryption().is_err()); } #[test] - fn public_key_bundle_try_from_roundtrip() -> Result<(), Box> { + fn public_key_bundle_try_from_roundtrip() { let bundle = PublicKeyBundle::new( KemPublicKey::new(vec![0xABu8; 48]), SignaturePqPublicKey::new(vec![0xCDu8; 96]), SignaturePublicKey::new(vec![0xEFu8; 32]), ); - let bytes = bundle.try_as_bytes()?; - let recovered = PublicKeyBundle::from_bytes_unvalidated(bytes.as_slice())?; - assert_eq!(bundle.try_as_bytes()?, recovered.try_as_bytes()?); - Ok(()) + let bytes: Vec = Vec::from(&bundle); + let recovered = PublicKeyBundle::try_from(bytes.as_slice()).unwrap(); + assert_eq!(bundle.as_bytes(), recovered.as_bytes()); } #[test] - fn keyring_roundtrip() -> Result<(), Box> { + fn keyring_roundtrip() { let keyring = Keyring::new( KemPublicKey::new(vec![1u8; 32]), KemPrivateKey::new(vec![2u8; 32]), @@ -697,8 +720,8 @@ mod tests { SignaturePublicKey::new(vec![5u8; 32]), SignaturePrivateKey::new(vec![6u8; 32]), ); - let bytes = keyring.try_to_bytes()?; - let recovered = Keyring::from_bytes(bytes.as_slice())?; + let bytes = keyring.to_bytes(); + let recovered = Keyring::from_bytes(&bytes).unwrap(); assert_eq!( keyring.kem_public_key.as_bytes(), recovered.kem_public_key.as_bytes() @@ -711,73 +734,10 @@ mod tests { keyring.sig_cl_public_key.as_bytes(), recovered.sig_cl_public_key.as_bytes() ); - Ok(()) } #[test] - fn keyring_try_to_bytes_rejects_fields_larger_than_wire_length() { - let keyring = Keyring::new( - KemPublicKey::new(vec![0u8; 65_536]), - KemPrivateKey::new(Vec::new()), - SignaturePqPublicKey::new(Vec::new()), - SignaturePqPrivateKey::new(Vec::new()), - SignaturePublicKey::new(Vec::new()), - SignaturePrivateKey::new(Vec::new()), - ); - assert!(matches!( - keyring.try_to_bytes(), - Err(crate::error::CryptoError::InvalidKeyLength) - )); - } - - #[test] - fn canonical_key_parsers_reject_trailing_bytes() -> Result<(), Box> { - let keyring = Keyring::new( - KemPublicKey::new(vec![1u8; 16]), - KemPrivateKey::new(vec![2u8; 16]), - SignaturePqPublicKey::new(vec![3u8; 16]), - SignaturePqPrivateKey::new(vec![4u8; 16]), - SignaturePublicKey::new(vec![5u8; 16]), - SignaturePrivateKey::new(vec![6u8; 16]), - ); - let mut keyring_bytes = keyring.try_to_bytes()?.to_vec(); - keyring_bytes.push(0xAA); - assert!(Keyring::from_bytes(&keyring_bytes).is_err()); - - let bundle = keyring.public_key_bundle(); - let mut bundle_bytes = bundle.try_as_bytes()?; - bundle_bytes.push(0xBB); - assert!(PublicKeyBundle::from_bytes(&bundle_bytes).is_err()); - Ok(()) - } - - #[test] - fn public_key_bundle_try_as_bytes_rejects_fields_larger_than_wire_length() { - let bundle = PublicKeyBundle::new( - KemPublicKey::new(vec![0u8; 65_536]), - SignaturePqPublicKey::new(Vec::new()), - SignaturePublicKey::new(Vec::new()), - ); - assert!(matches!( - bundle.try_as_bytes(), - Err(crate::error::CryptoError::InvalidKeyLength) - )); - } - - #[test] - fn validated_bundle_rejects_partial_suite_keys() -> Result<(), Box> { - let bundle = PublicKeyBundle::new( - KemPublicKey::new(vec![1u8; 32]), - SignaturePqPublicKey::new(vec![2u8; 64]), - SignaturePublicKey::new(vec![3u8; 32]), - ); - assert!(bundle.validate().is_err()); - assert!(PublicKeyBundle::from_bytes_validated(&bundle.try_as_bytes()?).is_err()); - Ok(()) - } - - #[test] - fn keyring_try_from_roundtrip() -> Result<(), Box> { + fn keyring_try_from_roundtrip() { let keyring = Keyring::new( KemPublicKey::new(vec![0u8; 16]), KemPrivateKey::new(vec![1u8; 16]), @@ -786,65 +746,18 @@ mod tests { SignaturePublicKey::new(vec![4u8; 16]), SignaturePrivateKey::new(vec![5u8; 16]), ); - let bytes = keyring.try_to_bytes()?; - let recovered = Keyring::try_from(bytes.as_slice())?; - assert_eq!(keyring.try_to_bytes()?, recovered.try_to_bytes()?); - Ok(()) + let bytes: Vec = Vec::from(&keyring); + let recovered = Keyring::try_from(bytes.as_slice()).unwrap(); + assert_eq!(keyring.to_bytes(), recovered.to_bytes()); } #[test] - fn hex_roundtrip() -> Result<(), Box> { + fn hex_roundtrip() { let key = KemPublicKey::new(vec![0xDE, 0xAD, 0xBE, 0xEF]); let hex = key.to_hex(); assert_eq!(hex, "deadbeef"); - let recovered = KemPublicKey::from_hex(&hex)?; + let recovered = KemPublicKey::from_hex(&hex).unwrap(); assert_eq!(key.as_bytes(), recovered.as_bytes()); - Ok(()) - } - - #[test] - fn keyring_hex_roundtrip() -> Result<(), Box> { - let keyring = Keyring::new( - KemPublicKey::new(vec![1u8; 16]), - KemPrivateKey::new(vec![2u8; 16]), - SignaturePqPublicKey::new(vec![3u8; 16]), - SignaturePqPrivateKey::new(vec![4u8; 16]), - SignaturePublicKey::new(vec![5u8; 16]), - SignaturePrivateKey::new(vec![6u8; 16]), - ); - let hex = keyring.try_to_hex()?; - let recovered = Keyring::from_hex(&hex)?; - assert_eq!(keyring.try_to_bytes()?, recovered.try_to_bytes()?); - Ok(()) - } - - #[test] - fn keyring_base64_roundtrip() -> Result<(), Box> { - let keyring = Keyring::new( - KemPublicKey::new(vec![1u8; 16]), - KemPrivateKey::new(vec![2u8; 16]), - SignaturePqPublicKey::new(vec![3u8; 16]), - SignaturePqPrivateKey::new(vec![4u8; 16]), - SignaturePublicKey::new(vec![5u8; 16]), - SignaturePrivateKey::new(vec![6u8; 16]), - ); - let b64 = keyring.try_to_base64()?; - let recovered = Keyring::from_base64(&b64)?; - assert_eq!(keyring.try_to_bytes()?, recovered.try_to_bytes()?); - Ok(()) - } - - #[test] - fn public_key_bundle_base64_roundtrip() -> Result<(), Box> { - let bundle = PublicKeyBundle::new( - KemPublicKey::new(vec![1u8; 32]), - SignaturePqPublicKey::new(vec![2u8; 64]), - SignaturePublicKey::new(vec![3u8; 32]), - ); - let b64 = bundle.try_to_base64()?; - let recovered = PublicKeyBundle::from_base64_unvalidated(&b64)?; - assert_eq!(bundle.try_as_bytes()?, recovered.try_as_bytes()?); - Ok(()) } #[test] diff --git a/crypto/src/lib.rs b/crypto/src/lib.rs index 4cd1e25..d093a7a 100644 --- a/crypto/src/lib.rs +++ b/crypto/src/lib.rs @@ -3,12 +3,6 @@ pub mod auth; pub mod error; 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")] pub mod hash; @@ -18,9 +12,6 @@ pub mod kdf; #[cfg(any(feature = "ed25519-dalek", feature = "ml-dsa"))] 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"))] pub use sign::SigAlgorithm; @@ -31,9 +22,6 @@ pub mod enc; pub mod helper; -#[cfg(feature = "tls")] -pub mod tls; - pub use aead::{AeadCipher, AeadDecrypt, AeadEncrypt}; pub use error::CryptoError; pub use keypair::{ @@ -43,7 +31,7 @@ pub use keypair::{ }; #[cfg(feature = "chacha20poly1305")] -pub use aead::{ChaCha20Poly1305, XChaCha20Poly1305}; +pub use aead::ChaCha20Poly1305; #[cfg(feature = "aes-gcm")] pub use aead::Aes256Gcm; @@ -55,13 +43,11 @@ pub use sign::{Ed25519Signer, SignatureScheme, verify_ed25519}; pub use sign::{MlDsaSigner, verify_ml_dsa}; #[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))] -pub use sign::{DualSignature, DualSigner, sign_dual}; +pub use sign::{DualSignature, sign_dual}; #[cfg(feature = "sha2")] pub use hash::{Sha256Hasher, sha256, sha256_double}; -#[cfg(feature = "password-kdf")] -pub use kdf::derive_password_key; #[cfg(feature = "hkdf")] pub use kdf::{derive_encryption_key, hkdf_expand, hkdf_extract}; @@ -70,25 +56,11 @@ pub use kem::{Encapsulated, HybridKem}; pub use enc::EncryptionType; -/// Install Rustls' AWS-LC provider once for the entire process. -/// -/// 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(); - }); -} - -pub use helper::{ENCRYPT_DOMAIN, KEY_WRAP_DOMAIN}; - #[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, -}; +pub use enc::{decrypt_with, encrypt_for}; + +#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] +pub use helper::{MultiEncryptedMessage, RecipientEntry, decrypt_multi, encrypt_multi}; /* ================================ TESTS ================================ */ #[cfg(test)] @@ -97,14 +69,13 @@ mod tests { #[cfg(feature = "chacha20poly1305")] #[test] - fn aead_encrypt_decrypt() -> Result<(), CryptoError> { + fn aead_encrypt_decrypt() { use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305}; let key = [0xAB; 32]; let cipher = ChaCha20Poly1305::new(key); - let ct = cipher.encrypt(b"hello world", b"aad")?; - let pt = cipher.decrypt(&ct, b"aad")?; + let ct = cipher.encrypt(b"hello world", b"aad").unwrap(); + let pt = cipher.decrypt(&ct, b"aad").unwrap(); assert_eq!(pt, b"hello world"); - Ok(()) } #[cfg(feature = "chacha20poly1305")] @@ -113,9 +84,7 @@ mod tests { use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305}; let cipher_a = ChaCha20Poly1305::new([0xAB; 32]); let cipher_b = ChaCha20Poly1305::new([0xCD; 32]); - let ct = cipher_a - .encrypt(b"hello", b"") - .expect("encryption should succeed"); + let ct = cipher_a.encrypt(b"hello", b"").unwrap(); assert!(cipher_b.decrypt(&ct, b"").is_err()); } @@ -124,9 +93,7 @@ mod tests { fn aead_wrong_aad_fails() { use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305}; let cipher = ChaCha20Poly1305::new([0xAB; 32]); - let ct = cipher - .encrypt(b"hello", b"correct-aad") - .expect("encryption should succeed"); + let ct = cipher.encrypt(b"hello", b"correct-aad").unwrap(); assert!(cipher.decrypt(&ct, b"wrong-aad").is_err()); } @@ -135,16 +102,12 @@ mod tests { fn ed25519_sign_verify() { let (signer, sk, pk) = Ed25519Signer::generate(); let msg = b"test message"; - let sig = signer.sign(msg).expect("signing should succeed"); - signer - .verify(msg, &sig) - .expect("verification should succeed"); - verify_ed25519(&pk, msg, &sig).expect("verification should succeed"); + let sig = signer.sign(msg).unwrap(); + signer.verify(msg, &sig).unwrap(); + verify_ed25519(&pk, msg, &sig).unwrap(); - let loaded = Ed25519Signer::new(&sk).expect("signer loading should succeed"); - loaded - .verify(msg, &sig) - .expect("verification should succeed"); + let loaded = Ed25519Signer::new(&sk).unwrap(); + loaded.verify(msg, &sig).unwrap(); } #[cfg(feature = "ed25519-dalek")] @@ -152,7 +115,7 @@ mod tests { fn ed25519_wrong_sig_fails() { let (signer, _, pk) = Ed25519Signer::generate(); let msg = b"test message"; - let sig = signer.sign(msg).expect("signing should succeed"); + let sig = signer.sign(msg).unwrap(); assert!(verify_ed25519(&pk, b"wrong message", &sig).is_err()); } @@ -161,16 +124,12 @@ mod tests { fn mldsa_sign_verify() { let (signer, sk, pk) = MlDsaSigner::generate(); let msg = b"test message"; - let sig = signer.sign(msg).expect("signing should succeed"); - signer - .verify(msg, &sig) - .expect("verification should succeed"); - verify_ml_dsa(&pk, msg, &sig).expect("verification should succeed"); + let sig = signer.sign(msg).unwrap(); + signer.verify(msg, &sig).unwrap(); + verify_ml_dsa(&pk, msg, &sig).unwrap(); - let loaded = MlDsaSigner::new(&sk, &pk).expect("signer loading should succeed"); - loaded - .verify(msg, &sig) - .expect("verification should succeed"); + let loaded = MlDsaSigner::new(&sk, &pk).unwrap(); + loaded.verify(msg, &sig).unwrap(); } #[cfg(feature = "ml-dsa")] @@ -178,7 +137,7 @@ mod tests { fn mldsa_wrong_sig_fails() { let (signer, _, pk) = MlDsaSigner::generate(); let msg = b"test message"; - let sig = signer.sign(msg).expect("signing should succeed"); + let sig = signer.sign(msg).unwrap(); assert!(verify_ml_dsa(&pk, b"wrong message", &sig).is_err()); } @@ -189,10 +148,9 @@ mod tests { let (ed_signer, _, _) = Ed25519Signer::generate(); let (ml_signer, _, _) = MlDsaSigner::generate(); - let dual = sign_dual(ed_signer.signing_key(), ml_signer.signing_key(), b"msg") - .expect("dual signing should succeed"); + let dual = sign_dual(ed_signer.signing_key(), ml_signer.signing_key(), b"msg").unwrap(); dual.verify(ed_signer.verifying_key(), ml_signer.verifying_key(), b"msg") - .expect("dual verification should succeed"); + .unwrap(); } #[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))] @@ -202,8 +160,7 @@ mod tests { let (ed_signer, _, _) = Ed25519Signer::generate(); let (ml_signer, _, _) = MlDsaSigner::generate(); - let dual = sign_dual(ed_signer.signing_key(), ml_signer.signing_key(), b"msg") - .expect("dual signing should succeed"); + let dual = sign_dual(ed_signer.signing_key(), ml_signer.signing_key(), b"msg").unwrap(); assert!( dual.verify( ed_signer.verifying_key(), @@ -214,39 +171,21 @@ 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")] #[test] fn hkdf_expand_produces_key() { - let key = derive_encryption_key(b"ikm", b"salt", b"context") - .expect("key derivation should succeed"); + let key = derive_encryption_key(b"ikm", b"salt", b"context").unwrap(); assert_eq!(key.len(), 32); - let expanded = - hkdf_expand(b"ikm", b"salt", b"info", 64).expect("HKDF expansion should succeed"); + let expanded = hkdf_expand(b"ikm", b"salt", b"info", 64).unwrap(); assert_eq!(expanded.len(), 64); } #[cfg(feature = "hkdf")] #[test] fn hkdf_different_info_different_key() { - let a = derive_encryption_key(b"ikm", b"salt", b"info-a") - .expect("key derivation should succeed"); - let b = derive_encryption_key(b"ikm", b"salt", b"info-b") - .expect("key derivation should succeed"); + let a = derive_encryption_key(b"ikm", b"salt", b"info-a").unwrap(); + let b = derive_encryption_key(b"ikm", b"salt", b"info-b").unwrap(); assert_ne!(a, b); } @@ -315,10 +254,8 @@ mod tests { #[test] fn keyring_serialize_roundtrip() { let kr = Keyring::generate(); - let bytes = kr - .try_to_bytes() - .expect("keyring serialization should succeed"); - let loaded = Keyring::from_bytes(&bytes).expect("keyring roundtrip should succeed"); + let bytes = kr.to_bytes(); + let loaded = Keyring::from_bytes(&bytes).unwrap(); assert_eq!( kr.kem_public_key.as_bytes(), loaded.kem_public_key.as_bytes() @@ -338,10 +275,8 @@ mod tests { fn public_key_bundle_serialize_roundtrip() { let kr = Keyring::generate(); let bundle = kr.public_key_bundle(); - let bytes = bundle - .try_as_bytes() - .expect("bundle serialization should succeed"); - let loaded = PublicKeyBundle::from_bytes(&bytes).expect("bundle roundtrip should succeed"); + let bytes = bundle.as_bytes(); + let loaded = PublicKeyBundle::from_bytes(&bytes).unwrap(); assert_eq!( bundle.kem_public_key.as_bytes(), loaded.kem_public_key.as_bytes() @@ -360,52 +295,22 @@ mod tests { #[test] fn hybrid_kem_roundtrip() { let (sk, pk) = HybridKem::generate_keypair(); - let enc = HybridKem::encapsulate(&pk).expect("encapsulation should succeed"); - let ss = - HybridKem::decapsulate(&sk, &enc.ciphertext).expect("decapsulation should succeed"); + let enc = HybridKem::encapsulate(&pk).unwrap(); + let ss = HybridKem::decapsulate(&sk, &enc.ciphertext).unwrap(); assert_eq!(enc.shared_secret, ss); } - #[cfg(all( - feature = "mlkem-tls", - feature = "hkdf", - feature = "ml-dsa", - feature = "ed25519-dalek" - ))] - fn multi_envelope_roundtrip(encryption_type: EncryptionType) { - use crate::helper::{decrypt_multi_for, encrypt_multi_for}; + #[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))] + #[test] + fn encrypt_multi_roundtrip() { + use crate::helper::{decrypt_multi, encrypt_multi}; use crate::keypair::Keyring; let kr = Keyring::generate(); let entities = vec![kr.public_key_bundle()]; let msg = b"secret data"; - let ct = encrypt_multi_for(encryption_type, 7, msg, &entities) - .expect("multi encrypt should succeed"); - let pt = decrypt_multi_for(&ct, 7, &kr).expect("multi decrypt should succeed"); + let ct = encrypt_multi(msg, b"aad", &entities).unwrap(); + let pt = decrypt_multi(&ct, b"aad", &kr).unwrap(); 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); - } } diff --git a/crypto/src/sign.rs b/crypto/src/sign.rs index 368c1fb..6a1d836 100644 --- a/crypto/src/sign.rs +++ b/crypto/src/sign.rs @@ -21,12 +21,13 @@ impl SigAlgorithm { } } +#[cfg(feature = "ed25519-dalek")] +use rand_core::RngCore; + #[cfg(feature = "ml-dsa")] use crate::keypair::{SignaturePqPrivateKey, SignaturePqPublicKey}; pub trait SignatureScheme { - /// The wire algorithm identifier produced by this signer. - fn algorithm(&self) -> u8; fn sign(&self, msg: &[u8]) -> Result, CryptoError>; fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError>; } @@ -49,12 +50,9 @@ impl Ed25519Signer { Ok(Self { secret, public }) } - #[cfg(feature = "ed25519-dalek")] pub fn generate() -> (Self, SignaturePrivateKey, SignaturePublicKey) { - use rand::RngExt; - let mut bytes = [0u8; 32]; - rand::rng().fill(&mut bytes); + rand_core::OsRng.fill_bytes(&mut bytes); let secret = ed25519_dalek::SigningKey::from_bytes(&bytes); let public = secret.verifying_key(); let priv_key = SignaturePrivateKey::new(secret.to_bytes().to_vec()); @@ -78,10 +76,6 @@ impl Ed25519Signer { #[cfg(feature = "ed25519-dalek")] impl SignatureScheme for Ed25519Signer { - fn algorithm(&self) -> u8 { - SigAlgorithm::ED25519 - } - fn sign(&self, msg: &[u8]) -> Result, CryptoError> { use ed25519_dalek::Signer; let signature = self.secret.sign(msg).to_bytes().to_vec(); @@ -176,10 +170,6 @@ impl MlDsaSigner { #[cfg(feature = "ml-dsa")] impl SignatureScheme for MlDsaSigner { - fn algorithm(&self) -> u8 { - SigAlgorithm::ML_DSA_65 - } - fn sign(&self, msg: &[u8]) -> Result, CryptoError> { use ml_dsa::Signer; let signature = self @@ -247,75 +237,6 @@ pub fn sign_dual( 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 { - 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, 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 { #[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))] pub fn verify( diff --git a/crypto/src/sign_parallel.rs b/crypto/src/sign_parallel.rs deleted file mode 100644 index 1eba5c2..0000000 --- a/crypto/src/sign_parallel.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! 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( - ed_signer: S1, - pq_signer: S2, - message: Vec, -) -> Result<(Vec, Vec), 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( - ed_signer: S1, - pq_signer: Arc, - message: Vec, -) -> Result<(Vec, Vec), 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, - ed_signature: Vec, - pq_signature: Vec, -) -> 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(()) -} diff --git a/crypto/src/tls.rs b/crypto/src/tls.rs deleted file mode 100644 index e8f2533..0000000 --- a/crypto/src/tls.rs +++ /dev/null @@ -1,45 +0,0 @@ -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, Vec), 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)) -} diff --git a/deny.toml b/deny.toml index 30bd4fb..41c6bb9 100644 --- a/deny.toml +++ b/deny.toml @@ -8,23 +8,9 @@ ignore = [] [bans] # Flag multiple versions of the same crate so duplicate trees are visible. -multiple-versions = "deny" +multiple-versions = "warn" 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] # Allowlist of licenses acceptable for this project's dependencies. allow = [ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index b02fa69..0000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,70 +0,0 @@ -# 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` or `Encrypted`), 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` and - `Encrypted` 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. diff --git a/docs/CONNECTIONS.md b/docs/CONNECTIONS.md deleted file mode 100644 index 69eebad..0000000 --- a/docs/CONNECTIONS.md +++ /dev/null @@ -1,34 +0,0 @@ -# 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. diff --git a/docs/CONNECTOR.md b/docs/CONNECTOR.md index 355faa5..0ef94c1 100644 --- a/docs/CONNECTOR.md +++ b/docs/CONNECTOR.md @@ -4,28 +4,23 @@ This file documents the connection and version negotiation logic. ## 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). 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. +The `registry` module provides a multi-version `Registry` used by the host for version negotiation. Accessed through the `mtp` facade (requires the `host` feature): ```rust -use mtp::codec::{Version, registry::Registry}; +use mtp::codec::registry::Registry; -let registry = Registry::builtin(); // loads all TypeMaps from the build config +let registry = Registry::builtin(); // loads all TypeMaps from config // Check if a version is supported -assert!(registry.supports(&Version(3, 0))); +assert!(registry.supports(&Version(1, 0))); // Find highest mutual version for a client -let client_versions = &[Version(2, 0), Version(3, 0)]; +let client_versions = &[Version(0, 0), Version(1, 0)]; let negotiated = registry.negotiate(client_versions); -assert_eq!(negotiated, Some(Version(3, 0))); +assert_eq!(negotiated, Some(Version(1, 0))); // Look up a version's TypeMap -let tm = registry.get(&Version(3, 0)).unwrap(); +let tm = registry.get(&Version(2, 0)).unwrap(); ``` The `Registry::builtin()` constructor uses the `TypeMap::vX_Y()` methods generated from the config. @@ -59,16 +54,39 @@ let mut host = MTPHost::new(config).await?; while let Some(conn) = host.accept().await? { // conn.version is the negotiated version // conn.codec is a VersionedCodec scoped to that version - // conn.sender / conn.receive() for application CommunicationValue I/O + // conn.sender / conn.receiver for raw CommunicationValue I/O - let msg = conn.receive().await?; + let msg = conn.receiver.receive().await?; } ``` -The host reads the reserved opening frame, extracts `DataType::Version`, calls `registry.negotiate`, and returns `AcceptError::UnsupportedVersion` when no registered version matches. +The host's `accept()` method: +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 -Authentication follows the version-bearing hello when the host enables it. -The sequence is defined in [Protocol Reference](PROTOCOL-REFERENCE.md). +### Login/Register Handshake + +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`. --- @@ -92,45 +110,34 @@ let conn = MTPClient::auth_connect(pinned.with_client_id(8765), &keys, &host_pk) 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 uses one version and does not import the registry. +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. --- ## Version Negotiation Flow ``` -Client (v3.0) Host (v3.0) +Client (v2.0) Host (v0.0, v1.0, v2.0) | | | QUIC connect | |----------------------->| | | | CommValue{ Ident. } | - | Version -> "3.0" | + | Version -> "2.0" | | Id -> 8765 | | (unsigned hello; auth | | challenge follows) | |----------------------->| - | | registry.negotiate(&[Version(3,0)]) - | | -> Some(Version(3,0)) + | | registry.negotiate(&[Version(2,0)]) + | | -> Some(Version(2,0)) | | - | Response | selected v3.0 TypeMap - |<-----------------------| - | Status, version | + | Response | + |<-----------------------| (uses v2.0 TypeMap for encoding) + | Status, Nonces, | + | Signature | | | - | subsequent messages | - | use v3.0 TypeMap | + | (subsequent messages | + | use v2.0 TypeMap) | ``` -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. +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. diff --git a/docs/ERRORS.md b/docs/ERRORS.md deleted file mode 100644 index 93365dc..0000000 --- a/docs/ERRORS.md +++ /dev/null @@ -1,48 +0,0 @@ -# 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). diff --git a/docs/NATIVE-CLIENT.md b/docs/NATIVE-CLIENT.md index 485e6ed..5f7d1ac 100644 --- a/docs/NATIVE-CLIENT.md +++ b/docs/NATIVE-CLIENT.md @@ -2,103 +2,74 @@ 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. -## Prerequisites +## Cargo Dependency -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). +Add the `mtp` umbrella crate with the `client` feature (and optionally `crypto` for authentication): -## Quick Start +```toml +[dependencies] +mtp = { path = "/path/to/mtp", features = ["client"] } -```rust -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; +# Add crypto for auth_connect / auth_register: +mtp = { path = "/path/to/mtp", features = ["client", "crypto"] } ``` -## Configuration +## ClientConfig ```rust use mtp::client::{ClientConfig, ClientTlsConfig}; -use std::time::Duration; let config = ClientConfig::new("https://host.example.com:4433") .with_tls(ClientTlsConfig::SystemRoots) - .with_client_id(0) - .with_ping_interval(Duration::from_secs(5)) - .with_max_missed_pings(3) - .with_ping_timestamp(true); + .with_client_id(0); ``` -| Field | Type | Default | Description | -|-------------------------|--------------------|------------------|---------------------------------------------| -| `url` | `String` | required | Host URL (`https://host:port`) | -| `tls` | `ClientTlsConfig` | `SystemRoots` | `SystemRoots` or `PinnedPem(Vec)` | -| `client_id` | `u64` | `0` | Client identifier (for login) | -| `description` | `Option` | `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` | `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 | +| Field | Type | Description | +|---------------|--------------------|-----------------------------------------------------| +| `url` | `String` | `https://host:port` address of the MTP host | +| `tls` | `ClientTlsConfig` | `SystemRoots` or `PinnedPem(pem_bytes)` | +| `client_id` | `u64` | Client identifier (ignored during `auth_register`) | +| `auth_timeout` | `Duration` (crypto) | Authentication handshake timeout (default 30s) | ### TLS Certificate Handling -`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. +When `tls` is `ClientTlsConfig::SystemRoots` (the default), the client loads the **system's +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). -## Connecting +For development or self-signed certificates, provide one or more PEM-encoded +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 -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 -let response = conn - .request(&request_value, Some(CommunicationType::Pong)) - .await?; -``` - -### Protocol keepalive - -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:?}"); +pub struct MTPConnection { + pub version: Version, + pub sender: Sender, + pub receiver: Receiver, + #[cfg(feature = "crypto")] + pub auth_state: AuthState, + #[cfg(feature = "crypto")] + pub client_id: u64, } ``` -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. +- `version` -- the negotiated protocol version +- `sender` / `receiver` -- for message I/O +- `client_id` -- the confirmed/assigned client identifier (crypto only) ### Unauthenticated Connect @@ -110,7 +81,8 @@ let config = ClientConfig::new("https://host.example.com:4433").with_client_id(4 let conn = MTPClient::connect(config).await?; ``` -Sends an `Identification` frame with the compiled-in protocol version and client ID. No cryptographic handshake is performed. +Sends an `Identification` frame with the compiled-in protocol version and +client ID. No cryptographic handshake is performed. ### Authenticated Login @@ -127,7 +99,21 @@ let config = ClientConfig::new("https://host.example.com:4433") let conn = MTPClient::auth_connect(config, &keys, &host_pk).await?; ``` -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). +Protocol (challenge-response, the host issues the freshness): +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 @@ -142,10 +128,11 @@ let conn = MTPClient::auth_register(config, &keyring, &host_pk).await?; // Save for next session let id = conn.client_id; -let keyring_bytes = keyring.try_to_bytes()?; +let keyring_bytes = keyring.to_bytes(); ``` -When callers already know whether a saved client ID exists, the convenience helper uses `Some(id)` for login and `None` for registration: +When callers already know whether a saved client id exists, the convenience +helper chooses login or registration: ```rust let conn = MTPClient::auth_connect_or_register( @@ -156,7 +143,17 @@ let conn = MTPClient::auth_connect_or_register( ).await?; ``` -Registration uses the authentication flow in [Protocol Reference](PROTOCOL-REFERENCE.md#authentication-flow). +Protocol (challenge-response): +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 @@ -175,13 +172,14 @@ pub struct Keyring { } ``` -- Serialise: `keyring.try_to_bytes()` -> `Result>, CryptoError>` +- Serialise: `keyring.to_bytes()` -> `Vec` - Deserialise: `Keyring::from_bytes(&bytes)` -> `Result` - Get public half: `keyring.public_key_bundle()` -> `PublicKeyBundle` ### PublicKeyBundle -The public half of a keyring, used by the host for signature verification and by the client for host signature verification: +The public half of a keyring, used by the host for signature verification and +by the client for host signature verification: ```rust pub struct PublicKeyBundle { @@ -191,11 +189,10 @@ pub struct PublicKeyBundle { } ``` -Obtain the host's `PublicKeyBundle` out of band (e.g. from files exported by the host, or from a trusted directory). +Obtain the host's `PublicKeyBundle` out of band (e.g. from files exported by +the host, or from a trusted directory). -## Communicate - -### Sending and Receiving Messages +## Sending and Receiving Messages ### CommunicationValue @@ -212,7 +209,9 @@ let msg = CommunicationValue::new(CommunicationType::Ping) .to_bytes(); ``` -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. +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. ### Send @@ -220,7 +219,9 @@ When the `registry` feature is enabled (via the `host` feature), you can also us conn.sender.send(&msg).await?; ``` -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 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: ```rust let response = conn @@ -228,97 +229,92 @@ let response = conn .await?; ``` -Requests are routed by id through the connection's receive dispatcher. Frames with other ids remain available through `conn.receive()`. +Frames with other ids are consumed by this helper. Applications that need +subscriptions or broad routing should use one receive task and correlate there. -Two send modes (configured via `mtp::client::Policy`): -- `PersistentStream` (default): reuses one QUIC unidirectional stream -- `SingleStreamPerMessage`: opens a new stream per message +Two send modes (configured via `mtp::transport::Policy`): +- `PersistentStream` (default) -- reuses one QUIC uni-directional stream +- `SingleStreamPerMessage` -- opens a new stream per message ### Receive ```rust -match conn.receive().await { +match conn.receiver.receive().await { Ok(msg) => { /* handle CommunicationValue */ } Err(e) => { /* connection closed or error */ } } ``` -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. +Inbound frames are queued internally. The `receive()` method returns the next +available message. ### Close ```rust -conn.sender.close().await; +conn.sender.close(); // or conn.receiver.close(); ``` -`Sender::close().await` gracefully finishes the active send stream, sends the -MTP close frame, and waits for `force_close_delay` (default 300ms) before -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. +Sends a close frame and signals the peer. The `Sender::close()` spawns an async +task that sends the frame, waits for `force_close_delay` (default 300ms), then +force-closes the QUIC connection if the peer has not already done so. -### Pipes +## Crypto Containers -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. - -## Appendix: Composable Data Protection - -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. +With the `crypto` feature, `DataValue` supports encrypted, signed, and +signed+encrypted containers. Encryption uses ML-KEM to encapsulate to a +recipient's KEM public key (from their `PublicKeyBundle`); only the holder of +the matching `Keyring` can decrypt. Signing uses the sender's Ed25519 key. ```rust -use mtp::codec::{ProtectionPurpose, DataTypeId, DataValue}; -use mtp::crypto::{Ed25519Signer, Keyring}; +use mtp::crypto::{EncryptionType, Ed25519Signer, SigAlgorithm}; -let sender_keyring = Keyring::generate(); -let recipient_keyring = Keyring::generate(); -let signer = Ed25519Signer::new(&sender_keyring.sig_cl_secret_key)?; -let recipient = recipient_keyring.public_key_bundle(); -let sender_public_keys = sender_keyring.public_key_bundle(); -let value = DataValue::Container(vec![ - (DataTypeId(32), DataValue::Str("secret".into())), +let enc_type = EncryptionType::MlKemChaCha20Poly1305; +let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)?; + +// `recipient` is the PublicKeyBundle of whoever should be able to decrypt +// (e.g. the host's bundle, obtained out of band). + +// Encrypted container +let mut enc = DataValue::Container(vec![ + (DataTypeId(1), DataValue::Str("secret".into())), ]); +enc.encrypt_container(enc_type, &recipient, b"aad"); -// The outer encrypted wrapper hides the signer metadata. -let private_signer = value.clone().sign(7, ProtectionPurpose::from(1), &signer)?; -let sealed = private_signer.encrypt_for( - std::slice::from_ref(&recipient), - ProtectionPurpose::from(2), -)?; +// Signed container +let mut sig = DataValue::Container(vec![ + (DataTypeId(1), DataValue::Str("signed".into())), +]); +sig.sign_container(SigAlgorithm::ED25519, &signer); + +// 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"); ``` -Reverse the calls when the signer identity should remain visible to the recipient before opening the encrypted value: +On the receiving side, the recipient decrypts with its own `Keyring` (each blob +is self-describing: its leading byte selects the algorithm and the matching KEM +key from the keyring): ```rust -let encrypted = value.encrypt_for( - std::slice::from_ref(&recipient), - ProtectionPurpose::from(2), -)?; -let public_signer = encrypted.sign(7, ProtectionPurpose::from(1), &signer)?; +enc.decrypt_into_container(&keyring, b"aad"); // -> Container +sig.verify_into_container(&verifier); // verifier: impl SignatureScheme +sec.decrypt_signed_encrypted_container(&keyring, b"aad"); // -> SignedContainer, then verify_into_container ``` -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 +## Policy Configuration The `Policy` struct controls transport behaviour: ```rust -use mtp::client::{Policy, SendMode}; +use mtp::transport::{Policy, SendMode}; let policy = Policy { send_mode: SendMode::PersistentStream, - max_message_size: 16 * 1024 * 1024, - handshake_max_message_size: 64 * 1024, + max_message_size: 1_000_000_000, open_stream_timeout: Duration::from_millis(2000), write_timeout: Duration::from_millis(2000), read_timeout: Duration::from_millis(30_000), @@ -328,20 +324,41 @@ let policy = Policy { }; ``` -Apply a custom policy with `ClientConfig::with_policy`: +To apply a custom policy, call `mtp_transport::connect()` directly instead of +using `MTPClient`: ```rust -let config = config.with_policy(policy); -let conn = MTPClient::connect(config).await?; +use mtp_transport::{connect, Policy}; + +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?; ``` -### Version +Then build and send the initial `Identification` frame manually to complete +version negotiation. -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`. +## Version -The client never imports the `registry` module; it uses a single compiled-in version and expects the host to negotiate a compatible 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`. -### Error Handling +The client never imports the `registry` module; it uses a single compiled-in +version and expects the host to negotiate a compatible version. -`CommunicationError` is summarized in the [Error Reference](ERRORS.md). -Native builds can expose additional variants that wrap QUIC and WebTransport errors. +## Error Handling + +`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 | diff --git a/docs/NATIVE-HOST-WEB-SERVER.md b/docs/NATIVE-HOST-WEB-SERVER.md deleted file mode 100644 index c92f18b..0000000 --- a/docs/NATIVE-HOST-WEB-SERVER.md +++ /dev/null @@ -1,184 +0,0 @@ -# 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` 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::(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` 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`. diff --git a/docs/NATIVE-HOST.md b/docs/NATIVE-HOST.md index 32d9146..caf92f9 100644 --- a/docs/NATIVE-HOST.md +++ b/docs/NATIVE-HOST.md @@ -1,23 +1,45 @@ # MTP Native Host -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. - -> **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. +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. ## Cargo Dependency -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). +```toml +[dependencies] +mtp = { path = "/path/to/mtp", features = ["host"] } + +# Add crypto for authenticated connections: +mtp = { path = "/path/to/mtp", features = ["host", "crypto"] } +``` ## HostConfig -`HostConfig::new` takes the bind address, port, PEM certificate chain, and PEM private key. Configure authentication and transport behavior with builders: - ```rust -let config = HostConfig::new(ip, port, certificate, private_key) - .with_pongs(true) - .with_policy(Policy::default()) - .with_authentication(host_keyring, get_existing_client, complete_register) - .with_authentication_policy(AuthenticationPolicy::ForceAuthentication); +use mtp::host::HostConfig; +use std::net::{IpAddr, Ipv4Addr}; + +let config = HostConfig::new( + IpAddr::V4(Ipv4Addr::UNSPECIFIED), + 4433, + std::fs::read("cert.pem")?, + std::fs::read("key.pem")?, +) +.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 | @@ -26,40 +48,15 @@ let config = HostConfig::new(ip, port, certificate, private_key) | `port` | `u16` | Listen port | | `tls_fullchain` | `Vec` | PEM-encoded TLS certificate chain | | `tls_key` | `Vec` | PEM-encoded TLS private key | -| `send_pongs` | `bool` | Sends a Pong for each received Ping (default `true`) | -| `authentication_policy` | `AuthenticationPolicy` (crypto) | `ForceAuthentication`, `AllowAuthentication`, or `Unauthenticated` | +| `require_authentication` | `bool` (crypto) | Enable login/register handshake | | `host_keyring` | `Keyring` (crypto) | Host's signing and KEM keys | -| `get_existing_client` | Async callback returning `Option` | Receives `(client_id, description)`. `Some` supplies the stored key bundle. `description = None` is used for guest-ID collision checks. | -| `guest_id_generator` | Async callback returning `Option` | 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). +| `get_existing_user` | `Fn(u64) -> Pin> + Send>> + Send + Sync` (crypto) | Async lookup callback for login | +| `complete_register` | `Fn(PublicKeyBundle) -> Pin + Send>> + Send + Sync` (crypto) | Async registration callback, returns new client ID | ### TLS -`HostConfig::new` always uses the certificate and key supplied by the caller. -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). +The host requires a TLS certificate. For development, generate a self-signed +certificate using `rcgen`. For production, use a CA-signed certificate. ## Accepting Connections @@ -76,44 +73,164 @@ while let Some(conn) = host.accept().await? { ### MTPConnection -`accept()` returns the shared connection shape in [MTP Connections](CONNECTIONS.md) -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 -network metadata, not an authenticated client identity. +Returned by `accept()` after version negotiation (and authentication if +enabled): + +```rust +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, +} +``` + +- `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 -`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. +When a client connects, `accept()` performs the following sequence: + +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 ```rust -use mtp::codec::Version; +use mtp::codec::registry::Registry; let registry = host.registry(); -assert!(registry.supports(&Version(3, 0))); +assert!(registry.supports(&Version(2, 0))); -let negotiated = registry.negotiate(&[Version(2, 0), Version(3, 0)]); -// -> Some(Version(3, 0)) for this repository's builtin map +let negotiated = registry.negotiate(&[Version(1, 0), Version(2, 0)]); +// -> Some(Version(2, 0)) if both versions are registered ``` ## Authentication Flow -The connection lifecycle and authentication sequence are in [Protocol Reference](PROTOCOL-REFERENCE.md). Host callback contracts are documented below. +When `require_authentication` is `true`, `accept()` runs a mutually-authenticated +**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. -After a successful handshake, `MTPConnection` exposes `AuthState::Authenticated`, the client ID, and the client's public key bundle when one is available. +All signed payloads begin with a one-byte domain-separation tag (see +`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 -Use `conn.sender` and `conn.receive()` for bidirectional message exchange. The -connection dispatcher owns the underlying receiver, especially when `pipes` is -enabled: +Use `conn.sender` and `conn.receiver` for bidirectional message exchange: ```rust while let Some(conn) = host.accept().await? { tokio::spawn(async move { loop { - match conn.receive().await { + match conn.receiver.receive().await { Ok(msg) => { let response = process_message(&msg, &conn); conn.sender.send(&response).await.ok(); @@ -127,7 +244,8 @@ while let Some(conn) = host.accept().await? { ### Versioned Codec -The `conn.codec` is a `VersionedCodec` pre-configured with the negotiated version. Use it to encode/decode with version-specific type maps: +The `conn.codec` is a `VersionedCodec` pre-configured with the negotiated +version. Use it to encode/decode with version-specific type maps: ```rust let tm = conn.codec.registry().get(&conn.version).unwrap(); @@ -137,67 +255,41 @@ let desc_id = DataTypeId(tm.data_id_enum(DataType::Description).unwrap()); let value = msg.get_data(desc_id); ``` -## Pipes - -The complete pipe protocol and host API are documented in [Pipes](PIPES.md). - ## Host Callbacks -### get_existing_client +### get_existing_user -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`. - -Must return `Some(PublicKeyBundle)` if the client ID is known, or `None` otherwise. +Called during login to retrieve a client's public key bundle for signature +verification. Must return `Some(PublicKeyBundle)` if the client ID is known, +or `None` to reject. ```rust -// db: Arc>> -let get_existing_client = |id: u64, _description: Option| { +let get_existing_user = |id: u64| { let db = db.clone(); - Box::pin(async move { db.lock().await.get(&id).cloned() }) + Box::pin(async move { db.lock().unwrap().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 -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. +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. ```rust -// db: Arc>> -let complete_register = |bundle: PublicKeyBundle, _description: Option| { +let complete_register = |bundle: PublicKeyBundle| { let db = db.clone(); let id = next_id.fetch_add(1, Ordering::SeqCst); Box::pin(async move { - db.lock().await.insert(id, bundle); + db.lock().unwrap().insert(id, bundle); id }) }; ``` -All callbacks are called from within `accept()` and must be `Send + Sync`. They are `async` (returning `Pin>`) 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. +Both callbacks are called from within `accept()` and must be `Send + Sync`. They +are `async` (returning `Pin>`) and are `.await`ed by the +host, so they can perform I/O or other async work as needed. ## Host Key Generation @@ -214,7 +306,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); // Save to disk -let bytes = host_keyring.try_to_bytes()?; +let bytes = host_keyring.to_bytes(); std::fs::write("host_keys.bin", bytes)?; ``` @@ -229,17 +321,21 @@ std::fs::write("host_sig_pq_pk.bin", bundle.sig_pq_public_key.as_bytes())?; ## Policy -Customize transport limits and timeouts through `HostConfig::with_policy`: +The transport `Policy` is set to defaults internally. To customise (timeouts, +send mode, etc.), use `mtp_transport::host()` directly instead of `MTPHost`: ```rust -let config = HostConfig::new(ip, port, cert, key) - .with_policy(custom_policy); -let host = MTPHost::new(config).await?; +use mtp_transport::{host, Policy}; + +let transport = host(ip, port, cert, key, custom_policy).await?; +// Then build version negotiation on top: +// - accept transport.next() +// - read first frame +// - registry.negotiate() +// - return MTPConnection ``` ## Graceful Shutdown -Drop the `MTPHost` to stop accepting new connections. Active connections 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. +Drop the `MTPHost` to stop accepting new connections. Active connections +continue until their `Sender`/`Receiver` are dropped or the peer disconnects. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md deleted file mode 100644 index 383b1d1..0000000 --- a/docs/OPERATIONS.md +++ /dev/null @@ -1,42 +0,0 @@ -# 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. diff --git a/docs/PIPES.md b/docs/PIPES.md deleted file mode 100644 index 0d276f4..0000000 --- a/docs/PIPES.md +++ /dev/null @@ -1,198 +0,0 @@ -# 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(), ¶ms, &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. diff --git a/docs/PROTOCOL-REFERENCE.md b/docs/PROTOCOL-REFERENCE.md deleted file mode 100644 index 133cfb0..0000000 --- a/docs/PROTOCOL-REFERENCE.md +++ /dev/null @@ -1,120 +0,0 @@ -# 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. diff --git a/docs/SECURITY.md b/docs/SECURITY.md deleted file mode 100644 index 76fe901..0000000 --- a/docs/SECURITY.md +++ /dev/null @@ -1,324 +0,0 @@ -# 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` | `MTP-DATA-SIGN-1`, signature algorithm, purpose, signer ID, and the exact serialized inner value. | -| `Encrypted` | `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. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md deleted file mode 100644 index ad83501..0000000 --- a/docs/TROUBLESHOOTING.md +++ /dev/null @@ -1,108 +0,0 @@ -# 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. diff --git a/docs/TYPE-MAP.md b/docs/TYPE-MAP.md index 28ab748..675b341 100644 --- a/docs/TYPE-MAP.md +++ b/docs/TYPE-MAP.md @@ -1,106 +1,6 @@ # Type Map -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`, `0x0B` to `Signed`, 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. +This file documents the Type Map & Registry configuration used by the MTP protocol. ## TypeMap & Compile-Time Type Safety @@ -123,12 +23,6 @@ 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)). -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 After editing the config and rebuilding, `CommunicationType` and `DataType` enums are generated automatically. Use them in code: @@ -136,49 +30,44 @@ After editing the config and rebuilding, `CommunicationType` and `DataType` enum ```rust use mtp::type_map::{CommunicationType, DataType, TypeMap}; -let tm = TypeMap::v3_0(); -let id = tm.data_id_enum(DataType::ExampleText).unwrap(); +let tm = TypeMap::v2_0(); +let id = tm.data_id_enum(DataType::SomeType).unwrap(); ``` -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. +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`: Encoding/decoding uses a `TypeMap` to resolve type names to wire IDs: ```rust -use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; +use mtp::codec::{encode, decode, DataValue}; use mtp::type_map::TypeMap; -let tm = TypeMap::v3_0(); -let value = CommunicationValue::new_with_type_map(CommunicationType::Ping, &tm) - .add_typed(DataType::Description, &tm, DataValue::Str("hello".into())); +let tm = TypeMap::v2_0(); +let value = DataValue::Str("hello".into()); -let bytes = value.to_bytes().unwrap(); -let decoded = CommunicationValue::from_bytes_with(&bytes, &tm).unwrap(); +let bytes = encode(&value, &tm).unwrap(); +let decoded = decode(&bytes, &tm).unwrap(); ``` ```rust -let tm_v3 = TypeMap::v3_0(); -assert!(tm_v3.data_id_enum(DataType::ExampleText).is_some()); -``` +let tm_v2 = TypeMap::v2_0(); +assert!(tm_v2.data_id_enum(DataType::SomeType).is_some()); // defined in v2.0 +assert!(tm_v2.data_id_enum(DataType::ExampleType).is_none()); // NOT in v2.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. +let tm_v1 = TypeMap::v1_0(); +assert!(tm_v1.data_id_enum(DataType::ExampleType).is_some()); // defined in v1.0 +``` ### 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: ``` -v3.0 client sends DataType::ExampleText → host encodes with v3.0 TypeMap → wire ID 43 -v3.0 host receives a version absent from the registry → version negotiation error +v2.0 client sends DataType::SomeType → host encodes with v2.0 TypeMap → wire ID 32 +v2.0 host receives DataType::ExampleType (from v1.0 client) → not in v2.0 TypeMap → None → Error ``` -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. +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. ## Registry @@ -193,40 +82,33 @@ mtp = { path = "..", features = ["host"] } ```rust use mtp::codec::registry::{Registry, VersionedCodec}; -use mtp::codec::{CommunicationType, CommunicationValue, DataValue}; -use mtp_type_map::Version; let registry = Registry::builtin(); -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); +let codec = VersionedCodec::new(registry); -// The value must retain the negotiated map used to construct it. -let bytes = codec.encode(&value).unwrap(); +// Encode with a specific version +let bytes = codec.encode(&value, Version(2, 0)).unwrap(); -let decoded = codec.decode(&bytes).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(); +// Decode with a specific version +let decoded = codec.decode(&bytes, Version(2, 0)).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 -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. +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. + +```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. -1. Create a `type-maps.yaml` in your project root (or anywhere else, adapt the path accordingly) +1. Create a `type-maps.yaml` in your project root 2. Set the `MTP_TYPE_MAPS` environment variable in `.cargo/config.toml`: ```toml diff --git a/docs/WASM-CLIENT.md b/docs/WASM-CLIENT.md index 8a56bbd..b70ab3a 100644 --- a/docs/WASM-CLIENT.md +++ b/docs/WASM-CLIENT.md @@ -2,17 +2,6 @@ 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 ```typescript @@ -28,17 +17,20 @@ import { mtp } from "mtp/vite"; ## Vite Type-Map Workflow -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). +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. -The browser build uses the map named by `protocol_version` and includes the -reserved MTP names. It does not advertise application names from other map -versions, because the generated WASM client is compiled for that one protocol -version. The selected version must exist in `type_maps`. +```typescript +// vite.config.ts +import { defineConfig } from "vite"; +import { mtp } from "mtp/vite"; + +export default defineConfig({ + plugins: [mtp({ typeMaps: "./type-maps.yaml" })], +}); +``` 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 ```typescript @@ -88,261 +80,6 @@ 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 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. @@ -411,30 +148,6 @@ If hashes are omitted, the browser uses its normal TLS root store. `maxMessageSize` caps inbound and outbound MTP frames before buffering/sending. `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 @@ -454,7 +167,7 @@ await client.send("SomeType", { value: "hello" }, { }); ``` -`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: +`request` sends one frame and resolves with the parsed response carrying the same frame id. `responseType` is validated after the id match: ```typescript const response = await client.request( @@ -474,61 +187,7 @@ const unsubscribe = client.subscribe("SomeType", (message) => { unsubscribe(); ``` -### 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: +Protocol pings are real MTP `Ping` frames sent by the WASM client, not just transport keepalives: ```typescript await MTPClient.create({ @@ -539,139 +198,14 @@ await MTPClient.create({ 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 The SDK logger receives parsed events: ```typescript type MTPLogEvent = - | { - hint: "info" | "warning"; - type: string; - data: unknown; - direction?: "send" | "recv"; - } - | { - hint: "error"; - type: string | "error"; - error: string; - data?: unknown; - direction?: "send" | "recv"; - }; + | { hint: "info" | "warning"; type: string; data: unknown } + | { hint: "error"; type: string | "error"; error: string }; ``` Incoming non-error frames and sent frames are logged as `info`. Error frames and transport errors are logged as `error`. @@ -706,46 +240,16 @@ config.free(); Raw callbacks receive parsed frames, not application-specific SDK objects: ```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 { id?: number; type: string; sender?: bigint; receiver?: bigint; - data: ParsedDataValue; + data: Record; raw: Uint8Array; } ``` -### Frames - Raw message helpers that remain available include: - `build_frame(messageType, data, options?)` @@ -754,18 +258,6 @@ Raw message helpers that remain available include: - `format_frame(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: - `ed25519_generate()` @@ -773,13 +265,9 @@ Raw crypto and key helpers include: - `keyring_generate()` - `keyring_from_ed25519(secretKey, publicKey)` - `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()` - `WasmEd25519Signer` - `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` Raw authenticated login and registration map directly to the Rust WASM layer: @@ -802,25 +290,4 @@ 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. - -### 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). diff --git a/example-type-maps.yaml b/example-type-maps.yaml index ae7c4ba..0041f48 100644 --- a/example-type-maps.yaml +++ b/example-type-maps.yaml @@ -1,78 +1,53 @@ -################################################################################# -# This is an example, overwrite it for your project to register your own types. # -################################################################################# - # The version a Client should use protocol_version: "0.0" # Note that markers 0 to 31 are reserved for default use, manually working with them is not recommended # Fixed CommunicationType markers are: -# Identification: 0 -# IdentificationResponse: 1 -# Register: 2 -# RegisterResponse: 3 -# Challenge: 4 -# ChallengeResponse: 5 -# Ping: 6 -# Pong: 7 -# Disconnect: 8 -# Redirect: 9 -# Shutdown: 10 -# Error: 11 -# ErrorParsing: 12 -# ErrorBadVersion: 13 -# BadRequest: 14 -# Unauthorized: 15 -# Forbidden: 16 -# NotFound: 17 -# TooManyRequests: 18 -# InternalServerError: 19 -# BadGateway: 20 -# ServiceUnavailable: 21 -# GatewayTimeout: 22 -# Relay: 26 -# PipeRequest: 23 -# PipeResponse: 24 -# PipeAbort: 25 +# Error: 0 +# ErrorParsing: 1 +# ErrorBadVersion: 2 +# Disconnect: 3 +# Redirect: 4 +# Shutdown: 5 +# BadRequest: 6 +# Unauthorized: 7 +# Forbidden: 8 +# NotFound: 9 +# TooManyRequests: 10 +# InternalServerError: 11 +# BadGateway: 12 +# ServiceUnavailable: 13 +# GatewayTimeout: 14 +# Identification: 15 +# IdentificationResponse: 16 +# Register: 17 +# RegisterResponse: 18 +# Ping: 19 +# Pong: 20 # # Fixed Data Type markers are: -# Version: 0 -# id: 1 -# ClientNonce: 2 -# ServerNonce: 3 -# PublicKeys: 4 -# Signature: 5 -# PqSignature: 6 -# Description: 7 -# Connected: 8 -# Timestamp: 9 -# Error: 10 -# ErrorParsing: 11 -# ErrorMessage: 12 -# Accepted: 13, -# RequirePq: 14 -# MessageId: 15 -# FinalRecipientId: 18 -# CreatedAt: 21 -# MessageType: 22 -# Content: 23 -# Metadata: 24 -# RelayVersion: 25 -# ProtectedVersion: 26 +# Error: 0 +# ErrorParsing: 1 +# ErrorMessage: 2 +# Version: 3 +# Description: 4 +# Timestamp: 5 +# Id: 6 +# ClientNonce: 7 +# ServerNonce: 8 +# PublicKeys: 9 +# Signature: 10 +# Connected: 11 # -# Types absent from a protocol version cannot be encoded for that version. +# If a Type can't be used it will be mapped to 0 type_maps: "0.0": # Protocol version 0.0 CommunicationTypes: - ProtectedMessage: 32 - AlternateMessage: 33 DataTypes: ExampleType: 32 "1.0": CommunicationTypes: - ProtectedMessage: 32 - AlternateMessage: 33 DataTypes: # If a v0.0 client connects # - the server can't use "AnotherType" @@ -82,8 +57,6 @@ type_maps: SomeType: 34 "2.0": CommunicationTypes: - ProtectedMessage: 32 - AlternateMessage: 33 DataTypes: # If a v0.0 client connects # - the server can't use "AnotherType" diff --git a/example/.gitignore b/example/.gitignore index 7f5a6d9..0d3408c 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -10,8 +10,3 @@ web-client/node_modules web-client/public/host_public_key_bundle.hex web-client/public/mtp_dev_cert_hash.txt web-client/dist/ - -client.id -*.mk -*.mpkb -metrics/ diff --git a/example/Cargo.lock b/example/Cargo.lock index f81b014..49da683 100644 --- a/example/Cargo.lock +++ b/example/Cargo.lock @@ -12,18 +12,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures 0.2.17", - "password-hash", -] - [[package]] name = "asn1-rs" version = "0.7.2" @@ -36,7 +24,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.20", + "thiserror 2.0.18", "time", ] @@ -48,7 +36,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", "synstructure", ] @@ -60,26 +48,9 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - [[package]] name = "autocfg" version = "1.5.1" @@ -88,9 +59,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -99,15 +70,14 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.44.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", "dunce", "fs_extra", - "pkg-config", ] [[package]] @@ -116,12 +86,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - [[package]] name = "base64ct" version = "1.8.3" @@ -139,18 +103,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest 0.10.7", -] +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "block-buffer" @@ -184,15 +139,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" [[package]] name = "cc" -version = "1.4.2" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "jobserver", @@ -208,9 +163,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" @@ -223,17 +178,6 @@ dependencies = [ "cpufeatures 0.2.17", ] -[[package]] -name = "chacha20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - [[package]] name = "chacha20poly1305" version = "0.10.1" @@ -241,7 +185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20 0.9.1", + "chacha20", "cipher", "poly1305", "zeroize", @@ -260,14 +204,12 @@ dependencies = [ [[package]] name = "client" -version = "0.3.0" +version = "0.1.0" dependencies = [ + "hex", "mtp", - "rand", - "serde", "serde_json", "tokio", - "tracing-subscriber", ] [[package]] @@ -286,14 +228,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] -name = "combine" -version = "4.6.7" +name = "const-oid" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "const-oid" @@ -375,23 +313,8 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "fiat-crypto 0.2.9", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "curve25519-dalek-derive", - "digest 0.11.3", - "fiat-crypto 0.3.0", + "digest 0.10.7", + "fiat-crypto", "rustc_version", "subtle", "zeroize", @@ -405,26 +328,36 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "data-encoding" -version = "2.11.1" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "der" -version = "0.8.1" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + [[package]] name = "der-parser" version = "10.0.0" @@ -453,7 +386,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", - "subtle", ] [[package]] @@ -463,20 +395,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] [[package]] name = "displaydoc" -version = "0.2.7" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] @@ -487,25 +419,24 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "ed25519" -version = "3.0.0" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", - "signature", + "pkcs8 0.10.2", + "signature 2.2.0", ] [[package]] name = "ed25519-dalek" -version = "3.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek 5.0.0", + "curve25519-dalek", "ed25519", "serde", - "sha2", - "signature", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -526,53 +457,17 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "fastbloom" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" -dependencies = [ - "foldhash", - "libm", - "portable-atomic", - "siphasher", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - [[package]] name = "fiat-crypto" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" -[[package]] -name = "fiat-crypto" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" - [[package]] name = "find-msvc-tools" -version = "0.1.10" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "form_urlencoded" @@ -589,90 +484,26 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "futures" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", - "futures-sink", -] - [[package]] name = "futures-core" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-executor" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" - -[[package]] -name = "futures-macro" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-task" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.34" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "futures-channel", "futures-core", - "futures-io", - "futures-macro", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] @@ -700,6 +531,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -707,86 +552,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", - "js-sys", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", - "wasm-bindgen", -] - -[[package]] -name = "h2" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "h3" -version = "0.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be" -dependencies = [ - "bytes", - "fastrand", - "futures-util", - "http", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "h3-datagram" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d2c9f77921668673721ae40f17c729fc48b9e38a663858097cea547484fdf0f" -dependencies = [ - "bytes", - "h3", - "pin-project-lite", -] - -[[package]] -name = "h3-quinn" -version = "0.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4" -dependencies = [ - "bytes", - "futures", - "h3", - "h3-datagram", - "quinn", - "tokio", - "tokio-util", -] - -[[package]] -name = "h3-webtransport" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d91a50fd582a5d67b1f756fba3cd9c66367ff4f23e1017c882f664d63b350a7" -dependencies = [ - "bytes", - "futures-util", - "h3", - "h3-datagram", - "http", - "pin-project-lite", - "tokio", - "tracing", ] [[package]] @@ -825,96 +593,16 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877" -[[package]] -name = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "ctutils", "typenum", ] -[[package]] -name = "hyper" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "bytes", - "http", - "http-body", - "hyper", - "pin-project-lite", - "tokio", -] - [[package]] name = "icu_collections" version = "2.2.0" @@ -1043,70 +731,21 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys", - "log", - "simd_cesu8", - "thiserror 2.0.20", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.119", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.119", -] - [[package]] name = "jobserver" -version = "0.1.35" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ "cfg-if", "futures-util", @@ -1124,21 +763,14 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd9697dc4a9a62e2da93389f34400b77a28f0287711263cabb203b3ccb9c0e4" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" dependencies = [ "cfg-if", "cpufeatures 0.3.0", ] -[[package]] -name = "keygen" -version = "0.3.0" -dependencies = [ - "mtp", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1147,15 +779,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "litemap" @@ -1186,9 +812,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "memchr" -version = "2.8.3" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "minimal-lexical" @@ -1198,9 +824,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.2.2" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -1213,14 +839,14 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" dependencies = [ - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", "hybrid-array", "module-lattice", - "pkcs8", + "pkcs8 0.11.0", "shake", - "signature", + "signature 3.0.0", ] [[package]] @@ -1262,156 +888,101 @@ dependencies = [ [[package]] name = "mtp" -version = "0.3.0" +version = "0.1.0" dependencies = [ "mtp-client", "mtp-codec", "mtp-common", "mtp-crypto", - "mtp-files", "mtp-host", "mtp-transport", "mtp-type-map", - "mtp-webserver", ] [[package]] name = "mtp-client" -version = "0.3.0" +version = "0.1.0" dependencies = [ "mtp-codec", "mtp-common", "mtp-crypto", "mtp-transport", - "rand", + "rand 0.8.6", "tokio", ] [[package]] name = "mtp-codec" -version = "0.3.0" +version = "0.1.0" dependencies = [ - "base64 0.23.1", + "base64", "byteorder", "mtp-common", "mtp-crypto", "mtp-type-map", - "rand", - "thiserror 2.0.20", + "rand 0.8.6", ] [[package]] name = "mtp-common" -version = "0.3.0" +version = "0.1.0" dependencies = [ "quinn", - "thiserror 2.0.20", + "rustls", + "thiserror 2.0.18", "wtransport", ] [[package]] name = "mtp-crypto" -version = "0.3.0" +version = "0.1.0" dependencies = [ - "argon2", - "base64 0.22.1", "chacha20poly1305", "ed25519-dalek", "getrandom 0.4.3", "hkdf", "ml-dsa", "mlkem-tls", - "rand", "rand_core 0.6.4", - "rcgen", - "rustls", "serde", - "sha2", + "sha2 0.11.0", "thiserror 1.0.69", - "time", - "tokio", - "zeroize", -] - -[[package]] -name = "mtp-files" -version = "0.3.0" -dependencies = [ - "mtp-crypto", - "rand", - "thiserror 2.0.20", "zeroize", ] [[package]] name = "mtp-host" -version = "0.3.0" +version = "0.1.0" dependencies = [ "mtp-codec", "mtp-common", "mtp-crypto", "mtp-transport", - "rand", - "thiserror 2.0.20", + "rand 0.8.6", "tokio", - "tracing", - "wtransport", ] [[package]] name = "mtp-transport" -version = "0.3.0" +version = "0.1.0" dependencies = [ - "async-trait", + "log", "mtp-codec", "mtp-common", - "mtp-crypto", - "rand", - "rcgen", "rustls", "rustls-native-certs", - "sha2", "tokio", - "tracing", "wtransport", - "zeroize", ] [[package]] name = "mtp-type-map" -version = "0.3.0" +version = "0.1.0" dependencies = [ "serde", "serde_yaml", ] -[[package]] -name = "mtp-webserver" -version = "0.3.0" -dependencies = [ - "async-trait", - "bytes", - "h3", - "h3-quinn", - "h3-webtransport", - "http", - "http-body-util", - "hyper", - "hyper-util", - "mtp-codec", - "mtp-common", - "mtp-crypto", - "mtp-host", - "mtp-transport", - "quinn", - "rustls", - "thiserror 2.0.20", - "tokio", - "tokio-rustls", - "tokio-stream", - "tracing", -] - [[package]] name = "nom" version = "7.1.3" @@ -1422,20 +993,11 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "num-bigint" -version = "0.4.8" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", @@ -1449,9 +1011,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.47" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ "num-traits", ] @@ -1467,9 +1029,9 @@ dependencies = [ [[package]] name = "octets" -version = "0.3.6" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "866cb5af6f3aa3c1b44c3c2d79d22165fbb1b102e1b3fb499864bfe34736ec4b" +checksum = "8311fa8ab7a57759b4ff1f851a3048d9ef0effaa0130726426b742d26d8a88e7" [[package]] name = "oid-registry" @@ -1521,32 +1083,21 @@ dependencies = [ "windows-link", ] -[[package]] -name = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "pem" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64 0.22.1", + "base64", "serde_core", ] [[package]] name = "pem-rfc7468" -version = "1.0.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" dependencies = [ "base64ct", ] @@ -1563,22 +1114,26 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", - "spki", + "der 0.8.0", + "spki 0.8.0", ] -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - [[package]] name = "poly1305" version = "0.8.0" @@ -1590,12 +1145,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "portable-atomic" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" - [[package]] name = "potential_utf" version = "0.1.5" @@ -1612,10 +1161,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "ppv-lite86" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -1628,14 +1186,13 @@ checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", - "futures-io", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", "rustls", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1643,24 +1200,21 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "aws-lc-rs", "bytes", - "fastbloom", - "getrandom 0.4.3", + "getrandom 0.3.4", "lru-slab", - "rand", - "rand_pcg", + "rand 0.9.4", "ring", "rustc-hash", "rustls", "rustls-pki-types", - "rustls-platform-verifier", "slab", - "thiserror 2.0.20", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1668,27 +1222,33 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.15" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.47" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1697,13 +1257,43 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.10.2" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ - "chacha20 0.10.2", - "getrandom 0.4.3", - "rand_core 0.10.1", + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1715,26 +1305,26 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - [[package]] name = "rcgen" -version = "0.14.9" +version = "0.14.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" dependencies = [ "aws-lc-rs", "pem", @@ -1770,9 +1360,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.3" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -1794,9 +1384,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "log", @@ -1822,46 +1412,19 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.1" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", ] -[[package]] -name = "rustls-platform-verifier" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" -dependencies = [ - "core-foundation", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - [[package]] name = "rustls-webpki" -version = "0.103.14" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -1871,9 +1434,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.23" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -1881,15 +1444,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - [[package]] name = "schannel" version = "0.1.29" @@ -1936,9 +1490,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -1946,29 +1500,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.229" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -1992,17 +1546,25 @@ dependencies = [ [[package]] name = "server" -version = "0.3.0" +version = "0.1.0" dependencies = [ - "base64 0.23.1", + "base64", "hex", - "http", "mtp", - "rand", - "serde", + "rcgen", "serde_json", "tokio", - "tracing-subscriber", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -2033,19 +1595,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" dependencies = [ "digest 0.11.3", - "keccak 0.2.1", + "keccak 0.2.0", "sponge-cursor", ] -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - [[package]] name = "shlex" version = "2.0.1" @@ -2062,6 +1615,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" @@ -2072,28 +1634,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "simd_cesu8" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "siphasher" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" - [[package]] name = "slab" version = "0.4.12" @@ -2108,14 +1648,24 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.5" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "spki" version = "0.8.0" @@ -2123,7 +1673,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.0", ] [[package]] @@ -2146,20 +1696,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.119" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -2174,7 +1713,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -2188,11 +1727,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.18", ] [[package]] @@ -2203,34 +1742,25 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", -] - -[[package]] -name = "thread_local" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" -dependencies = [ - "cfg-if", + "syn", ] [[package]] name = "time" -version = "0.3.55" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" dependencies = [ "deranged", "num-conv", @@ -2248,9 +1778,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.32" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" dependencies = [ "num-conv", "time-core", @@ -2268,9 +1798,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -2283,9 +1813,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -2300,48 +1830,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.2" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "libc", - "pin-project-lite", - "tokio", + "syn", ] [[package]] @@ -2350,7 +1845,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2364,7 +1858,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -2374,32 +1868,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "nu-ansi-term", - "sharded-slab", - "smallvec", - "thread_local", - "tracing-core", - "tracing-log", ] [[package]] @@ -2460,28 +1928,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2489,10 +1941,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasm-bindgen" -version = "0.2.127" +name = "wasip2" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -2503,9 +1964,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2513,22 +1974,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] @@ -2543,24 +2004,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "windows-link" version = "0.2.1" @@ -2573,7 +2016,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -2591,14 +2043,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -2607,48 +2076,102 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.3" @@ -2657,9 +2180,9 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wtransport" -version = "0.7.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4273ce3157a3262a68665f8d3f20a0ac0c5b8a69ffd67f05ae986832ebec036" +checksum = "ea4aacf790813ee1956751491800537f4e04af7557b7b370501ccbfbc85963e4" dependencies = [ "bytes", "pem", @@ -2668,9 +2191,9 @@ dependencies = [ "rustls", "rustls-native-certs", "rustls-pki-types", - "sha2", + "sha2 0.11.0", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.18", "time", "tokio", "tracing", @@ -2681,13 +2204,13 @@ dependencies = [ [[package]] name = "wtransport-proto" -version = "0.7.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aad9059572c7dbd6901ccef37f3b7321678cd708dcf58a64b1921dbeab7bfede" +checksum = "d5867c629e4252f7439d82315923daaf27f4fa442410d51b78ab93ef4c432a11" dependencies = [ "httlib-huffman", "octets", - "thiserror 2.0.20", + "thiserror 2.0.18", "url", ] @@ -2697,7 +2220,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ - "curve25519-dalek 4.1.3", + "curve25519-dalek", "rand_core 0.6.4", "serde", "zeroize", @@ -2718,7 +2241,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror 2.0.20", + "thiserror 2.0.18", "time", ] @@ -2751,10 +2274,30 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -2772,7 +2315,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", "synstructure", ] @@ -2793,7 +2336,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] @@ -2826,11 +2369,11 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn", ] [[package]] name = "zmij" -version = "1.0.23" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/example/Cargo.toml b/example/Cargo.toml index 48506d1..9ec3f02 100644 --- a/example/Cargo.toml +++ b/example/Cargo.toml @@ -1,6 +1,6 @@ [workspace] members = [ "server", - "client", "keygen", + "client", ] resolver = "3" diff --git a/example/client.id b/example/client.id deleted file mode 100644 index e37d32a..0000000 --- a/example/client.id +++ /dev/null @@ -1 +0,0 @@ -1000 \ No newline at end of file diff --git a/example/client/Cargo.toml b/example/client/Cargo.toml index be0ff55..b2c8193 100644 --- a/example/client/Cargo.toml +++ b/example/client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "client" -version = "0.3.0" +version = "0.1.0" edition = "2024" [[bin]] @@ -8,9 +8,7 @@ name = "client" path = "src/main.rs" [dependencies] -mtp = { version = "0.3.0", path = "../../", features = ["client", "crypto", "files", "pipes", "raw"] } +mtp = { path = "../../", features = ["client", "crypto"] } tokio = { version = "1", features = ["full"] } -rand = "0.10.1" -tracing-subscriber = "0.3.23" -serde = { version = "1", features = ["derive"] } serde_json = "1" +hex = "0.4" diff --git a/example/client/src/auth.rs b/example/client/src/auth.rs index 90334ef..5a4cf35 100644 --- a/example/client/src/auth.rs +++ b/example/client/src/auth.rs @@ -1,70 +1,52 @@ -use std::time::{Duration, Instant}; - -use tokio::fs; +use std::fs; use mtp::client::{ClientConfig, MTPClient, MTPConnection}; -use mtp::crypto::{Keyring, PublicKeyBundle}; -use mtp::files::{load_keyring_raw, save_keyring_raw}; +use mtp::crypto::{Ed25519Signer, Keyring, MlDsaSigner, PublicKeyBundle}; pub async fn connect_or_register( mut config: ClientConfig, host_public_key: PublicKeyBundle, - key_prefix: &str, -) -> Result<(MTPConnection, Keyring, String, Duration), Box> { - let keyring_path = format!("{key_prefix}.mk"); - let id_path = format!("{key_prefix}.id"); + client_key_path: &str, +) -> Result<(MTPConnection, Keyring), Box> { + if let Ok(data) = fs::read_to_string(client_key_path) { + let json: serde_json::Value = serde_json::from_str(&data)?; + 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"), + )?)?; - let file_load_started = Instant::now(); - if let (Ok(keyring), Ok(id)) = ( - load_keyring_raw(&keyring_path), - fs::read_to_string(&id_path).await, - ) { - let client_id: u64 = id.trim().parse()?; - println!( - "Loaded client keys (ID: {client_id}) in {:?}", - file_load_started.elapsed() - ); + println!("Loaded client keys (ID: {})", client_id); config.client_id = client_id; - let auth_started = Instant::now(); let conn = MTPClient::auth_connect(config, &keyring, &host_public_key).await?; - let auth_duration = auth_started.elapsed(); - println!( - "Authenticated (version {}) in {:?}", - conn.version, auth_duration + println!("Authenticated (version {})", conn.version); + Ok((conn, keyring)) + } else { + println!("No existing keys found: registering new client"); + + 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, ); - return Ok((conn, keyring, "connect".into(), auth_duration)); + + let keyring_bytes = keyring.to_bytes(); + let conn = MTPClient::auth_register(config, &keyring, &host_public_key).await?; + println!("Registered with ID: {}", conn.client_id); + + let json = serde_json::json!({ + "client_id": conn.client_id, + "keyring": hex::encode(&keyring_bytes), + }); + fs::write(client_key_path, serde_json::to_string_pretty(&json)?)?; + 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> { - 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) } diff --git a/example/client/src/main.rs b/example/client/src/main.rs index 06e1e9b..e940e0c 100644 --- a/example/client/src/main.rs +++ b/example/client/src/main.rs @@ -1,15 +1,11 @@ mod auth; -mod metrics; mod messages; -mod pipes; -mod protected; use std::fs; use std::path::Path; -use std::time::Duration; -use mtp::client::{AuthState, ClientConfig}; -use mtp::files::load_public_key_bundle; +use mtp::client::ClientConfig; +use mtp::crypto::{KemPublicKey, PublicKeyBundle, SignaturePqPublicKey, SignaturePublicKey}; fn dev_cert_path() -> String { std::env::var("MTP_DEV_CERT").unwrap_or_else(|_| { @@ -23,118 +19,35 @@ fn dev_cert_path() -> String { #[tokio::main] async fn main() -> Result<(), Box> { - tracing_subscriber::fmt::init(); let cert_path = dev_cert_path(); let cert_pem = fs::read(&cert_path).unwrap_or_else(|e| { panic!( "Missing TLS certificate at {cert_path}: enter the Nix shell first or run the server to generate it: {e}" ) }); - let host_public_key = match load_public_key_bundle("host.mpkb") { - Ok(bundle) => bundle, - Err(e) => { - return Err( - format!("Missing host.mpkb: run the server first to export it ({e})").into(), - ); - } - }; - - let mut client_metrics = metrics::ClientMetrics::load("metrics/client_sessions.json"); + let host_public_key = PublicKeyBundle::new( + KemPublicKey::new( + fs::read("host_enc_kem_pk.bin") + .expect("Missing host_enc_kem_pk.bin: run server first"), + ), + 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"), + ), + ); println!("Connecting to 127.0.0.1:8080 ..."); - let config = ClientConfig::new("https://127.0.0.1:8080") - .with_pinned_pem(cert_pem.clone()) - .with_description("MTP example client"); + let config = ClientConfig::new("https://127.0.0.1:8080").with_pinned_pem(cert_pem); let server_bundle = host_public_key.clone(); - let (conn, keyring, auth_method, auth_duration) = - match auth::connect_or_register(config, host_public_key, "client").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 (conn, keyring) = + auth::connect_or_register(config, host_public_key, "client_keys.json").await?; + messages::send_and_receive(&conn, &keyring, &server_bundle).await?; - 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"); Ok(()) } diff --git a/example/client/src/messages.rs b/example/client/src/messages.rs index 12af612..ca6ea50 100644 --- a/example/client/src/messages.rs +++ b/example/client/src/messages.rs @@ -1,59 +1,48 @@ -use std::time::{Duration, Instant}; - use mtp::client::MTPConnection; -use mtp::codec::ProtectionPurpose; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue}; -use mtp::crypto::{Ed25519Signer, Keyring, PublicKeyBundle}; +use mtp::crypto::{Ed25519Signer, EncryptionType, Keyring, PublicKeyBundle, SigAlgorithm}; use mtp::type_map::TypeMap; pub fn build_demo_message( client_id: u64, keyring: &Keyring, server_bundle: &PublicKeyBundle, -) -> Result> { +) -> CommunicationValue { // Encrypt to the server's KEM public key; the server decrypts with its keyring. - let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)?; + let enc_type = EncryptionType::MlKemChaCha20Poly1305; + let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key) + .expect("Ed25519 signer from keyring"); 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![ - (version_id, DataValue::Str("secret inner data".into())), - (id_id, DataValue::UnsignedNumber(42)), + (DataType::Version.to_id(&tm), DataValue::Str("secret inner data".into())), + (DataType::Id.to_id(&tm), DataValue::UnsignedNumber(42)), ]); - let dv_enc = inner_enc.encrypt_for( - std::slice::from_ref(server_bundle), - ProtectionPurpose::from(1), - )?; + let mut dv_enc = inner_enc; + dv_enc.encrypt_container(enc_type, server_bundle, b"demo-aad"); let inner_sig = DataValue::Container(vec![ - (version_id, DataValue::Str("signed by client".into())), - (id_id, DataValue::UnsignedNumber(99)), + (DataType::Version.to_id(&tm), DataValue::Str("signed by client".into())), + (DataType::Id.to_id(&tm), DataValue::UnsignedNumber(99)), ]); - let dv_sig = inner_sig.sign(client_id, ProtectionPurpose::from(2), &signer)?; + let mut dv_sig = inner_sig; + dv_sig.sign_container(SigAlgorithm::ED25519, &signer); let inner_sec = DataValue::Container(vec![ ( - version_id, + DataType::Version.to_id(&tm), DataValue::Str("signed+encrypted payload".into()), ), - (id_id, DataValue::UnsignedNumber(7)), + (DataType::Id.to_id(&tm), DataValue::UnsignedNumber(7)), ]); - let dv_sec = inner_sec - .sign(client_id, ProtectionPurpose::from(3), &signer)? - .encrypt_for( - std::slice::from_ref(server_bundle), - ProtectionPurpose::from(4), - )?; + let mut dv_sec = inner_sec; + dv_sec.sign_and_encrypt_container(SigAlgorithm::ED25519, &signer, enc_type, server_bundle, b"demo-aad"); let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_millis(); + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); let msg = CommunicationValue::new(CommunicationType::Ping) .add_typed_default( @@ -62,11 +51,11 @@ pub fn build_demo_message( ) .add_typed_default( DataType::Timestamp, - DataValue::UnsignedNumber(timestamp), + DataValue::UnsignedNumber(timestamp as u128), ) .add_typed_default(DataType::Data, DataValue::Str("Hello, MTP!".into())) .add_typed_default(DataType::Flags, DataValue::BoolTrue) - .add_typed_default(DataType::Value, DataValue::Float(1_234_500.0)) + .add_typed_default(DataType::Value, DataValue::Float(2, 12345)) .add_typed_default( DataType::BinaryData, DataValue::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42]), @@ -83,32 +72,25 @@ pub fn build_demo_message( .add_typed_default(DataType::SignedPayload, dv_sig) .add_typed_default(DataType::SecurePayload, dv_sec) .with_sender(client_id); - Ok(msg) + msg } pub async fn send_and_receive( conn: &MTPConnection, keyring: &Keyring, server_bundle: &PublicKeyBundle, -) -> Result> { - let msg = build_demo_message(conn.client_id, keyring, server_bundle)?; +) -> Result<(), Box> { + let msg = build_demo_message(conn.client_id, keyring, server_bundle); println!("Sending: {msg}"); - let start = Instant::now(); conn.sender.send(&msg).await?; - match conn.receive().await { + match conn.receiver.receive().await { Ok(resp) => { - let roundtrip = start.elapsed(); 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(()) } diff --git a/example/client/src/metrics.rs b/example/client/src/metrics.rs deleted file mode 100644 index 3bca4d0..0000000 --- a/example/client/src/metrics.rs +++ /dev/null @@ -1,558 +0,0 @@ -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, - pub message_roundtrip_ms: f64, - pub pipe_results: Vec, - 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, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ClientMetricsFile { - pub sessions: Vec, -} - -// --------------------------------------------------------------------------- -// Live metrics state -// --------------------------------------------------------------------------- - -pub struct ClientMetrics { - sessions: Vec, -} - -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::(&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, - message_roundtrip_ms: f64, - pipe_results: Vec, -} - -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::() - / 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::() - / 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); - } -} diff --git a/example/client/src/pipes.rs b/example/client/src/pipes.rs deleted file mode 100644 index 080a7c2..0000000 --- a/example/client/src/pipes.rs +++ /dev/null @@ -1,147 +0,0 @@ -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, Box> { - 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 = (0..size).map(|_| rand::random::()).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 -} diff --git a/example/client/src/protected.rs b/example/client/src/protected.rs deleted file mode 100644 index e9c2981..0000000 --- a/example/client/src/protected.rs +++ /dev/null @@ -1,199 +0,0 @@ -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> { - 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> { - 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> { - 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> { - 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::() - ); - 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> { - 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::()); - 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) -} diff --git a/example/keygen/Cargo.toml b/example/keygen/Cargo.toml deleted file mode 100644 index 4b0de60..0000000 --- a/example/keygen/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "keygen" -version = "0.3.0" -edition = "2024" - -[dependencies] -mtp = { version = "0.3.0", path = "../../", features = ["files", "raw"] } diff --git a/example/keygen/src/main.rs b/example/keygen/src/main.rs deleted file mode 100644 index a9543ee..0000000 --- a/example/keygen/src/main.rs +++ /dev/null @@ -1,40 +0,0 @@ -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(()) -} diff --git a/example/server/Cargo.toml b/example/server/Cargo.toml index da0baa7..7a12004 100644 --- a/example/server/Cargo.toml +++ b/example/server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "server" -version = "0.3.0" +version = "0.1.0" edition = "2024" [[bin]] @@ -8,12 +8,9 @@ name = "server" path = "src/main.rs" [dependencies] -mtp = { version = "0.3.0", path = "../../", features = ["crypto", "tls", "web-server", "files", "pipes", "raw"] } +mtp = { path = "../../", features = ["crypto", "host"] } +rcgen = "0.14" tokio = { version = "1", features = ["full"] } -http = "1" serde_json = { version = "1" } hex = "0.4" -base64 = "0.23" -tracing-subscriber = "0.3.23" -serde = { version = "1", features = ["derive"] } -rand = "0.10.1" +base64 = "0.22" diff --git a/example/server/src/clients.rs b/example/server/src/clients.rs index 241de6f..d9ad714 100644 --- a/example/server/src/clients.rs +++ b/example/server/src/clients.rs @@ -1,14 +1,14 @@ use std::collections::HashMap; +use std::fs; use std::sync::{Arc, Mutex}; -use tokio::fs; use mtp::crypto::PublicKeyBundle; -pub async fn load_client_db( +pub fn load_client_db( path: &str, ) -> Result<(Arc>>, Arc>), Box> { - let clients_map = match fs::read_to_string(path).await { + let clients_map = match fs::read_to_string(path) { Ok(data) => match serde_json::from_str(&data) { Ok(clients) => clients, Err(e) => { @@ -19,12 +19,7 @@ pub async fn load_client_db( Err(_) => HashMap::new(), }; let clients: Arc>> = Arc::new(Mutex::new(clients_map)); - 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_value = clients.lock().unwrap().keys().max().unwrap_or(&999) + 1; let next_id = Arc::new(Mutex::new(next_value)); Ok((clients, next_id)) } diff --git a/example/server/src/handlers.rs b/example/server/src/handlers.rs index 867a95d..5a797d3 100644 --- a/example/server/src/handlers.rs +++ b/example/server/src/handlers.rs @@ -1,282 +1,43 @@ -use std::collections::HashMap; - -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, +use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, TypeMap}; +use mtp::crypto::{ + CryptoError, Keyring, SignaturePublicKey, SignatureScheme, verify_ed25519, }; -fn resolve_signer_key( - signer_id: u64, - registered_clients: &HashMap, -) -> Option { - registered_clients.get(&signer_id).cloned() -} +struct Ed25519Verifier(SignaturePublicKey); -fn pong(tm: &TypeMap, data: impl Into) -> Result { - let desc_id = DataTypeId( - 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, - host_keyring: &Keyring, - accepted_messages: &mut InMemoryReplayGuard, -) -> Result { - if msg.receiver() != Some(DIRECT_DESTINATION_ID) { - return Err(format!( - "direct protected frame was addressed to {:?}, expected destination {DIRECT_DESTINATION_ID}", - msg.receiver() - )); +impl SignatureScheme for Ed25519Verifier { + fn sign(&self, _msg: &[u8]) -> Result, CryptoError> { + Err(CryptoError::SigningFailed) } - - 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" - ); + fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError> { + verify_ed25519(&self.0, msg, signature) } - pong( - tm, - format!("direct protected value verified for signer {signer_id}"), - ) -} - -fn process_sealed_relay( - msg: &CommunicationValue, - registered_clients: &HashMap, - host_keyring: &Keyring, - accepted_messages: &mut InMemoryReplayGuard, -) -> Result { - 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( msg: &CommunicationValue, tm: &TypeMap, client_pk: Option<&mtp::crypto::PublicKeyBundle>, - registered_clients: &HashMap, host_keyring: &Keyring, - accepted_direct_messages: &mut InMemoryReplayGuard, - accepted_relay_messages: &mut InMemoryReplayGuard, -) -> Result { - if msg.is_type(CommunicationType::ProtectedMessage) { - return process_direct_protected( - msg, - tm, - client_pk, - registered_clients, - host_keyring, - accepted_direct_messages, - ); - } - if msg.is_type(CommunicationType::Relay) { - return process_sealed_relay( - msg, - registered_clients, - host_keyring, - accepted_relay_messages, - ); - } +) -> CommunicationValue { + let desc_id = DataTypeId(tm.data_id_enum(DataType::Description).unwrap()); + let ts_id = DataTypeId(tm.data_id_enum(DataType::Timestamp).unwrap()); + let data_id = DataTypeId(tm.data_id_enum(DataType::Data).unwrap()); + let flags_id = DataTypeId(tm.data_id_enum(DataType::Flags).unwrap()); + let value_id = DataTypeId(tm.data_id_enum(DataType::Value).unwrap()); + let bin_id = DataTypeId(tm.data_id_enum(DataType::BinaryData).unwrap()); + let items_id = DataTypeId(tm.data_id_enum(DataType::Items).unwrap()); + let _enc_id = DataTypeId(tm.data_id_enum(DataType::EncryptedPayload).unwrap()); + let _sig_id = DataTypeId(tm.data_id_enum(DataType::SignedPayload).unwrap()); + let _secure_id = DataTypeId(tm.data_id_enum(DataType::SecurePayload).unwrap()); - let desc_id = DataTypeId( - 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 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); + let description = msg.get_data(DataType::Description); + let timestamp = msg.get_data(DataType::Timestamp); + let data = msg.get_data(DataType::Data); + let flags = msg.get_data(DataType::Flags); + let value = msg.get_data(DataType::Value); + let binary = msg.get_data(DataType::BinaryData); + let items = msg.get_data(DataType::Items); println!( " Description: {}", @@ -293,8 +54,10 @@ pub fn process_and_respond( let mut sig_status = String::from("SignedPayload: not present"); let mut secure_status = String::from("SecurePayload: not present"); - if let Some(enc @ DataValue::Encrypted(_)) = msg.get_data(DataType::EncryptedPayload) { - if let Ok(dv) = enc.decrypt(host_keyring, mtp::codec::ProtectionPurpose::from(1)) { + let enc = msg.get_data(DataType::EncryptedPayload); + if matches!(enc, DataValue::EncryptedContainer(_)) { + let mut dv = enc.clone(); + if dv.decrypt_into_container(host_keyring, b"demo-aad").is_some() { if let Some(entries) = dv.as_container() { println!(" Decrypted EncryptedPayload: {:?}", entries); enc_status = format!("EncryptedPayload decrypted OK ({} entries)", entries.len()); @@ -305,29 +68,13 @@ pub fn process_and_respond( } } - if let Some(sig @ DataValue::Signed(_)) = msg.get_data(DataType::SignedPayload) { + let sig = msg.get_data(DataType::SignedPayload); + if matches!(sig, DataValue::SignedContainer(_)) { if let Some(pk_bundle) = client_pk { - let signer_id = sig.as_signed().map(|signed| signed.signer_id); - if let Some(signer_id) = signer_id - && sig - .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()) { + let verifier = Ed25519Verifier(pk_bundle.sig_cl_public_key.clone()); + let mut dv = sig.clone(); + if dv.verify_into_container(&verifier).is_some() { + if let Some(entries) = dv.as_container() { println!(" Verified SignedPayload: {:?}", entries); sig_status = format!("SignedPayload verified OK ({} entries)", entries.len()); } @@ -341,29 +88,15 @@ pub fn process_and_respond( } } - if let Some(secure @ DataValue::Encrypted(_)) = msg.get_data(DataType::SecurePayload) { + let secure = msg.get_data(DataType::SecurePayload); + if matches!(secure, DataValue::SignedEncryptedContainer(_)) { if let Some(pk_bundle) = client_pk { - if let Ok(opened) = secure.decrypt(host_keyring, mtp::codec::ProtectionPurpose::from(4)) - && let Some(signed) = opened.as_signed() - && opened - .verify_with_policy( - signed.signer_id, - pk_bundle, - mtp::codec::ProtectionPurpose::from(3), - SIGNATURE_POLICY, - ) - .is_ok() + let verifier = Ed25519Verifier(pk_bundle.sig_cl_public_key.clone()); + let mut dv = secure.clone(); + if dv.decrypt_signed_encrypted_container(host_keyring, b"demo-aad").is_some() + && dv.verify_into_container(&verifier).is_some() { - 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()) { + if let Some(entries) = dv.as_container() { println!(" Verified SecurePayload: {:?}", entries); secure_status = format!( "SecurePayload decrypted+verified OK ({} entries)", @@ -382,14 +115,12 @@ pub fn process_and_respond( let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map_err(|e| e.to_string())? - .as_millis(); + .unwrap() + .as_secs(); - let response = CommunicationValue::from_comm(CommunicationType::Pong, tm) - .add_data(desc_id, description) - .map_err(|e| e.to_string())? - .add_data(ts_id, DataValue::UnsignedNumber(now)) - .map_err(|e| e.to_string())? + CommunicationValue::from_comm(CommunicationType::Pong, tm) + .add_data(desc_id, description.clone()) + .add_data(ts_id, DataValue::UnsignedNumber(now as u128)) .add_data( data_id, DataValue::Str(format!( @@ -397,14 +128,8 @@ pub fn process_and_respond( enc_status, sig_status, secure_status )), ) - .map_err(|e| e.to_string())? - .add_data(flags_id, flags) - .map_err(|e| e.to_string())? - .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) + .add_data(flags_id, flags.clone()) + .add_data(value_id, value.clone()) + .add_data(bin_id, binary.clone()) + .add_data(items_id, items.clone()) } diff --git a/example/server/src/keys.rs b/example/server/src/keys.rs index 41bd2d5..133eeba 100644 --- a/example/server/src/keys.rs +++ b/example/server/src/keys.rs @@ -1,35 +1,50 @@ -use tokio::fs; +use std::fs; -use mtp::crypto::Keyring; -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; +use mtp::crypto::kem::HybridKem; +use mtp::crypto::{Ed25519Signer, Keyring, MlDsaSigner}; pub fn load_or_generate_host_keys( - keyring_path: &str, + path: &str, ) -> Result<(u64, Keyring), Box> { - if let Ok(keyring) = load_keyring_raw(keyring_path) { - println!("Loaded host keyring from {keyring_path}"); - return Ok((HOST_ID, keyring)); + if let Ok(data) = fs::read_to_string(path) { + let json: serde_json::Value = serde_json::from_str(&data)?; + let hid = json["host_id"].as_u64().unwrap_or(1); + let keyring = Keyring::from_bytes(&hex::decode(json["keyring"].as_str().unwrap())?)?; + println!("Loaded host keys (ID: {})", hid); + return Ok((hid, keyring)); } - let keyring = Keyring::generate(); - save_keyring_raw(&keyring, keyring_path)?; - println!("Generated host keyring -> {keyring_path}"); - Ok((HOST_ID, keyring)) + let (_ed_signer, sig_sk, sig_pk) = Ed25519Signer::generate(); + let (_pq_signer, sig_pq_sk, sig_pq_pk) = MlDsaSigner::generate(); + let (kem_sk, kem_pk) = HybridKem::generate_keypair(); + let keyring = Keyring::new(kem_pk, kem_sk, sig_pq_pk, sig_pq_sk, sig_pk, sig_sk); + + 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 async fn export_host_public_keys( - host_keyring: &Keyring, -) -> Result<(), Box> { - let bundle = host_keyring.public_key_bundle(); - save_public_key_bundle(&bundle, "host.mpkb")?; +pub fn export_host_public_keys(host_keyring: &Keyring) -> Result<(), Box> { + let public_key_bundle_hex = hex::encode(host_keyring.public_key_bundle().as_bytes()); - /* The web client fetches the bundle as hex over HTTP. */ - let bundle_hex = hex::encode(bundle.try_as_bytes()?); - fs::write("host_public_key_bundle.hex", &bundle_hex).await?; - fs::create_dir_all("web-client/public").await?; - fs::write("web-client/public/host_public_key_bundle.hex", &bundle_hex).await?; + fs::write("host_public_key_bundle.hex", &public_key_bundle_hex)?; + fs::create_dir_all("web-client/public")?; + fs::write( + "web-client/public/host_public_key_bundle.hex", + &public_key_bundle_hex, + )?; + 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(()) } diff --git a/example/server/src/main.rs b/example/server/src/main.rs index 08969f4..a9854a2 100644 --- a/example/server/src/main.rs +++ b/example/server/src/main.rs @@ -1,20 +1,13 @@ mod clients; mod handlers; mod keys; -mod metrics; mod tls; -#[path = "web-server.rs"] -mod web_server; -use mtp::host::{AuthenticationPolicy, AuthState, HostConfig}; +use mtp::host::{HostConfig, MTPHost}; use mtp::type_map::TypeMap; use std::future::Future; use std::path::Path; 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) { let cert = std::env::var("MTP_DEV_CERT").unwrap_or_else(|_| { @@ -34,99 +27,64 @@ fn dev_cert_paths() -> (String, String) { (cert, key) } -async fn handle_pipe_loopback( - conn: &mtp::webserver::WebMTPConnection, - request: mtp::host::PipeRequest< - mtp::webserver::WebMtpSender, - mtp::webserver::WebMtpReceiver, - mtp::webserver::H3TransportReceiver, - >, -) -> Result> { - let pipe_id = request.id(); - println!(" [loopback] Accepting pipe {pipe_id} ..."); - let mut reader = request.accept().await?; - - let return_pipe = conn.create_pipe("loopback").await?; - println!( - " [loopback] Requested return pipe {}; waiting for client acceptance ...", - return_pipe.pipe_id() - ); - let Some(mut writer) = return_pipe.wait().await? else { - return Err("client denied the return pipe".into()); - }; - - 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> { - 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?; + let (cert_pem, key_pem) = tls::load_or_generate_tls(&cert_path, &key_path)?; + let cert_hash = tls::certificate_sha256_hex(&cert_pem)?; + tls::export_webtransport_cert_hash(&cert_hash)?; 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 (_host_id, host_keyring) = keys::load_or_generate_host_keys("host_keys.json")?; + keys::export_host_public_keys(&host_keyring)?; + + // The keyring is moved into the host config; keep a copy for decrypting the + // demo payloads clients encrypt to our KEM public key. + let decrypt_keyring = mtp::crypto::Keyring::from_bytes(&host_keyring.to_bytes()) + .expect("re-load host keyring for decryption"); + + let (clients, next_id) = clients::load_client_db("clients.json")?; - 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| { + let get_existing_user = move |id: u64| { let clients = clients_for_get.clone(); - Box::pin(async move { clients.lock().ok()?.get(&id).cloned() }) - as Pin> + Send>> + 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> + Send>> }; + let clients_for_register = clients.clone(); let next_id_for_register = next_id.clone(); - let complete_register = move |bundle: mtp::crypto::PublicKeyBundle, - _description: Option| { - let clients = clients_for_register.clone(); - let next_id = next_id_for_register.clone(); - + let clients_path = "clients.json".to_string(); + let complete_register = move |bundle: mtp::crypto::PublicKeyBundle| { + let db_arc = clients_for_register.clone(); + let nid_arc = next_id_for_register.clone(); + let path = clients_path.clone(); Box::pin(async move { - let id = { - let mut next = next_id.lock().expect("client id mutex poisoned"); - let id = *next; - *next += 1; - id - }; - - let json = { - let mut db = clients.lock().expect("client database mutex poisoned"); - db.insert(id, bundle); - serde_json::to_string_pretty(&*db).ok() - }; - - if let Some(json) = json - && let Err(error) = tokio::fs::write("clients.json", json).await - { - eprintln!("Failed to persist clients.json: {error}"); + let mut db = db_arc.lock().unwrap(); + let mut nid = nid_arc.lock().unwrap(); + let id = *nid; + *nid += 1; + db.insert(id, bundle); + match serde_json::to_string_pretty(&*db) { + Ok(json) => match std::fs::write(&path, json) { + Ok(()) => {} + Err(e) => eprintln!("Failed to persist client database to {path}: {e}"), + }, + Err(e) => eprintln!("Failed to serialize client database after registering {id}: {e}"), } - - println!("Registered new client with ID: {id}"); + println!("Registered new client with ID: {}", id); id }) as Pin + Send>> }; - 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 ..."); + println!("Starting MTP server on port 8080 ..."); let config = HostConfig::new( std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), @@ -134,169 +92,39 @@ async fn main() -> Result<(), Box> { cert_pem, key_pem, ) - .with_authentication( - host_keyring, - Box::new(get_existing_client), - Box::new(complete_register), - ) - .with_authentication_policy(AuthenticationPolicy::AllowAuthentication); + .with_authentication(host_keyring, get_existing_user, complete_register); - let mut host = mtp::webserver::MTPWebServer::new(config, web_server::config()?).await?; - println!("Server listening on https://{}", host.local_addr()); - println!("TCP: HTTP/1.1 and HTTP/2"); - println!("UDP: HTTP/3 and WebTransport"); + let mut host = MTPHost::new(config).await?; + println!("Server listening on {}", host.local_addr()); - loop { - let conn = match host.accept().await { - Ok(Some(conn)) => conn, - Ok(None) => break, + while let Some(conn) = host.accept().await? { + println!( + "\n--- New authenticated connection (version {}) ---", + conn.version + ); + println!("Client ID: {}", conn.client_id); + + let tm: &TypeMap = conn.codec.registry().get(&conn.version).unwrap(); + + match conn.receiver.receive().await { + Ok(msg) => { + println!("Received: {msg}"); + let response = handlers::process_and_respond( + &msg, + tm, + conn.client_public_key.as_ref(), + &decrypt_keyring, + ); + println!("Sending: {response}"); + conn.sender.send(&response).await?; + } 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; + eprintln!("Receive error: {e}"); } - }; - 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!( - "\n--- New connection (version {}, remote: {}, description: {desc}) ---", - conn.version, - conn.remote_addr - .map(|addr| addr.to_string()) - .unwrap_or_else(|| "unknown".into()) - ); - 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(); - - println!("Waiting for messages / pipe requests ..."); - let mut pipe_open = true; - let mut message_open = true; - let mut accepted_direct_messages = mtp::codec::InMemoryReplayGuard::default(); - 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, - conn.client_public_key.as_ref(), - ®istered_clients, - &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}"); - if let Err(error) = conn.sender.send(&response).await { - eprintln!("Send error: {error}"); - session.record_send_error(); - pipe_open = false; - message_open = false; - } - } - 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; - } - } - - let record = session.finish(exit_reason); - 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"); - }); + conn.sender.close(); + println!("Connection closed\n"); } Ok(()) diff --git a/example/server/src/metrics.rs b/example/server/src/metrics.rs deleted file mode 100644 index b994922..0000000 --- a/example/server/src/metrics.rs +++ /dev/null @@ -1,885 +0,0 @@ -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, - pub sessions: Vec, -} - -#[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, - pub sessions: Vec, -} - -// --------------------------------------------------------------------------- -// 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, - active_connections: u64, - completed_sessions: Vec, -} - -pub struct ServerMetrics { - inner: Mutex, -} - -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::(&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, -} - -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::() / 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); - } -} diff --git a/example/server/src/tls.rs b/example/server/src/tls.rs index 4b53f00..a119b5d 100644 --- a/example/server/src/tls.rs +++ b/example/server/src/tls.rs @@ -1,34 +1,39 @@ -use base64::Engine; +use std::fs; use std::path::Path; -use tokio::fs; -pub async fn load_or_generate_tls( +use base64::Engine; + +pub fn load_or_generate_tls( cert_path: &str, key_path: &str, ) -> Result<(Vec, Vec), Box> { - if let (Ok(c), Ok(k)) = (fs::read(cert_path).await, fs::read(key_path).await) { + if let (Ok(c), Ok(k)) = (fs::read(cert_path), fs::read(key_path)) { println!("Using existing TLS cert from {cert_path}"); return Ok((c, k)); } println!("Generating self-signed TLS certificate ..."); if let Some(parent) = Path::new(cert_path).parent() { - fs::create_dir_all(parent).await?; + fs::create_dir_all(parent)?; } if let Some(parent) = Path::new(key_path).parent() { - fs::create_dir_all(parent).await?; + fs::create_dir_all(parent)?; } + 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, key_pem) = mtp::crypto::tls::generate_self_signed_cert("localhost")?; + let cert_str = cert.pem(); + let key_str = key_pair.serialize_pem(); - fs::write(cert_path, &cert_pem).await?; - fs::write(key_path, &key_pem).await?; + fs::write(cert_path, cert_str.as_bytes())?; + fs::write(key_path, key_str.as_bytes())?; println!("Wrote {cert_path} and {key_path}"); - Ok((cert_pem, key_pem)) + Ok((cert_str.into_bytes(), key_str.into_bytes())) } -pub async fn certificate_sha256_hex(cert: &[u8]) -> Result> { +pub fn certificate_sha256_hex(cert: &[u8]) -> Result> { let der = if cert.starts_with(b"-----BEGIN CERTIFICATE-----") { let pem = std::str::from_utf8(cert)?; let base64 = pem @@ -43,14 +48,14 @@ pub async fn certificate_sha256_hex(cert: &[u8]) -> Result Result<(), Box> { +pub fn export_webtransport_cert_hash(hash: &str) -> Result<(), Box> { let public_dir = if Path::new("web-client").exists() { Path::new("web-client/public") } else { Path::new("example/web-client/public") }; - fs::create_dir_all(public_dir).await?; - fs::write(public_dir.join("mtp_dev_cert_hash.txt"), hash).await?; + fs::create_dir_all(public_dir)?; + fs::write(public_dir.join("mtp_dev_cert_hash.txt"), hash)?; let dev_cert_dir = if Path::new("dev-cert").exists() { Path::new("dev-cert") @@ -58,7 +63,7 @@ pub async fn export_webtransport_cert_hash(hash: &str) -> Result<(), Box 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 { - 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::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>, -) -> 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("MTP web client not built

Run pnpm --dir example/web-client build.

"); - }; - 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", - } -} diff --git a/example/type-maps.yaml b/example/type-maps.yaml index 0f96f2e..0289011 100644 --- a/example/type-maps.yaml +++ b/example/type-maps.yaml @@ -1,22 +1,28 @@ -protocol_version: "3.0" +protocol_version: "1.0" type_maps: - "3.0": + "0.0": CommunicationTypes: - ProtectedMessage: 32 - AlternateMessage: 33 DataTypes: + "1.0": + CommunicationTypes: + DataTypes: + Data: 32 Flags: 33 + Value: 34 + BinaryData: 35 + Items: 36 + EncryptedPayload: 37 + SignedPayload: 38 + SecurePayload: 39 + "2.0": + CommunicationTypes: + DataTypes: Data: 34 + Flags: 33 Value: 35 BinaryData: 36 Items: 37 EncryptedPayload: 38 SignedPayload: 39 SecurePayload: 40 - CommunicationType: 41 - DataType: 42 - ExampleText: 43 - ExampleNumber: 44 - ExampleRole: 45 - ExampleMetadata: 46 diff --git a/example/web-client/index.html b/example/web-client/index.html index db6008a..66e395b 100644 --- a/example/web-client/index.html +++ b/example/web-client/index.html @@ -1,97 +1,39 @@ - + - - - - MTP Web Client - - - -

MTP WebTransport Client

- - + + + + MTP Web Client + + + +

MTP WebTransport Client

+ + - - + + - - + + -
- - - - -
+
+ + + +
-
-

Pipe Demo

-
- - -
-
- -
-

Metrics

-
- -
-
Initializing...
-
- - +
Initializing...
+
+ + diff --git a/example/web-client/package.json b/example/web-client/package.json index 004772d..673f8e6 100644 --- a/example/web-client/package.json +++ b/example/web-client/package.json @@ -1,7 +1,7 @@ { "name": "mtp-web-client", "private": true, - "version": "0.3.0", + "version": "0.1.0", "type": "module", "packageManager": "pnpm@11.8.0", "scripts": { @@ -13,7 +13,7 @@ "mtp": "workspace:*" }, "devDependencies": { - "typescript": "^7.0.0", + "typescript": "^6.0.3", "vite": "^8.1.0" } } diff --git a/example/web-client/src/main.ts b/example/web-client/src/main.ts index ccdfe52..07f24bd 100644 --- a/example/web-client/src/main.ts +++ b/example/web-client/src/main.ts @@ -1,33 +1,14 @@ import { MTPClient } from "mtp"; -import type { - MTPCredentialStorage, - MTPLogEvent, - MTPPipeReader, - MTPPipeWriter, - ParsedFrame, -} from "mtp"; +import type { MTPCredentialStorage, MTPLogEvent, ParsedFrame } from "mtp"; const STATUS = document.getElementById("status")!; const KEY_STATUS = document.getElementById("key-status")!; const SERVER_URL = document.getElementById("server-url") as HTMLInputElement; -const HOST_PUBLIC_KEY = document.getElementById( - "host-public-key", -) as HTMLTextAreaElement; -const CLIENT_CREDENTIALS = document.getElementById( - "client-credentials", -) as HTMLTextAreaElement; -const GENERATE_KEYPAIR = document.getElementById( - "generate-keypair", -) as HTMLButtonElement; +const HOST_PUBLIC_KEY = document.getElementById("host-public-key") 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_UNAUTHENTICATED = document.getElementById( - "connect-unauthenticated", -) 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 HOST_PUBLIC_KEY_KEY = "mtp-web-client-host-public-key"; @@ -35,35 +16,12 @@ const HOST_PUBLIC_KEY_KEY = "mtp-web-client-host-public-key"; type SavedKeys = { clientId: string | null; keyring?: number[]; + keyringBytes?: number[]; hostPublicKey?: number[]; }; let clientId: bigint | null = null; let devCertHash = ""; -let activeClient: ReturnType extends Promise - ? 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 = { getItem: (key) => localStorage.getItem(key), @@ -78,140 +36,6 @@ function log(msg: string, cls = "") { 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(".metric-value")!.textContent = value; - return; - } - - const wrapper = document.createElement("div"); - wrapper.dataset.metric = name; - wrapper.innerHTML = `: `; - wrapper.querySelector(".metric-name")!.textContent = name; - wrapper.querySelector(".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; - 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 { return JSON.stringify(value, (_key, item) => { if (typeof item === "bigint") { @@ -246,16 +70,13 @@ function setKeyStatus(msg: 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 { const hex = value.replace(/[^0-9a-fA-F]/g, ""); 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); for (let i = 0; i < bytes.length; i += 1) { @@ -266,10 +87,7 @@ function hexToBytes(value: string): Uint8Array { function saveHostPublicKey() { 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 { localStorage.removeItem(HOST_PUBLIC_KEY_KEY); } @@ -284,15 +102,13 @@ function loadKeys() { if (!raw) { 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; } const data = JSON.parse(raw) as SavedKeys; clientId = data.clientId ? BigInt(data.clientId) : null; - const keyringLength = (data.keyring ?? []).length; + const keyringLength = (data.keyring ?? data.keyringBytes ?? []).length; CLIENT_CREDENTIALS.value = renderStructured({ clientId: data.clientId, keyringBytes: keyringLength, @@ -311,13 +127,11 @@ function loadKeys() { async function loadHostPublicKey() { 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; const hostPublicKey = (await response.text()).trim(); - if (!hostPublicKey || !/^[0-9a-f]+$/i.test(hostPublicKey)) return; + if (!hostPublicKey) return; HOST_PUBLIC_KEY.value = hostPublicKey; saveHostPublicKey(); @@ -329,14 +143,11 @@ async function loadHostPublicKey() { async function loadDevCertHash() { try { - const response = await fetch(`/mtp_dev_cert_hash.txt?t=${Date.now()}`, { - cache: "no-store", - }); + const response = await fetch("/mtp_dev_cert_hash.txt", { cache: "no-store" }); if (!response.ok) return; - const hash = (await response.text()).trim(); - if (/^[0-9a-f]{64}$/i.test(hash)) { - devCertHash = hash; + devCertHash = (await response.text()).trim(); + if (devCertHash) { log(`Loaded WebTransport certificate hash: ${devCertHash}`); } } catch { @@ -346,397 +157,82 @@ async function loadDevCertHash() { async function initWasm() { 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(); log(`WASM loaded. WebTransport supported: ${supported}`); CONNECT.disabled = !supported; - CONNECT_UNAUTHENTICATED.disabled = !supported; -} - -async function createClient() { - const hostPk = hexToBytes(HOST_PUBLIC_KEY.value); - await loadDevCertHash(); - const serverUrl = SERVER_URL.value.trim(); - const serverCertificateHashes = devCertHash ? [devCertHash] : undefined; - - const client = await MTPClient.create({ - url: serverUrl, - hostPublicKey: hostPk, - storage: credentialStorage, - credentialsStorageKey: CREDENTIALS_KEY, - serverCertificateHashes, - pings: { intervalMs: 30_000 }, - logger(event) { - 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; } + const hostPk = hexToBytes(HOST_PUBLIC_KEY.value); saveHostPublicKey(); await loadDevCertHash(); const serverUrl = SERVER_URL.value.trim(); - const serverCertificateHashes = devCertHash ? [devCertHash] : undefined; + const serverCertificateHashes = devCertHash ? [`sha-256:${devCertHash}`] : undefined; if (serverCertificateHashes) { log(`Pinning WebTransport certificate hash: ${serverCertificateHashes[0]}`); } else { - log( - "No WebTransport certificate hash loaded; relying on browser trust store.", - "state", - ); + log("No WebTransport certificate hash loaded; relying on browser trust store.", "state"); } try { - const client = await createClient(); - activeClient = client; + const client = await MTPClient.create({ + url: serverUrl, + hostPublicKey: hostPk, + storage: credentialStorage, + credentialsStorageKey: CREDENTIALS_KEY, + serverCertificateHashes, + pings: { intervalMs: 30_000 }, + logger(event) { + log(renderLoggerEvent(event), event.hint === "error" ? "error" : event.type === "state" ? "state" : ""); + }, + }); client.subscribe("Pong", (frame: ParsedFrame) => { log(`Subscribed Pong: ${formatParsedFrame(frame)}`, "received"); }); - const activeClientId = await client.auth(); + const existingClientId = client.credentials?.clientId; + const activeClientId = existingClientId == null + ? await client.register() + : (await client.connect(), BigInt(existingClientId)); clientId = activeClientId; loadKeys(); - log(`Connected as authenticated client ${activeClientId}`); + log(`Connected as client ${activeClientId}`); log("\nSending typed Ping..."); - await client.send( - "Ping", - { - Description: "MTP web client send ping", - Timestamp: BigInt(Date.now()), - }, - { sender: activeClientId }, - ); + await client.send("Ping", { + Description: "MTP web client send ping", + Timestamp: BigInt(Date.now()), + }, { sender: activeClientId }); log("Typed Ping sent."); log("\nRequesting Pong by Ping frame id..."); - const response = await client.request( - "Ping", - { - Description: "MTP web client request ping", - Timestamp: BigInt(Date.now()), - }, - { sender: activeClientId, responseType: "Pong" }, - ); + const response = await client.request("Ping", { + Description: "MTP web client request ping", + Timestamp: BigInt(Date.now()), + }, { sender: activeClientId, responseType: "Pong" }); log(`Request response: ${formatParsedFrame(response)}`, "received"); log("\nClient running. Waiting for incoming messages..."); - STREAM_MIC.disabled = false; - log("\nPipe demo ready. Click 'Stream Microphone' to start.", "pipe"); - updateMetrics(); } catch (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((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", () => { try { clientId = null; localStorage.removeItem(CREDENTIALS_KEY); 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."); } catch (e) { log(`Credential reset failed: ${e}`, "error"); @@ -755,13 +251,6 @@ CONNECT.addEventListener("click", () => { }); }); -CONNECT_UNAUTHENTICATED.addEventListener("click", () => { - connectUnauthenticated().catch((e) => { - log(`Unauthenticated connection failed: ${e}`, "error"); - console.error(e); - }); -}); - CLEAR_KEYS.addEventListener("click", () => { clientId = null; CLIENT_CREDENTIALS.value = ""; @@ -771,17 +260,6 @@ CLEAR_KEYS.addEventListener("click", () => { 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); initWasm() @@ -793,5 +271,3 @@ initWasm() log(`Fatal error: ${e}`, "error"); console.error(e); }); - -updateMetrics(); diff --git a/example/web-client/vite.config.ts b/example/web-client/vite.config.ts index 340deb2..6be6c39 100644 --- a/example/web-client/vite.config.ts +++ b/example/web-client/vite.config.ts @@ -1,41 +1,16 @@ -import { defineConfig, type Plugin } from 'vite'; +import { defineConfig } from 'vite'; import fs from 'fs'; import path from 'path'; import { mtp } from 'mtp/vite'; -const exampleDir = path.resolve(__dirname, '..'); -const devCertDir = path.join(exampleDir, 'dev-cert'); +const devCertDir = path.resolve(__dirname, '../dev-cert'); 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 hasDevCert = fs.existsSync(certPath) && fs.existsSync(keyPath); -const devFiles: Record = { - '/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({ - plugins: [mtp({ typeMaps: '../../example/type-maps.yaml' }), devFileServe()], + plugins: [mtp({ typeMaps: '../../example/type-maps.yaml' })], server: { https: hasDevCert ? { diff --git a/files/Cargo.toml b/files/Cargo.toml deleted file mode 100644 index 244d1d2..0000000 --- a/files/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[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"] } diff --git a/files/src/lib.rs b/files/src/lib.rs deleted file mode 100644 index 366f305..0000000 --- a/files/src/lib.rs +++ /dev/null @@ -1,460 +0,0 @@ -/* - * 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 { - 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 { - 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::OpenOptions::new() - .write(true) - .create_new(true) - .open(path) -} - -fn temporary_path(path: &Path, attempt: u64) -> io::Result { - 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, 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 { - 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, - 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(¶meters))?; - let mut payload = Vec::with_capacity(PROTECTED_PARAMS_LEN + encrypted.len()); - payload.extend_from_slice(¶meters); - 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, passphrase: &[u8]) -> Result { - 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) -> 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) -> Result { - 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, -) -> 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) -> Result { - 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> { - 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> { - 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> { - 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> { - 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> { - 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> { - 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> { - 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> { - 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> { - 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(()) - } -} diff --git a/flake.lock b/flake.lock index aeb3d70..0244fe2 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,23 @@ { "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1782467914, @@ -34,6 +52,7 @@ }, "root": { "inputs": { + "flake-utils": "flake-utils", "nixpkgs": "nixpkgs", "rust-overlay": "rust-overlay" } @@ -43,11 +62,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1782616745, - "narHash": "sha256-NN5B1cKBXF6h1Ec681gMGZ2o/99d7vKXAAfFNEvyOKA=", + "lastModified": 1782443907, + "narHash": "sha256-P+pADLtK7qC1mz0/5Xq9uF77oahUR4zYLTaitiHsUHg=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "b916d014dc57eb555f84e51172a82f7f6fb560d7", + "rev": "4b06ff4acf3491ff69721df852507fcc51d0a13d", "type": "github" }, "original": { @@ -55,6 +74,21 @@ "repo": "rust-overlay", "type": "github" } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 0a40973..cd24a7e 100644 --- a/flake.nix +++ b/flake.nix @@ -1,34 +1,22 @@ { description = "MTP - Methanium Transport Protocol"; + inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; rust-overlay.url = "github:oxalica/rust-overlay"; + flake-utils.url = "github:numtide/flake-utils"; }; - outputs = - { - self, - nixpkgs, - rust-overlay, - }: - let - systems = [ - "aarch64-darwin" - "aarch64-linux" - "x86_64-darwin" - "x86_64-linux" - ]; - eachSystem = - f: - nixpkgs.lib.foldl' nixpkgs.lib.recursiveUpdate { } ( - map (system: nixpkgs.lib.mapAttrs (_: value: { ${system} = value; }) (f system)) systems - ); - in - eachSystem ( - system: - let - overlays = [ rust-overlay.overlays.default ]; - pkgs = import nixpkgs { inherit system overlays; }; + outputs = { + self, + nixpkgs, + rust-overlay, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: let + overlays = [rust-overlay.overlays.default]; + pkgs = import nixpkgs {inherit system overlays;}; rustToolchain = pkgs.rust-bin.stable.latest.default.override { extensions = [ @@ -36,12 +24,12 @@ "clippy" "rustfmt" ]; - targets = [ "wasm32-unknown-unknown" ]; + targets = ["wasm32-unknown-unknown"]; }; clippyCheck = pkgs.writeShellApplication { name = "mtp-clippy"; - runtimeInputs = [ rustToolchain ]; + runtimeInputs = [rustToolchain]; text = '' 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 @@ -50,7 +38,7 @@ macheteCheck = pkgs.writeShellApplication { name = "mtp-machete"; - runtimeInputs = [ pkgs.cargo-machete ]; + runtimeInputs = [pkgs.cargo-machete]; text = '' cargo machete "$@" ''; @@ -58,15 +46,7 @@ buildAll = pkgs.writeShellApplication { name = "mtp-build-all"; - runtimeInputs = [ - rustToolchain - pkgs.cargo-deny - pkgs.wasm-pack - pkgs.pnpm - pkgs.coreutils - clippyCheck - macheteCheck - ]; + runtimeInputs = [rustToolchain pkgs.wasm-pack pkgs.pnpm pkgs.coreutils clippyCheck macheteCheck]; text = '' export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}" @@ -74,40 +54,30 @@ cargo fmt --all --check cargo b 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-machete + pnpm run dup 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 ''; }; healthCheck = pkgs.writeShellApplication { name = "mtp-health"; - runtimeInputs = [ - clippyCheck - macheteCheck - ]; + runtimeInputs = [clippyCheck macheteCheck]; text = '' mtp-clippy mtp-machete ''; }; - in - { + in { devShells = { default = pkgs.mkShell { name = "mtp-dev"; buildInputs = with pkgs; [ rustToolchain - cargo-deny cargo-machete wasm-pack pnpm diff --git a/host/Cargo.lock b/host/Cargo.lock index 2e89fc1..aa19d17 100644 --- a/host/Cargo.lock +++ b/host/Cargo.lock @@ -4,4 +4,4 @@ version = 4 [[package]] name = "host" -version = "0.2.0" +version = "0.1.0" diff --git a/host/Cargo.toml b/host/Cargo.toml index ad09a54..5c7ab76 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -1,21 +1,15 @@ [package] name = "mtp-host" -version = "0.3.0" +version = "0.1.0" edition = "2024" [dependencies] -mtp-common = { version = "0.3.0", path = "../common" } -mtp-codec = { version = "0.3.0", path = "../codec", features = ["registry"] } -mtp-transport = { version = "0.3.0", path = "../transport", features = ["host"] } -mtp-crypto = { version = "0.3.0", path = "../crypto", optional = true } -rand = "0.10" -thiserror = "2" -tokio = { version = "1", features = ["macros", "rt", "time", "sync"] } -tracing = "0.1" -wtransport = "0.7" +mtp-common = { path = "../common" } +mtp-codec = { path = "../codec", features = ["registry"] } +mtp-transport = { path = "../transport", features = ["host"] } +mtp-crypto = { path = "../crypto", optional = true } +rand = "0.8" +tokio = { version = "1", features = ["time"] } [features] - crypto = ["dep:mtp-crypto", "mtp-codec/crypto"] - -pipes = ["mtp-common/pipes", "mtp-transport/pipes"] diff --git a/host/src/config.rs b/host/src/config.rs deleted file mode 100644 index 99d62a2..0000000 --- a/host/src/config.rs +++ /dev/null @@ -1,525 +0,0 @@ -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, - ) - -> Pin> + 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 Pin> + 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, - ) -> Pin + 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, - ) -> Pin> + 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, - pub connection_id: u64, -} - -#[cfg(feature = "crypto")] -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AuthenticationAttempt { - pub peer_network_identity: Option, - pub connection_id: u64, - pub claimed_client_id: Option, - 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; -} - -#[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>>, -} - -#[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 { - 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 { - 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, - pub tls_key: Vec, - - 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>>, - #[cfg(feature = "crypto")] - pub(crate) registration_ids: Arc, u64>>>, - #[cfg(feature = "crypto")] - pub(crate) registration_lock: Arc>, - #[cfg(feature = "crypto")] - pub guest_id_generator: Option, - #[cfg(feature = "crypto")] - pub complete_register: CompleteRegister, - #[cfg(feature = "crypto")] - pub find_registered_client: Option, - #[cfg(feature = "crypto")] - pub auth_limiter: Arc, - #[cfg(feature = "crypto")] - pub conceal_authentication_identities: bool, -} - -impl HostConfig { - pub fn new(ip: IpAddr, port: u16, tls_fullchain: Vec, tls_key: Vec) -> 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, - ) -> 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(®istration) - .expect("registration attempt decision") - ); - assert!( - !limiter - .allow(®istration) - .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 - ); - } -} diff --git a/host/src/connection.rs b/host/src/connection.rs deleted file mode 100644 index 7871d55..0000000 --- a/host/src/connection.rs +++ /dev/null @@ -1,392 +0,0 @@ -#[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> + 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 { - self.receive().await - } -} -impl connection_capability::Sealed - for mtp_transport::GenericSender -{ -} -impl MtpSenderLike for mtp_transport::GenericSender {} -impl connection_capability::Sealed - for mtp_transport::GenericReceiver -{ -} -impl MtpReceiverLike for mtp_transport::GenericReceiver { - async fn receive_message(&self) -> Result { - 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, - #[cfg(feature = "pipes")] - pub(crate) app_rx: Mutex>>, - #[cfg(feature = "pipes")] - pub(crate) pipe_req_rx: Mutex>>, - #[cfg(feature = "pipes")] - pub(crate) pipe_dispatcher: Arc>, - #[cfg(not(feature = "pipes"))] - pub(crate) _pipe_stream: std::marker::PhantomData

, - pub description: Option, - 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, - #[cfg(feature = "crypto")] - pub auth_state: crate::error::AuthState, - #[cfg(feature = "crypto")] - pub client_id: u64, - #[cfg(feature = "crypto")] - pub client_public_key: Option, - #[cfg(feature = "crypto")] - pub(crate) guest_id_lease: Option, -} - -impl MTPConnection { - /// 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) { - self.guest_id_lease = lease; - } -} - -#[cfg(feature = "pipes")] -impl MTPConnection -where - S: PipeSender, - R: PipeReceiver

, - 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` 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, - ) -> 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, - remote_addr: Option, - ) -> 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, - remote_addr: Option, - policy: Arc, - ) -> 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 MTPConnection { - pub fn from_transport_parts( - version: Version, - codec: VersionedCodec, - sender: S, - receiver: R, - path: String, - description: Option, - ) -> 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, - remote_addr: Option, - ) -> 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 MTPConnection { - pub async fn receive(&self) -> Result { - let mut message = self.receiver.receive_message().await?; - message.set_type_map(self.codec.type_map()); - Ok(message) - } -} - -#[cfg(feature = "pipes")] -impl MTPConnection -where - S: PipeSender, - P: tokio::io::AsyncRead + Send + Unpin + 'static, -{ - pub async fn receive(&self) -> Result { - 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, 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::(); - 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, CommunicationError> { - self.pipe_req_rx - .lock() - .await - .recv() - .await - .ok_or(CommunicationError::StreamClosed) - } -} diff --git a/host/src/engine.rs b/host/src/engine.rs deleted file mode 100755 index 08ca87f..0000000 --- a/host/src/engine.rs +++ /dev/null @@ -1,1306 +0,0 @@ -//! Transport-independent MTP handshake engine. -//! -//! This module contains the shared state machine used by both native `MTPHost` -//! and the web server's `MTPWebServer` to perform the MTP opening handshake, -//! version negotiation, authentication, and guest assignment. - -use crate::config::{AuthenticationContext, HostConfig}; -use crate::error::AcceptError; -use mtp_codec::{ - CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, Version, - registry::{Registry, VersionedCodec}, -}; -use mtp_common::{CommunicationError, RejectionReason}; -#[cfg(feature = "crypto")] -use std::collections::HashSet; -use std::sync::Arc; -#[cfg(feature = "crypto")] -use std::sync::Mutex; - -/// Trait for sending handshake messages during the opening exchange. -/// -/// Implemented by both the concrete `Sender` and `GenericSender`. -pub trait HandshakeSender: Send + Sync { - fn send( - &self, - msg: &CommunicationValue, - ) -> impl std::future::Future> + Send; - fn finish_stream( - &self, - ) -> impl std::future::Future> + Send; - fn set_type_map(&self, _type_map: &TypeMap) -> impl std::future::Future + Send { - async {} - } - fn close(&self); -} - -/// Trait for receiving handshake messages during the opening exchange. -/// -/// Implemented by both the concrete `Receiver` and `GenericReceiver`. -pub trait HandshakeReceiver: Send + Sync { - fn receive( - &self, - ) -> impl std::future::Future> + Send; - - /// Bind subsequently decoded frames to the negotiated type map. - /// - /// The opening frame must be decoded with the transport's bootstrap map so - /// that it can reveal the version. Once negotiation succeeds, all later - /// frames—including the remainder of the authentication exchange—must use - /// the negotiated map rather than whichever map happens to be latest at - /// compile time. - fn set_type_map(&self, _type_map: &TypeMap) -> impl std::future::Future + Send { - async {} - } -} - -/// A non-zero guest ID reserved for the lifetime of a connected session. -/// -/// The lease is moved into the resulting `MTPConnection`, so dropping that -/// connection releases the ID for a later guest session. -#[cfg(feature = "crypto")] -#[derive(Debug)] -pub struct GuestIdLease { - active_ids: Arc>>, - id: u64, -} - -#[cfg(feature = "crypto")] -impl Drop for GuestIdLease { - fn drop(&mut self) { - if let Ok(mut active_ids) = self.active_ids.lock() { - active_ids.remove(&self.id); - } - } -} - -/// The result of a successful handshake, containing everything needed to -/// construct the final `MTPConnection`. -#[derive(Debug)] -pub struct HandshakeResult { - pub negotiated_version: Version, - pub codec: VersionedCodec, - pub description: Option, - #[cfg(feature = "crypto")] - pub auth_state: crate::error::AuthState, - #[cfg(feature = "crypto")] - pub client_id: u64, - #[cfg(feature = "crypto")] - pub client_public_key: Option, - #[cfg(feature = "crypto")] - pub guest_id_lease: Option, -} - -/// Transport-independent handshake state machine. -/// -/// Both `MTPHost` and `MTPWebServer` create a `HandshakeEngine` with the -/// shared `HostConfig` and delegate the full opening handshake to it. -pub struct HandshakeEngine { - registry: Registry, - #[cfg(feature = "crypto")] - config: Arc, -} - -impl HandshakeEngine { - #[cfg(feature = "crypto")] - pub fn new(registry: Registry, config: Arc) -> Self { - Self { registry, config } - } - - #[cfg(not(feature = "crypto"))] - pub fn new(registry: Registry, _config: Arc) -> Self { - Self { registry } - } - - /// Run the complete opening handshake with the given transport pair. - /// - /// This handles: - /// - Opening-frame timeout (when crypto is enabled) - /// - Opening-type classification (Identification, Register, or other) - /// - Version negotiation - /// - Authentication-policy selection (Unauthenticated, AllowAuthentication, ForceAuthentication) - /// - Guest allocation and collision avoidance - /// - Full challenge/response authentication when required - /// - PQ preflight checks and dual-signature verification - /// - Rejection response construction on failure - pub async fn accept( - &self, - sender: &S, - receiver: &R, - ) -> Result { - self.accept_with_context(sender, receiver, AuthenticationContext::default()) - .await - } - - /// Run the opening handshake with transport-provided authentication - /// scoping information. - pub async fn accept_with_context( - &self, - sender: &S, - receiver: &R, - context: AuthenticationContext, - ) -> Result { - #[cfg(feature = "crypto")] - { - self.accept_until_with_context( - sender, - receiver, - tokio::time::Instant::now() + self.config.auth_timeout, - context, - ) - .await - } - #[cfg(not(feature = "crypto"))] - { - let result = self.accept_inner(sender, receiver, &context).await; - if result.is_err() { - sender.close(); - } - result - } - } - - /// Run the crypto handshake until an absolute deadline. - /// - /// WebTransport authentication may wait for a shared semaphore before it - /// reaches this engine. Passing the deadline through keeps that queueing - /// time from silently granting the handshake another full timeout. - #[cfg(feature = "crypto")] - pub async fn accept_until( - &self, - sender: &S, - receiver: &R, - deadline: tokio::time::Instant, - ) -> Result { - self.accept_until_with_context(sender, receiver, deadline, AuthenticationContext::default()) - .await - } - - /// Run the crypto handshake until a deadline with transport-provided - /// authentication scoping information. - #[cfg(feature = "crypto")] - pub async fn accept_until_with_context( - &self, - sender: &S, - receiver: &R, - deadline: tokio::time::Instant, - context: AuthenticationContext, - ) -> Result { - match tokio::time::timeout_at(deadline, self.accept_inner(sender, receiver, &context)).await - { - Ok(result) => { - if result.is_err() { - sender.close(); - } - result - } - Err(_) => { - let error = AcceptError::AuthenticationTimedOut; - send_rejection_generic( - sender, - RejectionReason::AuthenticationFailed { - detail: error.to_string(), - }, - None, - ) - .await; - sender.close(); - Err(error) - } - } - } - - async fn accept_inner( - &self, - sender: &S, - receiver: &R, - _authentication_context: &AuthenticationContext, - ) -> Result { - let mut first_msg = receiver.receive().await.map_err(AcceptError::Receive)?; - tracing::debug!( - message_type = ?first_msg.get_type(), - version = ?first_msg.get_str(DataType::Version), - client_id = ?first_msg.get_data(DataType::Id), - "received MTP opening message" - ); - - let version_str = match first_msg.get_data(DataType::Version) { - Some(DataValue::Str(s)) => s.clone(), - _ => { - send_rejection_generic( - sender, - RejectionReason::AuthenticationFailed { - detail: "opening message omitted a valid protocol version".into(), - }, - None, - ) - .await; - sender.close(); - return Err(AcceptError::MissingVersion); - } - }; - let client_version = match Version::parse(&version_str) { - Some(v) => v, - _ => { - send_rejection_generic( - sender, - RejectionReason::AuthenticationFailed { - detail: "opening message omitted a valid protocol version".into(), - }, - None, - ) - .await; - sender.close(); - return Err(AcceptError::MissingVersion); - } - }; - - let negotiated = match self - .registry - .negotiate(std::slice::from_ref(&client_version)) - { - Some(v) => v, - None => { - send_rejection_generic( - sender, - RejectionReason::BadVersion { - supported_versions: self - .registry - .versions() - .map(|v| v.to_string()) - .collect(), - }, - None, - ) - .await; - sender.close(); - return Err(AcceptError::UnsupportedVersion(client_version)); - } - }; - tracing::debug!( - client_version = %client_version, - negotiated_version = %negotiated, - "MTP protocol version negotiated" - ); - - let codec = VersionedCodec::for_version(self.registry.clone(), negotiated.clone()) - .ok_or_else(|| AcceptError::UnsupportedVersion(negotiated.clone()))?; - - sender.set_type_map(codec.type_map()).await; - receiver.set_type_map(codec.type_map()).await; - first_msg.set_type_map(codec.type_map()); - - let description = match first_msg.get_data(DataType::Description) { - Some(DataValue::Str(s)) => Some(s.clone()), - _ => None, - }; - - #[cfg(feature = "crypto")] - { - let claimed_client_id = match first_msg.get_data(DataType::Id) { - Some(DataValue::UnsignedNumber(value)) => u64::try_from(*value).ok(), - _ => None, - }; - let registration = Some(first_msg.get_type()) - == CommunicationType::Register.try_to_id(codec.type_map()); - let authentication_requested = matches!( - self.config.authentication_policy, - crate::config::AuthenticationPolicy::ForceAuthentication - ) || registration - || first_msg.get_data(DataType::PublicKeys).is_some() - || claimed_client_id.is_some_and(|client_id| client_id != 0); - tracing::info!( - claimed_client_id = ?claimed_client_id, - registration, - authentication_requested, - "classified MTP opening authentication mode" - ); - if authentication_requested { - let attempt = crate::config::AuthenticationAttempt { - peer_network_identity: _authentication_context.peer_network_identity.clone(), - connection_id: _authentication_context.connection_id, - claimed_client_id, - registration, - }; - match self.config.auth_limiter.allow(&attempt) { - Ok(true) => {} - Ok(false) | Err(_) => { - let error = - AcceptError::AuthenticationFailed("authentication rejected".into()); - send_rejection_generic( - sender, - RejectionReason::RateLimited, - Some(codec.type_map()), - ) - .await; - sender.close(); - return Err(error); - } - } - } - - match self.config.authentication_policy { - crate::config::AuthenticationPolicy::ForceAuthentication => { - self.force_auth_handshake( - sender, - receiver, - first_msg, - negotiated, - codec, - description, - &version_str, - client_version, - ) - .await - } - crate::config::AuthenticationPolicy::AllowAuthentication => { - self.allow_auth_handshake( - sender, - receiver, - first_msg, - negotiated, - codec, - description, - &version_str, - client_version, - ) - .await - } - crate::config::AuthenticationPolicy::Unauthenticated => { - self.unauthenticated_handshake( - sender, - first_msg, - negotiated, - codec, - description, - ) - .await - } - } - } - - #[cfg(not(feature = "crypto"))] - { - let authentication_requested = Some(first_msg.get_type()) - == CommunicationType::Register.try_to_id(codec.type_map()) - || first_msg.get_data(DataType::PublicKeys).is_some(); - if authentication_requested { - let error = AcceptError::AuthenticationFailed( - "authentication is unavailable on this non-crypto host".into(), - ); - send_rejection_generic( - sender, - RejectionReason::AuthenticationFailed { - detail: error.to_string(), - }, - Some(codec.type_map()), - ) - .await; - sender.close(); - return Err(error); - } - let _ = receiver; - send_accepted_generic(sender, &negotiated, codec.type_map(), Some(0)) - .await - .map_err(AcceptError::Send)?; - Ok(HandshakeResult { - negotiated_version: negotiated, - codec, - description, - }) - } - } - - #[cfg(feature = "crypto")] - async fn unauthenticated_handshake( - &self, - sender: &S, - first_msg: CommunicationValue, - negotiated: Version, - codec: VersionedCodec, - description: Option, - ) -> Result { - let tm = codec.type_map(); - - // Reject explicit authentication attempts on unauthenticated hosts. - // Authenticated clients include PublicKeys in Identification as an - // intent marker; this avoids acknowledging the opening as a guest - // connection and leaving the client waiting for a Challenge. - if Some(first_msg.get_type()) == CommunicationType::Register.try_to_id(tm) - || first_msg.get_data(DataType::PublicKeys).is_some() - { - send_rejection_generic( - sender, - RejectionReason::AuthenticationFailed { - detail: "authentication not allowed on this host".into(), - }, - Some(tm), - ) - .await; - sender.close(); - return Err(AcceptError::AuthenticationFailed( - "authentication not allowed on this host".into(), - )); - } - - let guest_id_lease = match self.assign_guest_id().await { - Ok(lease) => lease, - Err(error) => { - reject_error_generic(sender, &error, tm).await; - return Err(error); - } - }; - let guest_id = guest_id_lease.id; - send_accepted_generic(sender, &negotiated, tm, Some(guest_id)) - .await - .map_err(AcceptError::Send)?; - - Ok(HandshakeResult { - negotiated_version: negotiated, - codec, - description, - auth_state: crate::error::AuthState::Unauthenticated, - client_id: guest_id, - client_public_key: None, - guest_id_lease: Some(guest_id_lease), - }) - } - - #[cfg(feature = "crypto")] - #[allow(clippy::too_many_arguments)] - async fn allow_auth_handshake( - &self, - sender: &S, - receiver: &R, - first_msg: CommunicationValue, - negotiated: Version, - codec: VersionedCodec, - description: Option, - version_str: &str, - client_version: Version, - ) -> Result { - let tm = codec.type_map(); - - // Register frames always go through full authentication - if Some(first_msg.get_type()) == CommunicationType::Register.try_to_id(tm) { - let bundle = match extract_register_bundle(&first_msg) { - Ok(bundle) => bundle, - Err(error) => { - reject_error_generic(sender, &error, tm).await; - return Err(error); - } - }; - let pk_bytes = match bundle.try_as_bytes() { - Ok(bytes) => bytes, - Err(error) => { - let error = AcceptError::AuthenticationFailed(error.to_string()); - reject_error_generic(sender, &error, tm).await; - return Err(error); - } - }; - return self - .complete_auth_handshake( - sender, - receiver, - Flow::Register { bundle, pk_bytes }, - CommunicationType::RegisterResponse, - &negotiated, - &codec, - description, - version_str, - client_version, - ) - .await; - } - - // Identification: try lookup, fall back to guest - if Some(first_msg.get_type()) == CommunicationType::Identification.try_to_id(tm) { - let cid = match first_msg.get_data(DataType::Id) { - Some(DataValue::UnsignedNumber(n)) => u64::try_from(*n).unwrap_or(0), - _ => 0, - }; - - if cid > 0 - && let Some(bundle) = - (self.config.get_existing_client)(cid, description.clone()).await - { - return self - .complete_auth_handshake( - sender, - receiver, - Flow::Login { id: cid, bundle }, - CommunicationType::IdentificationResponse, - &negotiated, - &codec, - description, - version_str, - client_version, - ) - .await; - } - - // Unknown or zero ID: an Identification carrying PublicKeys is an - // explicit authentication attempt, not a guest connection. - if first_msg.get_data(DataType::PublicKeys).is_some() { - if self.config.conceal_authentication_identities && cid > 0 { - /* Keep an unknown authenticated ID on the same - challenge/proof path as a known ID. The fixed host - identity makes the eventual proof fail without - disclosing whether the lookup succeeded. */ - return self - .complete_auth_handshake( - sender, - receiver, - Flow::Login { - id: cid, - bundle: self.config.host_keyring.public_key_bundle(), - }, - CommunicationType::IdentificationResponse, - &negotiated, - &codec, - description, - version_str, - client_version, - ) - .await; - } - let error = AcceptError::AuthenticationFailed( - "unknown authenticated client identity".into(), - ); - reject_error_generic(sender, &error, tm).await; - return Err(error); - } - - // Unknown or zero ID: fall back to guest - tracing::info!("allocating MTP guest identity"); - let guest_id_lease = match self.assign_guest_id().await { - Ok(lease) => lease, - Err(error) => { - reject_error_generic(sender, &error, tm).await; - return Err(error); - } - }; - let guest_id = guest_id_lease.id; - tracing::info!(guest_id, "allocated MTP guest identity"); - send_accepted_generic(sender, &negotiated, tm, Some(guest_id)) - .await - .map_err(AcceptError::Send)?; - return Ok(HandshakeResult { - negotiated_version: negotiated, - codec, - description, - auth_state: crate::error::AuthState::Unauthenticated, - client_id: guest_id, - client_public_key: None, - guest_id_lease: Some(guest_id_lease), - }); - } - - let error = AcceptError::AuthenticationFailed("unexpected message type".into()); - reject_error_generic(sender, &error, tm).await; - Err(error) - } - - #[cfg(feature = "crypto")] - #[allow(clippy::too_many_arguments)] - async fn force_auth_handshake( - &self, - sender: &S, - receiver: &R, - first_msg: CommunicationValue, - negotiated: Version, - codec: VersionedCodec, - description: Option, - version_str: &str, - client_version: Version, - ) -> Result { - let tm = codec.type_map(); - - let (flow, response_type) = if Some(first_msg.get_type()) - == CommunicationType::Identification.try_to_id(tm) - { - let cid = match first_msg.get_data(DataType::Id) { - Some(DataValue::UnsignedNumber(n)) => match u64::try_from(*n) { - Ok(id) => id, - Err(_) => { - let error = - AcceptError::AuthenticationFailed("client id is out of range".into()); - reject_error_generic(sender, &error, tm).await; - return Err(error); - } - }, - _ => { - let error = AcceptError::AuthenticationFailed("missing client id".into()); - reject_error_generic(sender, &error, tm).await; - return Err(error); - } - }; - let bundle = match (self.config.get_existing_client)(cid, description.clone()).await { - Some(b) => b, - None => { - if self.config.conceal_authentication_identities { - // Use a valid fixed-cost dummy identity so an unknown - // client follows the same challenge/proof sequence as - // a registered client. The host public bundle is - // already public and the peer cannot produce its - // private-key proof. - self.config.host_keyring.public_key_bundle() - } else { - let rejection = CommunicationValue::new_with_type_map( - CommunicationType::IdentificationResponse, - tm, - ) - .add_typed_default(DataType::Connected, DataValue::BoolFalse) - .add_typed_default( - DataType::ErrorMessage, - DataValue::Str("unknown client id".into()), - ); - let _ = sender.send(&rejection).await; - sender.close(); - return Err(AcceptError::AuthenticationFailed( - "unknown client id".into(), - )); - } - } - }; - ( - Flow::Login { id: cid, bundle }, - CommunicationType::IdentificationResponse, - ) - } else if Some(first_msg.get_type()) == CommunicationType::Register.try_to_id(tm) { - let bundle = match extract_register_bundle(&first_msg) { - Ok(bundle) => bundle, - Err(error) => { - reject_error_generic(sender, &error, tm).await; - return Err(error); - } - }; - let pk_bytes = match bundle.try_as_bytes() { - Ok(bytes) => bytes, - Err(error) => { - let error = AcceptError::AuthenticationFailed(error.to_string()); - reject_error_generic(sender, &error, tm).await; - return Err(error); - } - }; - ( - Flow::Register { bundle, pk_bytes }, - CommunicationType::RegisterResponse, - ) - } else { - let error = - AcceptError::AuthenticationFailed("unexpected authentication message".into()); - reject_error_generic(sender, &error, tm).await; - return Err(error); - }; - - self.complete_auth_handshake( - sender, - receiver, - flow, - response_type, - &negotiated, - &codec, - description, - version_str, - client_version, - ) - .await - } - - #[cfg(feature = "crypto")] - #[allow(clippy::too_many_arguments)] - async fn complete_auth_handshake( - &self, - sender: &S, - receiver: &R, - flow: Flow, - response_type: CommunicationType, - negotiated: &Version, - codec: &VersionedCodec, - description: Option, - version_str: &str, - _client_version: Version, - ) -> Result { - use mtp_crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme, auth, verify_ed25519}; - - let tm = codec.type_map(); - - // PQ preflight: host requiring PQ must have a PQ key - let pq_enabled = !self - .config - .host_keyring - .sig_pq_secret_key - .as_bytes() - .is_empty(); - if self.config.require_pq - && (!pq_enabled - || self - .config - .host_keyring - .sig_pq_public_key - .as_bytes() - .is_empty()) - { - send_rejection_generic( - sender, - RejectionReason::AuthenticationFailed { - detail: "host requires PQ authentication but has no PQ signing key".into(), - }, - Some(tm), - ) - .await; - sender.close(); - return Err(AcceptError::AuthenticationFailed( - "PQ authentication is required but the host PQ key is absent".into(), - )); - } - - // Initialize host signers - let host_pq_signer = if pq_enabled { - Some(Arc::new( - match MlDsaSigner::new( - &self.config.host_keyring.sig_pq_secret_key, - &self.config.host_keyring.sig_pq_public_key, - ) { - Ok(signer) => signer, - Err(error) => { - let error = AcceptError::AuthenticationFailed(error.to_string()); - reject_error_generic(sender, &error, tm).await; - return Err(error); - } - }, - )) - } else { - None - }; - - let host_sign = |payload: Vec| async { - let signer = Ed25519Signer::new(&self.config.host_keyring.sig_cl_secret_key) - .map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?; - if let Some(pq_signer) = host_pq_signer.as_ref() { - mtp_crypto::sign_parallel::sign_dual_parallel_shared_pq( - signer, - Arc::clone(pq_signer), - payload, - ) - .await - .map_err(|e| AcceptError::AuthenticationFailed(e.to_string())) - } else { - let sig = signer - .sign(&payload) - .map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?; - Ok((sig, Vec::new())) - } - }; - - // Sign and send challenge - let challenge_id = match &flow { - Flow::Login { id, .. } => *id, - Flow::Register { .. } => 0, - }; - - let server_challenge: u128 = rand::random(); - let (chal_sig, chal_pq_sig) = - match host_sign(auth::challenge_payload(challenge_id, server_challenge)).await { - Ok(signatures) => signatures, - Err(error) => { - reject_error_generic(sender, &error, tm).await; - return Err(error); - } - }; - - let mut challenge_msg = - CommunicationValue::new_with_type_map(CommunicationType::Challenge, tm) - .add_typed_default( - DataType::ServerNonce, - DataValue::UnsignedNumber(server_challenge), - ) - .add_typed_default(DataType::Signature, DataValue::Bytes(chal_sig)); - challenge_msg = challenge_msg.add_typed_default( - DataType::RequirePq, - if self.config.require_pq { - DataValue::BoolTrue - } else { - DataValue::BoolFalse - }, - ); - 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)); - } - - // Receive and verify client proof - let proof = receiver.receive().await.map_err(|e| { - sender.close(); - AcceptError::Receive(e) - })?; - if Some(proof.get_type()) != CommunicationType::ChallengeResponse.try_to_id(tm) { - let error = AcceptError::AuthenticationFailed("missing challenge response".into()); - reject_error_generic(sender, &error, tm).await; - return Err(error); - } - let client_nonce = match proof.get_data(DataType::ClientNonce) { - Some(DataValue::UnsignedNumber(n)) => *n, - _ => { - let error = AcceptError::AuthenticationFailed("missing client nonce".into()); - reject_error_generic(sender, &error, tm).await; - return Err(error); - } - }; - let sig_bytes = match proof.get_data(DataType::Signature) { - Some(DataValue::Bytes(b)) => b.clone(), - _ => { - let error = AcceptError::AuthenticationFailed("missing challenge signature".into()); - reject_error_generic(sender, &error, tm).await; - return Err(error); - } - }; - let pq_sig_bytes: Vec = match proof.get_data(DataType::PqSignature) { - Some(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 has_client_pq_key = !bundle.sig_pq_public_key.as_bytes().is_empty(); - let proof_ok = if pq_sig_bytes.is_empty() { - !self.config.require_pq - && verify_ed25519(&bundle.sig_cl_public_key, &proof_payload, &sig_bytes).is_ok() - } else if has_client_pq_key { - mtp_crypto::sign_parallel::verify_dual_parallel( - bundle.sig_cl_public_key.clone(), - bundle.sig_pq_public_key.clone(), - proof_payload, - sig_bytes, - pq_sig_bytes, - ) - .await - .is_ok() - } else { - false - }; - - if !proof_ok { - send_rejection_generic( - sender, - RejectionReason::AuthenticationFailed { - detail: "client proof signature invalid".into(), - }, - Some(tm), - ) - .await; - sender.close(); - return Err(AcceptError::AuthenticationFailed( - "client proof signature invalid".into(), - )); - } - - // Register or login - let (assigned_id, client_bundle) = match flow { - Flow::Login { id, bundle } => (id, bundle), - Flow::Register { bundle, .. } => { - let _registration_guard = self.config.registration_lock.lock().await; - let identity = match bundle.try_as_bytes() { - Ok(identity) => identity, - Err(error) => { - let error = AcceptError::AuthenticationFailed(error.to_string()); - reject_error_generic(sender, &error, tm).await; - return Err(error); - } - }; - let cached_id = self - .config - .registration_ids - .lock() - .ok() - .and_then(|registrations| registrations.get(&identity).copied()); - let new_id = if let Some(id) = cached_id { - id - } else if let Some(lookup) = &self.config.find_registered_client { - match lookup(bundle.clone(), description.clone()).await { - Some(id) => id, - None => { - (self.config.complete_register)(bundle.clone(), description.clone()) - .await - } - } - } else { - (self.config.complete_register)(bundle.clone(), description.clone()).await - }; - if new_id != 0 - && let Ok(mut registrations) = self.config.registration_ids.lock() - { - registrations.insert(identity, new_id); - } - if new_id == 0 { - let error = AcceptError::AuthenticationFailed( - "registration callback returned reserved client id 0".into(), - ); - reject_error_generic(sender, &error, tm).await; - return Err(error); - } - (new_id, bundle) - } - }; - if assigned_id == 0 { - let error = AcceptError::AuthenticationFailed( - "client id 0 is reserved for no authenticated identity".into(), - ); - reject_error_generic(sender, &error, tm).await; - return Err(error); - } - - // Sign and send final response - let (host_sig, host_pq_sig) = match host_sign(auth::host_final_payload( - assigned_id, - client_nonce, - server_challenge, - )) - .await - { - Ok(signatures) => signatures, - Err(error) => { - reject_error_generic(sender, &error, tm).await; - return Err(error); - } - }; - - let mut response = CommunicationValue::new_with_type_map(response_type, tm) - .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)); - response = - response.add_typed_default(DataType::Version, DataValue::Str(negotiated.to_string())); - 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)); - } - - Ok(HandshakeResult { - negotiated_version: negotiated.clone(), - codec: codec.clone(), - description, - auth_state: crate::error::AuthState::Authenticated, - client_id: assigned_id, - client_public_key: Some(client_bundle), - guest_id_lease: None, - }) - } -} - -#[cfg(feature = "crypto")] -enum Flow { - Login { - id: u64, - bundle: mtp_crypto::PublicKeyBundle, - }, - Register { - bundle: mtp_crypto::PublicKeyBundle, - pk_bytes: Vec, - }, -} - -// --------------------------------------------------------------------------- -// Guest ID allocation -// --------------------------------------------------------------------------- - -#[cfg(feature = "crypto")] -impl HandshakeEngine { - const GUEST_ID_MAX_RETRIES: u32 = 100; - - async fn assign_guest_id(&self) -> Result { - if let Some(ref generator) = self.config.guest_id_generator { - let id = generator().await.ok_or_else(|| { - AcceptError::AuthenticationFailed( - "guest id generator rejected the connection".into(), - ) - })?; - if id == 0 { - return Err(AcceptError::AuthenticationFailed( - "guest id 0 is reserved for no authenticated identity".into(), - )); - } - if let Some(lease) = self.try_reserve_guest_id(id).await? { - return Ok(lease); - } - } - self.random_guest_id().await - } - - async fn random_guest_id(&self) -> Result { - for _ in 0..Self::GUEST_ID_MAX_RETRIES { - let id = rand::random::(); - if let Some(lease) = self.try_reserve_guest_id(id).await? { - return Ok(lease); - } - } - Err(AcceptError::AuthenticationFailed( - "failed to allocate a unique guest id after retries".into(), - )) - } - - async fn try_reserve_guest_id(&self, id: u64) -> Result, AcceptError> { - if id == 0 || (self.config.get_existing_client)(id, None).await.is_some() { - return Ok(None); - } - let mut active_ids = self.config.active_guest_ids.lock().map_err(|_| { - AcceptError::AuthenticationFailed("guest ID registry is poisoned".into()) - })?; - if !active_ids.insert(id) { - return Ok(None); - } - Ok(Some(GuestIdLease { - active_ids: Arc::clone(&self.config.active_guest_ids), - id, - })) - } -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -#[cfg(feature = "crypto")] -fn extract_register_bundle( - msg: &CommunicationValue, -) -> Result { - match msg.get_data(DataType::PublicKeys) { - Some(DataValue::Bytes(b)) => mtp_crypto::PublicKeyBundle::from_bytes(b) - .map_err(|_| AcceptError::AuthenticationFailed("invalid public key bundle".into())), - _ => Err(AcceptError::AuthenticationFailed( - "missing public keys".into(), - )), - } -} - -async fn send_rejection_generic( - sender: &S, - reason: RejectionReason, - type_map: Option<&TypeMap>, -) { - let type_map = type_map.cloned().unwrap_or_else(TypeMap::latest); - let response = match &reason { - RejectionReason::BadVersion { supported_versions } => { - CommunicationValue::new_with_type_map(CommunicationType::ErrorBadVersion, &type_map) - .add_typed_default( - DataType::Version, - DataValue::Str(supported_versions.join(",")), - ) - .add_typed_default(DataType::ErrorMessage, DataValue::Str(reason.to_string())) - } - _ => CommunicationValue::new_with_type_map( - CommunicationType::IdentificationResponse, - &type_map, - ) - .add_typed_default(DataType::Connected, DataValue::BoolFalse) - .add_typed_default(DataType::ErrorMessage, DataValue::Str(reason.to_string())), - }; - tracing::debug!( - reason = %reason, - response_type = ?response.get_type(), - has_version = response.get_data(DataType::Version).is_some(), - "sending MTP handshake rejection" - ); - let _ = sender.send(&response).await; -} - -#[cfg(feature = "crypto")] -async fn reject_error_generic( - sender: &S, - error: &AcceptError, - type_map: &TypeMap, -) { - send_rejection_generic( - sender, - RejectionReason::AuthenticationFailed { - detail: error.to_string(), - }, - Some(type_map), - ) - .await; - sender.close(); -} - -async fn send_accepted_generic( - sender: &S, - version: &Version, - type_map: &TypeMap, - assigned_id: Option, -) -> Result<(), CommunicationError> { - let mut response = - CommunicationValue::new_with_type_map(CommunicationType::IdentificationResponse, type_map) - .add_typed_default(DataType::Connected, DataValue::BoolTrue) - .add_typed_default(DataType::Version, DataValue::Str(version.to_string())); - if let Some(id) = assigned_id { - response = response.add_typed_default(DataType::Id, DataValue::UnsignedNumber(id as u128)); - } - tracing::debug!( - version = %version, - assigned_id = ?assigned_id, - "sending accepted MTP handshake response" - ); - sender.send(&response).await?; - sender.finish_stream().await -} - -// --------------------------------------------------------------------------- -// Trait implementations for concrete transport types -// --------------------------------------------------------------------------- - -impl HandshakeSender for mtp_transport::Sender { - fn send( - &self, - msg: &CommunicationValue, - ) -> impl std::future::Future> + Send { - mtp_transport::Sender::send(self, msg) - } - fn finish_stream( - &self, - ) -> impl std::future::Future> + Send { - mtp_transport::Sender::finish_stream(self) - } - async fn set_type_map(&self, type_map: &TypeMap) { - self.set_type_map(type_map).await; - } - fn close(&self) { - let sender = self.clone(); - tokio::spawn(async move { sender.close().await }); - } -} - -impl HandshakeReceiver for mtp_transport::Receiver { - fn receive( - &self, - ) -> impl std::future::Future> + Send - { - mtp_transport::Receiver::receive(self) - } - - async fn set_type_map(&self, type_map: &TypeMap) { - self.set_type_map(type_map).await; - } -} - -impl HandshakeSender for mtp_transport::GenericSender { - fn send( - &self, - msg: &CommunicationValue, - ) -> impl std::future::Future> + Send { - mtp_transport::GenericSender::send(self, msg) - } - fn finish_stream( - &self, - ) -> impl std::future::Future> + Send { - mtp_transport::GenericSender::finish_stream(self) - } - async fn set_type_map(&self, type_map: &TypeMap) { - self.set_type_map(type_map).await; - } - fn close(&self) { - mtp_transport::GenericSender::close(self); - } -} - -impl HandshakeReceiver - for mtp_transport::GenericReceiver -{ - fn receive( - &self, - ) -> impl std::future::Future> + Send - { - mtp_transport::GenericReceiver::receive(self) - } - - async fn set_type_map(&self, type_map: &TypeMap) { - self.set_type_map(type_map).await; - } -} - -#[cfg(all(test, feature = "crypto"))] -mod tests { - use super::*; - use std::sync::Mutex; - - #[tokio::test] - async fn guest_generator_accepts_full_width_id_after_collision_check() - -> Result<(), Box> { - let lookups = Arc::new(Mutex::new(Vec::new())); - let recorded_lookups = Arc::clone(&lookups); - - let mut config = HostConfig::new("127.0.0.1".parse()?, 4433, Vec::new(), Vec::new()) - .with_guest_id_generator(Box::new(|| Box::pin(async { Some(u64::MAX) }))); - config.get_existing_client = Box::new(move |id, description| { - let recorded_lookups = Arc::clone(&recorded_lookups); - Box::pin(async move { - recorded_lookups - .lock() - .expect("guest ID lookup mutex should not be poisoned") - .push((id, description)); - None - }) - }); - - let engine = HandshakeEngine::new(Registry::builtin(), Arc::new(config)); - - assert_eq!(engine.assign_guest_id().await?.id, u64::MAX); - assert_eq!( - *lookups - .lock() - .expect("guest ID lookup mutex should not be poisoned"), - vec![(u64::MAX, None)] - ); - Ok(()) - } - - #[tokio::test] - async fn active_guest_ids_are_unique_and_released() -> Result<(), Box> { - let config = HostConfig::new("127.0.0.1".parse()?, 4433, Vec::new(), Vec::new()); - let engine = HandshakeEngine::new(Registry::builtin(), Arc::new(config)); - - let first = engine.try_reserve_guest_id(1).await?.unwrap(); - assert!(engine.try_reserve_guest_id(1).await?.is_none()); - - drop(first); - assert!(engine.try_reserve_guest_id(1).await?.is_some()); - Ok(()) - } - - #[tokio::test] - async fn zero_is_rejected_as_a_guest_id() -> Result<(), Box> { - let config = HostConfig::new("127.0.0.1".parse()?, 4433, Vec::new(), Vec::new()) - .with_guest_id_generator(Box::new(|| Box::pin(async { Some(0) }))); - let engine = HandshakeEngine::new(Registry::builtin(), Arc::new(config)); - - assert!(engine.assign_guest_id().await.is_err()); - Ok(()) - } -} diff --git a/host/src/error.rs b/host/src/error.rs deleted file mode 100644 index b7a8c4f..0000000 --- a/host/src/error.rs +++ /dev/null @@ -1,58 +0,0 @@ -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::() -} - -#[cfg(test)] -pub(crate) fn extract_version(msg: &CommunicationValue) -> Option { - 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, -} diff --git a/host/src/handshake.rs b/host/src/handshake.rs deleted file mode 100644 index 89924ab..0000000 --- a/host/src/handshake.rs +++ /dev/null @@ -1,334 +0,0 @@ -#[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, - pub(crate) handshakes: tokio::task::JoinSet, AcceptError>>, - pub(crate) transport_closed: bool, -} - -pub(crate) struct HandshakeContext { - pub(crate) registry: Registry, - pub(crate) config: Arc, -} - -impl MTPHost { - pub async fn new(config: HostConfig) -> Result { - 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, 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, 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, - ) -> 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, - } - } - } -} diff --git a/host/src/lib.rs b/host/src/lib.rs index c86a74c..389af78 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -1,78 +1,616 @@ -pub mod config; -pub mod connection; -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_codec::{ + CommunicationValue, DataType, DataValue, Version, + registry::{Registry, VersionedCodec}, }; +use mtp_common::CommunicationError; +use mtp_transport::{Policy, Receiver, Sender}; +use std::net::IpAddr; #[cfg(feature = "crypto")] -pub use error::AuthState; +use std::pin::Pin; +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> + Send>> + + Send + + Sync, +>; + +#[cfg(feature = "crypto")] +type CompleteRegister = Box< + dyn Fn(mtp_crypto::PublicKeyBundle) -> Pin + Send>> + + Send + + Sync, +>; + +/* Host configuration. */ +pub struct HostConfig { + pub ip: IpAddr, + pub port: u16, + pub tls_fullchain: Vec, + pub tls_key: Vec, + + #[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, tls_key: Vec) -> 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> + Send>, + > + Send + + Sync + + 'static, + complete_register: impl Fn( + mtp_crypto::PublicKeyBundle, + ) -> Pin + 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, +} + +/* 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 { + 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, 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, 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, + }, + } + + 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, Vec), 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 = 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 { + let value = msg.get_data(DataType::Version); + match value { + DataValue::Str(s) => Version::parse(s.as_str()), + _ => None, + } +} + +/* ================================ TESTS ================================ */ #[cfg(test)] mod tests { 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] fn version_extraction() { let tm = mtp_codec::TypeMap::latest(); - let msg = mtp_codec::CommunicationValue::from_comm(CommunicationType::Identification, &tm) - .add_typed(DataType::Version, &tm, DataValue::Str("3.0".to_string())); - let version = error::extract_version(&msg); - assert_eq!(version, Some(mtp_codec::Version(3, 0))); + let msg = mtp_codec::CommunicationValue::from_comm( + mtp_codec::CommunicationType::Identification, + &tm, + ) + .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] fn version_extraction_returns_none_for_missing() { let tm = mtp_codec::TypeMap::latest(); - let msg = mtp_codec::CommunicationValue::from_comm(CommunicationType::Identification, &tm); - assert!(error::extract_version(&msg).is_none()); + let msg = mtp_codec::CommunicationValue::from_comm( + mtp_codec::CommunicationType::Identification, + &tm, + ); + assert!(extract_version(&msg).is_none()); } #[test] fn version_extraction_bad_format() { let tm = mtp_codec::TypeMap::latest(); - let msg = mtp_codec::CommunicationValue::from_comm(CommunicationType::Identification, &tm) - .add_typed(DataType::Version, &tm, DataValue::UnsignedNumber(42)); - assert!(error::extract_version(&msg).is_none()); + let msg = mtp_codec::CommunicationValue::from_comm( + mtp_codec::CommunicationType::Identification, + &tm, + ) + .add_data(DataType::Version.to_id(&tm), DataValue::UnsignedNumber(42)); + assert!(extract_version(&msg).is_none()); } #[cfg(feature = "crypto")] @@ -81,34 +619,4 @@ mod tests { assert_ne!(AuthState::Unauthenticated, AuthState::Authenticated); assert_ne!(AuthState::Pending, AuthState::Authenticated); } - - #[test] - fn host_config_pongs_default_to_enabled() -> Result<(), Box> { - 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 = - 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")); - } } diff --git a/host/src/pipe.rs b/host/src/pipe.rs deleted file mode 100644 index 192e3d7..0000000 --- a/host/src/pipe.rs +++ /dev/null @@ -1,536 +0,0 @@ -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> + Send; - - fn open_pipe_stream( - &self, - pipe_id: u32, - description: &str, - ) -> impl std::future::Future, CommunicationError>> + Send; -} - -/// The receiver operations needed by the transport-independent pipe protocol. -pub trait PipeReceiver

: 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, 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, CommunicationError> { - self.open_pipe(pipe_id, description).await - } -} - -impl PipeReceiver 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, CommunicationError> { - self.receive_event().await - } -} - -impl PipeSender for mtp_transport::GenericSender -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, CommunicationError> { - self.open_pipe(pipe_id, description).await - } -} - -impl PipeReceiver for mtp_transport::GenericReceiver -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, CommunicationError> { - self.receive_event().await - } -} - -pub struct PipeHandle { - pub(crate) pipe_id: u32, - pub(crate) description: String, - pub(crate) sender: S, - pub(crate) response_rx: tokio::sync::oneshot::Receiver>, - pub(crate) dispatcher: Arc>, - pub(crate) token: Arc<()>, -} - -impl PipeHandle -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>, 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 Drop for PipeHandle -where - S: PipeSender, -{ - fn drop(&mut self) { - expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token); - } -} - -pub struct PipeRequest { - pub(crate) pipe_id: u32, - pub(crate) description: String, - pub(crate) sender: S, - pub(crate) receiver: R, - pub(crate) dispatcher: Arc>, -} - -struct ExpectedPipeGuard -where - R: PipeReceiver

, - P: tokio::io::AsyncRead + Send + Unpin + 'static, -{ - receiver: R, - pipe_id: u32, - armed: bool, - _stream: std::marker::PhantomData

, -} - -impl ExpectedPipeGuard -where - R: PipeReceiver

, - 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 Drop for ExpectedPipeGuard -where - R: PipeReceiver

, - P: tokio::io::AsyncRead + Send + Unpin + 'static, -{ - fn drop(&mut self) { - if self.armed { - self.receiver.cancel_expected_pipe(self.pipe_id); - } - } -} - -impl PipeRequest -where - S: PipeSender, - R: PipeReceiver

, - 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, 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(); - 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

{ - pub(crate) pending_creations: StdMutex>, - pub(crate) expired_creations: StdMutex>, - pub(crate) pending_pipes: Mutex>>>, - pub(crate) policy: Arc, - pub(crate) type_map: TypeMap, -} - -pub(crate) struct PendingCreation { - pub(crate) token: Arc<()>, - pub(crate) sender: tokio::sync::oneshot::Sender>, -} - -pub(crate) struct PendingCreationGuard

{ - dispatcher: Arc>, - pipe_id: u32, - token: Arc<()>, - armed: bool, -} - -impl

PendingCreationGuard

{ - pub(crate) fn new(dispatcher: Arc>, pipe_id: u32, token: Arc<()>) -> Self { - Self { - dispatcher, - pipe_id, - token, - armed: true, - } - } - - pub(crate) fn disarm(&mut self) { - self.armed = false; - } -} - -impl

Drop for PendingCreationGuard

{ - 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

( - 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 = 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

(dispatcher: &PipeDispatcher

, 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

(dispatcher: &PipeDispatcher

, 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

( - 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(); - } -} - -pub(crate) async fn fail_pending_pipes

(dispatcher: &PipeDispatcher

) { - dispatcher.pending_pipes.lock().await.clear(); -} - -pub(crate) async fn run_dispatcher( - receiver: R, - sender: S, - app_tx: mpsc::Sender>, - pipe_req_tx: mpsc::Sender>, - dispatcher: Arc>, -) where - S: PipeSender, - R: PipeReceiver

, - 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; - } - } - } -} diff --git a/mtp-webserver/Cargo.toml b/mtp-webserver/Cargo.toml deleted file mode 100644 index 6b5ef4e..0000000 --- a/mtp-webserver/Cargo.toml +++ /dev/null @@ -1,36 +0,0 @@ -[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"] diff --git a/mtp-webserver/src/error.rs b/mtp-webserver/src/error.rs deleted file mode 100644 index 0b02479..0000000 --- a/mtp-webserver/src/error.rs +++ /dev/null @@ -1,41 +0,0 @@ -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 for WebServerError { - fn from(e: CommunicationError) -> Self { - Self::Transport(e) - } -} diff --git a/mtp-webserver/src/h3.rs b/mtp-webserver/src/h3.rs deleted file mode 100644 index abb17aa..0000000 --- a/mtp-webserver/src/h3.rs +++ /dev/null @@ -1,365 +0,0 @@ -use crate::{ - HttpRequest, HttpResponse, Router, WebMTPConnection, WebServerError, WebServerMetrics, - transport::accept_web_connection, -}; -use bytes::{Buf, Bytes}; -use http::{Request, Response, StatusCode}; -use mtp_host::HostConfig; -use std::{net::SocketAddr, sync::Arc, time::Duration}; -use tokio::sync::{Semaphore, watch}; - -pub(crate) struct DriverConfig { - pub(crate) router: Router, - pub(crate) mtp_path: String, - pub(crate) max_request_body: usize, - pub(crate) request_timeout: Duration, - pub(crate) drain_timeout: Duration, - pub(crate) send_pongs: bool, - pub(crate) policy: mtp_transport::Policy, - pub(crate) host_config: Arc, - pub(crate) metrics: Option>, - pub(crate) auth_semaphore: Arc, -} - -pub(crate) async fn run_driver( - endpoint: quinn::Endpoint, - config: DriverConfig, - mtp_tx: tokio::sync::mpsc::Sender>, - connection_semaphore: Arc, - mut shutdown_rx: watch::Receiver<()>, -) { - let DriverConfig { - router, - mtp_path, - max_request_body, - request_timeout, - drain_timeout, - send_pongs, - policy, - host_config, - metrics, - auth_semaphore, - } = config; - let mut connection_tasks = tokio::task::JoinSet::new(); - loop { - tokio::select! { - biased; - _ = shutdown_rx.changed() => { - break; - } - incoming = endpoint.accept() => { - let Some(incoming) = incoming else { - break; - }; - // Do not await capacity here: doing so would prevent this loop - // from observing shutdown while all connection slots are in use. - let permit = match connection_semaphore.clone().try_acquire_owned() { - Ok(permit) => permit, - Err(_) => { - tracing::debug!("rejecting QUIC connection at configured connection limit"); - continue; - } - }; - let router = router.clone(); - let mtp_path = mtp_path.clone(); - let mtp_tx = mtp_tx.clone(); - let metrics = metrics.clone(); - let host_config = host_config.clone(); - let auth_semaphore = auth_semaphore.clone(); - connection_tasks.spawn(async move { - // The permit normally lives for this HTTP/3 connection. - // For an MTP session it is moved into the resulting - // connection so the limit covers the session lifetime. - let mut connection_permit = Some(permit); - let connect_start = std::time::Instant::now(); - let connection = match incoming.await { - Ok(connection) => connection, - Err(error) => { - tracing::debug!(%error, "QUIC connection failed during handshake"); - return; - } - }; - let mut builder = h3::server::builder(); - builder.enable_extended_connect(true); - builder.enable_webtransport(true); - builder.enable_datagram(true); - builder.max_webtransport_sessions(16); - let mut h3 = match builder - .build(h3_quinn::Connection::new(connection.clone())) - .await - { - Ok(connection) => connection, - Err(error) => { - tracing::debug!(%error, "HTTP/3 connection setup failed"); - return; - } - }; - - if let Some(ref m) = metrics { - m.connection_accepted(); - } - let remote_addr = connection.remote_address(); - - let mut tasks = tokio::task::JoinSet::new(); - loop { - let resolver = match h3.accept().await { - Ok(Some(resolver)) => resolver, - Ok(None) => break, - Err(error) => { - tracing::debug!(%error, "HTTP/3 request accept failed"); - break; - } - }; - let (request, mut stream) = match resolver.resolve_request().await { - Ok(request) => request, - Err(error) => { - tracing::debug!(%error, "HTTP/3 request parse failed"); - continue; - } - }; - if request.method() == http::Method::CONNECT && request.uri().path() == mtp_path { - if request.extensions().get::() - != Some(&h3::ext::Protocol::WEB_TRANSPORT) - { - let _ = stream - .send_response( - Response::builder() - .status(StatusCode::METHOD_NOT_ALLOWED) - .body(()) - .unwrap(), - ) - .await; - let _ = stream.finish().await; - continue; - } - let session = match h3_webtransport::server::WebTransportSession::accept( - request, stream, h3, - ) - .await - { - Ok(session) => Arc::new(session), - Err(error) => { - tracing::debug!(%error, "WebTransport session accept failed"); - return; - } - }; - tracing::debug!( - remote = %remote_addr, - session_id = ?session.session_id(), - "accepted WebTransport MTP session" - ); - tokio::spawn(run_session_requests( - session.clone(), - router.clone(), - max_request_body, - request_timeout, - metrics.clone(), - remote_addr, - )); - let mtp_tx = mtp_tx.clone(); - let mtp_queue_permit = match mtp_tx.clone().try_reserve_owned() { - Ok(permit) => permit, - Err(_) => { - tracing::debug!( - "rejecting MTP session because the application queue is full" - ); - connection.close( - quinn::VarInt::from_u32(0), - b"mtp application queue is full", - ); - return; - } - }; - let auth_semaphore = auth_semaphore.clone(); - let host_config = host_config.clone(); - let connection_guard = connection_permit.take(); - let connection = connection.clone(); - let close_connection = connection.clone(); - tokio::spawn(async move { - let result = accept_web_connection( - session, - mtp_path, - connection, - send_pongs, - policy, - host_config, - auth_semaphore, - connection_guard, - ) - .await; - if result.is_err() { - close_connection - .close(quinn::VarInt::from_u32(0), b"mtp handshake failed"); - } - mtp_queue_permit.send(result); - }); - return; - } - let router = router.clone(); - let metrics = metrics.clone(); - tasks.spawn(async move { - let path = request.uri().path().to_string(); - let response = crate::http::run_request( - &path, - request_timeout, - metrics.as_ref(), - handle_http_request(request, &mut stream, &router, max_request_body, remote_addr), - ).await; - if let Err(error) = write_response(&mut stream, response).await - && let Some(metrics) = &metrics - { - metrics.error_occurred(&WebServerError::Http(format!("HTTP/3 response write failed: {error}"))); - } - }); - } - tasks.join_all().await; - if let Some(ref m) = metrics { - m.connection_closed(connect_start.elapsed(), "normal"); - } - }); - } - } - } - - // --- Drain phase: wait for in-flight connections --- - - endpoint.close(quinn::VarInt::from_u32(0), b"mtp-webserver shutdown"); - - let drain_start = std::time::Instant::now(); - while !connection_tasks.is_empty() { - tokio::select! { - Some(result) = connection_tasks.join_next() => { - if let Err(e) = result { - tracing::warn!("Connection task panicked: {}", e); - } - } - _ = tokio::time::sleep(drain_timeout.saturating_sub(drain_start.elapsed())) => { - tracing::warn!( - "Drain timeout expired with {} connections still in flight", - connection_tasks.len() - ); - break; - } - } - } - - connection_tasks.shutdown().await; -} - -async fn handle_http_request( - request: Request<()>, - stream: &mut h3::server::RequestStream, - router: &Router, - max_request_body: usize, - remote_addr: SocketAddr, -) -> Result -where - S: h3::quic::BidiStream, -{ - let (request, too_large) = read_request(request, stream, max_request_body, remote_addr) - .await - .map_err(|e| WebServerError::Http(format!("request body read failed: {e}")))?; - if too_large { - return Err(WebServerError::PayloadTooLarge); - } - Ok(crate::http::dispatch_request(request, router).await) -} - -async fn run_session_requests( - session: Arc>, - router: Router, - max_request_body: usize, - request_timeout: Duration, - metrics: Option>, - remote_addr: SocketAddr, -) { - loop { - match session.accept_bi().await { - Ok(Some(h3_webtransport::server::AcceptedBi::Request(request, mut stream))) => { - let router = router.clone(); - let metrics = metrics.clone(); - tokio::spawn(async move { - let path = request.uri().path().to_string(); - let response = crate::http::run_request( - &path, - request_timeout, - metrics.as_ref(), - handle_http_request( - request, - &mut stream, - &router, - max_request_body, - remote_addr, - ), - ) - .await; - if let Err(error) = write_response(&mut stream, response).await - && let Some(metrics) = &metrics - { - metrics.error_occurred(&WebServerError::Http(format!( - "HTTP/3 response write failed: {error}" - ))); - } - }); - } - Ok(Some(h3_webtransport::server::AcceptedBi::BidiStream(_, _))) => {} - Ok(None) | Err(_) => break, - } - } -} - -async fn read_request( - request: Request<()>, - stream: &mut h3::server::RequestStream, - max_body: usize, - remote_addr: SocketAddr, -) -> Result<(HttpRequest, bool), h3::error::StreamError> -where - S: h3::quic::BidiStream, -{ - let (parts, _) = request.into_parts(); - let mut body = Vec::new(); - let mut too_large = false; - while let Some(chunk) = stream.recv_data().await? { - if body.len().saturating_add(chunk.remaining()) > max_body { - too_large = true; - break; - } - body.extend_from_slice(chunk.chunk()); - } - Ok(( - HttpRequest { - method: parts.method, - uri: parts.uri, - headers: parts.headers, - body: (!body.is_empty()).then(|| Bytes::from(body)), - remote_addr, - }, - too_large, - )) -} - -async fn write_response( - stream: &mut h3::server::RequestStream, - response: HttpResponse, -) -> Result<(), h3::error::StreamError> -where - S: h3::quic::BidiStream, -{ - let mut builder = Response::builder().status(response.status); - for (name, value) in &response.headers { - builder = builder.header(name, value); - } - stream - .send_response(builder.body(()).expect("valid HTTP response")) - .await?; - for chunk in response.body { - stream.send_data(chunk).await?; - } - if let Some(mut chunks) = response.stream { - while let Some(chunk) = chunks.recv().await { - stream.send_data(chunk).await?; - } - } - stream.finish().await -} diff --git a/mtp-webserver/src/http.rs b/mtp-webserver/src/http.rs deleted file mode 100644 index 94d1ac2..0000000 --- a/mtp-webserver/src/http.rs +++ /dev/null @@ -1,132 +0,0 @@ -use crate::{HttpRequest, HttpResponse, Router, WebServerError, WebServerMetrics}; -use http::StatusCode; -use std::{future::Future, sync::Arc, time::Duration}; - -pub(crate) async fn dispatch_request(request: HttpRequest, router: &Router) -> HttpResponse { - let path = request.uri.path(); - if let Some(handler) = router.handler(&request.method, path) { - return handler(request, HttpResponse::default()).await; - } - if let Some((handler, params)) = router.pattern_handler(&request.method, path) { - return handler(request, HttpResponse::default(), params).await; - } - if let Some(handler) = router.fallback_handler() { - return handler(request, HttpResponse::default()).await; - } - HttpResponse::new(StatusCode::NOT_FOUND) -} - -pub(crate) async fn run_request( - path: &str, - timeout: Duration, - metrics: Option<&Arc>, - future: F, -) -> HttpResponse -where - F: Future>, -{ - if let Some(metrics) = metrics { - metrics.request_started(path); - } - let started = std::time::Instant::now(); - let response = match tokio::time::timeout(timeout, future).await { - Ok(Ok(response)) => response, - Ok(Err(error)) => { - let status = if matches!(error, WebServerError::PayloadTooLarge) { - StatusCode::PAYLOAD_TOO_LARGE - } else { - StatusCode::BAD_REQUEST - }; - if let Some(metrics) = metrics { - metrics.error_occurred(&error); - } - HttpResponse::new(status) - } - Err(_) => { - let error = WebServerError::Http("request handler timed out".into()); - if let Some(metrics) = metrics { - metrics.error_occurred(&error); - } - HttpResponse::new(StatusCode::REQUEST_TIMEOUT) - } - }; - if let Some(metrics) = metrics { - metrics.request_completed(path, response.status.as_u16(), started.elapsed()); - } - response -} - -#[cfg(test)] -mod tests { - use super::*; - use http::{Method, Uri}; - - fn request(method: Method, uri: &'static str) -> HttpRequest { - HttpRequest { - method, - uri: Uri::from_static(uri), - headers: Default::default(), - body: None, - remote_addr: "127.0.0.1:1".parse().unwrap(), - } - } - - #[tokio::test] - async fn shared_dispatch_preserves_precedence_and_ignores_query() { - let router = Router::new() - .route("/items", |_, response| async move { - response.status(StatusCode::ACCEPTED) - }) - .unwrap() - .route_pattern("/{name}", |_, response, _| async move { - response.status(StatusCode::CREATED) - }) - .unwrap() - .fallback(|_, response| async move { response.status(StatusCode::IM_A_TEAPOT) }) - .unwrap(); - assert_eq!( - dispatch_request(request(Method::GET, "/items?q=1"), &router) - .await - .status, - StatusCode::ACCEPTED - ); - assert_eq!( - dispatch_request(request(Method::GET, "/other"), &router) - .await - .status, - StatusCode::CREATED - ); - } - - #[tokio::test] - async fn dispatch_matches_terminal_slashes_for_exact_and_pattern_routes() { - let router = Router::new() - .route("/api", |_, response| async move { - response.status(StatusCode::ACCEPTED) - }) - .unwrap() - .route_pattern("/api/test/{id}", |_, response, _| async move { - response.status(StatusCode::CREATED) - }) - .unwrap() - .fallback(|_, response| async move { response.status(StatusCode::IM_A_TEAPOT) }) - .unwrap(); - - for uri in ["/api", "/api/"] { - assert_eq!( - dispatch_request(request(Method::GET, uri), &router) - .await - .status, - StatusCode::ACCEPTED - ); - } - for uri in ["/api/test/1", "/api/test/1/"] { - assert_eq!( - dispatch_request(request(Method::GET, uri), &router) - .await - .status, - StatusCode::CREATED - ); - } - } -} diff --git a/mtp-webserver/src/lib.rs b/mtp-webserver/src/lib.rs deleted file mode 100644 index b7c044d..0000000 --- a/mtp-webserver/src/lib.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! HTTP routing primitives and the combined MTP web-server API. -//! -//! The public routing API is transport-independent. The HTTP/3 driver is -//! intentionally kept behind the crate's implementation boundary so callers -//! do not need to depend on a particular QUIC implementation. - -mod error; -mod h3; -mod http; -mod router; -mod server; -mod stream; -mod tcp; -mod transport; - -pub use error::WebServerError; -#[cfg(feature = "pipes")] -pub use mtp_transport::TransportEvent; -pub use router::{DynamicHttpHandler, HttpHandler, RouteParams, Router, RouterError}; -pub use server::{MTPWebServer, WebServerConfig, WebServerMetrics}; -#[allow(deprecated)] -pub use stream::{Http3Request, Http3Response, HttpRequest, HttpResponse}; -pub use transport::{ - H3TransportConnection, H3TransportReceiver, H3TransportSender, WebMTPConnection, - WebMtpReceiver, WebMtpSender, -}; diff --git a/mtp-webserver/src/router.rs b/mtp-webserver/src/router.rs deleted file mode 100644 index 29a9aa3..0000000 --- a/mtp-webserver/src/router.rs +++ /dev/null @@ -1,416 +0,0 @@ -use crate::{HttpRequest, HttpResponse}; -use http::Method; -use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; - -/// Values captured from a parameterized route. -pub type RouteParams = HashMap; - -/// An asynchronous HTTP route handler. -pub type HttpHandler = Arc< - dyn Fn(HttpRequest, HttpResponse) -> Pin + Send>> - + Send - + Sync, ->; - -/// An asynchronous handler for a parameterized HTTP route. -pub type DynamicHttpHandler = Arc< - dyn Fn( - HttpRequest, - HttpResponse, - RouteParams, - ) -> Pin + Send>> - + Send - + Sync, ->; - -/// Errors returned by [`Router`] route registration. -#[derive(Debug, thiserror::Error)] -pub enum RouterError { - #[error("duplicate route registration for {0}")] - DuplicateRoute(String), - - #[error("a router fallback is already registered")] - DuplicateFallback, - - #[error("invalid route pattern: {0}")] - InvalidPattern(String), -} - -#[derive(Clone)] -struct PatternRoute { - method: Option, - pattern: String, - segments: Vec, - static_segments: usize, - handler: DynamicHttpHandler, -} - -#[derive(Clone)] -enum PatternSegment { - Static(String), - Parameter(String), -} - -/// HTTP route table used by [`MTPWebServer`](crate::MTPWebServer). -#[derive(Clone, Default)] -pub struct Router { - routes: HashMap<(Option, String), HttpHandler>, - pattern_routes: Vec, - fallback: Option, -} - -impl Router { - pub fn new() -> Self { - Self::default() - } - - pub fn route(self, path: impl Into, handler: F) -> Result - where - F: Fn(HttpRequest, HttpResponse) -> Fut + Send + Sync + 'static, - Fut: Future + Send + 'static, - { - self.route_inner( - None, - path.into(), - Arc::new(move |request, response| Box::pin(handler(request, response))), - ) - } - - pub fn route_method( - self, - method: Method, - path: impl Into, - handler: F, - ) -> Result - where - F: Fn(HttpRequest, HttpResponse) -> Fut + Send + Sync + 'static, - Fut: Future + Send + 'static, - { - self.route_inner( - Some(method), - path.into(), - Arc::new(move |request, response| Box::pin(handler(request, response))), - ) - } - - /// Register a route containing named single-segment parameters such as - /// `/api/get/{userid}/profile.json`. - pub fn route_pattern( - self, - pattern: impl Into, - handler: F, - ) -> Result - where - F: Fn(HttpRequest, HttpResponse, RouteParams) -> Fut + Send + Sync + 'static, - Fut: Future + Send + 'static, - { - self.route_pattern_inner( - None, - pattern.into(), - Arc::new(move |request, response, params| Box::pin(handler(request, response, params))), - ) - } - - /// Register a method-specific route containing named single-segment - /// parameters. - pub fn route_pattern_method( - self, - method: Method, - pattern: impl Into, - handler: F, - ) -> Result - where - F: Fn(HttpRequest, HttpResponse, RouteParams) -> Fut + Send + Sync + 'static, - Fut: Future + Send + 'static, - { - self.route_pattern_inner( - Some(method), - pattern.into(), - Arc::new(move |request, response, params| Box::pin(handler(request, response, params))), - ) - } - - pub fn fallback(mut self, handler: F) -> Result - where - F: Fn(HttpRequest, HttpResponse) -> Fut + Send + Sync + 'static, - Fut: Future + Send + 'static, - { - if self.fallback.is_some() { - return Err(RouterError::DuplicateFallback); - } - self.fallback = Some(Arc::new(move |request, response| { - Box::pin(handler(request, response)) - })); - Ok(self) - } - - fn route_inner( - mut self, - method: Option, - path: String, - handler: HttpHandler, - ) -> Result { - let path = normalize_path(&path); - if self - .routes - .insert((method, path.clone()), handler) - .is_some() - { - return Err(RouterError::DuplicateRoute(path)); - } - Ok(self) - } - - fn route_pattern_inner( - mut self, - method: Option, - pattern: String, - handler: DynamicHttpHandler, - ) -> Result { - let pattern = normalize_path(&pattern); - let segments = parse_pattern(&pattern)?; - if self - .pattern_routes - .iter() - .any(|route| route.method == method && route.pattern == pattern) - { - return Err(RouterError::DuplicateRoute(pattern)); - } - let static_segments = segments - .iter() - .filter(|segment| matches!(segment, PatternSegment::Static(_))) - .count(); - self.pattern_routes.push(PatternRoute { - method, - pattern, - segments, - static_segments, - handler, - }); - Ok(self) - } - - pub(crate) fn handler(&self, method: &Method, path: &str) -> Option { - let path = normalize_path(path); - self.routes - .get(&(Some(method.clone()), path.clone())) - .or_else(|| self.routes.get(&(None, path))) - .cloned() - } - - pub(crate) fn pattern_handler( - &self, - method: &Method, - path: &str, - ) -> Option<(DynamicHttpHandler, RouteParams)> { - let path = normalize_path(path); - self.pattern_routes - .iter() - .filter(|route| route.method.is_none() || route.method.as_ref() == Some(method)) - .filter_map(|route| match_pattern(&route.segments, &path).map(|params| (route, params))) - .max_by_key(|(route, _)| (route.method.is_some(), route.static_segments)) - .map(|(route, params)| (route.handler.clone(), params)) - } - pub(crate) fn fallback_handler(&self) -> Option { - self.fallback.clone() - } -} - -/// Canonicalize a route path for matching. -/// -/// HTTP request paths are absolute, but accepting an omitted leading slash in -/// route registration is convenient. A terminal slash (or several terminal -/// slashes) does not identify a different resource, except for the root path. -fn normalize_path(path: &str) -> String { - let path = if path.starts_with('/') { - path.to_string() - } else { - format!("/{path}") - }; - let normalized = path.trim_end_matches('/'); - if normalized.is_empty() { - "/".to_string() - } else { - normalized.to_string() - } -} - -fn parse_pattern(pattern: &str) -> Result, RouterError> { - let path = pattern.strip_prefix('/').unwrap_or(pattern); - let path = path.strip_suffix('/').unwrap_or(path); - if path.is_empty() { - return Ok(Vec::new()); - } - path.split('/') - .map(|segment| { - if segment.starts_with('{') || segment.ends_with('}') { - if segment.len() < 3 || !segment.starts_with('{') || !segment.ends_with('}') { - return Err(RouterError::InvalidPattern(pattern.to_string())); - } - let name = &segment[1..segment.len() - 1]; - if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') - || name.chars().next().is_some_and(|c| c.is_ascii_digit()) - { - return Err(RouterError::InvalidPattern(pattern.to_string())); - } - Ok(PatternSegment::Parameter(name.to_string())) - } else if segment.contains('{') || segment.contains('}') { - Err(RouterError::InvalidPattern(pattern.to_string())) - } else { - Ok(PatternSegment::Static(segment.to_string())) - } - }) - .collect() -} - -fn match_pattern(segments: &[PatternSegment], path: &str) -> Option { - let path = path.strip_prefix('/').unwrap_or(path); - let path = path.strip_suffix('/').unwrap_or(path); - let actual: Vec<&str> = if path.is_empty() { - Vec::new() - } else { - path.split('/').collect() - }; - if actual.len() != segments.len() { - return None; - } - let mut params = RouteParams::new(); - for (segment, value) in segments.iter().zip(actual) { - match segment { - PatternSegment::Static(expected) if expected != value => return None, - PatternSegment::Static(_) => {} - PatternSegment::Parameter(name) => { - params.insert(name.clone(), percent_decode(value)?); - } - } - } - Some(params) -} - -fn percent_decode(value: &str) -> Option { - let mut bytes = Vec::with_capacity(value.len()); - let raw = value.as_bytes(); - let mut index = 0; - while index < raw.len() { - if raw[index] == b'%' { - if index + 2 >= raw.len() { - return None; - } - let high = hex_digit(raw[index + 1])?; - let low = hex_digit(raw[index + 2])?; - bytes.push(high * 16 + low); - index += 3; - } else { - bytes.push(raw[index]); - index += 1; - } - } - String::from_utf8(bytes).ok() -} - -fn hex_digit(value: u8) -> Option { - match value { - b'0'..=b'9' => Some(value - b'0'), - b'a'..=b'f' => Some(value - b'a' + 10), - b'A'..=b'F' => Some(value - b'A' + 10), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use bytes::Bytes; - use http::{Method, StatusCode, Uri}; - - #[tokio::test] - async fn route_dispatches_an_exact_path() { - let router = Router::new() - .route("/health", |_, response| async move { - response.status(StatusCode::NO_CONTENT) - }) - .unwrap(); - let request = HttpRequest { - method: Method::GET, - uri: Uri::from_static("/health"), - headers: Default::default(), - body: Some(Bytes::new()), - remote_addr: "127.0.0.1:4433".parse().unwrap(), - }; - let response = - router.handler(&Method::GET, "/health").unwrap()(request, HttpResponse::default()) - .await; - assert_eq!(response.status, StatusCode::NO_CONTENT); - assert!(router.handler(&Method::GET, "/missing").is_none()); - } - - #[tokio::test] - async fn route_pattern_extracts_decoded_parameters() { - let router = Router::new() - .route_pattern_method( - Method::GET, - "/api/get/{userid}/profile.json", - |_, response, params| async move { - response.body(params.get("userid").unwrap().clone()) - }, - ) - .unwrap(); - let (handler, params) = router - .pattern_handler(&Method::GET, "/api/get/user%2D123/profile.json") - .unwrap(); - let request = HttpRequest { - method: Method::GET, - uri: Uri::from_static("/api/get/user%2D123/profile.json"), - headers: Default::default(), - body: Some(Bytes::new()), - remote_addr: "127.0.0.1:4433".parse().unwrap(), - }; - let response = handler(request, HttpResponse::default(), params).await; - assert_eq!(response.body, vec![Bytes::from("user-123")]); - } - - #[test] - fn route_pattern_rejects_invalid_patterns_and_extra_segments() { - assert!(matches!( - Router::new() - .route_pattern("/users/{user-id}", |_, response, _| async move { response }), - Err(RouterError::InvalidPattern(_)) - )); - let router = Router::new() - .route_pattern("/users/{userid}", |_, response, _| async move { response }) - .unwrap(); - assert!( - router - .pattern_handler(&Method::GET, "/users/alex/details") - .is_none() - ); - } - - #[tokio::test] - async fn method_specific_and_static_routes_win() { - let router = Router::new() - .route_pattern( - "/api/{resource}/profile.json", - |_, response, _| async move { response.status(StatusCode::ACCEPTED) }, - ) - .unwrap() - .route_pattern_method( - Method::GET, - "/api/users/profile.json", - |_, response, _| async move { response.status(StatusCode::CREATED) }, - ) - .unwrap(); - let (handler, params) = router - .pattern_handler(&Method::GET, "/api/users/profile.json") - .unwrap(); - let request = HttpRequest { - method: Method::GET, - uri: Uri::from_static("/api/users/profile.json"), - headers: Default::default(), - body: None, - remote_addr: "127.0.0.1:4433".parse().unwrap(), - }; - let response = handler(request, HttpResponse::default(), params).await; - assert_eq!(response.status, StatusCode::CREATED); - } -} diff --git a/mtp-webserver/src/server.rs b/mtp-webserver/src/server.rs deleted file mode 100644 index de8a439..0000000 --- a/mtp-webserver/src/server.rs +++ /dev/null @@ -1,381 +0,0 @@ -use crate::{ - HttpRequest, HttpResponse, Router, RouterError, WebMTPConnection, WebServerError, - h3::{DriverConfig, run_driver}, - tcp::{TcpDriverConfig, run_driver as run_tcp_driver}, -}; -use http::Method; -use mtp_common::CommunicationError; -use mtp_host::HostConfig; -use rustls::pki_types::{PrivateKeyDer, pem::PemObject}; -use std::{net::SocketAddr, sync::Arc, time::Duration}; -use tokio::{ - net::TcpListener, - sync::{Semaphore, watch}, -}; - -/// Observability hooks for the web server. -/// -/// Implement this trait to receive metrics about connections, requests, and -/// errors. All methods have default no-op implementations so callers only -/// need to override the hooks they care about. -pub trait WebServerMetrics: Send + Sync { - 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) {} -} - -/// Configuration for the HTTPS/HTTP/3 server and MTP routing. -/// -/// Use the builder methods to customise behaviour. All fields have sensible -/// defaults so `WebServerConfig::new()` gives a usable production-ready -/// configuration. -#[derive(Clone)] -pub struct WebServerConfig { - pub(crate) router: Router, - pub(crate) mtp_path: String, - pub max_request_body: usize, - pub max_connections: usize, - pub serve_tcp_https: bool, - pub max_tcp_connections: usize, - pub tls_handshake_timeout: Duration, - pub request_timeout: Duration, - pub drain_timeout: Duration, - pub(crate) metrics: Option>, -} - -impl Default for WebServerConfig { - fn default() -> Self { - Self::new() - } -} - -impl WebServerConfig { - pub fn new() -> Self { - Self { - router: Router::new(), - mtp_path: "/".to_string(), - max_request_body: 4 * 1024 * 1024, - max_connections: 256, - serve_tcp_https: true, - max_tcp_connections: 256, - tls_handshake_timeout: Duration::from_secs(10), - request_timeout: Duration::from_secs(30), - drain_timeout: Duration::from_secs(5), - metrics: None, - } - } - - pub fn route(mut self, path: impl Into, handler: F) -> Result - where - F: Fn(HttpRequest, HttpResponse) -> Fut + Send + Sync + 'static, - Fut: std::future::Future + Send + 'static, - { - self.router = self.router.route(path, handler)?; - Ok(self) - } - - pub fn route_method( - mut self, - method: Method, - path: impl Into, - handler: F, - ) -> Result - where - F: Fn(HttpRequest, HttpResponse) -> Fut + Send + Sync + 'static, - Fut: std::future::Future + Send + 'static, - { - self.router = self.router.route_method(method, path, handler)?; - Ok(self) - } - - /// Register a route containing named single-segment parameters, such as - /// `/api/get/{userid}/profile.json`. - pub fn route_pattern( - mut self, - pattern: impl Into, - handler: F, - ) -> Result - where - F: Fn(HttpRequest, HttpResponse, crate::RouteParams) -> Fut + Send + Sync + 'static, - Fut: std::future::Future + Send + 'static, - { - self.router = self.router.route_pattern(pattern, handler)?; - Ok(self) - } - - /// Register a method-specific parameterized route. - pub fn route_pattern_method( - mut self, - method: Method, - pattern: impl Into, - handler: F, - ) -> Result - where - F: Fn(HttpRequest, HttpResponse, crate::RouteParams) -> Fut + Send + Sync + 'static, - Fut: std::future::Future + Send + 'static, - { - self.router = self.router.route_pattern_method(method, pattern, handler)?; - Ok(self) - } - - pub fn fallback(mut self, handler: F) -> Result - where - F: Fn(HttpRequest, HttpResponse) -> Fut + Send + Sync + 'static, - Fut: std::future::Future + Send + 'static, - { - self.router = self.router.fallback(handler)?; - Ok(self) - } - - pub fn mtp_path(mut self, path: impl Into) -> Self { - self.mtp_path = path.into(); - self - } - - pub fn max_request_body(mut self, bytes: usize) -> Self { - self.max_request_body = bytes; - self - } - - pub fn max_connections(mut self, max: usize) -> Self { - self.max_connections = max; - self - } - - pub fn serve_tcp_https(mut self, enabled: bool) -> Self { - self.serve_tcp_https = enabled; - self - } - - pub fn max_tcp_connections(mut self, max: usize) -> Self { - self.max_tcp_connections = max; - self - } - - pub fn tls_handshake_timeout(mut self, timeout: Duration) -> Self { - self.tls_handshake_timeout = timeout; - self - } - - pub fn request_timeout(mut self, timeout: Duration) -> Self { - self.request_timeout = timeout; - self - } - - pub fn drain_timeout(mut self, timeout: Duration) -> Self { - self.drain_timeout = timeout; - self - } - - pub fn with_metrics(mut self, metrics: Arc) -> Self { - self.metrics = Some(metrics); - self - } -} - -/// A combined HTTPS, HTTP/3, and WebTransport server. -/// -/// One task owns the Quinn endpoint and dispatches all HTTP/3 requests. This -/// is the required ownership model for adding WebTransport MTP sessions on the -/// same UDP socket without competing endpoint accept loops. -pub struct MTPWebServer { - endpoint: quinn::Endpoint, - mtp_incoming: tokio::sync::mpsc::Receiver>, - shutdown_tx: watch::Sender<()>, - quic_driver: Option>, - tcp_driver: Option>, - local_addr: SocketAddr, -} - -impl MTPWebServer { - pub async fn new( - mut host_config: HostConfig, - web_config: WebServerConfig, - ) -> Result { - mtp_crypto::ensure_crypto_provider(); - let certificates = - rustls::pki_types::CertificateDer::pem_slice_iter(&host_config.tls_fullchain) - .collect::, _>>() - .map_err(|_| CommunicationError::CertificateLoadFailed)?; - let key = PrivateKeyDer::from_pem_slice(&host_config.tls_key) - .map_err(|_| CommunicationError::CertificateParseFailed)?; - let tcp_listener = if web_config.serve_tcp_https { - let listener = TcpListener::bind(SocketAddr::new(host_config.ip, host_config.port)) - .await - .map_err(|error| CommunicationError::Other(error.to_string()))?; - host_config.port = listener - .local_addr() - .map_err(|error| CommunicationError::Other(error.to_string()))? - .port(); - Some(listener) - } else { - None - }; - let tcp_tls = tcp_listener - .as_ref() - .map(|_| build_tcp_tls(&certificates, key.clone_key())) - .transpose()?; - let endpoint = build_endpoint(&host_config, certificates, key)?; - let local_addr = endpoint - .local_addr() - .map_err(|error| CommunicationError::Other(error.to_string()))?; - let host_config = Arc::new(host_config); - let driver_endpoint = endpoint.clone(); - // A completed MTP handshake must never block the endpoint driver just - // because the application is briefly slow to call `accept()`. - let (mtp_tx, mtp_incoming) = tokio::sync::mpsc::channel(web_config.max_connections.max(1)); - let (shutdown_tx, shutdown_rx) = watch::channel(()); - let connection_semaphore = Arc::new(Semaphore::new(web_config.max_connections)); - let auth_semaphore = Arc::new(Semaphore::new(web_config.max_connections)); - let router = web_config.router.clone(); - let metrics = web_config.metrics.clone(); - let driver_config = DriverConfig { - router: web_config.router.clone(), - mtp_path: web_config.mtp_path.clone(), - max_request_body: web_config.max_request_body, - request_timeout: web_config.request_timeout, - drain_timeout: web_config.drain_timeout, - send_pongs: host_config.send_pongs, - policy: host_config.policy, - host_config, - metrics: web_config.metrics.clone(), - auth_semaphore, - }; - let quic_driver = tokio::spawn(run_driver( - driver_endpoint, - driver_config, - mtp_tx, - connection_semaphore, - shutdown_rx.clone(), - )); - let tcp_driver = tcp_listener.zip(tcp_tls).map(|(listener, tls)| { - tokio::spawn(run_tcp_driver( - listener, - tls, - TcpDriverConfig { - router, - max_request_body: web_config.max_request_body, - request_timeout: web_config.request_timeout, - tls_handshake_timeout: web_config.tls_handshake_timeout, - drain_timeout: web_config.drain_timeout, - max_connections: web_config.max_tcp_connections, - metrics, - }, - shutdown_rx, - )) - }); - Ok(Self { - endpoint, - mtp_incoming, - shutdown_tx, - quic_driver: Some(quic_driver), - tcp_driver, - local_addr, - }) - } - - pub fn local_addr(&self) -> SocketAddr { - self.local_addr - } - - pub async fn accept(&mut self) -> Result, mtp_host::AcceptError> { - match self.mtp_incoming.recv().await { - Some(result) => result.map(Some), - None => Ok(None), - } - } - - /// Signal the server to shut down gracefully. - /// - /// Stops accepting new QUIC connections, waits for the driver task to - /// finish its accept loop, then allows a configurable drain period for - /// in-flight requests to complete before closing the endpoint. - pub async fn shutdown(mut self) { - let _ = self.shutdown_tx.send(()); - if let Some(driver) = self.quic_driver.take() { - let _ = driver.await; - } - if let Some(driver) = self.tcp_driver.take() { - let _ = driver.await; - } - self.endpoint - .close(quinn::VarInt::from_u32(0), b"mtp-webserver shutdown"); - } - - /// Stop accepting new QUIC connections and wait briefly for the driver to stop. - pub async fn close(mut self) { - self.endpoint - .close(quinn::VarInt::from_u32(0), b"mtp-webserver shutdown"); - if let Some(driver) = self.quic_driver.take() { - driver.abort(); - let _ = driver.await; - } - if let Some(driver) = self.tcp_driver.take() { - driver.abort(); - let _ = driver.await; - } - } -} - -impl Drop for MTPWebServer { - fn drop(&mut self) { - if let Some(driver) = self.quic_driver.take() { - driver.abort(); - } - if let Some(driver) = self.tcp_driver.take() { - driver.abort(); - } - } -} - -fn build_endpoint( - config: &HostConfig, - certificates: Vec>, - key: PrivateKeyDer<'static>, -) -> Result { - let mut tls = rustls::ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(certificates, key) - .map_err(|_| CommunicationError::CertificateLoadFailed)?; - tls.alpn_protocols = vec![b"h3".to_vec()]; - - let mut server = quinn::ServerConfig::with_crypto(Arc::new( - quinn::crypto::rustls::QuicServerConfig::try_from(tls) - .map_err(|error| CommunicationError::Other(error.to_string()))?, - )); - // Apply policy keepalive and idle timeout settings to Quinn - server.transport_config({ - let mut transport = quinn::TransportConfig::default(); - if let Some(keep_alive) = config.policy.keep_alive_interval { - transport.keep_alive_interval(Some(keep_alive)); - } - transport.max_idle_timeout( - config - .policy - .max_idle_timeout - .map(|idle_timeout| { - idle_timeout - .try_into() - .map_err(|error| CommunicationError::Other(format!("{error}"))) - }) - .transpose()?, - ); - Arc::new(transport) - }); - quinn::Endpoint::server(server, SocketAddr::new(config.ip, config.port)) - .map_err(|error| CommunicationError::Other(error.to_string())) -} - -fn build_tcp_tls( - certificates: &[rustls::pki_types::CertificateDer<'static>], - key: PrivateKeyDer<'static>, -) -> Result, CommunicationError> { - let mut tls = rustls::ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(certificates.to_vec(), key) - .map_err(|_| CommunicationError::CertificateLoadFailed)?; - tls.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; - Ok(Arc::new(tls)) -} diff --git a/mtp-webserver/src/stream.rs b/mtp-webserver/src/stream.rs deleted file mode 100644 index 7e87f17..0000000 --- a/mtp-webserver/src/stream.rs +++ /dev/null @@ -1,100 +0,0 @@ -use bytes::Bytes; -use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri}; -use std::net::SocketAddr; -use tokio::sync::mpsc; - -/// An owned HTTP request passed to a route handler. -#[derive(Clone, Debug)] -pub struct HttpRequest { - pub method: Method, - pub uri: Uri, - pub headers: HeaderMap, - pub body: Option, - pub remote_addr: SocketAddr, -} - -/// An HTTP response returned from a route handler. -pub struct HttpResponse { - pub status: StatusCode, - pub headers: HeaderMap, - pub body: Vec, - pub(crate) stream: Option>, -} - -impl HttpResponse { - pub fn new(status: StatusCode) -> Self { - Self { - status, - headers: HeaderMap::new(), - body: Vec::new(), - stream: None, - } - } - - pub fn status(mut self, status: StatusCode) -> Self { - self.status = status; - self - } - - pub fn header(mut self, key: &str, value: &str) -> Self { - match (key.parse::(), value.parse::()) { - (Ok(key), Ok(value)) => { - self.headers.insert(key, value); - } - (Err(error), _) => { - tracing::warn!(%error, key, "discarding invalid HTTP response header") - } - (_, Err(error)) => { - tracing::warn!(%error, key, "discarding invalid HTTP response header") - } - } - self - } - pub fn try_header(mut self, key: &str, value: &str) -> Result { - let key = key.parse::().map_err(|e| e.to_string())?; - let value = value.parse::().map_err(|e| e.to_string())?; - self.headers.insert(key, value); - Ok(self) - } - - pub fn body(mut self, chunk: impl Into) -> Self { - self.body.push(chunk.into()); - self - } - /// Stream response chunks as they become available instead of buffering them. - pub fn stream(mut self, chunks: mpsc::Receiver) -> Self { - self.stream = Some(chunks); - self - } -} - -impl Default for HttpResponse { - fn default() -> Self { - Self::new(StatusCode::OK) - } -} - -#[deprecated(note = "use HttpRequest")] -pub type Http3Request = HttpRequest; - -#[deprecated(note = "use HttpResponse")] -pub type Http3Response = HttpResponse; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn response_collects_headers_and_body_chunks() { - let response = HttpResponse::new(StatusCode::CREATED) - .header("content-type", "text/plain") - .body("hello") - .body(" world"); - assert_eq!(response.status, StatusCode::CREATED); - assert_eq!(response.headers["content-type"], "text/plain"); - assert_eq!( - response.body, - vec![Bytes::from("hello"), Bytes::from(" world")] - ); - } -} diff --git a/mtp-webserver/src/tcp.rs b/mtp-webserver/src/tcp.rs deleted file mode 100644 index 2812ab1..0000000 --- a/mtp-webserver/src/tcp.rs +++ /dev/null @@ -1,202 +0,0 @@ -use crate::{HttpRequest, HttpResponse, Router, WebServerError, WebServerMetrics}; -use bytes::Bytes; -use http::{Request, Response, header::CONTENT_LENGTH}; -use http_body_util::{BodyExt, Full, StreamBody, combinators::BoxBody}; -use hyper::{ - body::{Frame, Incoming}, - service::service_fn, -}; -use hyper_util::{ - rt::{TokioExecutor, TokioIo}, - server::conn::auto::Builder, -}; -use std::{convert::Infallible, sync::Arc, time::Duration}; -use tokio::{ - net::TcpListener, - sync::{Semaphore, watch}, - task::JoinSet, -}; -use tokio_rustls::TlsAcceptor; -use tokio_stream::{StreamExt, wrappers::ReceiverStream}; - -pub(crate) struct TcpDriverConfig { - pub router: Router, - pub max_request_body: usize, - pub request_timeout: Duration, - pub tls_handshake_timeout: Duration, - pub drain_timeout: Duration, - pub max_connections: usize, - pub metrics: Option>, -} - -pub(crate) async fn run_driver( - listener: TcpListener, - tls: Arc, - config: TcpDriverConfig, - mut shutdown_rx: watch::Receiver<()>, -) { - let permits = Arc::new(Semaphore::new(config.max_connections)); - let mut tasks = JoinSet::new(); - loop { - tokio::select! { - biased; - _ = shutdown_rx.changed() => break, - accepted = listener.accept() => { - let Ok((stream, remote_addr)) = accepted else { break }; - let Ok(permit) = permits.clone().try_acquire_owned() else { - tracing::debug!(%remote_addr, "rejecting TCP connection at configured connection limit"); - continue; - }; - let acceptor = TlsAcceptor::from(tls.clone()); - let router = config.router.clone(); - let metrics = config.metrics.clone(); - let timeout = config.request_timeout; - let body_limit = config.max_request_body; - let handshake_timeout = config.tls_handshake_timeout; - let connection_shutdown = shutdown_rx.clone(); - tasks.spawn(async move { - let _permit = permit; - let started = std::time::Instant::now(); - let tls_stream = match tokio::time::timeout(handshake_timeout, acceptor.accept(stream)).await { - Ok(Ok(stream)) => stream, - Ok(Err(error)) => { - report_error(metrics.as_ref(), WebServerError::Tls(error.to_string())); - return; - } - Err(_) => { - report_error(metrics.as_ref(), WebServerError::Tls("TLS handshake timed out".into())); - return; - } - }; - if let Some(metrics) = &metrics { metrics.connection_accepted(); } - let service_metrics = metrics.clone(); - let service = service_fn(move |request| { - serve_request(request, remote_addr, router.clone(), body_limit, timeout, service_metrics.clone()) - }); - let builder = Builder::new(TokioExecutor::new()); - let connection = builder.serve_connection_with_upgrades(TokioIo::new(tls_stream), service); - tokio::pin!(connection); - tokio::select! { - result = &mut connection => { - if let Err(error) = result { - report_error(metrics.as_ref(), WebServerError::Http(format!("TCP HTTP connection failed: {error}"))); - } - } - _ = wait_for_shutdown(connection_shutdown) => { - connection.as_mut().graceful_shutdown(); - if let Err(error) = connection.await { - tracing::debug!(%error, "TCP HTTP connection ended during shutdown"); - } - } - } - if let Some(metrics) = &metrics { metrics.connection_closed(started.elapsed(), "normal"); } - }); - } - } - } - - let drain = async { while tasks.join_next().await.is_some() {} }; - if tokio::time::timeout(config.drain_timeout, drain) - .await - .is_err() - { - tasks.shutdown().await; - } -} - -async fn wait_for_shutdown(mut shutdown_rx: watch::Receiver<()>) { - let _ = shutdown_rx.changed().await; -} - -async fn serve_request( - request: Request, - remote_addr: std::net::SocketAddr, - router: Router, - max_body: usize, - timeout: Duration, - metrics: Option>, -) -> Result>, Infallible> { - let path = request.uri().path().to_string(); - let response = crate::http::run_request(&path, timeout, metrics.as_ref(), async move { - let request = read_request(request, remote_addr, max_body).await?; - Ok(crate::http::dispatch_request(request, &router).await) - }) - .await; - Ok(into_hyper_response(response)) -} - -async fn read_request( - request: Request, - remote_addr: std::net::SocketAddr, - max_body: usize, -) -> Result { - let (parts, mut body) = request.into_parts(); - if parts - .headers - .get(CONTENT_LENGTH) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()) - .is_some_and(|length| length > max_body) - { - return Err(WebServerError::PayloadTooLarge); - } - let mut chunks = Vec::new(); - let mut size = 0usize; - while let Some(frame) = body.frame().await { - let frame = frame - .map_err(|error| WebServerError::Http(format!("request body read failed: {error}")))?; - if let Ok(data) = frame.into_data() { - size = size.saturating_add(data.len()); - if size > max_body { - return Err(WebServerError::PayloadTooLarge); - } - chunks.push(data); - } - } - let body = if chunks.is_empty() { - None - } else { - let mut combined = bytes::BytesMut::with_capacity(size); - for chunk in chunks { - combined.extend_from_slice(&chunk); - } - Some(combined.freeze()) - }; - Ok(HttpRequest { - method: parts.method, - uri: parts.uri, - headers: parts.headers, - body, - remote_addr, - }) -} - -fn into_hyper_response(mut response: HttpResponse) -> Response> { - let mut builder = Response::builder().status(response.status); - if let Some(headers) = builder.headers_mut() { - *headers = response.headers; - } - let body = if let Some(stream) = response.stream.take() { - let stream = ReceiverStream::new(stream).map(|chunk| Ok(Frame::data(chunk))); - BodyExt::boxed(StreamBody::new(stream)) - } else { - let length: usize = response.body.iter().map(Bytes::len).sum(); - if let Some(headers) = builder.headers_mut() - && !headers.contains_key(CONTENT_LENGTH) - { - headers.insert(CONTENT_LENGTH, length.into()); - } - let mut body = bytes::BytesMut::with_capacity(length); - for chunk in response.body { - body.extend_from_slice(&chunk); - } - Full::new(body.freeze()).boxed() - }; - builder.body(body).expect("valid HTTP response") -} - -fn report_error(metrics: Option<&Arc>, error: WebServerError) { - if let Some(metrics) = metrics { - metrics.error_occurred(&error); - } -} diff --git a/mtp-webserver/src/transport.rs b/mtp-webserver/src/transport.rs deleted file mode 100644 index 9dcce5f..0000000 --- a/mtp-webserver/src/transport.rs +++ /dev/null @@ -1,428 +0,0 @@ -use bytes::Bytes; -use mtp_codec::registry::Registry; -use mtp_common::CommunicationError; -use mtp_host::AcceptError; -use mtp_host::HostConfig; -use mtp_transport::{ - GenericReceiver, GenericSender, Policy, TransportConnection, TransportRecvStream, - TransportSendStream, -}; -use std::sync::Arc; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tracing::error; - -type Session = h3_webtransport::server::WebTransportSession; -type H3SendStream = h3_webtransport::stream::SendStream, Bytes>; -type H3RecvStream = h3_webtransport::stream::RecvStream; - -/// h3-webtransport implementation of MTP's transport connection boundary. -/// -/// This is intentionally separate from [`WebMTPConnection`]: it is the -/// adapter used by the in-progress migration of `mtp_transport::Sender` and -/// `Receiver` away from concrete wtransport stream types. -#[derive(Clone)] -pub struct H3TransportConnection { - session: Arc, - quinn: quinn::Connection, -} - -pub struct H3TransportSender { - stream: H3SendStream, -} - -pub struct H3TransportReceiver { - stream: H3RecvStream, - quinn: quinn::Connection, - read_exact_calls: u64, -} - -impl H3TransportConnection { - pub(crate) fn new(session: Arc, quinn: quinn::Connection) -> Self { - Self { session, quinn } - } - - pub(crate) fn remote_addr(&self) -> std::net::SocketAddr { - self.quinn.remote_address() - } - - #[cfg(feature = "crypto")] - pub(crate) fn connection_id(&self) -> u64 { - self.quinn.stable_id() as u64 - } -} - -#[async_trait::async_trait] -impl TransportSendStream for H3TransportSender { - async fn write_all(&mut self, buf: &[u8]) -> Result<(), CommunicationError> { - self.stream - .write_all(buf) - .await - .map_err(|_| CommunicationError::DeliveryUnknown)?; - // Control/authentication frames use a persistent stream. h3 keeps - // those writes buffered until flushed; without this the peer can wait - // for the challenge while the server waits for its proof. - self.stream - .flush() - .await - .map_err(|_| CommunicationError::DeliveryUnknown) - } - - async fn finish(&mut self) -> Result<(), CommunicationError> { - self.stream - .shutdown() - .await - .map_err(|_| CommunicationError::StreamError) - } - - fn reset(&mut self, code: u32) -> Result<(), CommunicationError> { - h3::quic::SendStream::reset(&mut self.stream, code as u64); - Ok(()) - } -} - -#[async_trait::async_trait] -impl TransportRecvStream for H3TransportReceiver { - async fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), CommunicationError> { - let first_read = self.read_exact_calls == 0; - self.read_exact_calls += 1; - self.stream - .read_exact(buf) - .await - .map(|_| { - if first_read { - tracing::debug!( - remote = %self.quinn.remote_address(), - bytes = buf.len(), - header = ?buf, - "received first bytes from WebTransport MTP stream" - ); - } - }) - .map_err(|error| { - if error.kind() == std::io::ErrorKind::UnexpectedEof - || self.quinn.close_reason().is_some() - { - /* - * Reaching FIN, or losing the enclosing QUIC connection, - * is a normal stream-closure path. Do not turn it into a - * frame-header failure and close the connection again. - */ - return CommunicationError::StreamClosed; - } - error!( - "[mtp-webserver] receive stream read_exact failed ({} bytes): {error}", - buf.len() - ); - tracing::warn!( - remote = %self.quinn.remote_address(), - first_read, - len = buf.len(), - %error, - "WebTransport receive stream read_exact failed" - ); - CommunicationError::StreamError - }) - } - - async fn read_chunk(&mut self, max: usize) -> Result>, CommunicationError> { - let mut buf = vec![0; max]; - match self.stream.read(&mut buf).await { - Ok(0) => Ok(None), - Ok(size) => { - buf.truncate(size); - Ok(Some(buf)) - } - Err(error) => { - if self.quinn.close_reason().is_some() { - return Err(CommunicationError::StreamClosed); - } - error!( - "[mtp-webserver] receive stream read failed (max {} bytes): {error}", - max - ); - tracing::warn!(max, %error, "WebTransport receive stream read failed"); - Err(CommunicationError::StreamError) - } - } - } - - fn stop(mut self, code: u32) -> Result<(), CommunicationError> { - h3::quic::RecvStream::stop_sending(&mut self.stream, code as u64); - Ok(()) - } -} - -impl tokio::io::AsyncWrite for H3TransportSender { - fn poll_write( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - buf: &[u8], - ) -> std::task::Poll> { - std::pin::Pin::new(&mut self.stream).poll_write(cx, buf) - } - - fn poll_flush( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - std::pin::Pin::new(&mut self.stream).poll_flush(cx) - } - - fn poll_shutdown( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - std::pin::Pin::new(&mut self.stream).poll_shutdown(cx) - } -} - -impl tokio::io::AsyncRead for H3TransportReceiver { - fn poll_read( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - buf: &mut tokio::io::ReadBuf<'_>, - ) -> std::task::Poll> { - std::pin::Pin::new(&mut self.stream).poll_read(cx, buf) - } -} - -#[async_trait::async_trait] -impl TransportConnection for H3TransportConnection { - type SendStream = H3TransportSender; - type RecvStream = H3TransportReceiver; - - async fn open_uni(&self) -> Result { - self.session - .open_uni(self.session.session_id()) - .await - .map(|stream| H3TransportSender { stream }) - .map_err(|_| CommunicationError::StreamError) - } - - async fn accept_uni(&self) -> Result { - const MAX_CONSECUTIVE_ERRORS: u32 = 10; - const INITIAL_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(20); - - let mut consecutive_errors = 0_u32; - loop { - match self.session.accept_uni().await { - Ok(Some((id, stream))) if id == self.session.session_id() => { - let stream_id = h3::quic::RecvStream::recv_id(&stream); - tracing::debug!( - remote = %self.quinn.remote_address(), - session_id = ?self.session.session_id(), - stream_id = ?stream_id, - "accepted WebTransport MTP receive stream" - ); - return Ok(H3TransportReceiver { - stream, - quinn: self.quinn.clone(), - read_exact_calls: 0, - }); - } - Ok(Some((stream_session_id, _stream))) => { - consecutive_errors = 0; - tracing::debug!( - remote = %self.quinn.remote_address(), - session_id = ?self.session.session_id(), - stream_session_id = ?stream_session_id, - "ignored WebTransport receive stream belonging to another session" - ); - continue; - } - Ok(None) => return Err(CommunicationError::StreamClosed), - Err(error) => { - // A browser can reset an individual pipe stream while it - // is stopping MediaRecorder. h3-webtransport reports that - // through accept_uni even though the QUIC connection is - // still healthy. Do not turn that stream-local failure - // into a connection-wide MTP failure. - if self.quinn.close_reason().is_some() { - return Err(CommunicationError::StreamClosed); - } - - consecutive_errors += 1; - if consecutive_errors > MAX_CONSECUTIVE_ERRORS { - tracing::warn!( - %error, - consecutive_errors, - "WebTransport receive-stream accept repeatedly failed" - ); - return Err(CommunicationError::StreamError); - } - - let multiplier = 1_u32 << consecutive_errors.saturating_sub(1).min(5); - let retry_delay = INITIAL_RETRY_DELAY * multiplier; - tracing::debug!( - %error, - consecutive_errors, - ?retry_delay, - "retrying transient WebTransport receive-stream error" - ); - tokio::time::sleep(retry_delay).await; - } - } - } - } - - fn close_reason(&self) -> Option { - self.quinn - .close_reason() - .map(|_| CommunicationError::StreamClosed) - } - - fn close(&self, code: u32, reason: &[u8]) { - self.quinn.close(quinn::VarInt::from_u32(code), reason); - } -} - -/// Shared host MTP connection instantiated with HTTP/3 stream adapters. -pub type WebMtpSender = GenericSender; -pub type WebMtpReceiver = GenericReceiver; -pub type WebMTPConnection = - mtp_host::MTPConnection; - -#[allow(clippy::too_many_arguments)] -pub(crate) async fn accept_web_connection( - session: Arc, - path: String, - quinn: quinn::Connection, - send_pongs: bool, - policy: Policy, - host_config: Arc, - #[allow(unused_variables)] auth_semaphore: Arc, - connection_guard: Option, -) -> Result { - #[cfg(feature = "crypto")] - { - let deadline = tokio::time::Instant::now() + host_config.auth_timeout; - let permit = tokio::time::timeout_at(deadline, auth_semaphore.clone().acquire_owned()) - .await - .map_err(|_| AcceptError::AuthenticationTimedOut)? - .map_err(|_| { - AcceptError::AuthenticationFailed("authentication service stopped".into()) - })?; - let result = accept_web_connection_inner( - session, - path, - quinn, - send_pongs, - policy, - host_config, - Some(deadline), - connection_guard, - ) - .await; - drop(permit); - result - } - - #[cfg(not(feature = "crypto"))] - accept_web_connection_inner( - session, - path, - quinn, - send_pongs, - policy, - host_config, - None, - connection_guard, - ) - .await -} - -#[allow(clippy::too_many_arguments)] -async fn accept_web_connection_inner( - session: Arc, - path: String, - quinn: quinn::Connection, - send_pongs: bool, - policy: Policy, - host_config: Arc, - #[allow(unused_variables)] deadline: Option, - connection_guard: Option, -) -> Result { - let max_message_size = policy.max_message_size; - let transport = H3TransportConnection::new(session, quinn); - let remote_addr = transport.remote_addr(); - #[cfg(feature = "crypto")] - let connection_id = transport.connection_id(); - let policy = Arc::new(policy); - let sender = WebMtpSender::new(transport.clone(), policy.clone()); - let receiver = WebMtpReceiver::new(transport, policy.clone()); - - let engine = mtp_host::HandshakeEngine::new(Registry::builtin(), host_config); - #[cfg(feature = "crypto")] - let result = engine - .accept_until_with_context( - &sender, - &receiver, - deadline.expect("crypto WebTransport handshakes have a deadline"), - mtp_host::AuthenticationContext { - peer_network_identity: Some(remote_addr.to_string()), - connection_id, - }, - ) - .await; - #[cfg(feature = "crypto")] - if let Err(error) = &result { - tracing::warn!( - remote = %remote_addr, - connection_id, - %error, - "WebTransport MTP handshake failed" - ); - } - #[cfg(feature = "crypto")] - let result = result?; - #[cfg(not(feature = "crypto"))] - let result = engine.accept(&sender, &receiver).await?; - - let version = result.negotiated_version.clone(); - let codec = result.codec.clone(); - let description = result.description.clone(); - #[cfg(feature = "pipes")] - let connection: WebMTPConnection = mtp_host::MTPConnection::from_transport_parts_with_policy( - version, - codec, - sender, - receiver, - path, - description, - Some(remote_addr), - policy, - ); - #[cfg(not(feature = "pipes"))] - let connection: WebMTPConnection = - mtp_host::MTPConnection::from_transport_parts_with_remote_addr( - version, - codec, - sender, - receiver, - path, - description, - Some(remote_addr), - ); - - let mut connection = connection; - - #[cfg(feature = "crypto")] - { - connection.auth_state = result.auth_state; - connection.client_id = result.client_id; - connection.client_public_key = result.client_public_key; - connection.set_guest_id_lease(result.guest_id_lease); - } - if let Some(connection_guard) = connection_guard { - connection.set_connection_guard(connection_guard); - } - - if send_pongs { - connection - .receiver - .respond_to_pings(connection.sender.clone()) - .await; - } - connection.receiver.set_max_message_size(max_message_size); - Ok(connection) -} diff --git a/mtp-webserver/tests/integration.rs b/mtp-webserver/tests/integration.rs deleted file mode 100644 index 0848b12..0000000 --- a/mtp-webserver/tests/integration.rs +++ /dev/null @@ -1,547 +0,0 @@ -use http::{Method, StatusCode}; -use mtp_webserver::{MTPWebServer, WebServerConfig, WebServerError, WebServerMetrics}; -use rustls::pki_types::{CertificateDer, ServerName, pem::PemObject}; -use std::net::{IpAddr, SocketAddr}; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::Duration; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; - -fn generate_self_signed_cert() -> (Vec, Vec) { - let key_pair = rcgen::KeyPair::generate().expect("failed to generate self-signed key pair"); - let params = rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()]) - .expect("failed to build self-signed certificate params"); - let cert = params - .self_signed(&key_pair) - .expect("failed to self-sign certificate"); - ( - cert.pem().into_bytes(), - key_pair.serialize_pem().into_bytes(), - ) -} - -fn host_config(port: u16, cert: Vec, key: Vec) -> mtp_host::HostConfig { - mtp_host::HostConfig::new(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), port, cert, key) -} - -async fn tls_connect( - addr: SocketAddr, - cert_pem: &[u8], - alpn: Vec>, -) -> tokio_rustls::client::TlsStream { - let certs = CertificateDer::pem_slice_iter(cert_pem) - .collect::, _>>() - .unwrap(); - let mut roots = rustls::RootCertStore::empty(); - for cert in certs { - roots.add(cert).unwrap(); - } - let mut config = rustls::ClientConfig::builder() - .with_root_certificates(roots) - .with_no_client_auth(); - config.alpn_protocols = alpn; - let connector = tokio_rustls::TlsConnector::from(Arc::new(config)); - connector - .connect( - ServerName::try_from("localhost").unwrap().to_owned(), - tokio::net::TcpStream::connect(addr).await.unwrap(), - ) - .await - .unwrap() -} - -async fn http1_request(addr: SocketAddr, cert_pem: &[u8], request: &str) -> Vec { - let mut stream = tls_connect(addr, cert_pem, vec![b"http/1.1".to_vec()]).await; - stream.write_all(request.as_bytes()).await.unwrap(); - let mut response = Vec::new(); - stream.read_to_end(&mut response).await.unwrap(); - response -} - -#[test] -fn config_builder_defaults() { - let config = WebServerConfig::new(); - assert_eq!(config.max_request_body, 4 * 1024 * 1024); - assert_eq!(config.max_connections, 256); - assert!(config.serve_tcp_https); - assert_eq!(config.max_tcp_connections, 256); - assert_eq!(config.tls_handshake_timeout, Duration::from_secs(10)); - assert_eq!(config.request_timeout, Duration::from_secs(30)); -} - -#[test] -fn config_builder_chain() { - let config = WebServerConfig::new() - .max_connections(64) - .max_tcp_connections(32) - .tls_handshake_timeout(Duration::from_secs(2)) - .max_request_body(1024) - .request_timeout(Duration::from_secs(5)) - .mtp_path("/ws"); - assert_eq!(config.max_connections, 64); - assert_eq!(config.max_tcp_connections, 32); - assert_eq!(config.max_request_body, 1024); - assert_eq!(config.request_timeout, Duration::from_secs(5)); -} - -#[test] -fn config_builder_routes() { - let config = WebServerConfig::new() - .route( - "/health", - |_, resp| async move { resp.status(StatusCode::OK) }, - ) - .unwrap() - .route("/data", |_, resp| async move { - resp.status(StatusCode::NO_CONTENT) - }) - .unwrap(); - let config = config.mtp_path("/"); - drop(config); -} - -#[test] -fn config_duplicate_route_errors() { - let result = WebServerConfig::new() - .route("/dup", |_, resp| async move { resp }) - .unwrap() - .route("/dup", |_, resp| async move { resp }); - assert!(result.is_err()); -} - -#[test] -fn error_display() { - let err = WebServerError::WebTransport("session rejected".into()); - assert_eq!(err.to_string(), "webtransport error: session rejected"); - - let err = WebServerError::NotFound("/api".into()); - assert_eq!(err.to_string(), "route not found: /api"); - - let err = WebServerError::Http("body too large".into()); - assert_eq!(err.to_string(), "HTTP error: body too large"); - - let err = WebServerError::Transport(mtp_common::CommunicationError::StreamClosed); - assert_eq!(err.to_string(), "transport error: Stream Closed"); -} - -#[test] -fn error_from_communication_error() { - let comm_err = mtp_common::CommunicationError::StreamError; - let web_err: WebServerError = comm_err.into(); - assert!(matches!(web_err, WebServerError::Transport(_))); -} - -#[test] -fn error_source_chain() { - let inner = mtp_common::CommunicationError::StreamClosed; - let err = WebServerError::Transport(inner); - let source = std::error::Error::source(&err); - assert!(source.is_some()); -} - -struct TestMetrics { - connections_accepted: AtomicUsize, - connections_closed: AtomicUsize, - requests_started: AtomicUsize, - requests_completed: AtomicUsize, - errors: AtomicUsize, -} - -impl TestMetrics { - fn new() -> Self { - Self { - connections_accepted: AtomicUsize::new(0), - connections_closed: AtomicUsize::new(0), - requests_started: AtomicUsize::new(0), - requests_completed: AtomicUsize::new(0), - errors: AtomicUsize::new(0), - } - } -} - -impl WebServerMetrics for TestMetrics { - fn connection_accepted(&self) { - self.connections_accepted.fetch_add(1, Ordering::SeqCst); - } - fn connection_closed(&self, _duration: Duration, _reason: &str) { - self.connections_closed.fetch_add(1, Ordering::SeqCst); - } - fn request_started(&self, _path: &str) { - self.requests_started.fetch_add(1, Ordering::SeqCst); - } - fn request_completed(&self, _path: &str, _status: u16, _duration: Duration) { - self.requests_completed.fetch_add(1, Ordering::SeqCst); - } - fn error_occurred(&self, _error: &WebServerError) { - self.errors.fetch_add(1, Ordering::SeqCst); - } -} - -#[test] -fn metrics_trait_defaults_compile() { - struct NoopMetrics; - impl WebServerMetrics for NoopMetrics {} - let m = NoopMetrics; - m.connection_accepted(); - m.connection_closed(Duration::from_secs(1), "test"); - m.request_started("/test"); - m.request_completed("/test", 200, Duration::from_millis(50)); - m.error_occurred(&WebServerError::NotFound("x".into())); -} - -#[test] -fn config_with_metrics() { - let metrics: Arc = Arc::new(TestMetrics::new()); - let config = WebServerConfig::new().with_metrics(metrics); - let metrics: Arc = Arc::new(TestMetrics::new()); - let config = config.with_metrics(metrics); - drop(config); -} - -#[tokio::test] -async fn server_constructs_with_self_signed_cert() { - let (cert_pem, key_pem) = generate_self_signed_cert(); - let host_config = mtp_host::HostConfig::new( - IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), - 0, - cert_pem, - key_pem, - ); - let web_config = WebServerConfig::new(); - let server = MTPWebServer::new(host_config, web_config).await; - assert!(server.is_ok()); - let server = server.unwrap(); - let addr = server.local_addr(); - assert!(addr.port() > 0); - server.close().await; -} - -#[tokio::test] -async fn server_with_metrics_constructs() { - let (cert_pem, key_pem) = generate_self_signed_cert(); - let host_config = mtp_host::HostConfig::new( - IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), - 0, - cert_pem, - key_pem, - ); - let metrics: Arc = Arc::new(TestMetrics::new()); - let web_config = WebServerConfig::new() - .max_connections(10) - .request_timeout(Duration::from_secs(10)) - .with_metrics(metrics); - let server = MTPWebServer::new(host_config, web_config).await; - assert!(server.is_ok()); - server.unwrap().close().await; -} - -#[tokio::test] -async fn graceful_shutdown_completes() { - let (cert_pem, key_pem) = generate_self_signed_cert(); - let host_config = mtp_host::HostConfig::new( - IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), - 0, - cert_pem, - key_pem, - ); - let server = MTPWebServer::new(host_config, WebServerConfig::new()) - .await - .unwrap(); - let addr = server.local_addr(); - server.shutdown().await; - assert!(tokio::net::TcpStream::connect(addr).await.is_err()); - assert!(std::net::UdpSocket::bind(addr).is_ok()); -} - -#[tokio::test] -async fn tcp_and_udp_share_port_zero_assignment() { - let (cert, key) = generate_self_signed_cert(); - let server = MTPWebServer::new(host_config(0, cert, key), WebServerConfig::new()) - .await - .unwrap(); - let addr = server.local_addr(); - assert!(addr.port() > 0); - assert!(tokio::net::TcpStream::connect(addr).await.is_ok()); - assert!(std::net::UdpSocket::bind(addr).is_err()); - server.close().await; -} - -#[tokio::test] -async fn tcp_conflict_fails_and_udp_only_does_not_claim_tcp() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let (cert, key) = generate_self_signed_cert(); - assert!( - MTPWebServer::new(host_config(addr.port(), cert, key), WebServerConfig::new()) - .await - .is_err() - ); - drop(listener); - - let (cert, key) = generate_self_signed_cert(); - let server = MTPWebServer::new( - host_config(addr.port(), cert, key), - WebServerConfig::new().serve_tcp_https(false), - ) - .await - .unwrap(); - let tcp = tokio::net::TcpListener::bind(addr).await.unwrap(); - drop(tcp); - server.close().await; -} - -#[tokio::test] -async fn close_and_drop_release_tcp_listener() { - let (cert, key) = generate_self_signed_cert(); - let server = MTPWebServer::new(host_config(0, cert, key), WebServerConfig::new()) - .await - .unwrap(); - let addr = server.local_addr(); - server.close().await; - assert!(tokio::net::TcpListener::bind(addr).await.is_ok()); - - let (cert, key) = generate_self_signed_cert(); - let server = MTPWebServer::new(host_config(0, cert, key), WebServerConfig::new()) - .await - .unwrap(); - let addr = server.local_addr(); - drop(server); - tokio::task::yield_now().await; - assert!(tokio::net::TcpListener::bind(addr).await.is_ok()); -} - -#[tokio::test] -async fn http1_routes_bodies_chunks_and_timeout() { - let (cert, key) = generate_self_signed_cert(); - let config = WebServerConfig::new() - .max_request_body(8) - .request_timeout(Duration::from_millis(20)) - .route_method(Method::POST, "/exact", |request, response| async move { - response.body(request.body.unwrap()).body("-tail") - }) - .unwrap() - .route_pattern("/users/{user}", |request, response, params| async move { - response.body(format!( - "{}:{}", - params["user"], - request.uri.query().unwrap_or("") - )) - }) - .unwrap() - .route("/slow", |_, response| async move { - tokio::time::sleep(Duration::from_millis(100)).await; - response - }) - .unwrap() - .fallback(|_, response| async move { response.status(StatusCode::IM_A_TEAPOT) }) - .unwrap(); - let server = MTPWebServer::new(host_config(0, cert.clone(), key), config) - .await - .unwrap(); - let addr = server.local_addr(); - - let response = http1_request(addr, &cert, "POST /exact HTTP/1.1\r\nHost: localhost\r\nContent-Length: 4\r\nConnection: close\r\n\r\ndata").await; - assert!(String::from_utf8_lossy(&response).ends_with("data-tail")); - let response = http1_request( - addr, - &cert, - "GET /users/alice%2Dsmith?full=1 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", - ) - .await; - assert!(String::from_utf8_lossy(&response).ends_with("alice-smith:full=1")); - let response = http1_request( - addr, - &cert, - "GET /exact HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", - ) - .await; - assert!(String::from_utf8_lossy(&response).starts_with("HTTP/1.1 418")); - let response = http1_request(addr, &cert, "POST /exact HTTP/1.1\r\nHost: localhost\r\nContent-Length: 9\r\nConnection: close\r\n\r\n123456789").await; - assert!(String::from_utf8_lossy(&response).starts_with("HTTP/1.1 413")); - let response = http1_request( - addr, - &cert, - "GET /slow HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", - ) - .await; - assert!(String::from_utf8_lossy(&response).starts_with("HTTP/1.1 408")); - server.close().await; -} - -#[tokio::test] -async fn tls_negotiates_h2() { - let (cert, key) = generate_self_signed_cert(); - let config = WebServerConfig::new() - .route("/h2", |_, response| async move { response.body("over-h2") }) - .unwrap(); - let server = MTPWebServer::new(host_config(0, cert.clone(), key), config) - .await - .unwrap(); - let stream = tls_connect(server.local_addr(), &cert, vec![b"h2".to_vec()]).await; - assert_eq!(stream.get_ref().1.alpn_protocol(), Some(b"h2".as_slice())); - let (mut sender, connection) = hyper::client::conn::http2::handshake( - hyper_util::rt::TokioExecutor::new(), - hyper_util::rt::TokioIo::new(stream), - ) - .await - .unwrap(); - tokio::spawn(async move { - let _ = connection.await; - }); - let request = http::Request::builder() - .uri("https://localhost/h2") - .body(http_body_util::Empty::::new()) - .unwrap(); - let response = sender.send_request(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = http_body_util::BodyExt::collect(response.into_body()) - .await - .unwrap() - .to_bytes(); - assert_eq!(body, "over-h2"); - server.close().await; -} - -#[tokio::test] -async fn tcp_metrics_report_completion_and_errors() { - let (cert, key) = generate_self_signed_cert(); - let metrics = Arc::new(TestMetrics::new()); - let config = WebServerConfig::new() - .request_timeout(Duration::from_millis(10)) - .with_metrics(metrics.clone()) - .route("/slow", |_, response| async move { - tokio::time::sleep(Duration::from_millis(50)).await; - response - }) - .unwrap(); - let server = MTPWebServer::new(host_config(0, cert.clone(), key), config) - .await - .unwrap(); - let response = http1_request( - server.local_addr(), - &cert, - "GET /slow HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", - ) - .await; - assert!(String::from_utf8_lossy(&response).starts_with("HTTP/1.1 408")); - assert_eq!(metrics.requests_started.load(Ordering::SeqCst), 1); - assert_eq!(metrics.requests_completed.load(Ordering::SeqCst), 1); - assert_eq!(metrics.errors.load(Ordering::SeqCst), 1); - server.close().await; -} - -#[tokio::test] -async fn http1_keep_alive_and_streaming_work() { - let (cert, key) = generate_self_signed_cert(); - let config = WebServerConfig::new() - .route("/one", |_, response| async move { response.body("one") }) - .unwrap() - .route("/stream", |_, response| async move { - let (tx, rx) = tokio::sync::mpsc::channel(1); - tokio::spawn(async move { - tx.send(bytes::Bytes::from_static(b"first-")).await.unwrap(); - tokio::time::sleep(Duration::from_millis(10)).await; - let _ = tx.send(bytes::Bytes::from_static(b"second")).await; - }); - response.stream(rx) - }) - .unwrap(); - let server = MTPWebServer::new(host_config(0, cert.clone(), key), config) - .await - .unwrap(); - let addr = server.local_addr(); - let mut stream = tls_connect(addr, &cert, vec![b"http/1.1".to_vec()]).await; - stream.write_all(b"GET /one HTTP/1.1\r\nHost: localhost\r\n\r\nGET /one HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n").await.unwrap(); - let mut response = Vec::new(); - stream.read_to_end(&mut response).await.unwrap(); - assert_eq!( - String::from_utf8_lossy(&response) - .matches("HTTP/1.1 200") - .count(), - 2 - ); - - let response = http1_request( - addr, - &cert, - "GET /stream HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", - ) - .await; - let response = String::from_utf8_lossy(&response); - assert!(response.contains("first-")); - assert!(response.contains("second")); - assert!(!response.to_ascii_lowercase().contains("content-length:")); - server.close().await; -} - -#[tokio::test] -async fn shutdown_allows_active_request_within_drain_period() { - let (cert, key) = generate_self_signed_cert(); - let entered = Arc::new(tokio::sync::Notify::new()); - let handler_entered = Arc::clone(&entered); - let config = WebServerConfig::new() - .drain_timeout(Duration::from_millis(250)) - .route("/work", move |_, response| { - let entered = Arc::clone(&handler_entered); - async move { - entered.notify_one(); - tokio::time::sleep(Duration::from_millis(30)).await; - response.body("done") - } - }) - .unwrap(); - let server = MTPWebServer::new(host_config(0, cert.clone(), key), config) - .await - .unwrap(); - let addr = server.local_addr(); - let request = tokio::spawn(async move { - http1_request( - addr, - &cert, - "GET /work HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", - ) - .await - }); - entered.notified().await; - server.shutdown().await; - assert!(String::from_utf8_lossy(&request.await.unwrap()).ends_with("done")); -} - -#[tokio::test] -async fn shutdown_terminates_request_after_drain_period() { - let (cert, key) = generate_self_signed_cert(); - let entered = Arc::new(tokio::sync::Notify::new()); - let handler_entered = Arc::clone(&entered); - let config = WebServerConfig::new() - .drain_timeout(Duration::from_millis(20)) - .route("/stuck", move |_, response| { - let entered = Arc::clone(&handler_entered); - async move { - entered.notify_one(); - std::future::pending::<()>().await; - response - } - }) - .unwrap(); - let server = MTPWebServer::new(host_config(0, cert.clone(), key), config) - .await - .unwrap(); - let addr = server.local_addr(); - let request = tokio::spawn(async move { - let mut stream = tls_connect(addr, &cert, vec![b"http/1.1".to_vec()]).await; - stream - .write_all(b"GET /stuck HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") - .await - .unwrap(); - let mut response = Vec::new(); - let _ = stream.read_to_end(&mut response).await; - response - }); - entered.notified().await; - server.shutdown().await; - let response = tokio::time::timeout(Duration::from_millis(250), request) - .await - .expect("connection task survived drain timeout") - .unwrap(); - assert!(!String::from_utf8_lossy(&response).contains("HTTP/1.1 200")); -} diff --git a/package.json b/package.json index 72e0b12..f7f10e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mtp", - "version": "0.3.0", + "version": "0.1.0", "description": "MTP TypeScript SDK", "type": "module", "packageManager": "pnpm@11.8.0", @@ -32,49 +32,29 @@ "Cargo.lock", "dist/", "README.md", - "codec/Cargo.lock", "codec/Cargo.toml", "codec/src/", - "common/Cargo.lock", "common/Cargo.toml", "common/src/", - "crypto/Cargo.lock", "crypto/Cargo.toml", "crypto/src/", - "type-map/Cargo.lock", "type-map/Cargo.toml", "type-map/build.rs", - "type-map/reserved.json", "type-map/src/", "wasm/.cargo/", "wasm/Cargo.toml", "wasm/src/", - "wasm/pkg/", "tsconfig.json" ], "scripts": { "example": "pnpm install && pnpm run build:all && nix develop .#autoStart", - "clean": "rm -rf dist wasm/pkg mtp-*.tgz", - "build:wasm": "MTP_TYPE_MAPS=$PWD/example-type-maps.yaml wasm-pack build wasm --target web --out-dir pkg --release && rm -f wasm/pkg/.gitignore", - "build:ts": "rm -rf dist && tsc", - "build": "pnpm run build:wasm && pnpm run build:ts", - "pack": "pnpm run release:web", - "release:web": "node create-web-release.mjs", + "build": "MTP_TYPE_MAPS=$PWD/example/type-maps.yaml RUSTFLAGS='--cfg web_sys_unstable_apis' wasm-pack build wasm --target web --out-dir pkg --release && tsc", "build:all": "nix run .#build-all", - "test:e2e": "tsc && node test/e2ee.mjs", - "test:secrets": "tsc && node --test --test-isolation=none test/encrypted-secret.mjs", - "test:wasm-init": "tsc && node --test test/wasm-init.mjs", - "test:types": "tsc -p tsconfig.type-tests.json --noEmit", - "test:vite": "tsc && node test/vite-type-map.mjs", - "test:boundary": "node --test test/package-boundary.mjs", - "test": "pnpm run test:e2e && pnpm run test:secrets && pnpm run test:wasm-init && pnpm run test:types && pnpm run test:vite && pnpm run test:boundary" + "dup": "jscpd --pattern '**/*.rs' --ignore 'target/**' --ignore 'wasm/pkg/**' --ignore '.git/**' --min-lines 8 --min-tokens 80 --threshold 4 --reporters console --no-tips ." }, "devDependencies": { "@types/node": "^26.0.1", - "jscpd": "5.0.14", - "typescript": "^7.0.0" - }, - "dependencies": { - "yaml": "^2.8.1" + "jscpd": "5.0.11", + "typescript": "^6.0.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 460b3de..8797120 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,20 +7,16 @@ settings: importers: .: - dependencies: - yaml: - specifier: ^2.8.1 - version: 2.9.0 devDependencies: '@types/node': specifier: ^26.0.1 version: 26.0.1 jscpd: - specifier: 5.0.14 - version: 5.0.14 + specifier: 5.0.11 + version: 5.0.11 typescript: - specifier: ^7.0.0 - version: 7.0.2 + specifier: ^6.0.3 + version: 6.0.3 example/web-client: dependencies: @@ -29,11 +25,11 @@ importers: version: link:../.. devDependencies: typescript: - specifier: ^7.0.0 - version: 7.0.2 + specifier: ^6.0.3 + version: 6.0.3 vite: specifier: ^8.1.0 - version: 8.1.0(@types/node@26.0.1)(yaml@2.9.0) + version: 8.1.0(@types/node@26.0.1) packages: @@ -159,123 +155,36 @@ packages: '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} - '@typescript/typescript-aix-ppc64@7.0.2': - resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} - engines: {node: '>=16.20.0'} - cpu: [ppc64] - os: [aix] - - '@typescript/typescript-darwin-arm64@7.0.2': - resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} - engines: {node: '>=16.20.0'} + cpd-darwin-arm64@5.0.11: + resolution: {integrity: sha512-3QvH+4Dv7A7esVFM2tsRVWN3kn9EDu8dMYog6gYAVsCtxEf4xyxAwS/ef6LjC7/dh4+ATADFbg3H09A2fD//Qw==} cpu: [arm64] os: [darwin] - '@typescript/typescript-darwin-x64@7.0.2': - resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} - engines: {node: '>=16.20.0'} + cpd-darwin-x64@5.0.11: + resolution: {integrity: sha512-OvgM2ps0OFR5jUzx7+FK9URdJGxUzzM5KKk2F1V3vf1LooGDKwkivfIDyKsqEwp37zcbyUo7COvBpJXOT0dZmQ==} cpu: [x64] os: [darwin] - '@typescript/typescript-freebsd-arm64@7.0.2': - resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [freebsd] - - '@typescript/typescript-freebsd-x64@7.0.2': - resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [freebsd] - - '@typescript/typescript-linux-arm64@7.0.2': - resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} - engines: {node: '>=16.20.0'} + cpd-linux-arm64-gnu@5.0.11: + resolution: {integrity: sha512-pXMINibAeruglni8ZajlXEefZHDs7QFSG+vPtkBDu7uiIMpNU8aoktgO2vP+PbIRFD0vHkqTMb64kDtIOqQcwQ==} cpu: [arm64] os: [linux] + libc: [glibc] - '@typescript/typescript-linux-arm@7.0.2': - resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} - engines: {node: '>=16.20.0'} - cpu: [arm] - os: [linux] - - '@typescript/typescript-linux-loong64@7.0.2': - resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} - engines: {node: '>=16.20.0'} - cpu: [loong64] - os: [linux] - - '@typescript/typescript-linux-mips64el@7.0.2': - resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} - engines: {node: '>=16.20.0'} - cpu: [mips64el] - os: [linux] - - '@typescript/typescript-linux-ppc64@7.0.2': - resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} - engines: {node: '>=16.20.0'} - cpu: [ppc64] - os: [linux] - - '@typescript/typescript-linux-riscv64@7.0.2': - resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} - engines: {node: '>=16.20.0'} - cpu: [riscv64] - os: [linux] - - '@typescript/typescript-linux-s390x@7.0.2': - resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} - engines: {node: '>=16.20.0'} - cpu: [s390x] - os: [linux] - - '@typescript/typescript-linux-x64@7.0.2': - resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} - engines: {node: '>=16.20.0'} + cpd-linux-x64-gnu@5.0.11: + resolution: {integrity: sha512-rQ7DuF0lH1HLzjGxlE0aEP2ycfhXgZH/CLSeS7FXNJ38lRVp+iXkrlcrrY6mC4WW/NgbL+DkF7/0lv3tFvGmvg==} cpu: [x64] os: [linux] + libc: [glibc] - '@typescript/typescript-netbsd-arm64@7.0.2': - resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [netbsd] - - '@typescript/typescript-netbsd-x64@7.0.2': - resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} - engines: {node: '>=16.20.0'} + cpd-linux-x64-musl@5.0.11: + resolution: {integrity: sha512-Yh+7Go5+fA++I5ssAZg7gUkDCT5CxnzCPvrspbwDrfnwaY6nNM5g1C6Vs0+GJhsspuAKwydJl4nf7jkxzMwRQw==} cpu: [x64] - os: [netbsd] + os: [linux] + libc: [musl] - '@typescript/typescript-openbsd-arm64@7.0.2': - resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [openbsd] - - '@typescript/typescript-openbsd-x64@7.0.2': - resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [openbsd] - - '@typescript/typescript-sunos-x64@7.0.2': - resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [sunos] - - '@typescript/typescript-win32-arm64@7.0.2': - resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [win32] - - '@typescript/typescript-win32-x64@7.0.2': - resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} - engines: {node: '>=16.20.0'} + cpd-windows-x64-msvc@5.0.11: + resolution: {integrity: sha512-uV6w85qdfE0WJsrLcGw9A4Kv9ovSnlXZCybMK0esvuiJ7clgaZmDiDPozt5PvrOOShkK/NxzTZSORBSdVnquHA==} cpu: [x64] os: [win32] @@ -297,41 +206,8 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - jscpd-darwin-arm64@5.0.14: - resolution: {integrity: sha512-Ojjl79SBuj9tEW6WbjZ1a/1ZOR89dneH9yLQYQu8WyWaQownttnx7RYFEHU6aGhS4jIvwUEbr+1wxzFTb37cwg==} - cpu: [arm64] - os: [darwin] - - jscpd-darwin-x64@5.0.14: - resolution: {integrity: sha512-DxFg5XvjMZ81iVeqillnM5apqcGCfNTbroNF+mPLr7RkHLGH6mudLgtO+ILL/hfpZXy1bF9oIY5BSudPmN/k9A==} - cpu: [x64] - os: [darwin] - - jscpd-linux-arm64-gnu@5.0.14: - resolution: {integrity: sha512-1uw+XBHEt9pONXNICSp5HpaVWPjG6mQ6deDXaq9Yb0xCNJkX4/8gmn0vhzekIyZD2DspRYKPUolbDsqm/HEdYg==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - jscpd-linux-x64-gnu@5.0.14: - resolution: {integrity: sha512-dFTbyyrm+Z9pcXIVzJQCw8QAgiNqIiO69sm4AfA7/wFdPoizoVzjhaXsYXcSV4bs0aoPiWbNazg0J0HgslT/5A==} - cpu: [x64] - os: [linux] - libc: [glibc] - - jscpd-linux-x64-musl@5.0.14: - resolution: {integrity: sha512-SayS7qQJvixyy9eR0+UjepkTsUUwqvlsiuSxfIdHgG2qzqoh/thnkgiu4By8fsiiDpQONsQrRrZDwHRQ3GDrBQ==} - cpu: [x64] - os: [linux] - libc: [musl] - - jscpd-windows-x64-msvc@5.0.14: - resolution: {integrity: sha512-DqjxlVkUanlahGgY2lY7Zkrau4BUTI+AwWky+bPGK4kSK2AIOaUziY9Q19u8b58idXmJA9FKK98Fuu4ajNXVjQ==} - cpu: [x64] - os: [win32] - - jscpd@5.0.14: - resolution: {integrity: sha512-zge+FPZZAymt2Do5Z0+QHyIn4/XcUhrO/W7of9HcHZfx2AK8++dYhLA1uWtwXj47ml3Of8PbcUW4wUWvYMCc3w==} + jscpd@5.0.11: + resolution: {integrity: sha512-NfLrFJHRM6rIf3oVcdZ4sfhMVop1qxi5r8aC99lpj55YC8hiWaN4VmzU2wcXTwoAo+NS4npXPs9EVEQJ6jyRlg==} engines: {node: '>=18'} hasBin: true @@ -441,9 +317,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - typescript@7.0.2: - resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} - engines: {node: '>=16.20.0'} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} hasBin: true undici-types@8.3.0: @@ -492,11 +368,6 @@ packages: yaml: optional: true - yaml@2.9.0: - resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} - engines: {node: '>= 14.6'} - hasBin: true - snapshots: '@emnapi/core@1.11.1': @@ -584,64 +455,22 @@ snapshots: dependencies: undici-types: 8.3.0 - '@typescript/typescript-aix-ppc64@7.0.2': + cpd-darwin-arm64@5.0.11: optional: true - '@typescript/typescript-darwin-arm64@7.0.2': + cpd-darwin-x64@5.0.11: optional: true - '@typescript/typescript-darwin-x64@7.0.2': + cpd-linux-arm64-gnu@5.0.11: optional: true - '@typescript/typescript-freebsd-arm64@7.0.2': + cpd-linux-x64-gnu@5.0.11: optional: true - '@typescript/typescript-freebsd-x64@7.0.2': + cpd-linux-x64-musl@5.0.11: optional: true - '@typescript/typescript-linux-arm64@7.0.2': - optional: true - - '@typescript/typescript-linux-arm@7.0.2': - optional: true - - '@typescript/typescript-linux-loong64@7.0.2': - optional: true - - '@typescript/typescript-linux-mips64el@7.0.2': - optional: true - - '@typescript/typescript-linux-ppc64@7.0.2': - optional: true - - '@typescript/typescript-linux-riscv64@7.0.2': - optional: true - - '@typescript/typescript-linux-s390x@7.0.2': - optional: true - - '@typescript/typescript-linux-x64@7.0.2': - optional: true - - '@typescript/typescript-netbsd-arm64@7.0.2': - optional: true - - '@typescript/typescript-netbsd-x64@7.0.2': - optional: true - - '@typescript/typescript-openbsd-arm64@7.0.2': - optional: true - - '@typescript/typescript-openbsd-x64@7.0.2': - optional: true - - '@typescript/typescript-sunos-x64@7.0.2': - optional: true - - '@typescript/typescript-win32-arm64@7.0.2': - optional: true - - '@typescript/typescript-win32-x64@7.0.2': + cpd-windows-x64-msvc@5.0.11: optional: true detect-libc@2.1.2: {} @@ -653,32 +482,14 @@ snapshots: fsevents@2.3.3: optional: true - jscpd-darwin-arm64@5.0.14: - optional: true - - jscpd-darwin-x64@5.0.14: - optional: true - - jscpd-linux-arm64-gnu@5.0.14: - optional: true - - jscpd-linux-x64-gnu@5.0.14: - optional: true - - jscpd-linux-x64-musl@5.0.14: - optional: true - - jscpd-windows-x64-msvc@5.0.14: - optional: true - - jscpd@5.0.14: + jscpd@5.0.11: optionalDependencies: - jscpd-darwin-arm64: 5.0.14 - jscpd-darwin-x64: 5.0.14 - jscpd-linux-arm64-gnu: 5.0.14 - jscpd-linux-x64-gnu: 5.0.14 - jscpd-linux-x64-musl: 5.0.14 - jscpd-windows-x64-msvc: 5.0.14 + cpd-darwin-arm64: 5.0.11 + cpd-darwin-x64: 5.0.11 + cpd-linux-arm64-gnu: 5.0.11 + cpd-linux-x64-gnu: 5.0.11 + cpd-linux-x64-musl: 5.0.11 + cpd-windows-x64-msvc: 5.0.11 lightningcss-android-arm64@1.32.0: optional: true @@ -772,32 +583,11 @@ snapshots: tslib@2.8.1: optional: true - typescript@7.0.2: - optionalDependencies: - '@typescript/typescript-aix-ppc64': 7.0.2 - '@typescript/typescript-darwin-arm64': 7.0.2 - '@typescript/typescript-darwin-x64': 7.0.2 - '@typescript/typescript-freebsd-arm64': 7.0.2 - '@typescript/typescript-freebsd-x64': 7.0.2 - '@typescript/typescript-linux-arm': 7.0.2 - '@typescript/typescript-linux-arm64': 7.0.2 - '@typescript/typescript-linux-loong64': 7.0.2 - '@typescript/typescript-linux-mips64el': 7.0.2 - '@typescript/typescript-linux-ppc64': 7.0.2 - '@typescript/typescript-linux-riscv64': 7.0.2 - '@typescript/typescript-linux-s390x': 7.0.2 - '@typescript/typescript-linux-x64': 7.0.2 - '@typescript/typescript-netbsd-arm64': 7.0.2 - '@typescript/typescript-netbsd-x64': 7.0.2 - '@typescript/typescript-openbsd-arm64': 7.0.2 - '@typescript/typescript-openbsd-x64': 7.0.2 - '@typescript/typescript-sunos-x64': 7.0.2 - '@typescript/typescript-win32-arm64': 7.0.2 - '@typescript/typescript-win32-x64': 7.0.2 + typescript@6.0.3: {} undici-types@8.3.0: {} - vite@8.1.0(@types/node@26.0.1)(yaml@2.9.0): + vite@8.1.0(@types/node@26.0.1): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -807,6 +597,3 @@ snapshots: optionalDependencies: '@types/node': 26.0.1 fsevents: 2.3.3 - yaml: 2.9.0 - - yaml@2.9.0: {} diff --git a/renovate.json b/renovate.json deleted file mode 100644 index 7190a60..0000000 --- a/renovate.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json" -} diff --git a/src/lib.rs b/src/lib.rs index c87be5b..e1325bc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,18 +1,13 @@ pub use mtp_codec as codec; pub use mtp_common as common; +pub use mtp_transport as transport; pub use mtp_type_map as type_map; #[cfg(feature = "crypto")] pub use mtp_crypto as crypto; -#[cfg(any(feature = "host", feature = "web-server"))] +#[cfg(feature = "host")] pub use mtp_host as host; #[cfg(feature = "client")] pub use mtp_client as client; - -#[cfg(feature = "files")] -pub use mtp_files as files; - -#[cfg(feature = "web-server")] -pub use mtp_webserver as webserver; diff --git a/src/sdk/client.ts b/src/sdk/client.ts deleted file mode 100644 index e0307cd..0000000 --- a/src/sdk/client.ts +++ /dev/null @@ -1,2760 +0,0 @@ -// Private SDK client implementation. The public facade remains in index.ts. -import { - ConnectionConfig, - ConnectionState, - WasmClient, - WasmPipeHandle, - keyring_generate, -} from "mtp/raw"; -import * as bindings from "mtp/raw"; -import { unixTimeMillis, utf8Encode } from "./utils.js"; -import type * as RawBindings from "../raw/index"; -import type { MTPCommunicationType } from "../type-map/index"; -import { MTPProtocol } from "./schema.js"; -import type { - MTPMessageType, - MTPFrame, - MTPNoSchemas, - MTPRequestData, - MTPResponseFrame, - MTPSchemaRegistry, -} from "./schema.js"; -import type { MTPSessionStorage, MTPSessionState } from "./session"; -import { MTPSessionManager } from "./session.js"; -import { - assertApplicationCommunicationType, - assertKnownCommunicationType, - base64ToBytes, - bytesFrom, - bytesToBase64, - cloneParsedFrame, - cloneParsedValue, - codec, - crypto, - decode, - decodeDataValueWithLimits, - decodeWithLimits, - encode, - encodeMTPDataValue, - errorMessage, - format, - inputU64, - isBytes, - keyringToKeys, - normalizeBytes, - parseProtectedFrame, - protectedFrameBytes, - publicKeyBundleToKeys, - secretKeyFromString, - legacySecretKeyFromStringV1, - deriveKeyFromPassphraseSync, - validateMTPDataValue, -} from "./codec.js"; -import type { - MTPEncryptedSecretRecord, - MTPEncryptedSecretProvider, -} from "./encrypted-secret"; -import { InMemoryEncryptedSecretProvider } from "./encrypted-secret.js"; -import { InMemorySessionStorage } from "./session.js"; -import { - publicCredentials, - zeroCredentials, -} from "./credentials.js"; -import type { InternalCredentials } from "./credentials.js"; -import { withTimeout } from "./timeout.js"; -import { initWasmOnce } from "./wasm-init.js"; -import { - acceptMTPPipeSession, - acceptMTPPipeSessionAuto, - acceptMTPForwardSecurePipeSession, - initiateMTPForwardSecurePipeSession, - initiateMTPPipeSession, - MTPEncryptedPipeReader, - MTPEncryptedPipeWriter, - validateApplicationProtectionPurpose, -} from "./encrypted-pipe.js"; -import { - InMemoryReplayGuard, - MTPReplayError, - effectiveProtectionSignatureSuite, - normalizeRecipientBundles, - protectedOpeningError, - protectionSignatureSuiteValue, - resolveDecryptionIdentity, - resolveProtectionIdentity, -} from "./protection.js"; -import type { - ResolvedDecryptionIdentity, - SignerResolutionOptions, -} from "./protection.js"; -import { - MTPVerifiedRelayMetadata, - RELAY_METADATA_TOKEN, - relayMetadataState, - relayOpeningError, - registerRelayMetadata, -} from "./relay.js"; -import type { MTPRelayMetadataState } from "./relay.js"; -import { - DEFAULT_SIGNATURE_VERIFICATION_POLICY, - MTPSignatureVerificationError, - resolveSignatureVerificationPolicy, - signatureVerificationPolicyValue, - signerKeysUnavailable, -} from "./signature-policy.js"; -import type { MTPSignatureVerificationPolicy } from "./signature-policy.js"; -export type { - MTPSignatureVerificationErrorCode, - MTPSignatureVerificationPolicy, -} from "./signature-policy.js"; -export { - DEFAULT_SIGNATURE_VERIFICATION_POLICY, - MTPSignatureVerificationError, - resolveSignatureVerificationPolicy, -} from "./signature-policy.js"; - -export type StorageValue = string | null; - -export interface MTPCredentialStorage { - getItem(key: string): StorageValue | Promise; - setItem(key: string, value: string): void | Promise; - removeItem(key: string): void | Promise; -} - -export type MTPStorage = MTPCredentialStorage; - -export type MTPLogEvent = - | { - hint: "info" | "warning"; - type: string; - data: unknown; - direction?: "send" | "recv"; - } - | { - hint: "error"; - type: string | "error"; - error: string; - data?: unknown; - direction?: "send" | "recv"; - }; - -export type ParsedFrame = RawBindings.ParsedFrame; - -export type Ed25519GenerateResult = ReturnType< - typeof bindings.ed25519_generate ->; - -export type WasmEncapsulated = RawBindings.WasmEncapsulated; - -export interface MTPCrypto { - generateKeyring(): Uint8Array; - generateEd25519(): Ed25519GenerateResult; - keyringFromEd25519(secretKey: Uint8Array, publicKey: Uint8Array): Uint8Array; - verifyEd25519( - publicKey: Uint8Array, - message: Uint8Array, - signature: Uint8Array, - ): void; - deriveEncryptionKey( - ikm: Uint8Array, - salt: Uint8Array, - context: Uint8Array, - ): Uint8Array; - hkdfExpand( - ikm: Uint8Array, - salt: Uint8Array, - info: Uint8Array, - len: number, - ): Uint8Array; - sha256(data: Uint8Array): Uint8Array; - sha256Double(data: Uint8Array): Uint8Array; - keyringToKeys(keyring: MTPKeyMaterialInput): MTPKeyringKeys; - publicKeyBundleToKeys( - publicKeyBundle: MTPKeyMaterialInput, - ): MTPPublicKeyBundleKeys; - encrypt(key: Uint8Array, input: Uint8Array): Promise; - decrypt(key: Uint8Array, input: Uint8Array): Promise; - encryptText(key: Uint8Array, plaintext: string): Promise; - decryptText(key: Uint8Array, ciphertext: string): Promise; - encapsulate(otherPublicKey: Uint8Array): WasmEncapsulated; - decapsulate(ownPrivateKey: Uint8Array, ciphertext: Uint8Array): Uint8Array; -} - -export type MTPRawBindings = typeof bindings; - -export interface MTPRaw { - /** - * Underlying generated WASM client instance. - * - * Prefer the `MTPClient` methods for application code. Calling the raw client - * bypasses SDK-level validation, credential persistence, logging, timeout - * handling, frame parsing helpers, and ping lifecycle management. Use this - * escape hatch only when integrating a feature that the SDK wrapper does not - * expose yet. - */ - client: RawBindings.WasmClient; - - /** - * Generated WASM binding module exported by `mtp/raw`. - * - * These bindings mirror the lower-level WASM API and can change shape as the - * generated interface evolves. Prefer the SDK wrapper where possible so your - * code keeps the safer, typed MTPClient flow instead of depending directly on - * transport internals. - */ - bindings: MTPRawBindings; -} - -export type MTPBytesInput = Uint8Array | number[]; - -/** A textual key/blob input whose wire encoding is selected explicitly. */ -export interface MTPEncodedBytesInput { - value: string; - encoding: "hex" | "base64"; -} - -export type MTPKeyMaterialInput = MTPBytesInput | MTPEncodedBytesInput; - -export interface MTPCodecOptions { - id?: number; - sender?: bigint | number; - receiver?: bigint | number; -} - -export interface MTPCodec { - encode( - type: MTPCommunicationType, - data: Record, - options?: MTPCodecOptions, - ): Uint8Array; - decode(frame: MTPBytesInput): ParsedFrame; - format(frame: MTPBytesInput): string; -} - -export interface MTPCredentials { - clientId: bigint | string | number | null; - keyring: MTPBytesInput; - hostPublicKey?: MTPKeyMaterialInput; -} - -export interface MTPClientCredentials { - clientId: bigint | null; - keyring: Uint8Array; - hostPublicKey?: Uint8Array; -} - -export interface MTPKeyringKeys { - kemPublicKey: Uint8Array; - kemSecretKey: Uint8Array; - sigPqPublicKey: Uint8Array; - sigPqSecretKey: Uint8Array; - sigClPublicKey: Uint8Array; - sigClSecretKey: Uint8Array; -} - -export interface MTPPublicKeyBundleKeys { - kemPublicKey: Uint8Array; - sigPqPublicKey: Uint8Array; - sigClPublicKey: Uint8Array; -} - -export interface MTPClientOptions< - Registry extends MTPSchemaRegistry = MTPNoSchemas, -> { - url: string; - descriptor?: string; - hostPublicKey?: MTPKeyMaterialInput; - credentials?: MTPCredentials | string | null; - credentialsStorageKey?: string; - storage?: MTPCredentialStorage; - serverCertificateHashes?: string[]; - maxMessageSize?: number; - authTimeoutMs?: number; - /** Require the hybrid PQ authentication proof when the host supports it. */ - requirePq?: boolean; - requestTimeoutMs?: number; - pings?: boolean | { intervalMs?: number }; - wasm?: - | RawBindings.InitInput - | Promise - | { - module_or_path: RawBindings.InitInput | Promise; - }; - logger?: (event: MTPLogEvent) => void; - sessionStorage?: MTPSessionStorage; - /** - * Independent caller-managed encrypted-secret storage. Session state is not - * routed through this provider automatically. - */ - encryptedSecretProvider?: MTPEncryptedSecretProvider; - /** Default receiver policy for protected signatures. */ - defaultSignatureVerificationPolicy?: MTPSignatureVerificationPolicy; - /** Coherent security defaults for protected messages, encrypted pipes, and authentication. */ - securityProfile?: MTPSecurityProfile; - /** One receive resource policy shared by frame and protected-value opening. */ - receiveLimits?: MTPReceiveLimits; - /** Application request and response schemas, keyed by communication type. */ - schemas?: Registry; - /** Reject `request()` when the correlated response is an `Error*` frame. */ - throwProtocolErrors?: boolean; - /** Receives subscription validation failures. Request failures reject normally. */ - onValidationError?: (error: import("./schema.js").MTPValidationError) => void; -} - -export interface MTPSecurityProfile { - /** Signature suite used by protected-message and relay senders. */ - protectedSignatureSuite?: MTPProtectionSignatureSuite; - /** Receiver policy for protected messages and relay metadata/content. */ - protectedSignaturePolicy?: MTPSignatureVerificationPolicy; - /** Signature suite used by encrypted-pipe senders. */ - encryptedPipeSignatureSuite?: MTPProtectionSignatureSuite; - /** Receiver policy used by encrypted-pipe acceptors. */ - encryptedPipeSignaturePolicy?: MTPSignatureVerificationPolicy; - /** Authentication PQ requirement when the host supports the hybrid proof. */ - requirePq?: boolean; -} - -export interface MTPEncodeLimits { - maxDepth?: number; - maxValues?: number; - maxOutputSize?: number; -} - -/** Resource limits forwarded to the bounded native/WASM receive decoder. */ -export interface MTPReceiveLimits { - maxDepth?: number; - maxValues?: number; - maxBlobSize?: number; - maxRecipients?: number; - maxAllocatedBytes?: number; - /** Maximum reconstructed signed-value encoding size. */ - maxOutputSize?: number; - maxMessageIdBytes?: number; - maxMetadataEncodedBytes?: number; - maxSignerKeyHistory?: number; - maxDecryptionKeyHistory?: number; -} - -export type Unsubscribe = () => void; - -export interface MTPFrameIdOptions { - id?: number; -} - -export interface MTPAddressedFrameOptions extends MTPFrameIdOptions { - sender?: bigint | number; - receiver?: bigint | number; -} - -export interface MTPSendOptions extends MTPAddressedFrameOptions {} - -export interface MTPProtectionIdentity { - signerId: bigint | number | string; - keyring: MTPKeyMaterialInput; -} - -/** - * Key material used to open protected MTP values. - * - * This identity is independent from transport authentication. Its optional - * ID is used only for structural destination checks when a receive operation - * supports one. - */ -export interface MTPDecryptionIdentity { - id?: bigint | number | string; - keyring: MTPKeyMaterialInput; - /** - * Previously used recipient keyrings, ordered newest to oldest. The - * current keyring is always attempted first. - */ - keyringHistory?: MTPKeyMaterialInput[]; -} - -/** Decoded value returned by the MTP DataValue codec. */ -export type MTPDataValue = RawBindings.ParsedDataValue; - -/** JavaScript values accepted by the MTP DataValue encoder. */ -export type MTPDataValueInput = - | null - | boolean - | number - | bigint - | string - | Uint8Array - | MTPDataValueInput[] - | { [key: string]: MTPDataValueInput }; - -/** Public-key material trusted for one protected signer identity. */ -export type MTPResolvedSignerKeys = MTPKeyMaterialInput[]; - -/** - * Resolve trusted public-key bundles for a claimed, unverified signer ID. - * The ID is used only as a trusted-key lookup key and becomes authenticated - * after the native protected codec verifies the signature. - */ -export type MTPSignerKeyResolver = ( - signerId: bigint, -) => MTPResolvedSignerKeys | Promise; - -export interface MTPRelayPlan { - nextHopId: bigint | number | string; - finalRecipientId: bigint | number | string; - metadataRecipients: MTPKeyMaterialInput[]; - contentRecipients: MTPKeyMaterialInput[]; - metadata?: MTPDataValueInput; -} - -export interface MTPSendProtectedOptions extends MTPFrameIdOptions { - receiverId: bigint | number | string; - identity?: MTPProtectionIdentity; - recipients: MTPKeyMaterialInput[]; - signaturePurpose: number; - encryptionPurpose: number; - signatureSuite?: MTPProtectionSignatureSuite; - exposeSender?: boolean; - /** Semantic protected-field limits applied before the WASM boundary. */ - limits?: MTPReceiveLimits; -} - -export interface MTPSendSealedRelayOptions extends MTPRelayPlan { - identity?: MTPProtectionIdentity; - signatureSuite?: MTPProtectionSignatureSuite; - /** Semantic protected/relay limits applied before the WASM boundary. */ - limits?: MTPReceiveLimits; -} - -export interface MTPRelayVerificationOptions { - /** Key material used for protected opening, independent from transport auth. */ - recipient?: MTPDecryptionIdentity; - /** Require the protected signer to be this MTP identity. */ - expectedSignerId?: bigint | number | string; - /** Resolve trusted keys for a claimed, unverified signer lookup ID. */ - resolveSignerPublicKeys?: MTPSignerKeyResolver; - /** Receiver policy applied to relay metadata and content signatures. */ - signaturePolicy?: MTPSignatureVerificationPolicy; - /** Override the client's receive resource policy for this operation. */ - limits?: MTPReceiveLimits; -} - -export interface MTPOpenRelayMetadataOptions extends MTPRelayVerificationOptions { - /** Override the default process-local guard with an application-owned guard. */ - replayGuard?: MTPReplayGuard; -} - -export interface MTPOpenRelayContentOptions extends MTPRelayVerificationOptions { - /** Validate the authenticated final recipient when supplied. */ - expectedFinalRecipientId?: bigint | number | string; -} - -export interface MTPOpenProtectedOptions { - recipient?: MTPDecryptionIdentity; - - expectedSignerId?: bigint | number | string; - expectedReceiverId?: bigint | number | string; - - resolveSignerPublicKeys: MTPSignerKeyResolver; - - signaturePolicy?: MTPSignatureVerificationPolicy; - - signaturePurpose: number; - encryptionPurpose: number; - - /** Override the default process-local replay guard for durable storage. */ - replayGuard?: MTPReplayGuard; - /** Override the client's receive resource policy for this operation. */ - limits?: MTPReceiveLimits; -} - -export interface MTPVerifiedProtectedMessage { - type: string; - - /** Authenticated direct-message envelope schema version. */ - protectedVersion: number; - - signerId: bigint; - - /** Authenticated destination from the protected envelope. */ - finalRecipientId: bigint; - - /** Authenticated application message identifier. */ - messageId: string; - - /** Authenticated Unix epoch timestamp in milliseconds. */ - createdAt: bigint; - - receiver?: bigint; - outerSender?: bigint; - - data: T; -} - -export type MTPProtectedFrameInput = ParsedFrame | MTPBytesInput; - -/** Options shared by the sealed-relay subscription APIs. */ -export interface MTPEncryptedSubscriptionOptions - extends MTPOpenRelayContentOptions { - /** Consume authenticated relay IDs while dispatching the subscription. */ - replayGuard?: MTPReplayGuard; -} - -export type MTPProtectionSignatureSuite = "ed25519" | "dual"; - -export interface MTPReplayGuard { - /** - * Return true and atomically record the pair when it has not been seen. - * `createdAt` is authenticated metadata for retention/observability; the - * replay identity is only `(signerId, messageId)`. - */ - accept( - signerId: bigint, - messageId: string, - createdAt: bigint, - ): boolean | Promise; -} - -/** - * Bounded process-local duplicate-suppression guard used by high-level direct - * protected and relay receives when the caller does not provide durable - * storage. Once the fixed cache is full, the oldest entry is evicted and may - * be accepted again; use a durable `MTPReplayGuard` for security-sensitive - * replay protection that must survive cache eviction, reloads, or multiple - * receiver processes. - */ -export interface MTPVerifiedRelayContent { - type: string; - data: MTPDataValue; - signerId: bigint; - finalRecipientId: bigint; - messageId: string; - /** Unix epoch milliseconds from the authenticated relay metadata. */ - createdAt: bigint; - metadata?: MTPDataValue; -} - -export interface MTPRequestOptions extends MTPSendOptions { - responseType?: MTPCommunicationType; - timeoutMs?: number; -} - -export interface MTPPipeWriter { - write(data: Uint8Array): Promise; - close(): Promise; - abort(): void; - readonly pipeId: number; -} - -export interface MTPPipeReader { - read(): Promise; - readonly pipeId: number; - readonly description: string; -} - -export interface MTPPipeRequest { - pipeId: number; - description: string; -} - -export interface MTPOutgoingPipeHandle { - readonly pipeId: number; - readonly description: string; - wait(): Promise; -} - -export interface MTPCreateEncryptedPipeOptions { - recipientId: bigint | number | string; - recipientPublicKey?: MTPKeyMaterialInput; - recipientPublicKeys?: MTPKeyMaterialInput[]; - description?: string; - purpose?: number; - direction?: number; - signatureSuite?: MTPProtectionSignatureSuite; -} - -export interface MTPAcceptEncryptedPipeOptions { - senderId: bigint | number | string; - senderPublicKey?: MTPKeyMaterialInput; - senderPublicKeys?: MTPKeyMaterialInput[]; - purpose?: number; - direction?: number; - signaturePolicy?: MTPSignatureVerificationPolicy; -} - -type NormalizedMTPClientOptions = Omit< - MTPClientOptions, - "hostPublicKey" | "receiveLimits" -> & { - hostPublicKey?: Uint8Array; - receiveLimits?: MTPReceiveLimits; - receiveLimitsExplicit: boolean; - securityProfile: ResolvedSecurityProfile; -}; - -interface ResolvedSecurityProfile { - protectedSignatureSuite: MTPProtectionSignatureSuite; - protectedSignaturePolicy: MTPSignatureVerificationPolicy; - encryptedPipeSignatureSuite: MTPProtectionSignatureSuite; - encryptedPipeSignaturePolicy: MTPSignatureVerificationPolicy; - requirePq: boolean; -} - -const DEFAULT_CREDENTIALS_KEY = "mtp:credentials"; -const DEFAULT_MAX_MESSAGE_SIZE = 16 * 1024 * 1024; -const DEFAULT_MAX_DEPTH = 64; -const DEFAULT_MAX_VALUES = 65_536; -const DEFAULT_MAX_RECIPIENTS = 64; -/** Must match codec::DEFAULT_TRANSPORT_ALLOCATION_FACTOR. */ -export const DEFAULT_TRANSPORT_ALLOCATION_FACTOR = 4; - -function createMessageId(): string { - const bytes = new Uint8Array(16); - globalThis.crypto.getRandomValues(bytes); - return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join( - "", - ); -} - -function emit( - logger: MTPClientOptions["logger"] | undefined, - event: MTPLogEvent, -): void { - if (typeof logger === "function") { - logger(event); - } -} - -function isErrorType(type: string): boolean { - return ( - type === "Error" || - type.startsWith("Error") || - [ - "BadRequest", - "Unauthorized", - "Forbidden", - "NotFound", - "TooManyRequests", - "InternalServerError", - "BadGateway", - "ServiceUnavailable", - "GatewayTimeout", - ].includes(type) - ); -} - -async function storageGet( - storage: MTPCredentialStorage | undefined, - key: string, -): Promise { - return storage ? await storage.getItem(key) : null; -} - -async function storageSet( - storage: MTPCredentialStorage | undefined, - key: string, - value: string, -): Promise { - if (storage) { - await storage.setItem(key, value); - } -} - -async function storageRemove( - storage: MTPCredentialStorage | undefined, - key: string, -): Promise { - if (storage) { - await storage.removeItem(key); - } -} - -function normalizeCredentials( - value: MTPCredentials | string | null, -): MTPCredentials | null { - if (!value) { - return null; - } - - if (typeof value === "string") { - return JSON.parse(value); - } - - return value; -} - -function toBigInt( - value: bigint | string | number | null | undefined, -): bigint | null { - if (value == null || value === "") { - return null; - } - return inputU64(value, "clientId"); -} - -function generateKeyringBytes() { - const checkedGenerator = ( - bindings as typeof bindings & { - keyring_generate_checked?: () => Uint8Array; - } - ).keyring_generate_checked; - return checkedGenerator?.() ?? keyring_generate(); -} - -function serializeCredentials(credentials) { - return JSON.stringify({ - clientId: credentials.clientId?.toString() ?? null, - keyring: Array.from(credentials.keyring ?? []), - hostPublicKey: credentials.hostPublicKey - ? Array.from(credentials.hostPublicKey) - : undefined, - }); -} - -const RECEIVE_LIMIT_KEYS = [ - "maxDepth", - "maxValues", - "maxBlobSize", - "maxRecipients", - "maxAllocatedBytes", - "maxOutputSize", - "maxMessageIdBytes", - "maxMetadataEncodedBytes", - "maxSignerKeyHistory", - "maxDecryptionKeyHistory", -] as const; - -function normalizeReceiveLimits( - value: MTPReceiveLimits | undefined, - name: string, -): MTPReceiveLimits | undefined { - if (value == null) return undefined; - if (typeof value !== "object" || Array.isArray(value)) { - throw new TypeError(`${name} must be an object`); - } - const normalized: MTPReceiveLimits = {}; - for (const key of RECEIVE_LIMIT_KEYS) { - const limit = value[key]; - if (limit == null) continue; - if (!Number.isSafeInteger(limit) || limit < 0) { - throw new TypeError(`${name}.${key} must be a non-negative safe integer`); - } - normalized[key] = limit; - } - return normalized; -} - -function effectiveReceiveLimits( - configured: MTPReceiveLimits | undefined, - maxMessageSize: number, -): MTPReceiveLimits { - const transportBlob = Math.min( - Math.max(0, maxMessageSize - 4), - 0xffff_ffff, - ); - const transportAllocated = - maxMessageSize > Number.MAX_SAFE_INTEGER / DEFAULT_TRANSPORT_ALLOCATION_FACTOR - ? Number.MAX_SAFE_INTEGER - : maxMessageSize * DEFAULT_TRANSPORT_ALLOCATION_FACTOR; - const intersect = (left: number | undefined, right: number): number => - Math.min(left ?? right, right); - return { - ...configured, - maxDepth: intersect(configured?.maxDepth, DEFAULT_MAX_DEPTH), - maxValues: intersect(configured?.maxValues, DEFAULT_MAX_VALUES), - maxBlobSize: intersect(configured?.maxBlobSize, transportBlob), - maxRecipients: intersect(configured?.maxRecipients, DEFAULT_MAX_RECIPIENTS), - maxAllocatedBytes: intersect( - configured?.maxAllocatedBytes, - transportAllocated, - ), - maxOutputSize: intersect(configured?.maxOutputSize, maxMessageSize), - }; -} - -function resolveSecurityProfile(options: MTPClientOptions): ResolvedSecurityProfile { - const profile = options.securityProfile ?? {}; - const suite = (value: unknown, name: string): MTPProtectionSignatureSuite => { - if (value == null) return "ed25519"; - if (value !== "ed25519" && value !== "dual") { - throw new TypeError(`${name} must be 'ed25519' or 'dual'`); - } - return value; - }; - const policy = ( - value: MTPSignatureVerificationPolicy | undefined, - name: string, - ): MTPSignatureVerificationPolicy => { - try { - return resolveSignatureVerificationPolicy(value, undefined); - } catch (error) { - throw new TypeError(`${name} is invalid`, { cause: error }); - } - }; - if (profile.requirePq != null && typeof profile.requirePq !== "boolean") { - throw new TypeError("securityProfile.requirePq must be a boolean"); - } - return { - protectedSignatureSuite: suite( - profile.protectedSignatureSuite, - "securityProfile.protectedSignatureSuite", - ), - protectedSignaturePolicy: policy( - profile.protectedSignaturePolicy, - "securityProfile.protectedSignaturePolicy", - ), - encryptedPipeSignatureSuite: suite( - profile.encryptedPipeSignatureSuite, - "securityProfile.encryptedPipeSignatureSuite", - ), - encryptedPipeSignaturePolicy: policy( - profile.encryptedPipeSignaturePolicy, - "securityProfile.encryptedPipeSignaturePolicy", - ), - requirePq: profile.requirePq ?? true, - }; -} - -type BoundedBindings = typeof bindings & { - protected_claimed_signer_id_with_limits?: ( - frame: Uint8Array, - keyrings: Uint8Array[], - encryptionPurpose: number, - limits: MTPReceiveLimits, - ) => bigint; - open_protected_with_keyrings_with_limits_without_replay?: ( - frame: Uint8Array, - keyrings: Uint8Array[], - expectedSignerId: bigint, - signerPublicKeys: Uint8Array[], - expectedReceiverId: bigint | null, - signaturePurpose: number, - encryptionPurpose: number, - signatureSuite: number, - limits: MTPReceiveLimits, - ) => RawBindings.WasmVerifiedProtectedMessage; - relay_metadata_claimed_signer_id_with_limits?: ( - frame: Uint8Array, - keyrings: Uint8Array[], - limits: MTPReceiveLimits, - ) => bigint; - open_relay_metadata_with_keyrings_with_limits_without_replay?: ( - frame: Uint8Array, - keyrings: Uint8Array[], - expectedSignerId: bigint, - signerPublicKeys: Uint8Array[], - signatureSuite: number, - limits: MTPReceiveLimits, - ) => RawBindings.WasmVerifiedRelayMetadata; - open_relay_content_with_keyrings_with_limits_without_replay?: ( - metadata: RawBindings.WasmVerifiedRelayMetadata, - keyrings: Uint8Array[], - signerPublicKeys: Uint8Array[], - expectedFinalRecipientId: bigint | null, - signatureSuite: number, - limits: MTPReceiveLimits, - ) => RawBindings.WasmVerifiedRelayContent; -}; - -function boundedBindings(): BoundedBindings { - return bindings as unknown as BoundedBindings; -} - -function deserializeCredentials(credentials) { - const normalized = normalizeCredentials(credentials); - if (!normalized) { - return null; - } - - const keyring = normalized.keyring; - if (!isBytes(keyring)) { - throw new TypeError("credentials.keyring must be a Uint8Array or number[]"); - } - - return { - clientId: toBigInt(normalized.clientId), - keyring: bytesFrom(keyring, "credentials.keyring").slice(), - hostPublicKey: - normalized.hostPublicKey == null - ? undefined - : normalizeBytes(normalized.hostPublicKey, "credentials.hostPublicKey").slice(), - }; -} - -function validateOptions(options) { - if (!options || typeof options !== "object") { - throw new TypeError("MTPClient.create requires an options object"); - } - if (typeof options.url !== "string" || !options.url.trim()) { - throw new TypeError("MTPClient.create requires a non-empty url"); - } - if (options.descriptor != null && typeof options.descriptor !== "string") { - throw new TypeError("descriptor must be a string"); - } - resolveSignatureVerificationPolicy( - undefined, - options.defaultSignatureVerificationPolicy, - ); - if ( - options.securityProfile != null && - (typeof options.securityProfile !== "object" || - Array.isArray(options.securityProfile)) - ) { - throw new TypeError("securityProfile must be an object"); - } - resolveSecurityProfile(options); - normalizeReceiveLimits(options.receiveLimits, "receiveLimits"); - if (options.storage) { - for (const method of ["getItem", "setItem", "removeItem"]) { - if (typeof options.storage[method] !== "function") { - throw new TypeError(`storage.${method} must be a function`); - } - } - } - if ( - options.maxMessageSize != null && - (!Number.isSafeInteger(options.maxMessageSize) || - options.maxMessageSize <= 0) - ) { - throw new TypeError("maxMessageSize must be a positive safe integer"); - } - if ( - options.authTimeoutMs != null && - (!Number.isSafeInteger(options.authTimeoutMs) || options.authTimeoutMs <= 0) - ) { - throw new TypeError("authTimeoutMs must be a positive safe integer"); - } - if (options.requirePq != null && typeof options.requirePq !== "boolean") { - throw new TypeError("requirePq must be a boolean"); - } - if ( - options.requestTimeoutMs != null && - (!Number.isSafeInteger(options.requestTimeoutMs) || - options.requestTimeoutMs <= 0) - ) { - throw new TypeError("requestTimeoutMs must be a positive safe integer"); - } - if (options.schemas != null) { - if (typeof options.schemas !== "object" || Array.isArray(options.schemas)) { - throw new TypeError("schemas must be an object"); - } - for (const [type, pair] of Object.entries(options.schemas)) { - if ( - !pair || - typeof pair !== "object" || - typeof (pair as { request?: { parseAsync?: unknown } }).request - ?.parseAsync !== "function" || - typeof (pair as { response?: { parseAsync?: unknown } }).response - ?.parseAsync !== "function" - ) { - throw new TypeError( - `schemas.${type} must contain request and response schemas with parseAsync()`, - ); - } - } - } -} - -export class MTPClient { - static readonly crypto = crypto; - static readonly codec = codec; - - #credentials: InternalCredentials | null; - #options: NormalizedMTPClientOptions; - readonly #protocol: MTPProtocol | undefined; - readonly #protectedReplayGuard = new InMemoryReplayGuard(); - readonly #relayReplayGuard = new InMemoryReplayGuard(); - readonly raw: MTPRaw; - - readonly crypto = MTPClient.crypto; - readonly codec = MTPClient.codec; - - readonly sessionManager: MTPSessionManager; - /** Independent encrypted-secret storage selected by the caller. */ - readonly encryptedSecretProvider: MTPEncryptedSecretProvider; - - private constructor( - options: NormalizedMTPClientOptions, - client: RawBindings.WasmClient, - ) { - this.#options = options; - this.#protocol = options.schemas - ? new MTPProtocol({ - schemas: options.schemas, - throwProtocolErrors: options.throwProtocolErrors, - onValidationError: options.onValidationError, - }) - : undefined; - this.#credentials = deserializeCredentials(options.credentials); - this.raw = { client, bindings }; - this.encryptedSecretProvider = - options.encryptedSecretProvider ?? new InMemoryEncryptedSecretProvider(); - this.sessionManager = new MTPSessionManager( - options.sessionStorage ?? new InMemorySessionStorage(), - ); - } - - static async create< - const Registry extends MTPSchemaRegistry = MTPNoSchemas, - >( - options: MTPClientOptions, - ): Promise> { - validateOptions(options); - await MTPClient.init(options.wasm); - - const normalizedOptions = { - ...options, - hostPublicKey: - options.hostPublicKey == null - ? undefined - : normalizeBytes(options.hostPublicKey, "hostPublicKey").slice(), - receiveLimits: effectiveReceiveLimits( - normalizeReceiveLimits(options.receiveLimits, "receiveLimits"), - options.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE, - ), - // Always retain and apply the effective transport policy. Even when the - // caller did not provide overrides, protected/relay opening must not - // fall back to a larger codec default after frame admission. - receiveLimitsExplicit: true, - securityProfile: resolveSecurityProfile(options), - }; - - let sdk: MTPClient | undefined; - const client = new WasmClient( - (state) => - emit(normalizedOptions.logger, { - hint: "info", - type: "state", - data: ConnectionState[state] ?? state, - }), - (frame) => { - if (sdk) { - sdk.#handleFrame(frame); - } - }, - (error) => - emit(normalizedOptions.logger, { - hint: "error", - type: "Error", - error: String(error), - }), - ); - - if (normalizedOptions.receiveLimits) { - const rawClient = client as unknown as { - set_receive_limits?: (limits: MTPReceiveLimits) => void; - setReceiveLimits?: (limits: MTPReceiveLimits) => void; - }; - const setReceiveLimits = - rawClient.set_receive_limits ?? rawClient.setReceiveLimits; - if (!setReceiveLimits) { - throw new Error( - "effective receive limits require a rebuilt bounded WASM package", - ); - } - setReceiveLimits.call(rawClient, normalizedOptions.receiveLimits); - } - - sdk = new MTPClient(normalizedOptions, client); - await sdk.#loadStoredCredentials(); - if (!sdk.#credentials) { - sdk.#credentials = { - clientId: null, - keyring: generateKeyringBytes(), - hostPublicKey: normalizedOptions.hostPublicKey, - }; - } else if ( - !sdk.#credentials.hostPublicKey && - normalizedOptions.hostPublicKey - ) { - sdk.#credentials = { - ...sdk.#credentials, - hostPublicKey: normalizedOptions.hostPublicKey, - }; - } else if ( - !normalizedOptions.hostPublicKey && - sdk.#credentials.hostPublicKey - ) { - sdk.#options = { - ...sdk.#options, - hostPublicKey: sdk.#credentials.hostPublicKey, - }; - } - return sdk; - } - - static isSupported(): boolean { - return WasmClient.is_supported(); - } - - static async init( - wasm?: MTPClientOptions["wasm"], - ): Promise>> { - return await initWasmOnce(wasm); - } - - get credentials(): MTPClientCredentials | null { - return publicCredentials(this.#credentials); - } - - get defaultSignatureVerificationPolicy(): MTPSignatureVerificationPolicy { - return resolveSignatureVerificationPolicy( - undefined, - this.#options.defaultSignatureVerificationPolicy ?? - this.#options.securityProfile.protectedSignaturePolicy, - ); - } - - get state(): RawBindings.ConnectionState { - return this.raw.client.state; - } - - get pingMs(): number | null { - return this.raw.client.ping_ms ?? null; - } - - async #loadStoredCredentials() { - if (this.#credentials || !this.#options.storage) { - return; - } - - const stored = await storageGet( - this.#options.storage, - this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY, - ); - this.#credentials = deserializeCredentials(stored); - } - - #connectionConfig() { - const config = new ConnectionConfig(this.#options.url); - if (this.#options.serverCertificateHashes) { - config.server_certificate_hashes = this.#options.serverCertificateHashes; - } - if (this.#options.maxMessageSize != null) { - config.max_message_size = this.#options.maxMessageSize; - } - config.require_pq = - this.#options.requirePq ?? this.#options.securityProfile.requirePq; - if (this.#options.descriptor != null) { - config.description = this.#options.descriptor; - } - return config; - } - - async connect(): Promise { - if (this.#credentials?.clientId != null && this.#options.hostPublicKey) { - await this.auth(); - return; - } - - await this.connectUnauthenticated(); - } - - async connectUnauthenticated(): Promise { - const config = this.#connectionConfig(); - try { - // The deprecated raw method clones its borrowed config synchronously and - // remains safe when the SDK timeout wins the race. Keep using it here so - // applications that instrument the historical raw API continue to work. - await withTimeout( - this.raw.client.connect(config), - this.#options.authTimeoutMs, - "connection timed out", - () => this.raw.client.disconnect(), - ); - const clientId = ( - this.raw.client as RawBindings.WasmClient & { readonly client_id: bigint } - ).client_id; - this.#startPings(clientId); - } finally { - config.free(); - } - } - - async auth(): Promise { - if (!this.#options.hostPublicKey) { - throw new Error("MTPClient.auth requires hostPublicKey"); - } - return this.#credentials?.clientId == null - ? await this.register() - : await this.#connectAuthenticated(); - } - - async #connectAuthenticated() { - if (!this.#options.hostPublicKey) { - throw new Error( - "MTPClient.connect requires hostPublicKey for authenticated connections", - ); - } - if ( - !this.#credentials?.keyring?.length || - this.#credentials.clientId == null - ) { - throw new Error( - "MTPClient.connect requires credentials with clientId and keyring", - ); - } - - const config = this.#connectionConfig(); - try { - // Keep the deprecated raw spelling as the compatibility path. The WASM - // wrapper takes owned copies before entering its asynchronous handshake. - const clientId = await withTimeout( - this.raw.client.auth_connect( - config, - this.#options.hostPublicKey, - this.#credentials.keyring, - this.#credentials.clientId, - ), - this.#options.authTimeoutMs, - "authentication timed out", - () => this.raw.client.disconnect(), - ); - this.#credentials = { ...this.#credentials, clientId }; - await this.#persistCredentials(); - this.#startPings(clientId); - return clientId; - } finally { - config.free(); - } - } - - async register(): Promise { - if (!this.#options.hostPublicKey) { - throw new Error("MTPClient.register requires hostPublicKey"); - } - if (!this.#credentials?.keyring?.length) { - this.#credentials = { - clientId: null, - keyring: generateKeyringBytes(), - hostPublicKey: this.#options.hostPublicKey, - }; - } - - const config = this.#connectionConfig(); - try { - const clientId = await withTimeout( - this.raw.client.auth_register( - config, - this.#options.hostPublicKey, - this.#credentials.keyring, - ), - this.#options.authTimeoutMs, - "authentication timed out", - () => this.raw.client.disconnect(), - ); - this.#credentials = { ...this.#credentials, clientId }; - await this.#persistCredentials(); - this.#startPings(clientId); - return clientId; - } finally { - config.free(); - } - } - - async #persistCredentials() { - await storageSet( - this.#options.storage, - this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY, - serializeCredentials(this.#credentials), - ); - } - - async clearCredentials(): Promise { - zeroCredentials(this.#credentials); - this.#credentials = null; - await storageRemove( - this.#options.storage, - this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY, - ); - } - - #startPings(clientId) { - const pings = this.#options.pings; - if (!pings) { - this.raw.client.stop_protocol_pings(); - return; - } - const intervalMs = - typeof pings === "object" ? (pings.intervalMs ?? 30_000) : 30_000; - this.raw.client.start_protocol_pings(intervalMs, clientId); - } - - #encodeLimits(): MTPEncodeLimits { - return { - maxDepth: DEFAULT_MAX_DEPTH, - maxValues: DEFAULT_MAX_VALUES, - maxOutputSize: - this.#options.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE, - }; - } - - async #parseRequestData( - type: MTPCommunicationType, - data: unknown, - ): Promise> { - if (!this.#protocol || !this.#protocol.schemas[type]) { - return (data ?? {}) as Record; - } - const parsed = await this.#protocol.parseRequest( - type as MTPMessageType, - data as never, - ); - return (parsed ?? {}) as Record; - } - - async #parseResponseData( - requestedType: MTPCommunicationType, - frame: ParsedFrame, - phase: "response" | "subscription" = "response", - ): Promise> { - if (!this.#protocol || !this.#protocol.schemas[requestedType]) { - return frame; - } - return await this.#protocol.parseResponse( - requestedType as MTPMessageType, - frame, - phase, - ); - } - - #buildFrame(typeOrFrame, data, options) { - if (typeOrFrame instanceof Uint8Array) { - if ( - typeOrFrame.length > - (this.#options.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE) - ) { - throw new RangeError("MTP frame exceeds maxMessageSize"); - } - return typeOrFrame; - } - if (typeof typeOrFrame !== "string" || !typeOrFrame) { - throw new TypeError( - "message type must be a non-empty string or Uint8Array frame", - ); - } - if (data == null || typeof data !== "object" || Array.isArray(data)) { - throw new TypeError("message data must be an object"); - } - const limits = this.#encodeLimits(); - validateMTPDataValue(data as MTPDataValueInput, limits); - const bounded = ( - this.raw.bindings as typeof bindings & { - build_frame_with_limits?: ( - type: string, - data: Record, - options: MTPCodecOptions, - limits: MTPEncodeLimits, - ) => Uint8Array; - } - ).build_frame_with_limits; - if (!bounded) { - throw new Error("bounded WASM frame encoding is unavailable; rebuild mtp-wasm"); - } - const frame = bounded(typeOrFrame, data, options ?? {}, limits); - if (frame.length > (limits.maxOutputSize ?? DEFAULT_MAX_MESSAGE_SIZE)) { - throw new RangeError("MTP frame exceeds maxMessageSize"); - } - return frame; - } - - async send(message: Uint8Array): Promise; - async send>( - type: Type, - data?: MTPRequestData, - options?: MTPSendOptions, - ): Promise; - async send( - type: MTPCommunicationType, - data: Record, - options?: MTPSendOptions, - ): Promise; - async send( - typeOrFrame: Uint8Array | MTPCommunicationType, - data?: unknown, - options?: MTPSendOptions, - ): Promise { - const parsedData = - typeof typeOrFrame === "string" - ? await this.#parseRequestData(typeOrFrame, data) - : data; - const message = this.#buildFrame(typeOrFrame, parsedData, options); - - try { - const frame = this.raw.bindings.parse_frame(message); - emit( - this.#options.logger, - isErrorType(frame.type) - ? { - hint: "error", - type: frame.type, - error: errorMessage(frame), - data: frame.data, - direction: "send", - } - : { - hint: "info", - type: frame.type, - data: frame.data, - direction: "send", - }, - ); - } catch (error) { - emit(this.#options.logger, { - hint: "error", - type: "Error", - error: String(error), - direction: "send", - }); - } - - await this.raw.client.send(message); - } - - /** Re-route an opaque sealed relay payload without opening it. */ - forwardRelayFrame( - frame: Uint8Array | ParsedFrame, - nextHopId: bigint | number | string, - ): Uint8Array { - const raw = frame instanceof Uint8Array ? frame : frame.raw; - const nextHop = inputU64(nextHopId, "nextHopId"); - return bindings.forward_encrypted_relay_frame(raw, nextHop); - } - - /** Forward an opaque sealed relay frame through this client connection. */ - async forwardRelay( - frame: Uint8Array | ParsedFrame, - nextHopId: bigint | number | string, - ): Promise { - await this.send(this.forwardRelayFrame(frame, nextHopId)); - } - - async request( - message: Uint8Array, - data?: never, - options?: MTPRequestOptions, - ): Promise; - async request>( - type: Type, - data?: MTPRequestData, - options?: MTPRequestOptions, - ): Promise>; - async request( - type: MTPCommunicationType, - data: Record, - options?: MTPRequestOptions, - ): Promise; - async request( - typeOrFrame: Uint8Array | MTPCommunicationType, - data?: unknown, - options: MTPRequestOptions = {}, - ): Promise> { - const timeoutMs = - options.timeoutMs ?? this.#options.requestTimeoutMs ?? 30_000; - if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { - throw new TypeError("request timeoutMs must be a positive safe integer"); - } - const parsedData = - typeof typeOrFrame === "string" - ? await this.#parseRequestData(typeOrFrame, data) - : data; - const frame = this.#buildFrame(typeOrFrame, parsedData, options); - try { - const parsed = this.raw.bindings.parse_frame(frame); - emit( - this.#options.logger, - isErrorType(parsed.type) - ? { - hint: "error", - type: parsed.type, - error: errorMessage(parsed), - data: parsed.data, - direction: "send", - } - : { - hint: "info", - type: parsed.type, - data: parsed.data, - direction: "send", - }, - ); - } catch (error) { - emit(this.#options.logger, { - hint: "error", - type: "Error", - error: String(error), - direction: "send", - }); - } - // The WASM client owns request expiry and its late-response tombstones. - // Keeping a second Promise timer here can reject the SDK call while the - // protocol request is still allowed to complete successfully. - const response = await this.raw.client.request( - frame, - options.responseType ?? null, - timeoutMs, - ); - return typeof typeOrFrame === "string" - ? await this.#parseResponseData(typeOrFrame, response) - : response; - } - - subscribe>( - type: Type, - handler: ( - message: MTPResponseFrame, - ) => void | Promise, - ): Unsubscribe; - subscribe( - type: MTPCommunicationType, - handler: (message: ParsedFrame) => void | Promise, - ): Unsubscribe; - subscribe( - type: MTPCommunicationType, - handler: (message: any) => void | Promise, - ): Unsubscribe { - if (typeof type !== "string" || !type) { - throw new TypeError("subscription type must be a non-empty string"); - } - if (typeof handler !== "function") { - throw new TypeError("subscription handler must be a function"); - } - let active = true; - const id = this.raw.client.subscribe(type, (message) => { - if (!this.#protocol || !this.#protocol.schemas[type]) { - void handler(message); - return; - } - void this.#parseResponseData(type, message, "subscription").then( - (parsed) => { - if (active) void handler(parsed); - }, - (error) => { - this.#protocol?.reportValidationError(error); - }, - ); - }); - return () => { - active = false; - this.raw.client.unsubscribe(id); - }; - } - - #handleFrame(frame) { - if (isErrorType(frame.type)) { - emit(this.#options.logger, { - hint: "error", - type: frame.type, - error: errorMessage(frame), - data: frame.data, - direction: "recv", - }); - } else { - emit(this.#options.logger, { - hint: "info", - type: frame.type, - data: frame.data, - direction: "recv", - }); - } - } - - #getKemPublicKey(): Uint8Array { - if (!this.#credentials?.keyring?.length) { - throw new Error("No keyring available"); - } - const keys = keyringToKeys(this.#credentials.keyring); - return keys.kemPublicKey; - } - - #getKemSecretKey(): Uint8Array { - if (!this.#credentials?.keyring?.length) { - throw new Error("No keyring available"); - } - const keys = keyringToKeys(this.#credentials.keyring); - return keys.kemSecretKey; - } - - #getPublicKeyBundleBytes(): Uint8Array { - if (!this.#credentials?.keyring?.length) { - throw new Error("No keyring available"); - } - const keyring = bindings.WasmKeyring.from_bytes( - this.#credentials.keyring, - ); - try { - const bundle = keyring.public_key_bundle(); - try { - const fallible = ( - bundle as typeof bundle & { try_to_bytes?: () => Uint8Array } - ).try_to_bytes; - if (!fallible) { - throw new Error( - "fallible public-key serialization is unavailable; rebuild mtp-wasm", - ); - } - return fallible.call(bundle); - } finally { - bundle.free(); - } - } finally { - keyring.free(); - } - } - - #resolveDecryptionIdentity( - explicit?: MTPDecryptionIdentity, - ): ResolvedDecryptionIdentity { - return resolveDecryptionIdentity(explicit, this.#credentials); - } - - #assertExpectedSignerId( - signerId: bigint, - expectedSignerId: bigint | number | string | undefined, - ): void { - if ( - expectedSignerId != null && - inputU64(expectedSignerId, "expectedSignerId") !== signerId - ) { - throw new Error("protected signer ID mismatch"); - } - } - - async #resolveSignerPublicKeys( - signerId: bigint, - options: SignerResolutionOptions, - ): Promise { - if (options.resolveSignerPublicKeys) { - let resolved: MTPResolvedSignerKeys; - try { - resolved = await options.resolveSignerPublicKeys(signerId); - } catch { - throw signerKeysUnavailable(signerId); - } - if (!Array.isArray(resolved) || resolved.length === 0) { - throw signerKeysUnavailable(signerId); - } - return resolved.map((value, index) => { - try { - const bytes = normalizeBytes(value, `senderPublicKeys[${index}]`); - publicKeyBundleToKeys(bytes); - return bytes; - } catch { - throw signerKeysUnavailable(signerId); - } - }); - } - if (this.#credentials?.clientId === signerId) { - try { - const bytes = this.#getPublicKeyBundleBytes(); - publicKeyBundleToKeys(bytes); - return [bytes]; - } catch { - throw signerKeysUnavailable(signerId); - } - } - return null; - } - - /** - * Decrypt and verify only relay metadata. The content remains opaque so a - * metadata-only relay participant can index or forward it without possessing - * a content-recipient key. - */ - async openRelayMetadata( - frame: ParsedFrame, - options: MTPOpenRelayMetadataOptions = {}, - ): Promise { - // The resolver is asynchronous. Keep an immutable byte snapshot so the - // signer claim and the later native verification refer to one frame. - const signaturePolicy = resolveSignatureVerificationPolicy( - options.signaturePolicy, - this.#options.defaultSignatureVerificationPolicy ?? - this.#options.securityProfile.protectedSignaturePolicy, - ); - const requestedReceiveLimits = normalizeReceiveLimits( - options.limits, - "limits", - ); - const receiveLimits = requestedReceiveLimits - ? effectiveReceiveLimits( - requestedReceiveLimits, - this.#options.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE, - ) - : this.#options.receiveLimits; - const receiveLimitsExplicit = - options.limits != null || this.#options.receiveLimitsExplicit; - const frameBytes = frame.raw.slice(); - const frameSnapshot = receiveLimitsExplicit && receiveLimits - ? decodeWithLimits(frameBytes, receiveLimits) - : bindings.parse_frame(frameBytes); - const recipient = this.#resolveDecryptionIdentity(options.recipient); - let signerId: bigint; - try { - const bounded = boundedBindings(); - if (bounded.relay_metadata_claimed_signer_id_with_limits) { - signerId = BigInt( - bounded.relay_metadata_claimed_signer_id_with_limits( - frameBytes, - recipient.keyrings, - receiveLimits ?? {}, - ), - ); - } else if (receiveLimitsExplicit) { - throw new Error( - "configured receive limits require a rebuilt bounded WASM package", - ); - } else { - signerId = BigInt( - bindings.relay_metadata_claimed_signer_id( - frameBytes, - recipient.keyrings, - ), - ); - } - } catch (error) { - throw relayOpeningError(error); - } - this.#assertExpectedSignerId(signerId, options.expectedSignerId); - const signerBundles = await this.#resolveSignerPublicKeys( - signerId, - options, - ); - if (!signerBundles) { - throw signerKeysUnavailable(signerId); - } - - let native: RawBindings.WasmVerifiedRelayMetadata | undefined; - let ownershipTransferred = false; - try { - const bounded = boundedBindings(); - if (!bounded.open_relay_metadata_with_keyrings_with_limits_without_replay) { - throw new Error( - "configured receive limits require a rebuilt bounded WASM package", - ); - } - native = bounded.open_relay_metadata_with_keyrings_with_limits_without_replay( - frameBytes, - recipient.keyrings, - signerId, - signerBundles, - signatureVerificationPolicyValue(signaturePolicy), - receiveLimits ?? {}, - ); - } catch (error) { - throw relayOpeningError(error, signerId); - } - - try { - if (!native) throw new Error("relay metadata opening returned no handle"); - const metadataBytes = native.metadata(); - const hasApplicationMetadata = metadataBytes != null; - const applicationMetadata = hasApplicationMetadata - ? (cloneParsedValue( - receiveLimits - ? decodeDataValueWithLimits(metadataBytes, receiveLimits) - : bindings.parse_data_value(metadataBytes), - ) as MTPDataValue) - : undefined; - const nativeSignerId = BigInt(native.signer_id()); - const messageId = native.message_id(); - const createdAt = BigInt(native.created_at()); - const replayGuard = options.replayGuard ?? this.#relayReplayGuard; - const accepted = await replayGuard.accept( - nativeSignerId, - messageId, - createdAt, - ); - if (!accepted) throw new MTPReplayError(nativeSignerId, messageId); - - const matchedSignerKeyIndex = Number(native.matched_signer_key_index()); - if ( - !Number.isSafeInteger(matchedSignerKeyIndex) || - matchedSignerKeyIndex < 0 || - matchedSignerKeyIndex >= signerBundles.length - ) { - throw new Error("relay verification returned an invalid key index"); - } - - const verified = new MTPVerifiedRelayMetadata(RELAY_METADATA_TOKEN, { - frame: frameSnapshot, - native, - relayVersion: Number(native.relay_version()), - signerId: nativeSignerId, - finalRecipientId: BigInt(native.final_recipient_id()), - messageId, - createdAt, - hasMetadata: hasApplicationMetadata, - metadata: applicationMetadata, - encryptedContent: native.encrypted_content().slice(), - signerPublicKeys: signerBundles.map((bundle) => bundle.slice()), - matchedSignerKeyIndex, - signaturePolicy, - receiveLimits: receiveLimits ? { ...receiveLimits } : undefined, - receiveLimitsExplicit, - disposed: false, - finalizerToken: {}, - }); - // Register only after the JS wrapper owns the native handle. The - // finally block below handles every failure before this transfer. - const state = relayMetadataState.get(verified); - if (!state) throw new Error("relay metadata state was not initialized"); - registerRelayMetadata(verified, native, state.finalizerToken); - ownershipTransferred = true; - return verified; - } finally { - if (!ownershipTransferred) { - try { - native?.free(); - } catch { - // Preserve the original opening or conversion failure. - } - } - } - } - - /** Open and verify content after relay metadata has been authenticated. */ - async openRelayContent( - metadata: MTPVerifiedRelayMetadata, - options: MTPOpenRelayContentOptions = {}, - ): Promise { - const recipient = this.#resolveDecryptionIdentity(options.recipient); - const state = relayMetadataState.get(metadata); - if (!state) { - throw new Error( - "relay metadata was not produced by authenticated opening", - ); - } - if (state.disposed) { - throw new Error("relay metadata has been disposed"); - } - const expectedFinalRecipientId = - options.expectedFinalRecipientId == null - ? recipient.id - : inputU64( - options.expectedFinalRecipientId, - "expectedFinalRecipientId", - ); - const requestedReceiveLimits = normalizeReceiveLimits( - options.limits, - "limits", - ); - const receiveLimits = requestedReceiveLimits - ? effectiveReceiveLimits( - requestedReceiveLimits, - this.#options.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE, - ) - : state.receiveLimits; - const receiveLimitsExplicit = - options.limits != null || state.receiveLimitsExplicit; - // Content belongs to the authenticated metadata operation. Inherit its - // policy when no content override is supplied so a client default cannot - // split the two relay verification layers. - const signaturePolicy = resolveSignatureVerificationPolicy( - options.signaturePolicy, - state.signaturePolicy, - ); - if (signaturePolicy !== state.signaturePolicy) { - throw new MTPSignatureVerificationError( - "policy-rejected", - state.signerId, - ); - } - - let signerBundles: Uint8Array[] = state.signerPublicKeys.map((bundle) => - bundle.slice(), - ); - this.#assertExpectedSignerId(state.signerId, options.expectedSignerId); - if (options.resolveSignerPublicKeys != null) { - const resolved = await this.#resolveSignerPublicKeys( - state.signerId, - options, - ); - if (state.disposed) { - throw new Error("relay metadata has been disposed"); - } - if (!resolved) { - throw signerKeysUnavailable(state.signerId); - } - signerBundles = resolved; - } - - // A caller may dispose the capability while asynchronous signer-key - // resolution is in flight. Never hand a freed native handle back to - // wasm-bindgen after that await, even if the resolver returned no keys. - if (state.disposed) { - throw new Error("relay metadata has been disposed"); - } - - let nativeContent: RawBindings.WasmVerifiedRelayContent; - try { - const bounded = boundedBindings(); - const nativeExpectedFinalRecipientId = - expectedFinalRecipientId == null - ? null - : BigInt(expectedFinalRecipientId); - if (!bounded.open_relay_content_with_keyrings_with_limits_without_replay) { - throw new Error( - "configured receive limits require a rebuilt bounded WASM package", - ); - } - nativeContent = bounded.open_relay_content_with_keyrings_with_limits_without_replay( - state.native, - recipient.keyrings, - signerBundles, - nativeExpectedFinalRecipientId, - signatureVerificationPolicyValue(signaturePolicy), - receiveLimits ?? {}, - ); - } catch (error) { - throw relayOpeningError(error, state.signerId); - } - try { - return this.#formatRelayContent(nativeContent, state); - } finally { - nativeContent.free(); - } - } - - #formatRelayContent( - nativeContent: RawBindings.WasmVerifiedRelayContent, - state: MTPRelayMetadataState, - ): MTPVerifiedRelayContent { - const contentBytes = nativeContent.content(); - const data = state.receiveLimitsExplicit && state.receiveLimits - ? decodeDataValueWithLimits(contentBytes, state.receiveLimits) - : (bindings.parse_data_value(contentBytes) as MTPDataValue); - - return { - type: nativeContent.message_type(), - data: cloneParsedValue(data) as MTPDataValue, - signerId: BigInt(nativeContent.signer_id()), - finalRecipientId: BigInt(nativeContent.final_recipient_id()), - messageId: state.messageId, - createdAt: state.createdAt, - metadata: - state.hasMetadata - ? (cloneParsedValue(state.metadata) as MTPDataValue) - : undefined, - }; - } - - async #openProtectedFrame( - frameInput: MTPProtectedFrameInput, - options: MTPOpenProtectedOptions, - ): Promise<{ - frame: ParsedFrame; - message: MTPVerifiedProtectedMessage; - }> { - if (!options || typeof options !== "object") { - throw new TypeError("openProtected requires an options object"); - } - if (typeof options.resolveSignerPublicKeys !== "function") { - throw new TypeError("openProtected requires resolveSignerPublicKeys"); - } - - // Detach parsed-object inputs before awaiting signer-key resolution. This - // keeps the authenticated payload and routing fields bound to one - // snapshot even when a caller reuses or mutates its frame object. - const requestedReceiveLimits = normalizeReceiveLimits( - options.limits, - "limits", - ); - const receiveLimits = requestedReceiveLimits - ? effectiveReceiveLimits( - requestedReceiveLimits, - this.#options.maxMessageSize ?? DEFAULT_MAX_MESSAGE_SIZE, - ) - : this.#options.receiveLimits; - const receiveLimitsExplicit = - options.limits != null || this.#options.receiveLimitsExplicit; - const frame = cloneParsedFrame( - parseProtectedFrame( - frameInput, - receiveLimitsExplicit ? receiveLimits : undefined, - ), - ); - assertKnownCommunicationType(frame); - const frameBytes = protectedFrameBytes(frame, receiveLimits); - validateApplicationProtectionPurpose(options.signaturePurpose); - validateApplicationProtectionPurpose(options.encryptionPurpose); - - const recipient = this.#resolveDecryptionIdentity(options.recipient); - const signaturePolicy = resolveSignatureVerificationPolicy( - options.signaturePolicy, - this.#options.defaultSignatureVerificationPolicy ?? - this.#options.securityProfile.protectedSignaturePolicy, - ); - const expectedReceiverId = - options.expectedReceiverId == null - ? recipient.id - : inputU64(options.expectedReceiverId, "expectedReceiverId"); - let signerId: bigint; - try { - const bounded = boundedBindings(); - if (bounded.protected_claimed_signer_id_with_limits) { - signerId = BigInt( - bounded.protected_claimed_signer_id_with_limits( - frameBytes, - recipient.keyrings, - options.encryptionPurpose, - receiveLimits ?? {}, - ), - ); - } else if (receiveLimitsExplicit) { - throw new Error( - "configured receive limits require a rebuilt bounded WASM package", - ); - } else { - signerId = BigInt( - bindings.protected_claimed_signer_id( - frameBytes, - recipient.keyrings, - options.encryptionPurpose, - ), - ); - } - } catch (error) { - throw protectedOpeningError(error); - } - this.#assertExpectedSignerId(signerId, options.expectedSignerId); - const signerBundles = await this.#resolveSignerPublicKeys(signerId, options); - if (!signerBundles) { - throw signerKeysUnavailable(signerId); - } - - let native: RawBindings.WasmVerifiedProtectedMessage; - try { - const bounded = boundedBindings(); - const nativeExpectedReceiverId = - expectedReceiverId == null ? null : expectedReceiverId; - if (!bounded.open_protected_with_keyrings_with_limits_without_replay) { - throw new Error( - "configured receive limits require a rebuilt bounded WASM package", - ); - } - native = bounded.open_protected_with_keyrings_with_limits_without_replay( - frameBytes, - recipient.keyrings, - signerId, - signerBundles, - nativeExpectedReceiverId, - options.signaturePurpose, - options.encryptionPurpose, - signatureVerificationPolicyValue(signaturePolicy), - receiveLimits ?? {}, - ); - } catch (error) { - throw protectedOpeningError(error, signerId); - } - - const replayGuard = options.replayGuard ?? this.#protectedReplayGuard; - const nativeSignerId = BigInt(native.signer_id()); - const messageId = native.message_id(); - const createdAt = BigInt(native.created_at()); - try { - const accepted = await replayGuard.accept( - nativeSignerId, - messageId, - createdAt, - ); - if (!accepted) throw new MTPReplayError(nativeSignerId, messageId); - - const contentBytes = native.content(); - const data = receiveLimitsExplicit && receiveLimits - ? decodeDataValueWithLimits(contentBytes, receiveLimits) - : (bindings.parse_data_value(contentBytes) as MTPDataValue); - const receiver = frame.receiver; - const outerSender = frame.sender; - return { - frame, - message: { - type: native.message_type(), - protectedVersion: Number(native.protected_version()), - signerId: nativeSignerId, - finalRecipientId: BigInt(native.final_recipient_id()), - messageId, - createdAt, - ...(receiver == null ? {} : { receiver }), - ...(outerSender == null ? {} : { outerSender }), - data: cloneParsedValue(data) as MTPDataValue, - }, - }; - } finally { - native.free(); - } - } - - async openProtected( - frame: MTPProtectedFrameInput, - options: MTPOpenProtectedOptions, - ): Promise> { - const opened = await this.#openProtectedFrame(frame, options); - return opened.message as MTPVerifiedProtectedMessage; - } - - subscribeProtected( - type: MTPCommunicationType, - handler: ( - message: MTPVerifiedProtectedMessage, - frame: ParsedFrame, - ) => void | Promise, - options: MTPOpenProtectedOptions, - ): Unsubscribe { - if (typeof type !== "string" || !type) { - throw new TypeError( - "protected subscription type must be a non-empty string", - ); - } - const applicationType = assertApplicationCommunicationType(type); - if (typeof handler !== "function") { - throw new TypeError("protected handler must be a function"); - } - const replayGuard = options.replayGuard ?? new InMemoryReplayGuard(); - const subscriptionOptions = { ...options, replayGuard }; - - const sub = this.raw.client.subscribe( - applicationType, - async (frame: ParsedFrame) => { - try { - const opened = await this.#openProtectedFrame( - frame, - subscriptionOptions, - ); - if (opened.message.type !== applicationType) return; - await handler( - opened.message as MTPVerifiedProtectedMessage, - opened.frame, - ); - } catch (error) { - emit(this.#options.logger, { - hint: "error", - type: "E2EE", - error: String(error), - direction: "recv", - }); - } - }, - ); - - return () => this.raw.client.unsubscribe(sub); - } - - async sendProtected( - type: MTPCommunicationType, - data: MTPDataValueInput, - options: MTPSendProtectedOptions, - ): Promise { - const messageType = assertApplicationCommunicationType(type); - const identity = resolveProtectionIdentity( - options.identity, - this.#credentials, - ); - const receiverId = inputU64(options.receiverId, "receiverId"); - const recipients = normalizeRecipientBundles(options.recipients, "recipients"); - const messageId = createMessageId(); - const createdAt = unixTimeMillis(); - validateApplicationProtectionPurpose(options.signaturePurpose); - validateApplicationProtectionPurpose(options.encryptionPurpose); - const signatureSuite = effectiveProtectionSignatureSuite( - identity.keyring, - options.signatureSuite ?? - this.#options.securityProfile.protectedSignatureSuite, - ); - const encodeLimits = this.#encodeLimits(); - const protectedLimits = normalizeReceiveLimits(options.limits, "options.limits"); - const messageIdBytes = utf8Encode(messageId).length; - if ( - protectedLimits?.maxMessageIdBytes != null && - messageIdBytes > protectedLimits.maxMessageIdBytes - ) { - throw new RangeError("protected message ID exceeds maxMessageIdBytes"); - } - const encodedContent = encodeMTPDataValue(data, encodeLimits); - const builderLimits = { ...encodeLimits, ...protectedLimits }; - let frame: Uint8Array; - try { - const bounded = ( - bindings as typeof bindings & { - build_protected_frame_with_keyring_with_limits?: ( - messageType: string, - encodedContent: Uint8Array, - signerId: bigint, - finalRecipientId: bigint, - messageId: string, - createdAt: bigint, - signaturePurpose: number, - encryptionPurpose: number, - keyring: Uint8Array, - signatureSuite: number, - frameId: number | null, - exposeSender: boolean, - recipients: Uint8Array[], - limits: MTPReceiveLimits & MTPEncodeLimits, - ) => Uint8Array; - } - ).build_protected_frame_with_keyring_with_limits; - if (!bounded) { - throw new Error( - "bounded WASM protected-message encoding is unavailable; rebuild mtp-wasm", - ); - } - frame = bounded( - messageType, - encodedContent, - identity.signerId, - receiverId, - messageId, - createdAt, - options.signaturePurpose, - options.encryptionPurpose, - identity.keyring, - protectionSignatureSuiteValue(signatureSuite), - options.id ?? null, - options.exposeSender ?? false, - recipients, - builderLimits, - ); - } catch (error) { - throw protectedOpeningError(error, identity.signerId); - } - await this.send(frame); - } - - async sendSealedRelay( - type: MTPCommunicationType, - data: MTPDataValueInput, - options: MTPSendSealedRelayOptions, - ): Promise { - const messageType = assertApplicationCommunicationType(type); - const identity = resolveProtectionIdentity( - options.identity, - this.#credentials, - ); - const finalRecipientId = inputU64( - options.finalRecipientId, - "finalRecipientId", - ); - const nextHopId = inputU64(options.nextHopId, "nextHopId"); - const metadataRecipients = normalizeRecipientBundles( - options.metadataRecipients, - "metadataRecipients", - ); - const contentRecipients = normalizeRecipientBundles( - options.contentRecipients, - "contentRecipients", - ); - const signatureSuite = effectiveProtectionSignatureSuite( - identity.keyring, - options.signatureSuite ?? - this.#options.securityProfile.protectedSignatureSuite, - ); - const encodeLimits = this.#encodeLimits(); - const protectedLimits = normalizeReceiveLimits(options.limits, "options.limits"); - validateMTPDataValue(data, encodeLimits); - const encodedMetadata = - options.metadata === undefined - ? undefined - : encodeMTPDataValue(options.metadata, encodeLimits); - if ( - encodedMetadata && - protectedLimits?.maxMetadataEncodedBytes != null && - encodedMetadata.length > protectedLimits.maxMetadataEncodedBytes - ) { - throw new RangeError("relay metadata exceeds maxMetadataEncodedBytes"); - } - const builderLimits = { ...encodeLimits, ...protectedLimits }; - const messageId = createMessageId(); - const createdAt = unixTimeMillis(); - const bounded = ( - bindings as typeof bindings & { - build_encrypted_relay_frame_with_keyring_with_limits?: ( - messageType: string, - data: MTPDataValueInput, - signerId: bigint, - finalRecipientId: bigint, - nextHopId: bigint, - messageId: string, - createdAt: bigint, - encodedMetadata: Uint8Array | undefined, - keyring: Uint8Array, - signatureSuite: number, - metadataRecipients: Uint8Array[], - contentRecipients: Uint8Array[], - limits: MTPReceiveLimits & MTPEncodeLimits, - ) => Uint8Array; - } - ).build_encrypted_relay_frame_with_keyring_with_limits; - if (!bounded) { - throw new Error( - "bounded WASM relay encoding is unavailable; rebuild mtp-wasm", - ); - } - const frame = bounded( - messageType, - data, - identity.signerId, - finalRecipientId, - nextHopId, - messageId, - createdAt, - encodedMetadata, - identity.keyring, - protectionSignatureSuiteValue(signatureSuite), - metadataRecipients, - contentRecipients, - builderLimits, - ); - await this.send(frame); - } - - subscribeSealedRelay( - type: MTPCommunicationType, - handler: ( - content: MTPVerifiedRelayContent, - frame: ParsedFrame, - ) => void | Promise, - options?: MTPEncryptedSubscriptionOptions, - ): Unsubscribe; - subscribeSealedRelay( - handler: ( - content: MTPVerifiedRelayContent, - frame: ParsedFrame, - ) => void | Promise, - options?: MTPEncryptedSubscriptionOptions, - ): Unsubscribe; - subscribeSealedRelay( - typeOrHandler: - | MTPCommunicationType - | (( - content: MTPVerifiedRelayContent, - frame: ParsedFrame, - ) => void | Promise), - maybeHandlerOrOptions?: - | (( - content: MTPVerifiedRelayContent, - frame: ParsedFrame, - ) => void | Promise) - | MTPEncryptedSubscriptionOptions, - maybeOptions?: MTPEncryptedSubscriptionOptions, - ): Unsubscribe { - const expectedInnerType = - typeof typeOrHandler === "function" ? null : typeOrHandler; - if (expectedInnerType != null) { - assertApplicationCommunicationType(expectedInnerType); - } - const handler = - typeof typeOrHandler === "function" - ? typeOrHandler - : typeof maybeHandlerOrOptions === "function" - ? maybeHandlerOrOptions - : null; - if (!handler) { - throw new TypeError("sealed relay handler must be a function"); - } - const options: MTPEncryptedSubscriptionOptions = - typeof typeOrHandler === "function" - ? typeof maybeHandlerOrOptions === "function" || - maybeHandlerOrOptions == null - ? {} - : maybeHandlerOrOptions - : (maybeOptions ?? {}); - const replayGuard = options.replayGuard ?? new InMemoryReplayGuard(); - const subscriptionOptions = { ...options, replayGuard }; - const sub = this.raw.client.subscribe( - "Relay", - async (frame: ParsedFrame) => { - let metadata: MTPVerifiedRelayMetadata | undefined; - try { - metadata = await this.openRelayMetadata(frame, subscriptionOptions); - const opened = await this.openRelayContent( - metadata, - subscriptionOptions, - ); - if ( - !opened || - (expectedInnerType && opened.type !== expectedInnerType) - ) { - return; - } - const parsedFrame: ParsedFrame = { - ...frame, - type: opened.type, - data: opened.data as ParsedFrame["data"], - }; - await handler(opened, parsedFrame); - } catch (e) { - emit(this.#options.logger, { - hint: "error", - type: "E2EE", - error: String(e), - direction: "recv", - }); - } finally { - metadata?.dispose(); - } - }, - ); - - return () => this.raw.client.unsubscribe(sub); - } - - /** - * Subscribe to verified relay metadata without attempting content - * decryption. The metadata capability is callback-scoped: it is disposed - * after the handler resolves, so do not retain it for later content opening. - * Call `openRelayMetadata()` directly when a longer-lived capability is - * required and dispose it when finished. - */ - subscribeRelayMetadata( - handler: ( - metadata: MTPVerifiedRelayMetadata, - frame: ParsedFrame, - ) => void | Promise, - options: MTPOpenRelayMetadataOptions = {}, - ): Unsubscribe { - if (typeof handler !== "function") { - throw new TypeError("relay metadata handler must be a function"); - } - const replayGuard = options.replayGuard ?? new InMemoryReplayGuard(); - const subscriptionOptions = { ...options, replayGuard }; - const sub = this.raw.client.subscribe( - "Relay", - async (frame: ParsedFrame) => { - let metadata: MTPVerifiedRelayMetadata | undefined; - try { - metadata = await this.openRelayMetadata(frame, subscriptionOptions); - await handler(metadata, frame); - } catch (e) { - emit(this.#options.logger, { - hint: "error", - type: "E2EE", - error: String(e), - direction: "recv", - }); - } finally { - metadata?.dispose(); - } - }, - ); - return () => this.raw.client.unsubscribe(sub); - } - - /** Explicit alias for callers that want to emphasize encrypted metadata. */ - subscribeEncryptedMetadata( - handler: ( - metadata: MTPVerifiedRelayMetadata, - frame: ParsedFrame, - ) => void | Promise, - options: MTPOpenRelayMetadataOptions = {}, - ): Unsubscribe { - return this.subscribeRelayMetadata(handler, options); - } - - async setEncryptedSecret(record: MTPEncryptedSecretRecord): Promise { - await this.encryptedSecretProvider.set(record); - } - - async getEncryptedSecret(id: string): Promise { - return this.encryptedSecretProvider.get(id); - } - - async deleteEncryptedSecret(id: string): Promise { - await this.encryptedSecretProvider.delete(id); - } - - setOnPipeRequest(handler: ((request: MTPPipeRequest) => void) | null): void { - if (handler == null) { - this.raw.client.set_on_pipe_request(null); - return; - } - this.raw.client.set_on_pipe_request( - (event: { pipeId: number; description: string }) => { - emit(this.#options.logger, { - hint: "info", - type: "PipeRequest", - data: event, - direction: "recv", - }); - handler({ pipeId: event.pipeId, description: event.description }); - }, - ); - } - - async createPipe(description: string): Promise { - if (typeof description !== "string") { - throw new TypeError("description must be a string"); - } - const handle: WasmPipeHandle = - await this.raw.client.create_pipe(description); - const sdk = this; - return { - pipeId: handle.pipeId, - description: handle.description, - async wait(): Promise { - const result = await handle.wait(); - if (result == null) { - return null; - } - emit(sdk.#options.logger, { - hint: "info", - type: "PipeCreated", - data: { pipeId: result.pipeId }, - direction: "send", - }); - return result as unknown as MTPPipeWriter; - }, - }; - } - - /** - * Create and negotiate an encrypted pipe with the actual MTP pipe ID and - * local identity bound automatically. A recipient array creates a group - * bootstrap; membership changes should create a fresh session with the new - * array. - */ - async createEncryptedPipe( - options: MTPCreateEncryptedPipeOptions, - ): Promise { - const credentials = this.#credentials; - if (!credentials || credentials.clientId == null) { - throw new Error("Client not registered"); - } - const recipientId = inputU64(options.recipientId, "recipientId"); - if ( - options.recipientPublicKey != null && - options.recipientPublicKeys != null - ) { - throw new Error( - "provide recipientPublicKey or recipientPublicKeys, not both", - ); - } - const rawRecipients = - options.recipientPublicKeys ?? - (options.recipientPublicKey != null ? [options.recipientPublicKey] : []); - if (rawRecipients.length === 0) { - throw new Error("at least one recipient public key is required"); - } - const recipients = rawRecipients.map((value, index) => - normalizeBytes(value, `recipientPublicKeys[${index}]`), - ); - recipients.forEach((value) => publicKeyBundleToKeys(value)); - const purpose = options.purpose ?? 0x40; - const direction = options.direction ?? 0; - validateApplicationProtectionPurpose(purpose); - if (!Number.isInteger(direction) || direction < 0 || direction > 0xff) { - throw new RangeError("direction must be a u8"); - } - - const handle = await this.createPipe( - options.description ?? "encrypted pipe", - ); - const writer = await handle.wait(); - if (!writer) return null; - if (writer.pipeId !== handle.pipeId) { - throw new Error("created pipe ID does not match the accepted writer"); - } - const sessionId = new Uint8Array(32); - globalThis.crypto.getRandomValues(sessionId); - return initiateMTPPipeSession( - writer, - { - sessionId, - pipeId: writer.pipeId, - senderId: credentials.clientId, - recipientId, - purpose, - direction, - }, - credentials.keyring, - recipients, - options.signatureSuite ?? - this.#options.securityProfile.encryptedPipeSignatureSuite, - ); - } - - async acceptPipe(pipeId: number): Promise { - if (typeof pipeId !== "number" || !Number.isFinite(pipeId)) { - throw new TypeError("pipeId must be a finite number"); - } - const reader = await this.raw.client.accept_pipe(pipeId); - emit(this.#options.logger, { - hint: "info", - type: "PipeAccepted", - data: { pipeId: reader.pipeId, description: reader.description }, - direction: "send", - }); - return reader as unknown as MTPPipeReader; - } - - /** Accept a pipe and learn its authenticated session ID from the offer. */ - async acceptEncryptedPipe( - request: MTPPipeRequest, - options: MTPAcceptEncryptedPipeOptions, - ): Promise { - const credentials = this.#credentials; - if (!credentials || credentials.clientId == null) { - throw new Error("Client not registered"); - } - if (!Number.isSafeInteger(request.pipeId) || request.pipeId <= 0) { - throw new TypeError("request.pipeId must be a non-zero safe integer"); - } - const senderId = inputU64(options.senderId, "senderId"); - const purpose = options.purpose ?? 0x40; - const direction = options.direction ?? 0; - validateApplicationProtectionPurpose(purpose); - if (!Number.isInteger(direction) || direction < 0 || direction > 0xff) { - throw new RangeError("direction must be a u8"); - } - if ( - options.senderPublicKeys != null && - options.senderPublicKeys.length === 0 - ) { - throw new Error("senderPublicKeys must contain at least one key"); - } - const singleSenderPublicKey = options.senderPublicKey; - if (options.senderPublicKeys == null && singleSenderPublicKey == null) { - throw new Error("senderPublicKey or senderPublicKeys is required"); - } - const senderPublicKey = - options.senderPublicKeys != null - ? options.senderPublicKeys.map((value, index) => - normalizeBytes(value, `senderPublicKeys[${index}]`), - ) - : normalizeBytes(singleSenderPublicKey!, "senderPublicKey"); - const reader = await this.acceptPipe(request.pipeId); - if (reader.pipeId !== request.pipeId) { - throw new Error("accepted pipe ID does not match the requested pipe"); - } - const signaturePolicy = resolveSignatureVerificationPolicy( - options.signaturePolicy, - this.#options.defaultSignatureVerificationPolicy ?? - this.#options.securityProfile.encryptedPipeSignaturePolicy, - ); - return acceptMTPPipeSessionAuto( - reader, - { - pipeId: reader.pipeId, - senderId, - recipientId: credentials.clientId, - purpose, - direction, - }, - credentials.keyring, - senderPublicKey, - signaturePolicy, - ); - } - - async denyPipe(pipeId: number): Promise { - if (typeof pipeId !== "number" || !Number.isFinite(pipeId)) { - throw new TypeError("pipeId must be a finite number"); - } - await this.raw.client.deny_pipe(pipeId); - emit(this.#options.logger, { - hint: "info", - type: "PipeDenied", - data: { pipeId }, - direction: "send", - }); - } - - disconnect(): void { - this.raw.client.stop_protocol_pings(); - this.raw.client.disconnect(); - } -} - -export { ConnectionState, bindings as raw }; -export { - crypto, - codec, - encode, - decode, - decodeDataValueWithLimits, - decodeWithLimits, - format, - bytesToBase64, - base64ToBytes, - bytesFromEncodedString, - strictHexDecode, - strictBase64Decode, - secretKeyFromBytes, - secretKeyFromHex, - secretKeyFromBase64, - secretKeyFromString, - legacySecretKeyFromStringV1, - deriveKeyFromPassphrase, - deriveKeyFromPassphraseSync, - keyringToKeys, - publicKeyBundleToKeys, -} from "./codec.js"; -export { - InMemoryReplayGuard, - MTPReplayError, - MTPMissingProtectedVersionError, - MTPResourceLimitError, - MTPUnsupportedProtectedVersionError, -} from "./protection.js"; -export { - MTPMissingRelayVersionError, - MTPUnsupportedRelayVersionError, - MTPVerifiedRelayMetadata, -} from "./relay.js"; - -// E2EE exports -export type { - MTPSessionState, - MTPSessionStorage, - MTPSessionTranscriptContext, - SkippedMessageKey, -} from "./session"; -export { - MTPSessionManager, - InMemorySessionStorage, - derivePeerSessionId, - deriveSessionKeys, - buildSessionTranscript, -} from "./session.js"; -export { MTPRatchet } from "./ratchet.js"; -export type { RatchetStep } from "./ratchet.js"; -export { - MTPEncryptedPipeError, - MTPEncryptedPipeReader, - MTPEncryptedPipeWriter, - MTPPipeProtectionContext, - MAX_ENCRYPTED_PIPE_RECORD, - pipeSessionSignaturePurpose, - pipeSessionEncryptionPurpose, - validateApplicationProtectionPurpose, - MAX_PIPE_SESSION_OFFER, - initiateMTPPipeSession, - acceptMTPPipeSession, - acceptMTPPipeSessionAuto, - initiateMTPForwardSecurePipeSession, - acceptMTPForwardSecurePipeSession, -} from "./encrypted-pipe.js"; -export type { - MTPReadablePipe, - MTPWritablePipe, - MTPPipeSessionParameters, - MTPPipeSessionExpectation, - MTPDuplexPipe, - MTPEncryptedPipeReaderSource, - MTPEncryptedPipeWriterSource, -} from "./encrypted-pipe.js"; -export { - serializeEncryptedMessage, - parseEncryptedMessage, - encryptPayload, - decryptPayload, - MTP_E2EE_VERSION, - FLAG_INIT, - MAX_RATCHET_SKIP, -} from "./encrypted-message.js"; -export type { - EncryptedMessageHeader, - SerializedEncryptedMessage, -} from "./encrypted-message"; -export type { - MTPEncryptedSecretRecord, - MTPEncryptedSecretProvider, -} from "./encrypted-secret"; -export { InMemoryEncryptedSecretProvider } from "./encrypted-secret.js"; diff --git a/src/sdk/codec.ts b/src/sdk/codec.ts deleted file mode 100644 index 05252dd..0000000 --- a/src/sdk/codec.ts +++ /dev/null @@ -1,959 +0,0 @@ -import * as bindings from "mtp/raw"; -import type { MTPCommunicationType } from "../type-map/index"; -import { RESERVED_COMMUNICATION_TYPE_IDS } from "../type-map/reserved.js"; -import { utf8Encode } from "./utils.js"; -import { initWasmOnce } from "./wasm-init.js"; -import type { - MTPBytesInput, - MTPCodec, - MTPCodecOptions, - MTPDataValue, - MTPDataValueInput, - MTPEncodeLimits, - MTPEncodedBytesInput, - MTPKeyringKeys, - MTPKeyMaterialInput, - MTPReceiveLimits, - MTPCrypto, - MTPPublicKeyBundleKeys, - MTPProtectedFrameInput, - MTPProtectionSignatureSuite, - ParsedFrame, -} from "./client.js"; - -const checkedKeyringGenerator = ( - bindings as typeof bindings & { - keyring_generate_checked?: () => Uint8Array; - } -).keyring_generate_checked; - -export const crypto: MTPCrypto = { - generateKeyring: () => - checkedKeyringGenerator?.() ?? bindings.keyring_generate(), - generateEd25519: () => bindings.ed25519_generate(), - keyringFromEd25519: (secretKey, publicKey) => - bindings.keyring_from_ed25519(secretKey, publicKey), - verifyEd25519: (publicKey, message, signature) => - bindings.ed25519_verify(publicKey, message, signature), - deriveEncryptionKey: (ikm, salt, context) => - bindings.wasm_derive_encryption_key(ikm, salt, context), - hkdfExpand: (ikm, salt, info, len) => - bindings.wasm_hkdf_expand(ikm, salt, info, len), - sha256: (data) => bindings.wasm_sha256(data), - sha256Double: (data) => bindings.wasm_sha256_double(data), - keyringToKeys: (keyring) => keyringToKeys(keyring), - publicKeyBundleToKeys: (publicKeyBundle) => - publicKeyBundleToKeys(publicKeyBundle), - - encrypt: async (key, input) => { - const cipher = new bindings.WasmChaCha20Poly1305(key); - try { - return cipher.encrypt(input, new Uint8Array(0)); - } finally { - cipher.free(); - } - }, - - decrypt: async (key, input) => { - const cipher = new bindings.WasmChaCha20Poly1305(key); - try { - return cipher.decrypt(input, new Uint8Array(0)); - } finally { - cipher.free(); - } - }, - - encryptText: async (key, plaintext) => { - const cipher = new bindings.WasmChaCha20Poly1305(key); - try { - const ciphertext = cipher.encrypt( - utf8Encode(plaintext), - new Uint8Array(0), - ); - return bytesToBase64(ciphertext); - } finally { - cipher.free(); - } - }, - - decryptText: async (key, ciphertext) => { - const cipher = new bindings.WasmChaCha20Poly1305(key); - try { - const decoded = base64ToBytes(ciphertext); - const plaintext = cipher.decrypt(decoded, new Uint8Array(0)); - return utf8Decode(plaintext); - } finally { - cipher.free(); - } - }, - - encapsulate: (otherPublicKey) => - bindings.wasm_kem_encapsulate(otherPublicKey), - - decapsulate: (ownPrivateKey, ciphertext) => - bindings.wasm_kem_decapsulate(ownPrivateKey, ciphertext), -}; - -export function encode( - type: MTPCommunicationType, - data: Record, - options?: MTPCodecOptions, -): Uint8Array { - const limits: MTPEncodeLimits = { - maxDepth: MAX_DATA_VALUE_DEPTH, - maxValues: MAX_DATA_VALUE_VALUES, - maxOutputSize: 16 * 1024 * 1024, - }; - const maxOutputSize = limits.maxOutputSize ?? 16 * 1024 * 1024; - validateMTPDataValue(data as MTPDataValueInput, limits); - const bounded = ( - bindings as typeof bindings & { - build_frame_with_limits?: ( - type: string, - data: Record, - options: MTPCodecOptions, - limits: MTPEncodeLimits, - ) => Uint8Array; - } - ).build_frame_with_limits; - if (!bounded) { - throw new Error("bounded WASM frame encoding is unavailable; rebuild mtp-wasm"); - } - const frame = bounded(type, data, options ?? {}, limits); - if (frame.length > maxOutputSize) { - throw new RangeError("MTP frame encoded output limit exceeded"); - } - return frame; -} - -export function decode(frame: MTPBytesInput): ParsedFrame { - return bindings.parse_frame(bytesFrom(frame, "frame")); -} - -export function decodeWithLimits( - frame: MTPBytesInput, - limits: MTPReceiveLimits, -): ParsedFrame { - const parse = ( - bindings as typeof bindings & { - parse_frame_with_limits?: ( - frame: Uint8Array, - limits: MTPReceiveLimits, - ) => ParsedFrame; - } - ).parse_frame_with_limits; - if (!parse) { - throw new Error( - "configured receive limits require a rebuilt bounded WASM package", - ); - } - return parse(bytesFrom(frame, "frame"), limits); -} - -export function decodeDataValueWithLimits( - value: MTPBytesInput, - limits: MTPReceiveLimits, -): MTPDataValue { - const parse = ( - bindings as typeof bindings & { - parse_data_value_with_limits?: ( - value: Uint8Array, - limits: MTPReceiveLimits, - ) => MTPDataValue; - } - ).parse_data_value_with_limits; - if (!parse) { - throw new Error( - "configured receive limits require a rebuilt bounded WASM package", - ); - } - return parse(bytesFrom(value, "data value"), limits); -} - -export function format(frame: MTPBytesInput): string { - return bindings.format_frame(bytesFrom(frame, "frame")); -} - -export const codec: MTPCodec = { encode, decode, format }; - -export function isBytes(value: unknown): value is MTPBytesInput { - return value instanceof Uint8Array || Array.isArray(value); -} - -export function bytesFrom(value: MTPBytesInput, name: string): Uint8Array { - if (value instanceof Uint8Array) return value.slice(); - if (Array.isArray(value)) { - for (const byte of value) { - if (!Number.isInteger(byte) || byte < 0 || byte > 255) { - throw new RangeError(`${name} contains a non-byte value`); - } - } - return Uint8Array.from(value); - } - throw new TypeError(`${name} must be a Uint8Array or number[]`); -} - -export function strictHexDecode(value: string, name = "value"): Uint8Array { - if (typeof value !== "string") throw new TypeError(`${name} must be a string`); - const text = value.replace(/^0x/i, ""); - if (text.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(text)) { - throw new TypeError(`${name} must be an even-length hexadecimal string`); - } - const bytes = new Uint8Array(text.length / 2); - for (let i = 0; i < bytes.length; i += 1) { - bytes[i] = Number.parseInt(text.slice(i * 2, i * 2 + 2), 16); - } - return bytes; -} - -export function strictBase64Decode(value: string, name = "value"): Uint8Array { - if (typeof value !== "string") throw new TypeError(`${name} must be a string`); - if (value.length === 0) return new Uint8Array(0); - if ( - value.length % 4 !== 0 || - !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( - value, - ) - ) { - throw new TypeError(`${name} is not valid padded base64`); - } - - let bytes: Uint8Array; - try { - if (typeof atob === "function") { - const binary = atob(value); - bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) { - bytes[i] = binary.charCodeAt(i); - } - } else if (typeof Buffer !== "undefined") { - bytes = new Uint8Array(Buffer.from(value, "base64")); - } else { - throw new TypeError("base64 decoding is not available in this environment"); - } - } catch (error) { - throw new TypeError(`${name} is not valid base64`, { cause: error }); - } - - if (bytesToBase64(bytes) !== value) { - throw new TypeError(`${name} is not canonical padded base64`); - } - - return bytes; -} - -export function bytesFromEncodedString( - value: string, - encoding: "hex" | "base64", - name: string, -): Uint8Array { - return encoding === "hex" - ? strictHexDecode(value, name) - : strictBase64Decode(value, name); -} - -/* - * Compatibility parser for the historical format-detecting API. New callers - * should select `bytesFromEncodedString` explicitly so a value cannot change - * meaning when it happens to contain only hexadecimal characters. - */ -/** @deprecated Use `bytesFromEncodedString(value, encoding, name)`. */ -export function bytesFromString(value: string, name: string): Uint8Array { - const trimmed = value.trim(); - if (!trimmed) throw new TypeError(`${name} must not be empty`); - - const hex = trimmed.replace(/^(0x)/i, "").replace(/[\s:_-]/g, ""); - if (/^[0-9a-fA-F]+$/.test(hex)) { - return strictHexDecode(hex, name); - } - return strictBase64Decode(trimmed, name); -} - -const HEX_DIGITS = "0123456789abcdef"; - -function bytesToHex(bytes: Uint8Array): string { - let out = ""; - for (let i = 0; i < bytes.length; i += 1) { - out += HEX_DIGITS[(bytes[i] >> 4) & 0xf] + HEX_DIGITS[bytes[i] & 0xf]; - } - return out; -} - -export function bytesToBase64(bytes: Uint8Array): string { - if (typeof btoa === "function") { - let binary = ""; - for (let i = 0; i < bytes.length; i += 1) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); - } - if (typeof Buffer !== "undefined") { - return Buffer.from(bytes).toString("base64"); - } - throw new TypeError("base64 encoding is not available in this environment"); -} - -export function base64ToBytes(input: string): Uint8Array { - return strictBase64Decode(input, "base64"); -} - -function utf8Decode(bytes: Uint8Array): string { - if (typeof TextDecoder !== "undefined") { - try { - return new TextDecoder("utf-8", { fatal: true }).decode(bytes); - } catch (error) { - throw new TypeError("invalid UTF-8", { cause: error }); - } - } - let out = ""; - let i = 0; - while (i < bytes.length) { - const b = bytes[i]; - if (b < 0x80) { - out += String.fromCharCode(b); - i += 1; - } else if (b >= 0xc2 && b <= 0xdf) { - if (i + 1 >= bytes.length || (bytes[i + 1] & 0xc0) !== 0x80) { - throw new TypeError("invalid UTF-8"); - } - out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i + 1] & 0x3f)); - i += 2; - } else if (b >= 0xe0 && b <= 0xef) { - if ( - i + 2 >= bytes.length || - (bytes[i + 1] & 0xc0) !== 0x80 || - (bytes[i + 2] & 0xc0) !== 0x80 || - (b === 0xe0 && bytes[i + 1] < 0xa0) || - (b === 0xed && bytes[i + 1] >= 0xa0) - ) { - throw new TypeError("invalid UTF-8"); - } - out += String.fromCharCode( - ((b & 0x0f) << 12) | - ((bytes[i + 1] & 0x3f) << 6) | - (bytes[i + 2] & 0x3f), - ); - i += 3; - } else if (b >= 0xf0 && b <= 0xf4) { - if ( - i + 3 >= bytes.length || - (bytes[i + 1] & 0xc0) !== 0x80 || - (bytes[i + 2] & 0xc0) !== 0x80 || - (bytes[i + 3] & 0xc0) !== 0x80 || - (b === 0xf0 && bytes[i + 1] < 0x90) || - (b === 0xf4 && bytes[i + 1] >= 0x90) - ) { - throw new TypeError("invalid UTF-8"); - } - const cp = - ((b & 0x07) << 18) | - ((bytes[i + 1] & 0x3f) << 12) | - ((bytes[i + 2] & 0x3f) << 6) | - (bytes[i + 3] & 0x3f); - out += String.fromCodePoint(cp); - i += 4; - } else { - throw new TypeError("invalid UTF-8"); - } - } - return out; -} - -function requiredSecretKeyLength(): number { - const lengthBinding = ( - bindings as typeof bindings & { - mtp_symmetric_key_length?: () => number; - } - ).mtp_symmetric_key_length; - if (!lengthBinding) return 32; - try { - return lengthBinding(); - } catch { - // The generated WASM wrapper is callable only after initialization. Keep - // the historical size as a pre-initialization validation fallback. - return 32; - } -} - -export function secretKeyFromBytes(value: MTPBytesInput): Uint8Array { - const bytes = bytesFrom(value, "secret key"); - const requiredLength = requiredSecretKeyLength(); - if (bytes.length !== requiredLength) { - throw new RangeError(`secret key must be exactly ${requiredLength} bytes`); - } - return bytes; -} - -export function secretKeyFromHex(value: string): Uint8Array { - return secretKeyFromBytes(strictHexDecode(value, "secret key")); -} - -export function secretKeyFromBase64(value: string): Uint8Array { - return secretKeyFromBytes(strictBase64Decode(value, "secret key")); -} - -/* - * Compatibility entry point. It now accepts only explicitly encoded key - * material; arbitrary strings are no longer silently treated as passphrases. - */ -/** @deprecated Use `secretKeyFromBytes`, `secretKeyFromHex`, or `secretKeyFromBase64`. */ -export function secretKeyFromString(secret: string): Uint8Array { - if (typeof secret !== "string" || !secret.trim()) { - throw new TypeError("secret must be a non-empty string"); - } - const trimmed = secret.trim(); - const hex = trimmed.replace(/^(0x)/i, ""); - if (/^[0-9a-fA-F]+$/.test(hex)) return secretKeyFromHex(hex); - return secretKeyFromBase64(trimmed); -} - -/** - * Reproduce the pre-v1 implicit-HKDF derivation for data migration only. - * - * @deprecated Do not use for new secrets. Replace this with explicit key - * material or `deriveKeyFromPassphrase` and persist a password-KDF salt. - */ -export function legacySecretKeyFromStringV1(secret: string): Uint8Array { - if (typeof secret !== "string" || !secret.trim()) { - throw new TypeError("secret must be a non-empty string"); - } - const trimmed = secret.trim(); - const hex = trimmed.replace(/^(0x)/i, "").replace(/[\s:_-]/g, ""); - if (/^[0-9a-fA-F]+$/.test(hex) && hex.length === 64) { - return strictHexDecode(hex, "legacy secret key"); - } - try { - const decoded = bytesFromString(trimmed, "legacy secret key"); - if (decoded.length === requiredSecretKeyLength()) return decoded; - } catch { - // Preserve the historical fallback to HKDF for non-encoded strings. - } - const context = utf8Encode("mtp-symmetric-key"); - return bindings.wasm_derive_encryption_key( - utf8Encode(trimmed), - context, - context, - ); -} - -export interface PasswordKdfParameters { - memoryKiB: number; - iterations: number; - lanes: number; -} - -function validatePasswordKdfInput( - passphrase: string, - salt: MTPBytesInput, - parameters: PasswordKdfParameters, -): { passphrase: string; salt: Uint8Array; parameters: PasswordKdfParameters } { - if (typeof passphrase !== "string" || passphrase.length === 0) { - throw new TypeError("passphrase must not be empty"); - } - const saltBytes = bytesFrom(salt, "passphrase salt"); - if (saltBytes.length < 16) { - throw new RangeError("passphrase salt must be at least 16 bytes"); - } - if ( - !Number.isInteger(parameters.memoryKiB) || - parameters.memoryKiB < 8 * 1024 || - parameters.memoryKiB > 256 * 1024 || - !Number.isInteger(parameters.iterations) || - parameters.iterations < 1 || - parameters.iterations > 10 || - !Number.isInteger(parameters.lanes) || - parameters.lanes < 1 || - parameters.lanes > 8 - ) { - throw new RangeError("invalid Argon2id password-KDF parameters"); - } - return { passphrase, salt: saltBytes, parameters }; -} - -function deriveKeyFromPassphraseSyncImpl( - passphrase: string, - salt: MTPBytesInput, - parameters: PasswordKdfParameters, -): Uint8Array { - const validated = validatePasswordKdfInput(passphrase, salt, parameters); - const kdf = (bindings as unknown as { - wasm_argon2id?: ( - passphrase: Uint8Array, - salt: Uint8Array, - memoryKiB: number, - iterations: number, - lanes: number, - ) => Uint8Array; - }).wasm_argon2id; - if (!kdf) { - throw new Error("Argon2id password derivation is unavailable in this WASM build"); - } - return kdf( - utf8Encode(validated.passphrase), - validated.salt, - validated.parameters.memoryKiB, - validated.parameters.iterations, - validated.parameters.lanes, - ); -} - -/** - * Derive a passphrase key without yielding. Prefer the asynchronous API in - * browser applications; this form is retained for workers and synchronous - * command-line migrations. - */ -/** @deprecated Use `deriveKeyFromPassphrase` in browser-facing code. */ -export function deriveKeyFromPassphraseSync( - passphrase: string, - salt: MTPBytesInput, - parameters: PasswordKdfParameters, -): Uint8Array { - return deriveKeyFromPassphraseSyncImpl(passphrase, salt, parameters); -} - -/** - * Derive a passphrase key off the browser main thread when workers are - * available. The worker imports the same generated WASM binding, so the - * Argon2id computation does not block UI/event-loop work. - */ -export function deriveKeyFromPassphrase( - passphrase: string, - salt: MTPBytesInput, - parameters: PasswordKdfParameters, -): Promise { - const validated = validatePasswordKdfInput(passphrase, salt, parameters); - if (typeof Worker === "undefined") { - return initWasmOnce().then( - () => - new Promise((resolve) => { - setTimeout( - () => - resolve( - deriveKeyFromPassphraseSyncImpl( - validated.passphrase, - validated.salt, - validated.parameters, - ), - ), - 0, - ); - }), - ); - } - - const worker = new Worker(new URL("./passphrase-worker.js", import.meta.url), { - type: "module", - }); - return new Promise((resolve, reject) => { - const cleanup = () => worker.terminate(); - worker.onmessage = (event: MessageEvent) => { - cleanup(); - if (event.data && "error" in event.data) { - reject(new Error(event.data.error)); - } else { - resolve(new Uint8Array(event.data)); - } - }; - worker.onerror = (event) => { - cleanup(); - reject(new Error(event.message || "Argon2id worker failed")); - }; - const passphraseBytes = utf8Encode(validated.passphrase); - const saltBytes = validated.salt.slice(); - worker.postMessage( - { - passphrase: passphraseBytes, - salt: saltBytes, - parameters: validated.parameters, - }, - [passphraseBytes.buffer, saltBytes.buffer], - ); - }); -} - -export function normalizeBytes( - value: string | MTPBytesInput | MTPEncodedBytesInput, - name: string, - encoding?: "hex" | "base64", -): Uint8Array { - if (typeof value === "string") { - if (!encoding) { - throw new TypeError( - `${name} string input requires an explicit 'hex' or 'base64' encoding`, - ); - } - return bytesFromEncodedString(value, encoding, name); - } - if ( - value !== null && - typeof value === "object" && - !(value instanceof Uint8Array) && - !Array.isArray(value) - ) { - const encoded = value as Partial; - if ( - typeof encoded.value !== "string" || - (encoded.encoding !== "hex" && encoded.encoding !== "base64") - ) { - throw new TypeError( - `${name} must be bytes or { value: string, encoding: 'hex' | 'base64' }`, - ); - } - return bytesFromEncodedString(encoded.value, encoded.encoding, name); - } - return bytesFrom(value, name); -} - -export function inputU64(value: bigint | number | string, name: string): bigint { - if (typeof value === "number" && !Number.isSafeInteger(value)) { - throw new RangeError( - `${name} must be a safe integer number, bigint, or integer string`, - ); - } - let result: bigint; - try { - result = BigInt(value); - } catch (error) { - throw new RangeError(`${name} must be an integer`, { cause: error }); - } - if (result < 0n || result > 0xffff_ffff_ffff_ffffn) { - throw new RangeError(`${name} must be a u64`); - } - return result; -} - -export function toBigInt( - value: bigint | string | number | null | undefined, -): bigint | null { - if (value == null || value === "") return null; - return inputU64(value, "clientId"); -} - -const KEM_PUBLIC_KEY_LEN = 1216; -const SIG_PQ_PUBLIC_KEY_LEN = 1952; -const SIG_CL_PUBLIC_KEY_LEN = 32; - -export function keyringToKeys(keyring: MTPKeyMaterialInput): MTPKeyringKeys { - const bytes = normalizeBytes(keyring, "keyring"); - if (bytes.length < 12) { - throw new TypeError("keyring data is too short to contain 6 keys"); - } - - let offset = 0; - const readKey = () => { - if (offset + 2 > bytes.length) throw new TypeError("keyring is truncated"); - const len = (bytes[offset] << 8) | bytes[offset + 1]; - offset += 2; - if (offset + len > bytes.length) throw new TypeError("keyring is truncated"); - const key = bytes.slice(offset, offset + len); - offset += len; - return key; - }; - - const result = { - kemPublicKey: readKey(), - kemSecretKey: readKey(), - sigPqPublicKey: readKey(), - sigPqSecretKey: readKey(), - sigClPublicKey: readKey(), - sigClSecretKey: readKey(), - }; - if (offset !== bytes.length) throw new TypeError("keyring has trailing data"); - return result; -} - -export function publicKeyBundleToKeys( - publicKeyBundle: MTPKeyMaterialInput, -): MTPPublicKeyBundleKeys { - const bytes = normalizeBytes(publicKeyBundle, "publicKeyBundle"); - if (bytes.length < 6) { - throw new TypeError("public key bundle data is too short to contain 3 keys"); - } - - let offset = 0; - const readKey = () => { - if (offset + 2 > bytes.length) { - throw new TypeError("public key bundle is truncated"); - } - const len = (bytes[offset] << 8) | bytes[offset + 1]; - offset += 2; - if (offset + len > bytes.length) { - throw new TypeError("public key bundle is truncated"); - } - const key = bytes.slice(offset, offset + len); - offset += len; - return key; - }; - - const result = { - kemPublicKey: readKey(), - sigPqPublicKey: readKey(), - sigClPublicKey: readKey(), - }; - if (offset !== bytes.length) { - throw new TypeError("public key bundle has trailing data"); - } - if ( - result.kemPublicKey.length !== KEM_PUBLIC_KEY_LEN || - result.sigPqPublicKey.length !== SIG_PQ_PUBLIC_KEY_LEN || - result.sigClPublicKey.length !== SIG_CL_PUBLIC_KEY_LEN - ) { - throw new TypeError("public key bundle contains invalid suite key lengths"); - } - return result; -} - -export function cloneParsedValue(value: unknown): unknown { - if (value instanceof Uint8Array) return value.slice(); - if (Array.isArray(value)) return value.map(cloneParsedValue); - if (value !== null && typeof value === "object") { - return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, cloneParsedValue(entry)]), - ); - } - return value; -} - -export function cloneParsedFrame(frame: ParsedFrame): ParsedFrame { - return cloneParsedValue(frame) as ParsedFrame; -} - -function parsedDataObject( - data: ParsedFrame["data"] | null | undefined, -): Record { - if ( - data === null || - typeof data !== "object" || - Array.isArray(data) || - data instanceof Uint8Array - ) { - return {}; - } - const object = data as Record; - if (object.kind === "encrypted" || object.kind === "signed") return {}; - return object; -} - -export function errorMessage( - frame: Pick | null | undefined, -): string { - const data = parsedDataObject(frame?.data); - return String( - data.ErrorMessage ?? - data.Error ?? - data.Description ?? - `Received ${frame?.type ?? "error"} frame`, - ); -} - -export function parseProtectedFrame( - frame: MTPProtectedFrameInput, - limits?: MTPReceiveLimits, -): ParsedFrame { - const parse = (bytes: Uint8Array): ParsedFrame => - limits ? decodeWithLimits(bytes, limits) : bindings.parse_frame(bytes); - if (isBytes(frame)) return parse(bytesFrom(frame, "frame")); - if ( - frame === null || - typeof frame !== "object" || - typeof frame.type !== "string" - ) { - throw new TypeError("frame must be a parsed MTP frame or serialized bytes"); - } - if (frame.raw instanceof Uint8Array) return parse(frame.raw); - return frame; -} - -export function assertKnownCommunicationType(frame: ParsedFrame): void { - if (!frame.type || /^[0-9]+$/.test(frame.type)) { - throw new Error(`Unknown communication type: ${frame.type || "unknown"}`); - } - try { - bindings.build_frame(frame.type, null, {}); - } catch (error) { - throw new Error(`Unknown communication type: ${frame.type}`, { cause: error }); - } -} - -export function protectedFrameBytes( - frame: ParsedFrame, - limits?: MTPReceiveLimits, -): Uint8Array { - if (frame.raw instanceof Uint8Array) return frame.raw.slice(); - const data = - frame.data !== null && - typeof frame.data === "object" && - !Array.isArray(frame.data) && - !(frame.data instanceof Uint8Array) - ? (frame.data as Record) - : null; - const encoded = data?.encoded; - if (data?.kind !== "encrypted" || !(encoded instanceof Uint8Array)) { - throw new Error("protected frame payload is not encrypted"); - } - const options = { - id: frame.id, - ...(frame.sender == null ? {} : { sender: frame.sender }), - ...(frame.receiver == null ? {} : { receiver: frame.receiver }), - }; - const bounded = ( - bindings as typeof bindings & { - build_frame_with_payload_with_limits?: ( - type: string, - payload: Uint8Array, - options: MTPCodecOptions, - limits: MTPReceiveLimits, - ) => Uint8Array; - } - ).build_frame_with_payload_with_limits; - if (!bounded) { - throw new Error("bounded WASM frame encoding is unavailable; rebuild mtp-wasm"); - } - return bounded(frame.type, encoded, options, limits ?? {}); -} - -export function assertApplicationCommunicationType(type: string): string { - if (typeof type !== "string" || !type.trim() || /^[0-9]+$/.test(type)) { - throw new Error(`Unknown communication type: ${type || "unknown"}`); - } - if (Object.prototype.hasOwnProperty.call(RESERVED_COMMUNICATION_TYPE_IDS, type)) { - throw new Error( - `MTP control communication type ${type} cannot be used as application content`, - ); - } - try { - bindings.build_frame(type, null, {}); - } catch (error) { - throw new Error(`Unknown communication type: ${type}`, { cause: error }); - } - return type; -} - -export const MAX_DATA_VALUE_DEPTH = 64; -export const MAX_DATA_VALUE_VALUES = 65_536; - -const DEFAULT_ENCODE_LIMITS: Required = { - maxDepth: MAX_DATA_VALUE_DEPTH, - maxValues: MAX_DATA_VALUE_VALUES, - maxOutputSize: 16 * 1024 * 1024, -}; - -function normalizedEncodeLimits( - limits: MTPEncodeLimits | undefined, -): Required { - const result = { ...DEFAULT_ENCODE_LIMITS, ...(limits ?? {}) }; - for (const [key, value] of Object.entries(result)) { - if (!Number.isSafeInteger(value) || value < 0) { - throw new TypeError(`encode limits ${key} must be a non-negative safe integer`); - } - } - return result as Required; -} - -/** Validate a JS DataValue before crossing into the recursive WASM parser. */ -export function validateMTPDataValue( - value: MTPDataValueInput, - limits?: MTPEncodeLimits, -): void { - const effective = normalizedEncodeLimits(limits); - const ancestors = new WeakSet(); - let values = 0; - const validate = (candidate: unknown, depth: number): void => { - values += 1; - if (values > effective.maxValues) { - throw new RangeError("MTP DataValue value-count limit exceeded"); - } - if (depth > effective.maxDepth) { - throw new RangeError("MTP DataValue nesting-depth limit exceeded"); - } - if ( - candidate === null || - typeof candidate === "boolean" || - typeof candidate === "string" || - typeof candidate === "bigint" || - candidate instanceof Uint8Array - ) { - return; - } - if (typeof candidate === "number") { - if (Number.isInteger(candidate) && !Number.isSafeInteger(candidate)) { - throw new TypeError("unsafe integral MTP DataValue inputs must use bigint"); - } - return; - } - if (typeof candidate !== "object") { - throw new TypeError(`unsupported MTP DataValue input: ${typeof candidate}`); - } - const object = candidate as object; - if (ancestors.has(object)) throw new TypeError("MTP DataValue input must not be cyclic"); - if ( - !Array.isArray(candidate) && - Object.getPrototypeOf(candidate) !== Object.prototype && - Object.getPrototypeOf(candidate) !== null - ) { - throw new TypeError("MTP DataValue containers must be plain objects"); - } - ancestors.add(object); - const entries = Array.isArray(candidate) - ? candidate - : Object.values(candidate as Record); - try { - for (const entry of entries) validate(entry, depth + 1); - } finally { - ancestors.delete(object); - } - }; - validate(value, 0); -} - -export function encodeMTPDataValue( - value: MTPDataValueInput, - limits?: MTPEncodeLimits, -): Uint8Array { - const effective = normalizedEncodeLimits(limits); - validateMTPDataValue(value, effective); - const bounded = ( - bindings as typeof bindings & { - encode_data_value_with_limits?: ( - value: MTPDataValueInput, - limits: MTPEncodeLimits, - ) => Uint8Array; - } - ).encode_data_value_with_limits; - if (!bounded) { - throw new Error("bounded WASM DataValue encoding is unavailable; rebuild mtp-wasm"); - } - const encoded = bounded(value, effective); - if (encoded.length > effective.maxOutputSize) { - throw new RangeError("MTP DataValue encoded output limit exceeded"); - } - return encoded; -} - -export function inputDataValueBigInt(value: unknown, name: string): bigint { - try { - if (typeof value === "bigint") return value; - if (typeof value === "number" && Number.isSafeInteger(value)) return BigInt(value); - if (typeof value === "string" && value.length > 0) return BigInt(value); - } catch { - // Normalize malformed protected metadata below. - } - throw new Error(`protected metadata field ${name} is not an integer`); -} - -export function inputDataValueString(value: unknown, name: string): string { - if (typeof value === "string" && value.length > 0) return value; - throw new Error(`protected metadata field ${name} is not a non-empty string`); -} - -export function signatureSuiteValue( - suite: MTPProtectionSignatureSuite, -): number { - return suite === "dual" - ? bindings.mtp_protection_signature_suite_dual() - : bindings.mtp_protection_signature_suite_ed25519(); -} - -export function formatDataValue(value: MTPDataValue): MTPDataValue { - return cloneParsedValue(value) as MTPDataValue; -} diff --git a/src/sdk/credentials.ts b/src/sdk/credentials.ts deleted file mode 100644 index 0470b9d..0000000 --- a/src/sdk/credentials.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { MTPClientCredentials } from "./index.js"; - -export type InternalCredentials = { - clientId: bigint | null; - keyring: Uint8Array; - hostPublicKey?: Uint8Array; -}; - -export function publicCredentials( - credentials: InternalCredentials | null, -): MTPClientCredentials | null { - if (!credentials) { - return null; - } - return { - clientId: credentials.clientId, - keyring: credentials.keyring.slice(), - hostPublicKey: credentials.hostPublicKey?.slice(), - }; -} - -export function zeroCredentials(credentials: InternalCredentials | null): void { - // The host public key is intentionally not wiped: it is public configuration - // and may also be retained by the connection options. - credentials?.keyring.fill(0); -} diff --git a/src/sdk/encrypted-message.ts b/src/sdk/encrypted-message.ts deleted file mode 100644 index 60449c7..0000000 --- a/src/sdk/encrypted-message.ts +++ /dev/null @@ -1,362 +0,0 @@ -import * as bindings from "mtp/raw"; -import { MTPRatchet } from "./ratchet.js"; -import type { MTPSessionState } from "./session"; -import { concatBytes, writeU64BE } from "./utils.js"; - -export const MTP_E2EE_VERSION = 1; -export const FLAG_INIT = 0x01; -export const FLAG_DEVICE_SECRET = 0x02; -export const FLAG_KEY_ROTATION = 0x04; -export const MAX_RATCHET_SKIP = 100; -const SUPPORTED_FLAGS = FLAG_INIT | FLAG_DEVICE_SECRET | FLAG_KEY_ROTATION; -const HEADER_FIXED_LEN = 1 + 1 + 8 + 8 + 4 + 2 + 4; - -export interface ParsedEncryptedMessage { - version: 1; - flags: number; - senderId: bigint; - recipientId: bigint; - messageNumber: number; - kemCiphertext?: Uint8Array; - ciphertext: Uint8Array; - /** Compatibility alias for older SDK tests/callers. */ - header?: EncryptedMessageHeader; - /** Compatibility alias for older SDK tests/callers. */ - aeadPayload?: Uint8Array; -} - -export interface EncryptedMessageHeader { - version: 1; - flags: number; - senderId: bigint; - recipientId: bigint; - messageNumber: number; - kemCiphertext?: Uint8Array; -} -export interface SerializedEncryptedMessage { - header: EncryptedMessageHeader; - aeadPayload: Uint8Array; -} - -function readU64BE(bytes: Uint8Array, offset: number): bigint { - let value = 0n; - for (let i = 0; i < 8; i++) { - value = (value << 8n) | BigInt(bytes[offset + i]); - } - return value; -} - -function writeU32BE(value: number): Uint8Array { - if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff) { - throw new Error("u32 value out of range"); - } - return new Uint8Array([ - (value >>> 24) & 0xff, - (value >>> 16) & 0xff, - (value >>> 8) & 0xff, - value & 0xff, - ]); -} - -function assertSupported(message: ParsedEncryptedMessage): void { - if (message.version !== MTP_E2EE_VERSION) { - throw new Error( - `Unsupported encrypted message version: ${message.version}`, - ); - } - if ((message.flags & ~SUPPORTED_FLAGS) !== 0) { - throw new Error(`Unsupported encrypted message flags: ${message.flags}`); - } - const isInit = (message.flags & FLAG_INIT) !== 0; - if (isInit && !message.kemCiphertext?.length) { - throw new Error("Init message must include KEM ciphertext"); - } - if (!isInit && message.kemCiphertext?.length) { - throw new Error("Non-init message must not include KEM ciphertext"); - } - if (!message.ciphertext.length) { - throw new Error("Encrypted message ciphertext must be non-empty"); - } -} - -export function serializeEncryptedMessage( - message: ParsedEncryptedMessage, -): Uint8Array; -export function serializeEncryptedMessage( - message: SerializedEncryptedMessage, -): Uint8Array; -export function serializeEncryptedMessage( - message: ParsedEncryptedMessage | SerializedEncryptedMessage, -): Uint8Array { - const normalized: ParsedEncryptedMessage = - "header" in message - ? ({ - ...message.header, - ciphertext: message.aeadPayload, - } as ParsedEncryptedMessage) - : message; - - assertSupported(normalized); - const kemCiphertext = normalized.kemCiphertext ?? new Uint8Array(0); - if (kemCiphertext.length > 0xffff) { - throw new Error("KEM ciphertext too long"); - } - - return concatBytes([ - new Uint8Array([normalized.version]), - new Uint8Array([normalized.flags]), - writeU64BE(normalized.senderId), - writeU64BE(normalized.recipientId), - writeU32BE(normalized.messageNumber), - new Uint8Array([ - (kemCiphertext.length >>> 8) & 0xff, - kemCiphertext.length & 0xff, - ]), - kemCiphertext, - writeU32BE(normalized.ciphertext.length), - normalized.ciphertext, - ]); -} - -export function parseEncryptedMessage( - bytes: Uint8Array, -): ParsedEncryptedMessage { - let offset = 0; - if (!(bytes instanceof Uint8Array)) { - throw new Error("Encrypted message must be bytes"); - } - if (bytes.length < HEADER_FIXED_LEN) { - throw new Error("Encrypted message too short"); - } - - const version = bytes[offset++]; - const flags = bytes[offset++]; - const senderId = readU64BE(bytes, offset); - offset += 8; - const recipientId = readU64BE(bytes, offset); - offset += 8; - const messageNumber = - ((bytes[offset] << 24) | - (bytes[offset + 1] << 16) | - (bytes[offset + 2] << 8) | - bytes[offset + 3]) >>> - 0; - offset += 4; - const kemLen = (bytes[offset] << 8) | bytes[offset + 1]; - offset += 2; - - let kemCiphertext: Uint8Array | undefined; - if (kemLen > 0) { - if (bytes.length < offset + kemLen + 4) { - throw new Error("Encrypted message KEM ciphertext truncated"); - } - kemCiphertext = bytes.slice(offset, offset + kemLen); - offset += kemLen; - } - - if (bytes.length < offset + 4) { - throw new Error("Encrypted message missing ciphertext length"); - } - const ciphertextLen = - ((bytes[offset] << 24) | - (bytes[offset + 1] << 16) | - (bytes[offset + 2] << 8) | - bytes[offset + 3]) >>> - 0; - offset += 4; - if (bytes.length < offset + ciphertextLen) { - throw new Error("Encrypted message ciphertext truncated"); - } - const ciphertext = bytes.slice(offset, offset + ciphertextLen); - offset += ciphertextLen; - if (offset !== bytes.length) { - throw new Error("Encrypted message has trailing data"); - } - - const parsed: ParsedEncryptedMessage = { - version: version as 1, - flags, - senderId, - recipientId, - messageNumber, - kemCiphertext, - ciphertext, - }; - parsed.header = { - version: parsed.version, - flags: parsed.flags, - senderId: parsed.senderId, - recipientId: parsed.recipientId, - messageNumber: parsed.messageNumber, - kemCiphertext: parsed.kemCiphertext, - }; - parsed.aeadPayload = parsed.ciphertext; - assertSupported(parsed); - return parsed; -} - -function buildAAD(header: EncryptedMessageHeader): Uint8Array { - return concatBytes([ - new Uint8Array([header.version]), - new Uint8Array([header.flags]), - writeU64BE(header.senderId), - writeU64BE(header.recipientId), - writeU32BE(header.messageNumber), - ]); -} - -export function encryptedMessageAAD( - header: EncryptedMessageHeader, - extra?: Uint8Array, -): Uint8Array { - return extra?.length - ? concatBytes([buildAAD(header), extra]) - : buildAAD(header); -} - -export async function encryptPayload(args: { - plaintext: Uint8Array; - session: MTPSessionState; - kemCiphertext?: Uint8Array; - aad?: Uint8Array; -}): Promise<{ - payload: Uint8Array; - session: MTPSessionState; -}> { - const step = await MTPRatchet.stepSend(args.session.sendChainKey); - const header: EncryptedMessageHeader = { - version: 1, - flags: args.kemCiphertext ? FLAG_INIT : 0, - senderId: args.session.localId, - recipientId: args.session.remoteId, - messageNumber: args.session.sendCount, - kemCiphertext: args.kemCiphertext, - }; - const aad = args.aad ?? encryptedMessageAAD(header); - const cipher = new bindings.WasmChaCha20Poly1305(step.key); - let ciphertext: Uint8Array; - try { - ciphertext = cipher.encrypt(args.plaintext, aad); - } finally { - cipher.free(); - step.key.fill(0); - } - - const payload = serializeEncryptedMessage({ ...header, ciphertext }); - return { - payload, - session: { - ...args.session, - sendChainKey: step.chainKey, - sendCount: args.session.sendCount + 1, - updatedAt: Date.now(), - }, - }; -} - -export async function decryptPayload(args: { - payload: Uint8Array; - session: MTPSessionState; - expectedRecipientId?: bigint; - aad?: Uint8Array; -}): Promise<{ - plaintext: Uint8Array; - session: MTPSessionState; -}> { - const parsed = parseEncryptedMessage(args.payload); - const expectedRecipientId = args.expectedRecipientId ?? args.session.localId; - if (parsed.recipientId !== expectedRecipientId) { - throw new Error("Encrypted message recipient mismatch"); - } - if (parsed.senderId !== args.session.remoteId) { - throw new Error("Encrypted message sender mismatch"); - } - - const existingSkippedMessageKeys = args.session.skippedMessageKeys ?? []; - const cachedKeyIndex = existingSkippedMessageKeys.findIndex( - (skipped) => skipped.messageNumber === parsed.messageNumber, - ); - if (parsed.messageNumber < args.session.recvCount && cachedKeyIndex < 0) { - throw new Error("Encrypted message replay message number"); - } - - let chainKey = args.session.recvChainKey; - let messageKey: Uint8Array | undefined; - let skippedMessageKeys = existingSkippedMessageKeys.slice(); - const newlyDerivedKeys: Uint8Array[] = []; - let nextRecvCount = args.session.recvCount; - - if (cachedKeyIndex >= 0) { - // Work on a copy so an invalid ciphertext cannot consume the cached key. - messageKey = skippedMessageKeys[cachedKeyIndex].key.slice(); - } else { - const gap = parsed.messageNumber - args.session.recvCount; - if (gap > MAX_RATCHET_SKIP) { - throw new Error( - `Encrypted message receive gap exceeds max skip (${MAX_RATCHET_SKIP})`, - ); - } - - const steps = gap + 1; - for (let i = 0; i < steps; i += 1) { - const step = await MTPRatchet.stepRecv(chainKey); - if (i === steps - 1) { - messageKey = step.key; - } else { - skippedMessageKeys.push({ - messageNumber: args.session.recvCount + i, - key: step.key, - }); - newlyDerivedKeys.push(step.key); - } - if (chainKey !== args.session.recvChainKey) chainKey.fill(0); - chainKey = step.chainKey; - } - nextRecvCount = parsed.messageNumber + 1; - } - if (!messageKey) { - throw new Error("Failed to derive receive message key"); - } - - const header: EncryptedMessageHeader = { - version: parsed.version, - flags: parsed.flags, - senderId: parsed.senderId, - recipientId: parsed.recipientId, - messageNumber: parsed.messageNumber, - kemCiphertext: parsed.kemCiphertext, - }; - const aad = args.aad ?? encryptedMessageAAD(header); - const cipher = new bindings.WasmChaCha20Poly1305(messageKey); - let plaintext: Uint8Array; - try { - plaintext = cipher.decrypt(parsed.ciphertext, aad); - } catch (error) { - for (const key of newlyDerivedKeys) key.fill(0); - if (chainKey !== args.session.recvChainKey) chainKey.fill(0); - throw error; - } finally { - cipher.free(); - messageKey.fill(0); - } - - if (cachedKeyIndex >= 0) { - const [consumed] = skippedMessageKeys.splice(cachedKeyIndex, 1); - consumed.key.fill(0); - } - while (skippedMessageKeys.length > MAX_RATCHET_SKIP) { - const evicted = skippedMessageKeys.shift(); - evicted?.key.fill(0); - } - - return { - plaintext, - session: { - ...args.session, - recvChainKey: chainKey, - recvCount: nextRecvCount, - skippedMessageKeys, - updatedAt: Date.now(), - }, - }; -} diff --git a/src/sdk/encrypted-pipe.ts b/src/sdk/encrypted-pipe.ts deleted file mode 100644 index b616654..0000000 --- a/src/sdk/encrypted-pipe.ts +++ /dev/null @@ -1,1523 +0,0 @@ -import * as bindings from "mtp/raw"; -import type { - MTPBytesInput, - MTPPipeReader, - MTPPipeWriter, - MTPProtectionSignatureSuite, -} from "./index.js"; -import { - MTPSignatureVerificationError, - resolveSignatureVerificationPolicy, - signatureVerificationFailure, - verifyDataValueWithPolicy, -} from "./signature-policy.js"; -import type { MTPSignatureVerificationPolicy } from "./signature-policy.js"; -import { concatBytes, utf8Encode, writeU64BE } from "./utils.js"; - -const PIPE_E2EE_DOMAIN = utf8Encode("MTP-PIPE-E2EE-1"); -const PIPE_RECORD_KDF_DOMAIN = utf8Encode("MTP-PIPE-E2EE-1/KEY"); -const PIPE_TRANSCRIPT_DOMAIN = utf8Encode("MTP-PIPE-TRANSCRIPT-1"); -const PIPE_RECORD_MESSAGE_LABEL = utf8Encode("/message"); -const PIPE_RECORD_NEXT_LABEL = utf8Encode("/next"); -const XCHACHA_OVERHEAD = 24 + 16; -const MAX_SESSION_ID = 1024; -export const MAX_ENCRYPTED_PIPE_RECORD = 16 * 1024 * 1024; -export const MAX_PIPE_SESSION_OFFER = 64 * 1024; -const PIPE_SESSION_OFFER_DOMAIN = "MTP-PIPE-SESSION-1"; -const FS_INIT_DOMAIN = "MTP-PIPE-FS-INIT-1"; -const FS_RESPONSE_DOMAIN = "MTP-PIPE-FS-RESPONSE-1"; -const FS_FINISH_DOMAIN = "MTP-PIPE-FS-FINISH-1"; -const FS_ROOT_INFO = utf8Encode("MTP-PIPE-FS-ROOT-1"); -const RECORD_TYPE_DATA = 0; -const RECORD_TYPE_FINAL = 1; -const MAX_PIPE_BUFFER = MAX_ENCRYPTED_PIPE_RECORD + 5; -// Session setup may leave one encrypted record in the same transport chunk -// after an offer. Bound that carry-over buffer before concatenating attacker- -// controlled chunks, just as the record reader bounds its input buffer. -const MAX_SESSION_BUFFER = MAX_PIPE_BUFFER + MAX_PIPE_SESSION_OFFER + 4; -const KEM_PUBLIC_KEY_LEN = 1216; -const SIG_PQ_PUBLIC_KEY_LEN = 1952; -const SIG_CL_PUBLIC_KEY_LEN = 32; - -export function pipeSessionSignaturePurpose(): number { - return bindings.mtp_pipe_session_signature_purpose(); -} - -export function pipeSessionEncryptionPurpose(): number { - return bindings.mtp_pipe_session_encryption_purpose(); -} - -/** - * Validate a purpose supplied for application pipe records. MTP-owned - * purpose bytes come from the WASM protocol registry so browser callers do - * not have to duplicate the numeric allocation. - */ -export function validateApplicationProtectionPurpose(purpose: number): number { - if (!Number.isInteger(purpose) || purpose < 0 || purpose > 0xff) { - throw new MTPEncryptedPipeError("context", "purpose must be a u8"); - } - const reserved = new Set([ - bindings.mtp_relay_metadata_encryption_purpose(), - bindings.mtp_relay_content_signature_purpose(), - bindings.mtp_relay_content_encryption_purpose(), - bindings.mtp_relay_metadata_signature_purpose(), - bindings.mtp_pipe_session_signature_purpose(), - bindings.mtp_pipe_session_encryption_purpose(), - ]); - if (reserved.has(purpose)) { - throw new MTPEncryptedPipeError( - "context", - "purpose is reserved for an MTP protocol operation", - ); - } - return purpose; -} - -export class MTPEncryptedPipeError extends Error { - readonly code: - | "context" - | "record-length" - | "sequence" - | "truncated" - | "authentication" - | "io" - | "setup" - | "state"; - - constructor( - code: MTPEncryptedPipeError["code"], - message: string, - options?: ErrorOptions, - ) { - super(message, options); - this.name = "MTPEncryptedPipeError"; - this.code = code; - } -} - -export class MTPPipeProtectionContext { - readonly sessionId: Uint8Array; - readonly purpose: number; - readonly direction: number; - readonly transcriptHash: Uint8Array; - - constructor( - sessionId: Uint8Array, - purpose: number, - direction: number, - transcriptHash?: Uint8Array, - ) { - if ( - !(sessionId instanceof Uint8Array) || - sessionId.length === 0 || - sessionId.length > MAX_SESSION_ID - ) { - throw new MTPEncryptedPipeError( - "context", - "sessionId must contain between 1 and 1024 bytes", - ); - } - validateApplicationProtectionPurpose(purpose); - if (!Number.isInteger(direction) || direction < 0 || direction > 0xff) { - throw new MTPEncryptedPipeError("context", "direction must be a u8"); - } - this.sessionId = sessionId.slice(); - this.purpose = purpose; - this.direction = direction; - if (transcriptHash != null) { - if ( - !(transcriptHash instanceof Uint8Array) || - transcriptHash.length !== 32 - ) { - throw new MTPEncryptedPipeError( - "context", - "transcriptHash must be 32 bytes", - ); - } - this.transcriptHash = transcriptHash.slice(); - } else { - this.transcriptHash = baseTranscriptHash( - this.sessionId, - purpose, - direction, - ); - } - } -} - -function u32(value: number): Uint8Array { - const result = new Uint8Array(4); - new DataView(result.buffer).setUint32(0, value, false); - return result; -} - -function sessionTranscriptHash(params: MTPPipeSessionParameters): Uint8Array { - return bindings.wasm_sha256( - concatBytes([ - PIPE_TRANSCRIPT_DOMAIN, - u32(params.sessionId.length), - params.sessionId, - u32(params.pipeId), - writeU64BE(params.senderId), - writeU64BE(params.recipientId), - new Uint8Array([params.purpose, params.direction]), - ]), - ); -} - -function baseTranscriptHash( - sessionId: Uint8Array, - purpose: number, - direction: number, -): Uint8Array { - return bindings.wasm_sha256( - concatBytes([ - PIPE_TRANSCRIPT_DOMAIN, - u32(sessionId.length), - sessionId, - new Uint8Array([purpose, direction]), - ]), - ); -} - -function recordLength(plaintextLength: number): number { - const length = plaintextLength + XCHACHA_OVERHEAD; - if ( - !Number.isSafeInteger(length) || - length < XCHACHA_OVERHEAD || - length > MAX_ENCRYPTED_PIPE_RECORD || - length > 0xffff_ffff - ) { - throw new MTPEncryptedPipeError( - "record-length", - `invalid encrypted pipe record length: ${length}`, - ); - } - return length; -} - -function aad( - context: MTPPipeProtectionContext, - sequence: bigint, - encodedLength: number, - recordType: number, -): Uint8Array { - return concatBytes([ - PIPE_E2EE_DOMAIN, - new Uint8Array([context.purpose, context.direction]), - context.transcriptHash, - writeU64BE(sequence), - u32(encodedLength), - new Uint8Array([recordType]), - ]); -} - -function keyBytes(key: Uint8Array): Uint8Array { - if (!(key instanceof Uint8Array) || key.length !== 32) { - throw new MTPEncryptedPipeError( - "context", - "pipe session key must be 32 bytes", - ); - } - return key.slice(); -} - -function recordKeyInfo( - context: MTPPipeProtectionContext, - sequence: bigint, - label: Uint8Array, -): Uint8Array { - return concatBytes([ - PIPE_RECORD_KDF_DOMAIN, - new Uint8Array([context.purpose, context.direction]), - context.transcriptHash, - writeU64BE(sequence), - label, - ]); -} - -function deriveRecordKeys( - chainKey: Uint8Array, - context: MTPPipeProtectionContext, - sequence: bigint, -): { messageKey: Uint8Array; nextChainKey: Uint8Array } { - try { - return { - messageKey: bindings.wasm_hkdf_expand( - chainKey, - context.transcriptHash, - recordKeyInfo(context, sequence, PIPE_RECORD_MESSAGE_LABEL), - 32, - ), - nextChainKey: bindings.wasm_hkdf_expand( - chainKey, - context.transcriptHash, - recordKeyInfo(context, sequence, PIPE_RECORD_NEXT_LABEL), - 32, - ), - }; - } catch (error) { - throw new MTPEncryptedPipeError( - "authentication", - "encrypted pipe record key derivation failed", - { cause: error }, - ); - } -} - -export interface MTPWritablePipe { - write(data: Uint8Array): Promise; - close(): Promise; - abort(): void; -} - -export interface MTPReadablePipe { - read(): Promise; -} - -/** Advanced duplex transport required by the forward-secure handshake. */ -export interface MTPDuplexPipe extends MTPWritablePipe, MTPReadablePipe { - readonly pipeId?: number; -} - -export interface MTPPipeSessionParameters { - sessionId: Uint8Array; - pipeId: number; - senderId: bigint; - recipientId: bigint; - purpose: number; - direction: number; -} - -/** Session fields the receiver can know before decrypting the offer. */ -export type MTPPipeSessionExpectation = Omit< - MTPPipeSessionParameters, - "sessionId" ->; - -function contextForSessionParameters( - params: MTPPipeSessionParameters, -): MTPPipeProtectionContext { - return new MTPPipeProtectionContext( - params.sessionId, - params.purpose, - params.direction, - sessionTranscriptHash(params), - ); -} - -function sessionError(message: string, cause?: unknown): MTPEncryptedPipeError { - return new MTPEncryptedPipeError("setup", message, { cause }); -} - -function bytesInput(value: MTPBytesInput, name: string): Uint8Array { - if (value instanceof Uint8Array) return value.slice(); - if (Array.isArray(value)) return new Uint8Array(value); - throw sessionError(`${name} must be a Uint8Array or number[]`); -} - -function publicKeyBundleInputs(value: Uint8Array): void { - let offset = 0; - const lengths: number[] = []; - for (let index = 0; index < 3; index += 1) { - if (offset + 2 > value.length) - throw sessionError("public key bundle is truncated"); - const length = new DataView( - value.buffer, - value.byteOffset, - value.byteLength, - ).getUint16(offset, false); - offset += 2; - if (offset + length > value.length) { - throw sessionError("public key bundle is truncated"); - } - lengths.push(length); - offset += length; - } - if ( - offset !== value.length || - lengths[0] !== KEM_PUBLIC_KEY_LEN || - lengths[1] !== SIG_PQ_PUBLIC_KEY_LEN || - lengths[2] !== SIG_CL_PUBLIC_KEY_LEN - ) { - throw sessionError("public key bundle contains invalid suite key lengths"); - } -} - -function recipientBundleInputs( - value: MTPBytesInput | MTPBytesInput[], -): Uint8Array[] { - // A number[] is one serialized bundle; an array whose first element is a - // byte array is the multi-recipient form. - if (value instanceof Uint8Array) return [value.slice()]; - if ( - Array.isArray(value) && - (value.length === 0 || typeof value[0] === "number") - ) { - return [bytesInput(value as MTPBytesInput, "recipientPublicKey")]; - } - if (!Array.isArray(value)) { - throw sessionError( - "recipientPublicKey must be bytes or an array of bundles", - ); - } - const result = value.map((entry, index) => - bytesInput(entry, `recipientPublicKeys[${index}]`), - ); - if (result.length === 0) { - throw sessionError("at least one recipient public key is required"); - } - return result; -} - -function validateSessionParameters( - params: MTPPipeSessionParameters, -): MTPPipeSessionParameters { - if ( - !(params.sessionId instanceof Uint8Array) || - params.sessionId.length === 0 || - params.sessionId.length > MAX_SESSION_ID - ) { - throw sessionError("sessionId must contain between 1 and 1024 bytes"); - } - if ( - !Number.isInteger(params.pipeId) || - params.pipeId <= 0 || - params.pipeId > 0xffff_ffff - ) { - throw sessionError("pipeId must be a non-zero u32"); - } - if (params.senderId < 0n || params.senderId > 0xffff_ffff_ffff_ffffn) { - throw sessionError("senderId must be a u64"); - } - if (params.recipientId < 0n || params.recipientId > 0xffff_ffff_ffff_ffffn) { - throw sessionError("recipientId must be a u64"); - } - validateApplicationProtectionPurpose(params.purpose); - if ( - !Number.isInteger(params.direction) || - params.direction < 0 || - params.direction > 0xff - ) { - throw sessionError("direction must be a u8"); - } - return { ...params, sessionId: params.sessionId.slice() }; -} - -function serializedKeyring(keyring: MTPBytesInput): { - bytes: Uint8Array; - hasPqSigningKey: boolean; -} { - const bytes = bytesInput(keyring, "keyring"); - let offset = 0; - const fields: Uint8Array[] = []; - for (let index = 0; index < 6; index += 1) { - if (offset + 2 > bytes.length) throw sessionError("keyring is truncated"); - const length = new DataView( - bytes.buffer, - bytes.byteOffset, - bytes.byteLength, - ).getUint16(offset, false); - offset += 2; - if (offset + length > bytes.length) - throw sessionError("keyring is truncated"); - fields.push(bytes.slice(offset, offset + length)); - offset += length; - } - if (offset !== bytes.length || fields[5].length !== 32) { - throw sessionError("keyring does not contain a valid Ed25519 secret key"); - } - const hasPqPublicKey = fields[2].length > 0; - const hasPqSecretKey = fields[3].length > 0; - return { - bytes, - hasPqSigningKey: hasPqPublicKey && hasPqSecretKey, - }; -} - -function selectedSignatureSuite( - keyring: ReturnType, - requested: MTPProtectionSignatureSuite | undefined, -): MTPProtectionSignatureSuite { - const suite = requested ?? "ed25519"; - if (suite !== "dual" && suite !== "ed25519") { - throw sessionError("signatureSuite must be 'dual' or 'ed25519'"); - } - if (suite === "dual" && !keyring.hasPqSigningKey) { - throw sessionError( - "dual protected signatures require a complete ML-DSA key pair; choose 'ed25519' for a partial keyring", - ); - } - return suite; -} - -function asBigInt(value: unknown, name: string): bigint { - try { - if (typeof value === "bigint") return value; - if (typeof value === "number" && Number.isSafeInteger(value)) - return BigInt(value); - if (typeof value === "string" && value.length > 0) return BigInt(value); - } catch (error) { - throw sessionError(`${name} is not an integer`, error); - } - throw sessionError(`${name} is not an integer`); -} - -function asBytes(value: unknown, name: string): Uint8Array { - if (value instanceof Uint8Array) return value; - throw sessionError(`${name} is not binary`); -} - -function appendChunk(buffer: Uint8Array, chunk: Uint8Array): Uint8Array { - if ( - buffer.length > MAX_SESSION_BUFFER || - chunk.length > MAX_SESSION_BUFFER - buffer.length - ) { - throw sessionError("encrypted pipe session input buffer is too large"); - } - const combined = new Uint8Array(buffer.length + chunk.length); - combined.set(buffer); - combined.set(chunk, buffer.length); - return combined; -} - -async function readSessionOffer(reader: MTPReadablePipe): Promise<{ - offer: Uint8Array; - remainder: Uint8Array; -}> { - let buffer: Uint8Array = new Uint8Array(0); - const ensure = async (length: number): Promise => { - while (buffer.length < length) { - const chunk = await reader.read(); - if (chunk == null) - throw sessionError("pipe ended before session setup completed"); - if (!(chunk instanceof Uint8Array)) - throw sessionError("pipe reader returned non-byte data"); - if (chunk.length > 0) buffer = appendChunk(buffer, chunk); - } - }; - - await ensure(4); - const offerLength = new DataView( - buffer.buffer, - buffer.byteOffset, - buffer.byteLength, - ).getUint32(0, false); - if (offerLength === 0 || offerLength > MAX_PIPE_SESSION_OFFER) { - throw sessionError(`invalid pipe session offer length: ${offerLength}`); - } - await ensure(4 + offerLength); - return { - offer: buffer.slice(4, 4 + offerLength), - remainder: buffer.slice(4 + offerLength), - }; -} - -class MTPSessionOfferReader { - private buffered: Uint8Array = new Uint8Array(0); - - constructor(private readonly reader: MTPReadablePipe) {} - - async read(): Promise { - const result = await readSessionOfferWithBuffer(this.reader, this.buffered); - this.buffered = result.remainder; - return result.offer; - } - - remainder(): Uint8Array { - return this.buffered.slice(); - } -} - -async function readSessionOfferWithBuffer( - reader: MTPReadablePipe, - initialBuffer: Uint8Array, -): Promise<{ offer: Uint8Array; remainder: Uint8Array }> { - let buffer: Uint8Array = initialBuffer.slice(); - const ensure = async (length: number): Promise => { - while (buffer.length < length) { - const chunk = await reader.read(); - if (chunk == null) - throw sessionError("pipe ended before session setup completed"); - if (!(chunk instanceof Uint8Array)) { - throw sessionError("pipe reader returned non-byte data"); - } - if (chunk.length > 0) buffer = appendChunk(buffer, chunk); - } - }; - - await ensure(4); - const offerLength = new DataView( - buffer.buffer, - buffer.byteOffset, - buffer.byteLength, - ).getUint32(0, false); - if (offerLength === 0 || offerLength > MAX_PIPE_SESSION_OFFER) { - throw sessionError(`invalid pipe session offer length: ${offerLength}`); - } - await ensure(4 + offerLength); - return { - offer: buffer.slice(4, 4 + offerLength), - remainder: buffer.slice(4 + offerLength), - }; -} - -async function writeSessionOffer( - writer: MTPWritablePipe, - offer: Uint8Array, -): Promise { - if (offer.length === 0 || offer.length > MAX_PIPE_SESSION_OFFER) { - throw sessionError(`invalid pipe session offer length: ${offer.length}`); - } - const prefix = new Uint8Array(4); - new DataView(prefix.buffer).setUint32(0, offer.length, false); - await writer.write(concatBytes([prefix, offer])); -} - -function handshakeHash(parts: readonly Uint8Array[]): Uint8Array { - return bindings.wasm_sha256( - concatBytes(parts.flatMap((part) => [u32(part.length), part])), - ); -} - -function fsCommonFields(params: MTPPipeSessionParameters): unknown[] { - return [ - params.sessionId, - BigInt(params.pipeId), - params.senderId, - params.recipientId, - BigInt(params.purpose), - BigInt(params.direction), - ]; -} - -function fsInitValue( - params: MTPPipeSessionParameters, - nonce: Uint8Array, -): unknown[] { - return [FS_INIT_DOMAIN, ...fsCommonFields(params), nonce]; -} - -function fsResponseValue( - params: MTPPipeSessionParameters, - initHash: Uint8Array, - ephemeralPublicKey: Uint8Array, -): unknown[] { - return [ - FS_RESPONSE_DOMAIN, - ...fsCommonFields(params), - initHash, - ephemeralPublicKey, - ]; -} - -function fsFinishValue( - params: MTPPipeSessionParameters, - responseHash: Uint8Array, - ciphertext: Uint8Array, -): unknown[] { - return [ - FS_FINISH_DOMAIN, - ...fsCommonFields(params), - responseHash, - ciphertext, - ]; -} - -function signHandshakeValue( - value: unknown, - signerId: bigint, - keyring: ReturnType, - suite: MTPProtectionSignatureSuite, -): Uint8Array { - return bindings.sign_data_value_with_keyring( - bindings.encode_data_value(value), - signerId, - pipeSessionSignaturePurpose(), - keyring.bytes, - suite === "dual" - ? bindings.mtp_protection_signature_suite_dual() - : bindings.mtp_protection_signature_suite_ed25519(), - ); -} - -function verifiedHandshakeValue( - encoded: Uint8Array, - expectedSignerId: bigint, - senderPublicKey: Uint8Array, - policy: MTPSignatureVerificationPolicy, -): unknown[] { - return verifiedHandshakeValueWithKeys( - encoded, - expectedSignerId, - [senderPublicKey], - policy, - ); -} - -function verifiedHandshakeValueWithKeys( - encoded: Uint8Array, - expectedSignerId: bigint, - senderPublicKeys: Uint8Array[], - policy: MTPSignatureVerificationPolicy, -): unknown[] { - const errors: unknown[] = []; - let verified = false; - for (const senderPublicKey of senderPublicKeys) { - try { - verifyDataValueWithPolicy( - encoded, - senderPublicKey, - expectedSignerId, - pipeSessionSignaturePurpose(), - policy, - ); - verified = true; - break; - } catch (error) { - errors.push(error); - } - } - if (!verified) throw signatureVerificationFailure(errors, expectedSignerId); - const parsed = bindings.parse_data_value(encoded); - if ( - parsed === null || - typeof parsed !== "object" || - Array.isArray(parsed) || - (parsed as Record).kind !== "signed" - ) { - throw sessionError("forward-secure handshake value is not signed"); - } - const fields = (parsed as { value?: unknown }).value; - if (!Array.isArray(fields)) { - throw sessionError("forward-secure handshake value is not an array"); - } - return fields; -} - -function validateFsCommon( - fields: unknown[], - expected: MTPPipeSessionParameters, - domain: string, - length: number, -): void { - if (fields.length !== length || fields[0] !== domain) { - throw sessionError("forward-secure handshake domain or length mismatch"); - } - const sessionId = asBytes(fields[1], "sessionId"); - if ( - sessionId.length !== expected.sessionId.length || - sessionId.some((byte, index) => byte !== expected.sessionId[index]) - ) { - throw sessionError("forward-secure handshake session mismatch"); - } - if (asBigInt(fields[2], "pipeId") !== BigInt(expected.pipeId)) { - throw sessionError("forward-secure handshake pipe mismatch"); - } - if (asBigInt(fields[3], "senderId") !== expected.senderId) { - throw sessionError("forward-secure handshake sender mismatch"); - } - if (asBigInt(fields[4], "recipientId") !== expected.recipientId) { - throw sessionError("forward-secure handshake recipient mismatch"); - } - if (asBigInt(fields[5], "purpose") !== BigInt(expected.purpose)) { - throw sessionError("forward-secure handshake purpose mismatch"); - } - if (asBigInt(fields[6], "direction") !== BigInt(expected.direction)) { - throw sessionError("forward-secure handshake direction mismatch"); - } -} - -function forwardSecureContext( - params: MTPPipeSessionParameters, - handshakeTranscript: Uint8Array, -): MTPPipeProtectionContext { - return new MTPPipeProtectionContext( - params.sessionId, - params.purpose, - params.direction, - bindings.wasm_sha256( - concatBytes([ - PIPE_TRANSCRIPT_DOMAIN, - sessionTranscriptHash(params), - handshakeTranscript, - ]), - ), - ); -} - -function validateOfferFields( - signedValue: unknown, - expected: MTPPipeSessionParameters, -): Uint8Array { - if ( - signedValue === null || - typeof signedValue !== "object" || - Array.isArray(signedValue) || - (signedValue as Record).kind !== "signed" - ) { - throw sessionError("pipe session offer is not signed"); - } - const fields = (signedValue as { value?: unknown }).value; - if (!Array.isArray(fields) || fields.length !== 8) { - throw sessionError("pipe session offer has invalid fields"); - } - if (fields[0] !== PIPE_SESSION_OFFER_DOMAIN) { - throw sessionError("pipe session offer domain mismatch"); - } - if (asBytes(fields[1], "sessionId").length !== expected.sessionId.length) { - throw sessionError("pipe session offer session mismatch"); - } - const sessionId = asBytes(fields[1], "sessionId"); - if (sessionId.some((byte, index) => byte !== expected.sessionId[index])) { - throw sessionError("pipe session offer session mismatch"); - } - if (asBigInt(fields[2], "pipeId") !== BigInt(expected.pipeId)) { - throw sessionError("pipe session offer pipe mismatch"); - } - if (asBigInt(fields[3], "senderId") !== expected.senderId) { - throw sessionError("pipe session offer sender mismatch"); - } - if (asBigInt(fields[4], "recipientId") !== expected.recipientId) { - throw sessionError("pipe session offer recipient mismatch"); - } - if (asBigInt(fields[5], "purpose") !== BigInt(expected.purpose)) { - throw sessionError("pipe session offer purpose mismatch"); - } - if (asBigInt(fields[6], "direction") !== BigInt(expected.direction)) { - throw sessionError("pipe session offer direction mismatch"); - } - const key = asBytes(fields[7], "session key"); - if (key.length !== 32) - throw sessionError("pipe session offer key is not 32 bytes"); - return key.slice(); -} - -function senderBundleInputs( - value: MTPBytesInput | MTPBytesInput[], -): Uint8Array[] { - const bundles = recipientBundleInputs(value); - bundles.forEach((bundle) => publicKeyBundleInputs(bundle)); - return bundles; -} - -function verifyPipeSessionValue( - signed: Uint8Array, - senderBundles: Uint8Array[], - expectedSignerId: bigint, - policy: MTPSignatureVerificationPolicy, -): void { - const errors: unknown[] = []; - for (const senderBundle of senderBundles) { - try { - verifyDataValueWithPolicy( - signed, - senderBundle, - expectedSignerId, - pipeSessionSignaturePurpose(), - policy, - ); - return; - } catch (error) { - errors.push(error); - } - } - throw signatureVerificationFailure(errors, expectedSignerId); -} - -function sessionIdFromOffer(parsed: unknown): Uint8Array { - if ( - parsed === null || - typeof parsed !== "object" || - Array.isArray(parsed) || - (parsed as Record).kind !== "signed" - ) { - throw sessionError("pipe session offer is not signed"); - } - const fields = (parsed as { value?: unknown }).value; - if (!Array.isArray(fields) || fields.length !== 8) { - throw sessionError("pipe session offer has invalid fields"); - } - const sessionId = asBytes(fields[1], "sessionId"); - if (sessionId.length === 0 || sessionId.length > MAX_SESSION_ID) { - throw sessionError("pipe session offer session ID is invalid"); - } - return sessionId.slice(); -} - -/** Send a signed/KEM-protected pipe session offer and return its record writer. */ -export async function initiateMTPPipeSession( - writer: MTPPipeWriter & MTPWritablePipe, - params: MTPPipeSessionParameters, - senderKeyring: MTPBytesInput, - recipientPublicKey: MTPBytesInput | MTPBytesInput[], - signatureSuite?: MTPProtectionSignatureSuite, -): Promise { - const checked = validateSessionParameters(params); - if (writer.pipeId != null && writer.pipeId !== checked.pipeId) { - throw sessionError("pipeId does not match the actual writer pipe"); - } - const recipientBundles = recipientBundleInputs(recipientPublicKey); - const keyring = serializedKeyring(senderKeyring); - const suite = selectedSignatureSuite(keyring, signatureSuite); - const key = new Uint8Array(32); - globalThis.crypto.getRandomValues(key); - const payload = bindings.encode_data_value([ - PIPE_SESSION_OFFER_DOMAIN, - checked.sessionId, - BigInt(checked.pipeId), - checked.senderId, - checked.recipientId, - checked.purpose, - checked.direction, - key, - ]); - const signed = bindings.sign_data_value_with_keyring( - payload, - checked.senderId, - pipeSessionSignaturePurpose(), - keyring.bytes, - suite === "dual" - ? bindings.mtp_protection_signature_suite_dual() - : bindings.mtp_protection_signature_suite_ed25519(), - ); - const encrypted = bindings.encrypt_data_value_for_recipients( - signed, - recipientBundles, - pipeSessionEncryptionPurpose(), - ); - if (encrypted.length > MAX_PIPE_SESSION_OFFER) { - throw sessionError(`pipe session offer is too large: ${encrypted.length}`); - } - const prefix = new Uint8Array(4); - new DataView(prefix.buffer).setUint32(0, encrypted.length, false); - try { - await writer.write(concatBytes([prefix, encrypted])); - return new MTPEncryptedPipeWriter( - writer, - key, - contextForSessionParameters(checked), - ); - } catch (error) { - key.fill(0); - throw sessionError("failed to write pipe session offer", error); - } finally { - key.fill(0); - } -} - -/** Read and verify a pipe session offer, then return its record reader. */ -export async function acceptMTPPipeSession( - reader: MTPPipeReader & MTPReadablePipe, - params: MTPPipeSessionParameters, - recipientKeyring: MTPBytesInput, - senderPublicKey: MTPBytesInput | MTPBytesInput[], - signaturePolicy?: MTPSignatureVerificationPolicy, -): Promise { - const checked = validateSessionParameters(params); - if (reader.pipeId != null && reader.pipeId !== checked.pipeId) { - throw sessionError("pipeId does not match the actual reader pipe"); - } - const { offer, remainder } = await readSessionOffer(reader); - const signed = bindings.decrypt_data_value( - offer, - bytesInput(recipientKeyring, "recipientKeyring"), - pipeSessionEncryptionPurpose(), - ); - const senderBundles = senderBundleInputs(senderPublicKey); - const policy = resolveSignatureVerificationPolicy(signaturePolicy); - verifyPipeSessionValue(signed, senderBundles, checked.senderId, policy); - const parsed = bindings.parse_data_value(signed); - const key = validateOfferFields(parsed, checked); - const result = new MTPEncryptedPipeReader( - reader, - key, - contextForSessionParameters(checked), - remainder, - ); - key.fill(0); - return result; -} - -/** - * Accept a pipe session without making the caller copy the sender's random - * session ID out of band. The ID is learned only after recipient decryption - * and signature verification, then all record context uses that ID. - */ -export async function acceptMTPPipeSessionAuto( - reader: MTPPipeReader & MTPReadablePipe, - expected: MTPPipeSessionExpectation, - recipientKeyring: MTPBytesInput, - senderPublicKey: MTPBytesInput | MTPBytesInput[], - signaturePolicy?: MTPSignatureVerificationPolicy, -): Promise { - if (reader.pipeId != null && reader.pipeId !== expected.pipeId) { - throw sessionError("pipeId does not match the actual reader pipe"); - } - const { offer, remainder } = await readSessionOffer(reader); - const signed = bindings.decrypt_data_value( - offer, - bytesInput(recipientKeyring, "recipientKeyring"), - pipeSessionEncryptionPurpose(), - ); - const senderBundles = senderBundleInputs(senderPublicKey); - const policy = resolveSignatureVerificationPolicy(signaturePolicy); - verifyPipeSessionValue(signed, senderBundles, expected.senderId, policy); - const parsed = bindings.parse_data_value(signed); - const sessionId = sessionIdFromOffer(parsed); - const checked = validateSessionParameters({ ...expected, sessionId }); - const key = validateOfferFields(parsed, checked); - const result = new MTPEncryptedPipeReader( - reader, - key, - contextForSessionParameters(checked), - remainder, - ); - key.fill(0); - return result; -} - -/** - * Establish a forward-secure encrypted pipe over a bidirectional transport. - * - * The responder contributes a fresh ephemeral hybrid-KEM key. Long-term - * identity keys authenticate the three-message exchange, but are not used to - * encrypt the resulting record chain, so later compromise of a long-term KEM - * key does not recover recorded sessions. - */ -export async function initiateMTPForwardSecurePipeSession( - stream: MTPDuplexPipe, - params: MTPPipeSessionParameters, - senderKeyring: MTPBytesInput, - recipientPublicKey: MTPBytesInput, - signatureSuite?: MTPProtectionSignatureSuite, - signaturePolicy?: MTPSignatureVerificationPolicy, -): Promise { - const checked = validateSessionParameters(params); - if (stream.pipeId != null && stream.pipeId !== checked.pipeId) { - throw sessionError("pipeId does not match the actual duplex pipe"); - } - const recipientBundle = bytesInput(recipientPublicKey, "recipientPublicKey"); - publicKeyBundleInputs(recipientBundle); - const keyring = serializedKeyring(senderKeyring); - const suite = selectedSignatureSuite(keyring, signatureSuite); - const policy = resolveSignatureVerificationPolicy(signaturePolicy); - const nonce = new Uint8Array(32); - globalThis.crypto.getRandomValues(nonce); - const initBytes = signHandshakeValue( - fsInitValue(checked, nonce), - checked.senderId, - keyring, - suite, - ); - await writeSessionOffer(stream, initBytes); - - const offerReader = new MTPSessionOfferReader(stream); - const responseBytes = await offerReader.read(); - const responseFields = verifiedHandshakeValue( - responseBytes, - checked.recipientId, - recipientBundle, - policy, - ); - validateFsCommon(responseFields, checked, FS_RESPONSE_DOMAIN, 9); - const initHash = handshakeHash([initBytes]); - const receivedInitHash = asBytes(responseFields[7], "initHash"); - if ( - receivedInitHash.length !== initHash.length || - receivedInitHash.some((byte, index) => byte !== initHash[index]) - ) { - throw sessionError("forward-secure handshake init transcript mismatch"); - } - const ephemeralPublicKey = asBytes(responseFields[8], "ephemeralPublicKey"); - let encapsulated: - ReturnType | undefined; - try { - encapsulated = bindings.wasm_kem_encapsulate(ephemeralPublicKey); - const ciphertext = encapsulated.ciphertext; - const finishBytes = signHandshakeValue( - fsFinishValue(checked, handshakeHash([responseBytes]), ciphertext), - checked.senderId, - keyring, - suite, - ); - await writeSessionOffer(stream, finishBytes); - const handshakeTranscript = handshakeHash([ - initBytes, - responseBytes, - finishBytes, - ]); - const chainKey = encapsulated.shared_secret; - const recordKey = bindings.wasm_hkdf_expand( - chainKey, - handshakeTranscript, - FS_ROOT_INFO, - 32, - ); - try { - return new MTPEncryptedPipeWriter( - stream, - recordKey, - forwardSecureContext(checked, handshakeTranscript), - ); - } finally { - chainKey.fill(0); - recordKey.fill(0); - } - } catch (error) { - if (error instanceof MTPSignatureVerificationError) throw error; - throw sessionError("forward-secure pipe handshake failed", error); - } finally { - encapsulated?.free(); - nonce.fill(0); - } -} - -/** - * Accept the forward-secure handshake. The session ID is learned from the - * authenticated initiator message; the remaining endpoint and pipe fields - * are supplied as the pre-decryption expectation. - */ -export async function acceptMTPForwardSecurePipeSession( - stream: MTPDuplexPipe, - expected: MTPPipeSessionExpectation, - recipientKeyring: MTPBytesInput, - senderPublicKey: MTPBytesInput | MTPBytesInput[], - signatureSuite?: MTPProtectionSignatureSuite, - signaturePolicy?: MTPSignatureVerificationPolicy, -): Promise { - if (stream.pipeId != null && stream.pipeId !== expected.pipeId) { - throw sessionError("pipeId does not match the actual duplex pipe"); - } - const recipientKeys = serializedKeyring(recipientKeyring); - const suite = selectedSignatureSuite(recipientKeys, signatureSuite); - const policy = resolveSignatureVerificationPolicy(signaturePolicy); - const senderBundles = senderBundleInputs(senderPublicKey); - const offerReader = new MTPSessionOfferReader(stream); - const initBytes = await offerReader.read(); - const initFields = verifiedHandshakeValueWithKeys( - initBytes, - expected.senderId, - senderBundles, - policy, - ); - if (initFields.length !== 8 || initFields[0] !== FS_INIT_DOMAIN) { - throw sessionError("forward-secure init message is malformed"); - } - const sessionId = asBytes(initFields[1], "sessionId"); - const checked = validateSessionParameters({ ...expected, sessionId }); - validateFsCommon(initFields, checked, FS_INIT_DOMAIN, 8); - const nonce = asBytes(initFields[7], "nonce"); - if (nonce.length !== 32) - throw sessionError("forward-secure nonce is not 32 bytes"); - - const ephemeral = bindings.wasm_kem_generate_keypair(); - try { - const responseBytes = signHandshakeValue( - fsResponseValue( - checked, - handshakeHash([initBytes]), - ephemeral.public_key, - ), - checked.recipientId, - recipientKeys, - suite, - ); - await writeSessionOffer(stream, responseBytes); - const finishBytes = await offerReader.read(); - const finishFields = verifiedHandshakeValueWithKeys( - finishBytes, - checked.senderId, - senderBundles, - policy, - ); - validateFsCommon(finishFields, checked, FS_FINISH_DOMAIN, 9); - const responseHash = handshakeHash([responseBytes]); - const receivedResponseHash = asBytes(finishFields[7], "responseHash"); - if ( - receivedResponseHash.length !== responseHash.length || - receivedResponseHash.some((byte, index) => byte !== responseHash[index]) - ) { - throw sessionError( - "forward-secure handshake response transcript mismatch", - ); - } - const sharedSecret = bindings.wasm_kem_decapsulate( - ephemeral.secret_key, - asBytes(finishFields[8], "ciphertext"), - ); - const handshakeTranscript = handshakeHash([ - initBytes, - responseBytes, - finishBytes, - ]); - const recordKey = bindings.wasm_hkdf_expand( - sharedSecret, - handshakeTranscript, - FS_ROOT_INFO, - 32, - ); - try { - return new MTPEncryptedPipeReader( - stream, - recordKey, - forwardSecureContext(checked, handshakeTranscript), - offerReader.remainder(), - ); - } finally { - sharedSecret.fill(0); - recordKey.fill(0); - } - } catch (error) { - if (error instanceof MTPSignatureVerificationError) throw error; - throw sessionError("forward-secure pipe handshake failed", error); - } finally { - ephemeral.free(); - } -} - -/** Encrypts ordered records on top of a negotiated MTP pipe. */ -export class MTPEncryptedPipeWriter { - readonly pipeId?: number; - private chainKey: Uint8Array; - private readonly context: MTPPipeProtectionContext; - private sequence = 0n; - private writeChain: Promise = Promise.resolve(); - private state: "open" | "finalized" | "failed" = "open"; - - constructor( - private readonly writer: MTPWritablePipe, - key: Uint8Array, - context: MTPPipeProtectionContext, - ) { - this.chainKey = keyBytes(key); - this.context = context; - this.pipeId = (writer as MTPPipeWriter).pipeId; - } - - get sequenceNumber(): bigint { - return this.sequence; - } - - writeRecord(plaintext: Uint8Array): Promise { - if (!(plaintext instanceof Uint8Array)) { - return Promise.reject(new TypeError("pipe record must be a Uint8Array")); - } - if (this.state !== "open") { - return Promise.reject( - new MTPEncryptedPipeError( - "state", - "encrypted pipe is no longer writable", - ), - ); - } - const input = plaintext.slice(); - const operation = this.writeChain.then(() => - this.writeRecordInternal(input), - ); - this.writeChain = operation - .catch((error) => { - this.poison(); - throw error; - }) - .catch(() => undefined); - return operation; - } - - private async writeRecordInternal( - plaintext: Uint8Array, - recordType = RECORD_TYPE_DATA, - ): Promise { - if (this.state !== "open") { - throw new MTPEncryptedPipeError( - "state", - "encrypted pipe is no longer writable", - ); - } - if (this.sequence === 0xffff_ffff_ffff_ffffn) { - throw new MTPEncryptedPipeError( - "sequence", - "encrypted pipe sequence exhausted", - ); - } - const encodedLength = recordLength(plaintext.length); - const { messageKey, nextChainKey } = deriveRecordKeys( - this.chainKey, - this.context, - this.sequence, - ); - let committed = false; - let ciphertext: Uint8Array; - try { - try { - const cipher = new bindings.WasmChaCha20Poly1305(messageKey); - try { - ciphertext = cipher.encrypt( - plaintext, - aad(this.context, this.sequence, encodedLength, recordType), - ); - } finally { - cipher.free(); - } - } catch (error) { - throw new MTPEncryptedPipeError( - "authentication", - "encrypted pipe record encryption failed", - { cause: error }, - ); - } - if (ciphertext.length !== encodedLength) { - throw new MTPEncryptedPipeError( - "record-length", - `encrypted pipe cipher returned ${ciphertext.length} bytes, expected ${encodedLength}`, - ); - } - const prefix = new Uint8Array(4); - new DataView(prefix.buffer).setUint32(0, encodedLength, false); - try { - await this.writer.write( - concatBytes([prefix, new Uint8Array([recordType]), ciphertext]), - ); - } catch (error) { - throw new MTPEncryptedPipeError("io", "encrypted pipe write failed", { - cause: error, - }); - } - this.chainKey.fill(0); - this.chainKey = nextChainKey; - this.sequence += 1n; - committed = true; - } finally { - messageKey.fill(0); - if (!committed) nextChainKey.fill(0); - } - } - - async close(): Promise { - if (this.state !== "open") { - throw new MTPEncryptedPipeError( - "state", - "encrypted pipe is no longer open", - ); - } - const operation = this.writeChain.then(async () => { - await this.writeRecordInternal(new Uint8Array(0), RECORD_TYPE_FINAL); - this.state = "finalized"; - try { - await this.writer.close(); - } catch (error) { - throw new MTPEncryptedPipeError("io", "encrypted pipe close failed", { - cause: error, - }); - } - }); - this.writeChain = operation - .catch((error) => { - this.poison(); - throw error; - }) - .catch(() => undefined); - await operation; - } - - abort(): void { - this.poison(); - this.writer.abort(); - } - - private poison(): void { - this.chainKey.fill(0); - this.state = "failed"; - } -} - -/** Reads and authenticates ordered records on top of a negotiated MTP pipe. */ -export class MTPEncryptedPipeReader { - private chainKey: Uint8Array; - private readonly context: MTPPipeProtectionContext; - private sequence = 0n; - private buffered = new Uint8Array(0); - private ended = false; - private readChain: Promise = Promise.resolve(); - private state: "open" | "finalized" | "failed" = "open"; - - constructor( - private readonly reader: MTPReadablePipe, - key: Uint8Array, - context: MTPPipeProtectionContext, - initialBuffer: Uint8Array = new Uint8Array(0), - ) { - this.chainKey = keyBytes(key); - this.context = context; - this.buffered = initialBuffer.slice(); - } - - get sequenceNumber(): bigint { - return this.sequence; - } - - private append(chunk: Uint8Array): void { - if (this.buffered.length + chunk.length > MAX_PIPE_BUFFER) { - throw new MTPEncryptedPipeError( - "record-length", - "encrypted pipe input buffer is too large", - ); - } - const combined = new Uint8Array(this.buffered.length + chunk.length); - combined.set(this.buffered); - combined.set(chunk, this.buffered.length); - this.buffered = combined; - } - - private async ensure(length: number): Promise { - while (this.buffered.length < length && !this.ended) { - let chunk: Uint8Array | null; - try { - chunk = await this.reader.read(); - } catch (error) { - throw new MTPEncryptedPipeError("io", "encrypted pipe read failed", { - cause: error, - }); - } - if (chunk == null) { - this.ended = true; - break; - } - if (!(chunk instanceof Uint8Array)) { - throw new MTPEncryptedPipeError( - "io", - "pipe reader returned a non-byte chunk", - ); - } - if (chunk.length > 0) this.append(chunk); - } - return this.buffered.length >= length; - } - - readRecord(): Promise { - if (this.state === "finalized") return Promise.resolve(null); - if (this.state === "failed") { - return Promise.reject( - new MTPEncryptedPipeError( - "state", - "encrypted pipe is no longer readable", - ), - ); - } - const operation = this.readChain.then(() => this.readRecordInternal()); - this.readChain = operation - .catch((error) => { - this.poison(); - throw error; - }) - .then( - () => undefined, - () => undefined, - ); - return operation; - } - - private async readRecordInternal(): Promise { - if (this.state !== "open") { - if (this.state === "finalized") return null; - throw new MTPEncryptedPipeError( - "state", - "encrypted pipe is no longer readable", - ); - } - if (this.sequence === 0xffff_ffff_ffff_ffffn) { - throw new MTPEncryptedPipeError( - "sequence", - "encrypted pipe sequence exhausted", - ); - } - if (!(await this.ensure(4))) { - throw new MTPEncryptedPipeError( - "truncated", - "encrypted pipe ended without an authenticated final record", - ); - } - const encodedLength = new DataView( - this.buffered.buffer, - this.buffered.byteOffset, - this.buffered.byteLength, - ).getUint32(0, false); - if ( - encodedLength < XCHACHA_OVERHEAD || - encodedLength > MAX_ENCRYPTED_PIPE_RECORD - ) { - throw new MTPEncryptedPipeError( - "record-length", - `invalid encrypted pipe record length: ${encodedLength}`, - ); - } - const totalLength = 5 + encodedLength; - if (!(await this.ensure(totalLength))) { - throw new MTPEncryptedPipeError( - "truncated", - "truncated encrypted pipe record", - ); - } - const recordType = this.buffered[4]; - if (recordType !== RECORD_TYPE_DATA && recordType !== RECORD_TYPE_FINAL) { - throw new MTPEncryptedPipeError( - "record-length", - `invalid encrypted pipe record type: ${recordType}`, - ); - } - const ciphertext = this.buffered.slice(5, totalLength); - this.buffered = this.buffered.slice(totalLength); - const { messageKey, nextChainKey } = deriveRecordKeys( - this.chainKey, - this.context, - this.sequence, - ); - let committed = false; - let plaintext: Uint8Array; - try { - try { - const cipher = new bindings.WasmChaCha20Poly1305(messageKey); - try { - plaintext = cipher.decrypt( - ciphertext, - aad(this.context, this.sequence, encodedLength, recordType), - ); - } finally { - cipher.free(); - } - } catch (error) { - throw new MTPEncryptedPipeError( - "authentication", - "encrypted pipe record authentication failed", - { cause: error }, - ); - } - this.chainKey.fill(0); - this.chainKey = nextChainKey; - this.sequence += 1n; - committed = true; - if (recordType === RECORD_TYPE_FINAL) { - if (plaintext.length !== 0) { - throw new MTPEncryptedPipeError( - "record-length", - "encrypted pipe final record must be empty", - ); - } - this.state = "finalized"; - return null; - } - return plaintext; - } finally { - messageKey.fill(0); - if (!committed) nextChainKey.fill(0); - } - } - - private poison(): void { - this.chainKey.fill(0); - this.state = "failed"; - } -} - -export type MTPEncryptedPipeWriterSource = MTPPipeWriter & MTPWritablePipe; -export type MTPEncryptedPipeReaderSource = MTPPipeReader & MTPReadablePipe; diff --git a/src/sdk/encrypted-secret.ts b/src/sdk/encrypted-secret.ts deleted file mode 100644 index 2c8f1ec..0000000 --- a/src/sdk/encrypted-secret.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Encrypted secret material persisted for MTP cryptographic facilities. - * - * `id` is an opaque, MTP-owned or caller-derived identifier. The provider - * does not interpret it or infer an identity hierarchy from it. - */ -export interface MTPEncryptedSecretRecord { - id: string; - encryptedSecret: Uint8Array; - formatVersion: number; - wrappingScheme: string; - wrappingKeyId?: string; - createdAt: number; - updatedAt: number; -} - -export interface MTPEncryptedSecretProvider { - get(id: string): Promise; - set(record: MTPEncryptedSecretRecord): Promise; - delete(id: string): Promise; -} - -function requireId(id: string): string { - if (typeof id !== "string" || id.length === 0) { - throw new TypeError("encrypted secret id must be a non-empty string"); - } - return id; -} - -function validateTimestamp(value: number, name: string): void { - if (!Number.isSafeInteger(value) || value < 0) { - throw new TypeError(`${name} must be a non-negative safe integer`); - } -} - -function cloneRecord(record: MTPEncryptedSecretRecord): MTPEncryptedSecretRecord { - return { - ...record, - encryptedSecret: new Uint8Array(record.encryptedSecret), - }; -} - -function validateRecord(record: MTPEncryptedSecretRecord): void { - requireId(record.id); - if ( - !(record.encryptedSecret instanceof Uint8Array) || - record.encryptedSecret.length === 0 - ) { - throw new TypeError( - "encrypted secret requires non-empty encryptedSecret bytes", - ); - } - if (!Number.isSafeInteger(record.formatVersion) || record.formatVersion < 0) { - throw new TypeError( - "encrypted secret formatVersion must be a non-negative safe integer", - ); - } - if (typeof record.wrappingScheme !== "string" || !record.wrappingScheme) { - throw new TypeError("encrypted secret requires wrappingScheme"); - } - validateTimestamp(record.createdAt, "encrypted secret createdAt"); - validateTimestamp(record.updatedAt, "encrypted secret updatedAt"); - if ( - record.wrappingKeyId !== undefined && - (typeof record.wrappingKeyId !== "string" || !record.wrappingKeyId) - ) { - throw new TypeError("encrypted secret wrappingKeyId must be non-empty"); - } -} - -/** A small reference implementation for callers that need local persistence. */ -export class InMemoryEncryptedSecretProvider - implements MTPEncryptedSecretProvider -{ - private store = new Map(); - - async get(id: string): Promise { - const record = this.store.get(requireId(id)); - return record ? cloneRecord(record) : null; - } - - async set(record: MTPEncryptedSecretRecord): Promise { - validateRecord(record); - this.store.set(record.id, cloneRecord(record)); - } - - async delete(id: string): Promise { - this.store.delete(requireId(id)); - } -} diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 5e3a1e9..1ba75bb 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -1,8 +1,601 @@ -/** - * Public SDK compatibility facade. - * - * Implementation lives in private SDK modules. This entry point intentionally - * keeps the package's historical exports stable. - */ -export * from "./client.js"; -export * from "./schema.js"; +import initWasm, { + ConnectionConfig, + ConnectionState, + WasmClient, + keyring_generate, +} from "mtp/raw"; +import * as bindings from "mtp/raw"; +import type * as RawBindings from "../raw/index"; +import type { MTPCommunicationType } from "../type-map/index"; + +export type StorageValue = string | null; + +export interface MTPCredentialStorage { + getItem(key: string): StorageValue | Promise; + setItem(key: string, value: string): void | Promise; + removeItem(key: string): void | Promise; +} + +export type MTPStorage = MTPCredentialStorage; + +export type MTPLogEvent = + | { hint: "info" | "warning"; type: string; data: unknown } + | { hint: "error"; type: string | "error"; error: string }; + +export type ParsedFrame = RawBindings.ParsedFrame; + +export type Ed25519GenerateResult = ReturnType; + +export interface MTPCrypto { + generateKeyring(): Uint8Array; + generateEd25519(): Ed25519GenerateResult; + keyringFromEd25519(secretKey: Uint8Array, publicKey: Uint8Array): Uint8Array; + verifyEd25519(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): void; + deriveEncryptionKey(ikm: Uint8Array, salt: Uint8Array, context: Uint8Array): Uint8Array; + hkdfExpand(ikm: Uint8Array, salt: Uint8Array, info: Uint8Array, len: number): Uint8Array; + sha256(data: Uint8Array): Uint8Array; + sha256Double(data: Uint8Array): Uint8Array; +} + +export const crypto: MTPCrypto = { + generateKeyring: () => bindings.keyring_generate(), + generateEd25519: () => bindings.ed25519_generate(), + keyringFromEd25519: (secretKey, publicKey) => bindings.keyring_from_ed25519(secretKey, publicKey), + verifyEd25519: (publicKey, message, signature) => bindings.ed25519_verify(publicKey, message, signature), + deriveEncryptionKey: (ikm, salt, context) => bindings.wasm_derive_encryption_key(ikm, salt, context), + hkdfExpand: (ikm, salt, info, len) => bindings.wasm_hkdf_expand(ikm, salt, info, len), + sha256: (data) => bindings.wasm_sha256(data), + sha256Double: (data) => bindings.wasm_sha256_double(data), +}; + +export type MTPRawBindings = typeof bindings; + +export interface MTPRaw { + /** + * Underlying generated WASM client instance. + * + * Prefer the `MTPClient` methods for application code. Calling the raw client + * bypasses SDK-level validation, credential persistence, logging, timeout + * handling, frame parsing helpers, and ping lifecycle management. Use this + * escape hatch only when integrating a feature that the SDK wrapper does not + * expose yet. + */ + client: RawBindings.WasmClient; + + /** + * Generated WASM binding module exported by `mtp/raw`. + * + * These bindings mirror the lower-level WASM API and can change shape as the + * generated interface evolves. Prefer the SDK wrapper where possible so your + * code keeps the safer, typed MTPClient flow instead of depending directly on + * transport internals. + */ + bindings: MTPRawBindings; +} + +export interface MTPCredentials { + clientId: bigint | string | number | null; + keyring: Uint8Array | number[]; + /** @deprecated Use keyring. Kept as a migration alias for existing callers. */ + keyringBytes?: Uint8Array | number[]; + hostPublicKey?: Uint8Array | number[]; +} + +export interface MTPClientOptions { + url: string; + hostPublicKey?: Uint8Array | string; + credentials?: MTPCredentials | string | null; + credentialsStorageKey?: string; + storage?: MTPCredentialStorage; + serverCertificateHashes?: string[]; + maxMessageSize?: number; + authTimeoutMs?: number; + pings?: boolean | { intervalMs?: number }; + wasm?: RawBindings.InitInput | Promise | { module_or_path: RawBindings.InitInput | Promise }; + logger?: (event: MTPLogEvent) => void; +} + +export type Unsubscribe = () => void; + +export interface MTPSendOptions { + id?: number; + sender?: bigint | number; + receiver?: bigint | number; +} + +export interface MTPRequestOptions extends MTPSendOptions { + responseType?: MTPCommunicationType; +} + +type InternalCredentials = Omit & { + clientId: bigint | null; + keyringBytes: Uint8Array; + hostPublicKey?: Uint8Array; +}; + +type NormalizedMTPClientOptions = Omit & { + hostPublicKey?: Uint8Array; +}; + +const DEFAULT_CREDENTIALS_KEY = "mtp:credentials"; + +function emit(logger, event) { + if (typeof logger === "function") { + logger(event); + } +} + +function isErrorType(type) { + return type === "Error" || type.startsWith("Error") || [ + "BadRequest", + "Unauthorized", + "Forbidden", + "NotFound", + "TooManyRequests", + "InternalServerError", + "BadGateway", + "ServiceUnavailable", + "GatewayTimeout", + ].includes(type); +} + +function errorMessage(frame) { + const data = frame?.data ?? {}; + return String(data.ErrorMessage ?? data.Error ?? data.Description ?? `Received ${frame?.type ?? "error"} frame`); +} + +async function storageGet(storage, key) { + return storage ? await storage.getItem(key) : null; +} + +async function storageSet(storage, key, value) { + if (storage) { + await storage.setItem(key, value); + } +} + +async function storageRemove(storage, key) { + if (storage) { + await storage.removeItem(key); + } +} + +function isBytes(value) { + return value instanceof Uint8Array || Array.isArray(value); +} + +function bytesFrom(value, name) { + if (value instanceof Uint8Array) { + return value; + } + if (Array.isArray(value)) { + return new Uint8Array(value); + } + throw new TypeError(`${name} must be a Uint8Array or number[]`); +} + +function bytesFromString(value, name) { + const trimmed = value.trim(); + if (!trimmed) { + throw new TypeError(`${name} must not be empty`); + } + + const hex = trimmed.replace(/^(0x)/i, "").replace(/[\s:_-]/g, ""); + if (/^[0-9a-fA-F]+$/.test(hex)) { + if (hex.length % 2 !== 0) { + throw new TypeError(`${name} hex string has an odd length`); + } + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i += 1) { + bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; + } + + if (typeof atob === "function") { + const binary = atob(trimmed); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } + + if (typeof Buffer !== "undefined") { + return new Uint8Array(Buffer.from(trimmed, "base64")); + } + + throw new TypeError(`${name} must be bytes, hex, or base64`); +} + +function normalizeBytes(value, name) { + if (typeof value === "string") { + return bytesFromString(value, name); + } + return bytesFrom(value, name); +} + +function normalizeCredentials(value) { + if (!value) { + return null; + } + + if (typeof value === "string") { + return JSON.parse(value); + } + + return value; +} + +function toBigInt(value) { + if (value == null || value === "") { + return null; + } + return typeof value === "bigint" ? value : BigInt(value); +} + +function generateKeyringBytes() { + return keyring_generate(); +} + +function serializeCredentials(credentials) { + return JSON.stringify({ + clientId: credentials.clientId?.toString() ?? null, + keyring: Array.from(credentials.keyringBytes ?? []), + hostPublicKey: credentials.hostPublicKey ? Array.from(credentials.hostPublicKey) : undefined, + }); +} + +function deserializeCredentials(credentials) { + const normalized = normalizeCredentials(credentials); + if (!normalized) { + return null; + } + + const keyring = normalized.keyring ?? normalized.keyringBytes; + if (!isBytes(keyring)) { + throw new TypeError("credentials.keyring must be a Uint8Array or number[]"); + } + + return { + clientId: toBigInt(normalized.clientId), + keyringBytes: bytesFrom(keyring, "credentials.keyring"), + hostPublicKey: normalized.hostPublicKey == null + ? undefined + : normalizeBytes(normalized.hostPublicKey, "credentials.hostPublicKey"), + }; +} + +function publicCredentials(credentials) { + if (!credentials) { + return null; + } + return { + clientId: credentials.clientId, + keyring: credentials.keyringBytes, + keyringBytes: credentials.keyringBytes, + hostPublicKey: credentials.hostPublicKey, + }; +} + +function validateOptions(options) { + if (!options || typeof options !== "object") { + throw new TypeError("MTPClient.create requires an options object"); + } + if (typeof options.url !== "string" || !options.url.trim()) { + throw new TypeError("MTPClient.create requires a non-empty url"); + } + if (options.storage) { + for (const method of ["getItem", "setItem", "removeItem"]) { + if (typeof options.storage[method] !== "function") { + throw new TypeError(`storage.${method} must be a function`); + } + } + } + if (options.maxMessageSize != null && (!Number.isSafeInteger(options.maxMessageSize) || options.maxMessageSize <= 0)) { + throw new TypeError("maxMessageSize must be a positive safe integer"); + } + if (options.authTimeoutMs != null && (!Number.isSafeInteger(options.authTimeoutMs) || options.authTimeoutMs <= 0)) { + throw new TypeError("authTimeoutMs must be a positive safe integer"); + } +} + +async function withTimeout(promise, timeoutMs, message) { + if (!timeoutMs) { + return await promise; + } + + let timeoutId; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); + }), + ]); + } finally { + clearTimeout(timeoutId); + } +} + +export class MTPClient { + static readonly crypto = crypto; + + #credentials: InternalCredentials | null; + #options: NormalizedMTPClientOptions; + readonly raw: MTPRaw; + + readonly crypto = MTPClient.crypto; + + private constructor(options: NormalizedMTPClientOptions, client: RawBindings.WasmClient) { + this.#options = options; + this.#credentials = deserializeCredentials(options.credentials); + this.raw = { client, bindings }; + } + + static async create(options: MTPClientOptions = {} as MTPClientOptions): Promise { + validateOptions(options); + await MTPClient.init(options.wasm); + + const normalizedOptions = { + ...options, + hostPublicKey: options.hostPublicKey == null + ? undefined + : normalizeBytes(options.hostPublicKey, "hostPublicKey"), + }; + + let sdk: MTPClient | undefined; + const client = new WasmClient( + (state) => emit(normalizedOptions.logger, { + hint: "info", + type: "state", + data: ConnectionState[state] ?? state, + }), + (frame) => { + if (sdk) { + sdk.#handleFrame(frame); + } + }, + (error) => emit(normalizedOptions.logger, { + hint: "error", + type: "error", + error: String(error), + }), + ); + + sdk = new MTPClient(normalizedOptions, client); + await sdk.#loadStoredCredentials(); + if (!sdk.#credentials) { + sdk.#credentials = { + clientId: null, + keyringBytes: generateKeyringBytes(), + hostPublicKey: normalizedOptions.hostPublicKey, + }; + } else if (!sdk.#credentials.hostPublicKey && normalizedOptions.hostPublicKey) { + sdk.#credentials = { ...sdk.#credentials, hostPublicKey: normalizedOptions.hostPublicKey }; + } else if (!normalizedOptions.hostPublicKey && sdk.#credentials.hostPublicKey) { + sdk.#options = { ...sdk.#options, hostPublicKey: sdk.#credentials.hostPublicKey }; + } + return sdk; + } + + static isSupported(): boolean { + return WasmClient.is_supported(); + } + + static async init(wasm?: MTPClientOptions["wasm"]): Promise>> { + return await initWasm(wasm); + } + + get credentials(): MTPCredentials | null { + return publicCredentials(this.#credentials); + } + + get state(): RawBindings.ConnectionState { + return this.raw.client.state; + } + + async #loadStoredCredentials() { + if (this.#credentials || !this.#options.storage) { + return; + } + + const stored = await storageGet( + this.#options.storage, + this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY, + ); + this.#credentials = deserializeCredentials(stored); + } + + #connectionConfig() { + const config = new ConnectionConfig(this.#options.url); + if (this.#options.serverCertificateHashes) { + config.server_certificate_hashes = this.#options.serverCertificateHashes; + } + if (this.#options.maxMessageSize != null) { + config.max_message_size = this.#options.maxMessageSize; + } + return config; + } + + async connect(): Promise { + if (this.#credentials?.clientId != null && this.#options.hostPublicKey) { + await this.#connectAuthenticated(); + return; + } + + const config = this.#connectionConfig(); + try { + await withTimeout( + this.raw.client.connect(config), + this.#options.authTimeoutMs, + "connection timed out", + ); + this.#startPings(0n); + } finally { + config.free(); + } + } + + async #connectAuthenticated() { + if (!this.#options.hostPublicKey) { + throw new Error("MTPClient.connect requires hostPublicKey for authenticated connections"); + } + if (!this.#credentials?.keyringBytes?.length || this.#credentials.clientId == null) { + throw new Error("MTPClient.connect requires credentials with clientId and keyring"); + } + + const config = this.#connectionConfig(); + try { + const clientId = await withTimeout( + this.raw.client.auth_connect( + config, + this.#options.hostPublicKey, + this.#credentials.keyringBytes, + this.#credentials.clientId, + ), + this.#options.authTimeoutMs, + "authentication timed out", + ); + this.#credentials = { ...this.#credentials, clientId }; + await this.#persistCredentials(); + this.#startPings(clientId); + return clientId; + } finally { + config.free(); + } + } + + async register(): Promise { + if (!this.#options.hostPublicKey) { + throw new Error("MTPClient.register requires hostPublicKey"); + } + if (!this.#credentials?.keyringBytes?.length) { + this.#credentials = { + clientId: null, + keyringBytes: generateKeyringBytes(), + hostPublicKey: this.#options.hostPublicKey, + }; + } + + const config = this.#connectionConfig(); + try { + const clientId = await withTimeout( + this.raw.client.auth_register( + config, + this.#options.hostPublicKey, + this.#credentials.keyringBytes, + ), + this.#options.authTimeoutMs, + "authentication timed out", + ); + this.#credentials = { ...this.#credentials, clientId }; + await this.#persistCredentials(); + this.#startPings(clientId); + return clientId; + } finally { + config.free(); + } + } + + async #persistCredentials() { + await storageSet( + this.#options.storage, + this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY, + serializeCredentials(this.#credentials), + ); + } + + async clearCredentials(): Promise { + this.#credentials = null; + await storageRemove( + this.#options.storage, + this.#options.credentialsStorageKey ?? DEFAULT_CREDENTIALS_KEY, + ); + } + + #startPings(clientId) { + const pings = this.#options.pings; + if (!pings) { + this.raw.client.stop_protocol_pings(); + return; + } + const intervalMs = typeof pings === "object" ? pings.intervalMs ?? 30_000 : 30_000; + this.raw.client.start_protocol_pings(intervalMs, clientId); + } + + #buildFrame(typeOrFrame, data, options) { + if (typeOrFrame instanceof Uint8Array) { + return typeOrFrame; + } + if (typeof typeOrFrame !== "string" || !typeOrFrame) { + throw new TypeError("message type must be a non-empty string or Uint8Array frame"); + } + if (data == null || typeof data !== "object" || Array.isArray(data)) { + throw new TypeError("message data must be an object"); + } + return this.raw.bindings.build_frame(typeOrFrame, data, options ?? {}); + } + + async send(message: Uint8Array): Promise; + async send(type: MTPCommunicationType, data: Record, options?: MTPSendOptions): Promise; + async send(typeOrFrame: Uint8Array | MTPCommunicationType, data?: Record, options?: MTPSendOptions): Promise { + const message = this.#buildFrame(typeOrFrame, data, options); + + try { + const frame = this.raw.bindings.parse_frame(message); + emit(this.#options.logger, isErrorType(frame.type) + ? { hint: "error", type: frame.type, error: errorMessage(frame) } + : { hint: "info", type: frame.type, data: frame.data }); + } catch (error) { + emit(this.#options.logger, { + hint: "error", + type: "error", + error: String(error), + }); + } + + await this.raw.client.send(message); + } + + async request(message: Uint8Array, data?: never, options?: MTPRequestOptions): Promise; + async request(type: MTPCommunicationType, data: Record, options?: MTPRequestOptions): Promise; + async request(typeOrFrame: Uint8Array | MTPCommunicationType, data?: Record, options: MTPRequestOptions = {}): Promise { + const frame = this.#buildFrame(typeOrFrame, data, options); + return await this.raw.client.request(frame, options.responseType ?? null); + } + + subscribe(type: MTPCommunicationType, handler: (message: ParsedFrame) => void): Unsubscribe { + if (typeof type !== "string" || !type) { + throw new TypeError("subscription type must be a non-empty string"); + } + if (typeof handler !== "function") { + throw new TypeError("subscription handler must be a function"); + } + const id = this.raw.client.subscribe(type, handler); + return () => this.raw.client.unsubscribe(id); + } + + #handleFrame(frame) { + if (isErrorType(frame.type)) { + emit(this.#options.logger, { + hint: "error", + type: frame.type, + error: errorMessage(frame), + }); + } else { + emit(this.#options.logger, { + hint: "info", + type: frame.type, + data: frame.data, + }); + } + } + + disconnect(): void { + this.raw.client.stop_protocol_pings(); + this.raw.client.disconnect(); + } +} + +export { ConnectionState, bindings as raw }; diff --git a/src/sdk/passphrase-worker.ts b/src/sdk/passphrase-worker.ts deleted file mode 100644 index 78f37b4..0000000 --- a/src/sdk/passphrase-worker.ts +++ /dev/null @@ -1,33 +0,0 @@ -import initWasm, * as bindings from "mtp/raw"; - -interface PasswordKdfWorkerRequest { - passphrase: Uint8Array; - salt: Uint8Array; - parameters: { - memoryKiB: number; - iterations: number; - lanes: number; - }; -} - -const scope = globalThis as unknown as { - onmessage: ((event: MessageEvent) => void) | null; - postMessage(message: Uint8Array | { error: string }, transfer?: Transferable[]): void; -}; - -scope.onmessage = async (event) => { - try { - await initWasm(); - const { passphrase, salt, parameters } = event.data; - const key = bindings.wasm_argon2id( - passphrase, - salt, - parameters.memoryKiB, - parameters.iterations, - parameters.lanes, - ); - scope.postMessage(key, [key.buffer]); - } catch (error) { - scope.postMessage({ error: String(error) }); - } -}; diff --git a/src/sdk/protection.ts b/src/sdk/protection.ts deleted file mode 100644 index b440276..0000000 --- a/src/sdk/protection.ts +++ /dev/null @@ -1,258 +0,0 @@ -import type { InternalCredentials } from "./credentials.js"; -import { - inputU64, - keyringToKeys, - normalizeBytes, - publicKeyBundleToKeys, - signatureSuiteValue, -} from "./codec.js"; -import { - MTPSignatureVerificationError, - signerKeysUnavailable, -} from "./signature-policy.js"; -import type { MTPSignatureVerificationPolicy } from "./signature-policy.js"; -import type { - MTPDecryptionIdentity, - MTPProtectionIdentity, - MTPProtectionSignatureSuite, - MTPReplayGuard, - MTPSignerKeyResolver, - MTPBytesInput, - MTPKeyMaterialInput, -} from "./client.js"; - -export class InMemoryReplayGuard implements MTPReplayGuard { - #accepted = new Set(); - readonly #capacity = 10_000; - - accept(signerId: bigint, messageId: string, _createdAt: bigint): boolean { - const key = `${signerId}:${messageId}`; - if (this.#accepted.has(key)) return false; - this.#accepted.add(key); - if (this.#accepted.size > this.#capacity) { - const oldest = this.#accepted.values().next().value; - if (oldest !== undefined) this.#accepted.delete(oldest); - } - return true; - } -} - -export class MTPReplayError extends Error { - readonly signerId: bigint; - readonly messageId: string; - - constructor(signerId: bigint, messageId: string) { - super(`message ${messageId} from signer ${signerId} was already accepted`); - this.name = "MTPReplayError"; - this.signerId = signerId; - this.messageId = messageId; - } -} - -export class MTPMissingProtectedVersionError extends Error { - constructor() { - super("protected message does not declare a protected version"); - this.name = "MTPMissingProtectedVersionError"; - } -} - -export class MTPUnsupportedProtectedVersionError extends Error { - readonly protectedVersion: bigint; - - constructor(protectedVersion: bigint) { - super(`unsupported protected message version ${protectedVersion}`); - this.name = "MTPUnsupportedProtectedVersionError"; - this.protectedVersion = protectedVersion; - } -} - -export class MTPResourceLimitError extends Error { - constructor(message = "MTP receive resource limit exceeded") { - super(message); - this.name = "MTPResourceLimitError"; - } -} - -export interface ResolvedProtectionIdentity { - signerId: bigint; - keyring: Uint8Array; -} - -export interface ResolvedDecryptionIdentity { - id?: bigint; - keyrings: Uint8Array[]; -} - -export interface SignerResolutionOptions { - expectedSignerId?: bigint | number | string; - resolveSignerPublicKeys?: MTPSignerKeyResolver; -} - -export function protectionSignatureSuiteValue( - suite: MTPProtectionSignatureSuite, -): number { - return signatureSuiteValue(suite); -} - -export function effectiveProtectionSignatureSuite( - keyring: Uint8Array, - requested?: MTPProtectionSignatureSuite, -): MTPProtectionSignatureSuite { - const keys = keyringToKeys(keyring); - const hasPqPublicKey = keys.sigPqPublicKey.length > 0; - const hasPqSecretKey = keys.sigPqSecretKey.length > 0; - const suite = requested ?? "ed25519"; - if (suite !== "ed25519" && suite !== "dual") { - throw new Error("signatureSuite must be 'ed25519' or 'dual'"); - } - if (suite === "dual" && !(hasPqPublicKey && hasPqSecretKey)) { - throw new Error( - "dual protected signatures require a complete ML-DSA key pair; choose 'ed25519' for a partial keyring", - ); - } - return suite; -} - -function sameBytes(left: Uint8Array, right: Uint8Array): boolean { - if (left.length !== right.length) return false; - for (let index = 0; index < left.length; index += 1) { - if (left[index] !== right[index]) return false; - } - return true; -} - -function normalizeDecryptionKeyrings( - identity: MTPDecryptionIdentity, -): Uint8Array[] { - const current = normalizeBytes(identity.keyring, "recipient.keyring"); - if (current.length === 0) throw new Error("recipient.keyring must not be empty"); - if ( - identity.keyringHistory !== undefined && - !Array.isArray(identity.keyringHistory) - ) { - throw new TypeError("recipient.keyringHistory must be an array"); - } - - const keyrings: Uint8Array[] = []; - const add = (value: MTPKeyMaterialInput, name: string): void => { - const bytes = normalizeBytes(value, name); - if (bytes.length === 0) throw new Error(`${name} must not be empty`); - if (!keyrings.some((existing) => sameBytes(existing, bytes))) { - keyrings.push(bytes.slice()); - } - }; - add(current, "recipient.keyring"); - for (const [index, history] of (identity.keyringHistory ?? []).entries()) { - add(history, `recipient.keyringHistory[${index}]`); - } - if (keyrings.length === 0) throw new Error("recipient must contain at least one keyring"); - return keyrings; -} - -export function normalizeRecipientBundles( - recipients: MTPKeyMaterialInput[], - name: string, -): Uint8Array[] { - if (!Array.isArray(recipients) || recipients.length === 0) { - throw new TypeError(`${name} must contain at least one public key bundle`); - } - return recipients.map((value, index) => { - const bundle = normalizeBytes(value, `${name}[${index}]`); - publicKeyBundleToKeys(bundle); - return bundle.slice(); - }); -} - -export function resolveProtectionIdentity( - explicit: MTPProtectionIdentity | undefined, - stored: InternalCredentials | null, -): ResolvedProtectionIdentity { - if (explicit) { - return { - signerId: inputU64(explicit.signerId, "identity.signerId"), - keyring: normalizeBytes(explicit.keyring, "identity.keyring").slice(), - }; - } - if (stored?.clientId != null && stored.keyring.length > 0) { - return { signerId: stored.clientId, keyring: stored.keyring.slice() }; - } - throw new Error( - "protected send requires an explicit protection identity or stored registered credentials", - ); -} - -export function resolveDecryptionIdentity( - explicit: MTPDecryptionIdentity | undefined, - stored: InternalCredentials | null, -): ResolvedDecryptionIdentity { - if (explicit) { - return { - id: explicit.id == null ? undefined : inputU64(explicit.id, "recipient.id"), - keyrings: normalizeDecryptionKeyrings(explicit), - }; - } - if (stored?.clientId != null && stored.keyring.length > 0) { - return { - id: stored.clientId, - keyrings: normalizeDecryptionKeyrings({ - id: stored.clientId, - keyring: stored.keyring, - }), - }; - } - throw new Error( - "protected receive requires an explicit decryption identity or stored registered credentials", - ); -} - -export function protectedOpeningError(error: unknown, signerId?: bigint): Error { - if (error !== null && typeof error === "object") { - const structured = error as { code?: unknown; protectedVersion?: unknown }; - if (typeof structured.code === "string") { - switch (structured.code) { - case "missing-protected-version": - return new MTPMissingProtectedVersionError(); - case "unsupported-protected-version": - if ( - typeof structured.protectedVersion === "bigint" || - typeof structured.protectedVersion === "number" || - typeof structured.protectedVersion === "string" - ) { - return new MTPUnsupportedProtectedVersionError( - inputU64(structured.protectedVersion, "protectedVersion"), - ); - } - break; - case "no-matching-recipient": - return new Error("Unable to decrypt protected value with supplied recipient keyrings"); - case "reserved-application-type": - return new Error("MTP control communication types cannot be used as application content"); - case "signature-policy-mismatch": - return new MTPSignatureVerificationError("policy-rejected", signerId); - case "unsupported-signature-suite": - return new MTPSignatureVerificationError("unsupported-suite", signerId); - case "invalid-signature": - return new MTPSignatureVerificationError("invalid-signature", signerId); - case "signer-id-mismatch": - return new Error("protected signer ID mismatch"); - case "receiver-id-mismatch": - return new Error("protected frame receiver ID mismatch"); - case "message-type-mismatch": - return new Error("protected message type does not match outer routing"); - case "final-recipient-mismatch": - return new Error("protected final recipient does not match outer routing receiver"); - case "sender-id-mismatch": - return new Error("protected frame sender does not match authenticated signer"); - case "signer-key-not-found": - return signerKeysUnavailable(signerId); - case "replay": - return new Error("protected message was already accepted"); - case "resource-limit": - return new MTPResourceLimitError(); - } - } - } - return error instanceof Error ? error : new Error(String(error)); -} - -export type { MTPSignatureVerificationPolicy }; diff --git a/src/sdk/ratchet.ts b/src/sdk/ratchet.ts deleted file mode 100644 index 1fe14a2..0000000 --- a/src/sdk/ratchet.ts +++ /dev/null @@ -1,40 +0,0 @@ -import * as bindings from "mtp/raw"; -import { utf8Encode } from "./utils.js"; - -const HKDF_MESSAGE_KEY = "mtp-e2ee-v1-message-key"; -const HKDF_NEXT_CHAIN = "mtp-e2ee-v1-next-chain"; - -export interface RatchetStep { - key: Uint8Array; - chainKey: Uint8Array; -} - -export class MTPRatchet { - static async stepSend(chainKey: Uint8Array): Promise { - return this.step(chainKey); - } - - static async stepRecv(chainKey: Uint8Array): Promise { - return this.step(chainKey); - } - - static async step(chainKey: Uint8Array): Promise { - const messageKey = bindings.wasm_hkdf_expand( - chainKey, - new Uint8Array(0), - utf8Encode(HKDF_MESSAGE_KEY), - 32, - ); - const nextChainKey = bindings.wasm_hkdf_expand( - chainKey, - new Uint8Array(0), - utf8Encode(HKDF_NEXT_CHAIN), - 32, - ); - - return { - key: messageKey, - chainKey: nextChainKey, - }; - } -} diff --git a/src/sdk/relay.ts b/src/sdk/relay.ts deleted file mode 100644 index 1e0fbec..0000000 --- a/src/sdk/relay.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type * as RawBindings from "../raw/index"; -import { cloneParsedFrame, cloneParsedValue, inputU64 } from "./codec.js"; -import { - MTPSignatureVerificationError, - signerKeysUnavailable, -} from "./signature-policy.js"; -import { MTPResourceLimitError } from "./protection.js"; -import type { MTPSignatureVerificationPolicy } from "./signature-policy.js"; -import type { - MTPDataValue, - MTPReceiveLimits, - MTPVerifiedRelayContent, - ParsedFrame, -} from "./client.js"; - -export class MTPMissingRelayVersionError extends Error { - constructor() { - super("relay frame does not declare a relay version"); - this.name = "MTPMissingRelayVersionError"; - } -} - -export class MTPUnsupportedRelayVersionError extends Error { - readonly relayVersion: bigint; - - constructor(relayVersion: bigint) { - super(`unsupported relay version ${relayVersion}`); - this.name = "MTPUnsupportedRelayVersionError"; - this.relayVersion = relayVersion; - } -} - -export function relayOpeningError(error: unknown, signerId?: bigint): Error { - if (error !== null && typeof error === "object") { - const structured = error as { code?: unknown; relayVersion?: unknown }; - if (typeof structured.code === "string") { - switch (structured.code) { - case "missing-relay-version": - return new MTPMissingRelayVersionError(); - case "unsupported-relay-version": - if ( - typeof structured.relayVersion === "bigint" || - typeof structured.relayVersion === "number" || - typeof structured.relayVersion === "string" - ) { - return new MTPUnsupportedRelayVersionError( - inputU64(structured.relayVersion, "relayVersion"), - ); - } - break; - case "no-matching-recipient": - return new Error("Unable to decrypt protected value with supplied recipient keyrings"); - case "not-final-recipient": - return new Error("relay content is addressed to a different final recipient"); - case "reserved-application-type": - return new Error("relay application message type is reserved for MTP control"); - case "signature-policy-mismatch": - return new MTPSignatureVerificationError("policy-rejected", signerId); - case "unsupported-signature-suite": - return new MTPSignatureVerificationError("unsupported-suite", signerId); - case "invalid-signature": - return new MTPSignatureVerificationError("invalid-signature", signerId); - case "signer-id-mismatch": - return new Error("relay signer ID mismatch"); - case "purpose-mismatch": - return new Error("relay protection purpose mismatch"); - case "signer-key-not-found": - return signerKeysUnavailable(signerId); - case "replay": - return new Error("relay message was already accepted"); - case "resource-limit": - return new MTPResourceLimitError(); - } - } - } - return error instanceof Error ? error : new Error(String(error)); -} - -export interface MTPRelayMetadataState { - frame: ParsedFrame; - native: RawBindings.WasmVerifiedRelayMetadata; - relayVersion: number; - signerId: bigint; - finalRecipientId: bigint; - messageId: string; - createdAt: bigint; - hasMetadata: boolean; - metadata?: MTPDataValue; - encryptedContent: Uint8Array; - signerPublicKeys: Uint8Array[]; - matchedSignerKeyIndex: number; - signaturePolicy: MTPSignatureVerificationPolicy; - receiveLimits?: MTPReceiveLimits; - receiveLimitsExplicit: boolean; - disposed: boolean; - finalizerToken: object; -} - -export const relayMetadataState = new WeakMap< - MTPVerifiedRelayMetadata, - MTPRelayMetadataState ->(); - -const relayMetadataFinalizer = new FinalizationRegistry< - RawBindings.WasmVerifiedRelayMetadata ->((native) => { - try { - native.free(); - } catch { - // The WASM instance may already have been torn down during page unload. - } -}); - -export const RELAY_METADATA_TOKEN = Symbol("mtp-authenticated-relay-metadata"); - -export class MTPVerifiedRelayMetadata { - constructor( - token: typeof RELAY_METADATA_TOKEN, - state: MTPRelayMetadataState, - ) { - if (token !== RELAY_METADATA_TOKEN) { - throw new Error("relay metadata must be created by authenticated opening"); - } - relayMetadataState.set(this, state); - } - - private get state(): MTPRelayMetadataState { - const state = relayMetadataState.get(this); - if (!state) throw new Error("relay metadata authentication state is missing"); - if (state.disposed) throw new Error("relay metadata has been disposed"); - return state; - } - - dispose(): void { - const state = relayMetadataState.get(this); - if (!state || state.disposed) return; - state.disposed = true; - relayMetadataFinalizer.unregister(state.finalizerToken); - try { - state.native.free(); - } catch { - // The WASM instance may already have been torn down during page unload. - } - } - - free(): void { - this.dispose(); - } - - [Symbol.dispose](): void { - this.dispose(); - } - - get frame(): ParsedFrame { - return cloneParsedFrame(this.state.frame); - } - get signerId(): bigint { - return this.state.signerId; - } - get relayVersion(): number { - return this.state.relayVersion; - } - get finalRecipientId(): bigint { - return this.state.finalRecipientId; - } - get messageId(): string { - return this.state.messageId; - } - get createdAt(): bigint { - return this.state.createdAt; - } - get metadata(): MTPDataValue | undefined { - return this.state.hasMetadata - ? (cloneParsedValue(this.state.metadata) as MTPDataValue) - : undefined; - } - get encryptedContent(): Uint8Array { - return this.state.encryptedContent.slice(); - } - get signerPublicKeys(): Uint8Array[] { - return this.state.signerPublicKeys.map((bundle) => bundle.slice()); - } - get matchedSignerKeyIndex(): number { - return this.state.matchedSignerKeyIndex; - } - get matchedSignerPublicKey(): Uint8Array { - const key = this.state.signerPublicKeys[this.state.matchedSignerKeyIndex]; - if (!key) throw new Error("relay verification matched an unavailable signer key"); - return key.slice(); - } - get signaturePolicy(): MTPSignatureVerificationPolicy { - return this.state.signaturePolicy; - } -} - -export function registerRelayMetadata( - metadata: MTPVerifiedRelayMetadata, - native: RawBindings.WasmVerifiedRelayMetadata, - finalizerToken: object, -): void { - relayMetadataFinalizer.register(metadata, native, finalizerToken); -} - -export type { MTPVerifiedRelayContent }; diff --git a/src/sdk/schema.ts b/src/sdk/schema.ts deleted file mode 100644 index 9b9aaf6..0000000 --- a/src/sdk/schema.ts +++ /dev/null @@ -1,234 +0,0 @@ -import type { MTPRequestOptions, ParsedFrame, Unsubscribe } from "./client.js"; -import type { MTPCommunicationType } from "../type-map/index.js"; - -export interface MTPSchema { - readonly _input: Input; - readonly _output: Output; - parseAsync(value: unknown): Promise; -} - -export interface MTPSchemaPair< - Request extends MTPSchema = MTPSchema, - Response extends MTPSchema = MTPSchema, -> { - request: Request; - response: Response; -} - -export type MTPSchemaRegistry = Record; -export type MTPNoSchemas = Record; - -export type MTPSchemaInput = Schema["_input"]; -export type MTPSchemaOutput = Schema["_output"]; -export type MTPMessageType = - keyof Registry & string; - -export type MTPFrame = { - id?: number; - type: string; - data: Data; - sender?: ParsedFrame["sender"]; - receiver?: ParsedFrame["receiver"]; - raw?: ParsedFrame["raw"]; -}; - -export type MTPTypedFrame = MTPFrame; - -export type MTPResponseFrame< - Registry extends MTPSchemaRegistry, - Type extends MTPMessageType, -> = MTPTypedFrame>; - -export type MTPRequestData< - Registry extends MTPSchemaRegistry, - Type extends MTPMessageType, -> = MTPSchemaInput; - -export type MTPRequestFunction = < - Type extends MTPMessageType, ->( - type: Type, - data?: MTPRequestData, - options?: MTPRequestOptions, -) => Promise>; - -export type MTPSubscriptionFunction = < - Type extends MTPMessageType, ->( - type: Type, - handler: (message: MTPResponseFrame) => void | Promise, -) => Unsubscribe; - -export class MTPValidationError extends Error { - readonly phase: "request" | "response" | "subscription"; - readonly messageType: string; - readonly frame?: MTPFrame; - - constructor( - phase: MTPValidationError["phase"], - messageType: string, - cause: unknown, - frame?: MTPFrame, - ) { - super(`${phase} validation failed for ${messageType}`, { cause }); - this.name = "MTPValidationError"; - this.phase = phase; - this.messageType = messageType; - this.frame = frame; - } -} - -export class MTPProtocolError extends Error { - readonly type: string; - readonly id: number | undefined; - readonly communicationType: string; - readonly requestId: number | undefined; - readonly errorType: string | undefined; - readonly frame: MTPFrame; - - constructor(frame: MTPFrame) { - const errorType = - frame.data && - typeof frame.data === "object" && - !Array.isArray(frame.data) && - typeof (frame.data as Record).ErrorType === "string" - ? ((frame.data as Record).ErrorType as string) - : undefined; - super(errorType ? `${frame.type}: ${errorType}` : frame.type); - this.name = "MTPProtocolError"; - this.type = frame.type; - this.id = frame.id; - this.communicationType = frame.type; - this.requestId = frame.id; - this.errorType = errorType; - this.frame = frame; - } -} - -export interface MTPProtocolOptions { - schemas: Registry; - throwProtocolErrors?: boolean; - onValidationError?: (error: MTPValidationError) => void; -} - -function isErrorFrame(frame: MTPFrame): boolean { - return frame.type.startsWith("Error"); -} - -export class MTPProtocol { - readonly schemas: Registry; - readonly #throwProtocolErrors: boolean; - readonly #onValidationError: - | ((error: MTPValidationError) => void) - | undefined; - - constructor(options: MTPProtocolOptions) { - this.schemas = options.schemas; - this.#throwProtocolErrors = options.throwProtocolErrors ?? false; - this.#onValidationError = options.onValidationError; - } - - async parseRequest>( - type: Type, - data: MTPRequestData | undefined, - ): Promise> { - try { - return await this.schemas[type].request.parseAsync(data); - } catch (error) { - throw new MTPValidationError("request", type, error); - } - } - - async parseResponse>( - requestedType: Type, - frame: MTPFrame, - phase: "response" | "subscription" = "response", - ): Promise> { - if (isErrorFrame(frame)) { - if (phase === "response" && this.#throwProtocolErrors) { - throw new MTPProtocolError(frame); - } - return frame as MTPResponseFrame; - } - - const schema = - this.schemas[frame.type]?.response ?? - this.schemas[requestedType].response; - try { - const data = await schema.parseAsync(frame.data); - return { ...frame, data } as MTPResponseFrame; - } catch (error) { - throw new MTPValidationError( - phase, - frame.type || requestedType, - error, - frame, - ); - } - } - - reportValidationError(error: unknown): void { - if (error instanceof MTPValidationError) { - this.#onValidationError?.(error); - } - } -} - -export interface MTPProxyAdapter { - request( - type: MTPCommunicationType, - data: Record, - options?: MTPRequestOptions, - ): Promise; - subscribe( - type: MTPCommunicationType, - handler: (message: MTPFrame) => void, - ): Unsubscribe; -} - -export class MTPProxyConnection { - readonly #adapter: MTPProxyAdapter; - readonly #protocol: MTPProtocol; - - constructor(adapter: MTPProxyAdapter, options: MTPProtocolOptions) { - this.#adapter = adapter; - this.#protocol = new MTPProtocol(options); - } - - async request>( - type: Type, - data?: MTPRequestData, - options?: MTPRequestOptions, - ): Promise> { - const parsed = await this.#protocol.parseRequest(type, data); - const response = await this.#adapter.request( - type, - (parsed ?? {}) as Record, - options, - ); - return await this.#protocol.parseResponse(type, response); - } - - subscribe>( - type: Type, - handler: ( - message: MTPResponseFrame, - ) => void | Promise, - ): Unsubscribe { - let active = true; - const unsubscribe = this.#adapter.subscribe(type, (message) => { - void this.#protocol.parseResponse(type, message, "subscription").then( - (parsed) => { - if (active) void handler(parsed); - }, - (error) => { - this.#protocol.reportValidationError(error); - }, - ); - }); - return () => { - active = false; - unsubscribe(); - }; - } -} diff --git a/src/sdk/session.ts b/src/sdk/session.ts deleted file mode 100644 index 1abca71..0000000 --- a/src/sdk/session.ts +++ /dev/null @@ -1,275 +0,0 @@ -import * as bindings from "mtp/raw"; -import { concatBytes, utf8Encode, writeU64BE } from "./utils.js"; - -export const HKDF_SALT_ROOT = "mtp-e2ee-v1-root"; -const HKDF_INITIATOR_SEND = "mtp-e2ee-v1-initiator-send"; -const HKDF_INITIATOR_RECV = "mtp-e2ee-v1-initiator-recv"; -const SESSION_TRANSCRIPT_DOMAIN = "mtp-e2ee-session-transcript-v1"; - -export interface MTPSessionTranscriptContext { - /** Application-selected identity for this stateful MTP session. */ - sessionId: string; - /** MTP identity that initiated key establishment. */ - initiatorId: bigint; - /** MTP identity that receives the key-establishment message. */ - recipientId: bigint; - /** Public key used by the recipient for this key establishment. */ - recipientPublicKey: Uint8Array; - /** KEM ciphertext used by the key establishment. */ - kemCiphertext: Uint8Array; - /** Opaque, application-owned context included by hash only. */ - applicationContext?: Uint8Array; -} - -export interface MTPSessionState { - version: 1; - sessionId: string; - localId: bigint; - remoteId: bigint; - remotePublicKey: Uint8Array; - sendChainKey: Uint8Array; - recvChainKey: Uint8Array; - sendCount: number; - recvCount: number; - /** Derived receive keys retained for bounded out-of-order delivery. */ - skippedMessageKeys?: SkippedMessageKey[]; - createdAt: number; - updatedAt: number; -} - -export interface SkippedMessageKey { - messageNumber: number; - key: Uint8Array; -} - -export interface MTPSessionStorage { - getSession(sessionId: string): Promise; - setSession(state: MTPSessionState): Promise; - deleteSession(sessionId: string): Promise; -} - -function requireSessionId(sessionId: string): string { - if (typeof sessionId !== "string" || sessionId.length === 0) { - throw new TypeError("sessionId must be a non-empty string"); - } - return sessionId; -} - -function requireMtpId(id: bigint, name: string): bigint { - if (typeof id !== "bigint") { - throw new TypeError(`${name} must be a bigint`); - } - // Validate the range once at the API boundary. The returned value is still - // the original bigint so callers do not observe a representation change. - writeU64BE(id); - return id; -} - -export class InMemorySessionStorage implements MTPSessionStorage { - private store = new Map(); - - private cloneSession(state: MTPSessionState): MTPSessionState { - return { - ...state, - remotePublicKey: state.remotePublicKey.slice(), - sendChainKey: state.sendChainKey.slice(), - recvChainKey: state.recvChainKey.slice(), - skippedMessageKeys: (state.skippedMessageKeys ?? []).map((skipped) => ({ - messageNumber: skipped.messageNumber, - key: skipped.key.slice(), - })), - }; - } - - private zeroizeSession(state: MTPSessionState): void { - state.sendChainKey.fill(0); - state.recvChainKey.fill(0); - for (const skipped of state.skippedMessageKeys ?? []) skipped.key.fill(0); - } - - async getSession(sessionId: string): Promise { - const state = this.store.get(requireSessionId(sessionId)); - return state ? this.cloneSession(state) : null; - } - - async setSession(state: MTPSessionState): Promise { - const sessionId = requireSessionId(state.sessionId); - const replacement = this.cloneSession(state); - const previous = this.store.get(sessionId); - if (previous) this.zeroizeSession(previous); - this.store.set(sessionId, replacement); - } - - async deleteSession(sessionId: string): Promise { - const key = requireSessionId(sessionId); - const previous = this.store.get(key); - if (previous) this.zeroizeSession(previous); - this.store.delete(key); - } -} - -function writeU32BE(value: number): Uint8Array { - if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff) { - throw new Error("u32 value out of range"); - } - return new Uint8Array([ - (value >>> 24) & 0xff, - (value >>> 16) & 0xff, - (value >>> 8) & 0xff, - value & 0xff, - ]); -} - -function transcriptField(label: string, value: Uint8Array): Uint8Array { - const labelBytes = utf8Encode(label); - return concatBytes([ - writeU32BE(labelBytes.length), - labelBytes, - writeU32BE(value.length), - value, - ]); -} - -/** - * Build a length-delimited transcript for one MTP session. - * - * Application context is intentionally hashed as an opaque byte string. MTP - * therefore provides domain separation without parsing or naming any fields - * owned by the consuming application. - */ -export function buildSessionTranscript( - args: MTPSessionTranscriptContext, -): Uint8Array { - const sessionId = requireSessionId(args.sessionId); - const initiatorId = requireMtpId(args.initiatorId, "initiatorId"); - const recipientId = requireMtpId(args.recipientId, "recipientId"); - const recipientPublicKeyHash = bindings.wasm_sha256(args.recipientPublicKey); - const kemHash = bindings.wasm_sha256(args.kemCiphertext); - const applicationContextHash = bindings.wasm_sha256( - args.applicationContext ?? new Uint8Array(0), - ); - - return concatBytes([ - transcriptField("domain", utf8Encode(SESSION_TRANSCRIPT_DOMAIN)), - transcriptField("version", utf8Encode("1")), - transcriptField("sessionId", utf8Encode(sessionId)), - transcriptField("initiatorId", writeU64BE(initiatorId)), - transcriptField("recipientId", writeU64BE(recipientId)), - transcriptField("recipientPublicKeyHash", recipientPublicKeyHash), - transcriptField("kemCiphertextHash", kemHash), - transcriptField("applicationContextHash", applicationContextHash), - ]); -} - -export async function deriveSessionKeys( - sharedSecret: Uint8Array, - transcript: Uint8Array = new Uint8Array(0), -): Promise<{ - root: Uint8Array; - initiatorSend: Uint8Array; - initiatorRecv: Uint8Array; -}> { - const rootInfo = concatBytes([utf8Encode(HKDF_SALT_ROOT), transcript]); - const root = bindings.wasm_hkdf_expand( - sharedSecret, - new Uint8Array(0), - rootInfo, - 32, - ); - const initiatorSend = bindings.wasm_hkdf_expand( - root, - new Uint8Array(0), - utf8Encode(HKDF_INITIATOR_SEND), - 32, - ); - const initiatorRecv = bindings.wasm_hkdf_expand( - root, - new Uint8Array(0), - utf8Encode(HKDF_INITIATOR_RECV), - 32, - ); - return { root, initiatorSend, initiatorRecv }; -} - -/** - * Derive a stable ID for the unordered pair of MTP identities. - * - * This helper is only a convenience. Session storage and the manager accept - * caller-selected IDs directly, so applications can keep multiple sessions - * between the same pair of identities. - */ -export function derivePeerSessionId( - localId: bigint, - remoteId: bigint, -): string { - const ids = [ - requireMtpId(localId, "localId"), - requireMtpId(remoteId, "remoteId"), - ].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); - return `${ids[0].toString(16)}:${ids[1].toString(16)}`; -} - -export class MTPSessionManager { - constructor(private storage: MTPSessionStorage) {} - - getSession(sessionId: string): Promise { - return this.storage.getSession(requireSessionId(sessionId)); - } - - async saveSession(state: MTPSessionState): Promise { - await this.storage.setSession({ - ...state, - updatedAt: Date.now(), - }); - } - - async deleteSession(sessionId: string): Promise { - await this.storage.deleteSession(requireSessionId(sessionId)); - } - - async createSession(args: { - sessionId: string; - localId: bigint; - remoteId: bigint; - remotePublicKey: Uint8Array; - sharedSecret: Uint8Array; - role: "initiator" | "receiver"; - transcript?: Uint8Array; - transcriptContext?: MTPSessionTranscriptContext; - }): Promise { - const sessionId = requireSessionId(args.sessionId); - const localId = requireMtpId(args.localId, "localId"); - const remoteId = requireMtpId(args.remoteId, "remoteId"); - const transcript = args.transcript - ? args.transcript.slice() - : args.transcriptContext - ? buildSessionTranscript(args.transcriptContext) - : null; - if (!transcript || transcript.length === 0) { - throw new Error( - "session creation requires a non-empty authenticated transcript or transcriptContext", - ); - } - const { root, initiatorSend, initiatorRecv } = await deriveSessionKeys( - args.sharedSecret, - transcript, - ); - const now = Date.now(); - const state: MTPSessionState = { - version: 1, - sessionId, - localId, - remoteId, - remotePublicKey: args.remotePublicKey.slice(), - sendChainKey: args.role === "initiator" ? initiatorSend : initiatorRecv, - recvChainKey: args.role === "initiator" ? initiatorRecv : initiatorSend, - sendCount: 0, - recvCount: 0, - skippedMessageKeys: [], - createdAt: now, - updatedAt: now, - }; - root.fill(0); - return state; - } -} diff --git a/src/sdk/signature-policy.ts b/src/sdk/signature-policy.ts deleted file mode 100644 index 47518ce..0000000 --- a/src/sdk/signature-policy.ts +++ /dev/null @@ -1,176 +0,0 @@ -import * as bindings from "mtp/raw"; - -export type MTPSignatureVerificationPolicy = - | "ed25519" - | "dual" - | "any-supported"; - -/** - * The SDK default is deliberately fixed. Senders also default to the - * interoperable Ed25519 suite; dual signatures require an explicit sender - * suite and receiver policy. - */ -export const DEFAULT_SIGNATURE_VERIFICATION_POLICY: MTPSignatureVerificationPolicy = - "ed25519"; - -export type MTPSignatureVerificationErrorCode = - | "unsupported-suite" - | "policy-rejected" - | "invalid-signature" - | "signer-keys-unavailable"; - -const POLICY_NAMES: Record< - MTPSignatureVerificationErrorCode, - string -> = { - "unsupported-suite": "unsupported signature suite", - "policy-rejected": "signature rejected by policy", - "invalid-signature": "signature cryptographically invalid", - "signer-keys-unavailable": "signer public keys unavailable", -}; - -/** Caller-facing signature verification failure without cryptographic detail. */ -export class MTPSignatureVerificationError extends Error { - readonly code: MTPSignatureVerificationErrorCode; - readonly signerId?: bigint; - - constructor( - code: MTPSignatureVerificationErrorCode, - signerId?: bigint, - ) { - super( - signerId == null - ? POLICY_NAMES[code] - : `${POLICY_NAMES[code]} for signer ${signerId}`, - ); - this.name = "MTPSignatureVerificationError"; - this.code = code; - this.signerId = signerId; - } -} - -function validPolicy( - value: unknown, -): value is MTPSignatureVerificationPolicy { - return ( - value === "ed25519" || - value === "dual" || - value === "any-supported" - ); -} - -/** - * Resolve receiver policy in operation, client, library order. - */ -export function resolveSignatureVerificationPolicy( - operationPolicy: MTPSignatureVerificationPolicy | undefined, - clientDefaultPolicy?: MTPSignatureVerificationPolicy, -): MTPSignatureVerificationPolicy { - if (operationPolicy != null && !validPolicy(operationPolicy)) { - throw new TypeError( - "signaturePolicy must be 'ed25519', 'dual', or 'any-supported'", - ); - } - if (clientDefaultPolicy != null && !validPolicy(clientDefaultPolicy)) { - throw new TypeError( - "defaultSignatureVerificationPolicy must be 'ed25519', 'dual', or 'any-supported'", - ); - } - return ( - operationPolicy ?? - clientDefaultPolicy ?? - DEFAULT_SIGNATURE_VERIFICATION_POLICY - ); -} - -/** Convert the SDK policy into the raw WASM verifier's policy value. */ -export function signatureVerificationPolicyValue( - policy: MTPSignatureVerificationPolicy, -): number { - switch (policy) { - case "ed25519": - return bindings.mtp_protection_signature_suite_ed25519(); - case "dual": - return bindings.mtp_protection_signature_suite_dual(); - case "any-supported": { - const compatibility = ( - bindings as typeof bindings & { - mtp_protection_signature_suite_any_supported?: () => number; - } - ).mtp_protection_signature_suite_any_supported; - return compatibility?.() ?? 0; - } - } -} - -/** Verify one protected value and sanitize raw WASM failure details. */ -export function verifyDataValueWithPolicy( - value: Uint8Array, - publicKeyBundle: Uint8Array, - expectedSignerId: bigint, - expectedPurpose: number, - policy: MTPSignatureVerificationPolicy, -): void { - try { - bindings.verify_data_value_with_policy( - value, - publicKeyBundle, - expectedSignerId, - expectedPurpose, - signatureVerificationPolicyValue(policy), - ); - } catch (error) { - throw classifySignatureVerificationFailure(error, expectedSignerId); - } -} - -function rawErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -/** Classify a raw verifier failure without returning its cryptographic cause. */ -export function classifySignatureVerificationFailure( - error: unknown, - signerId?: bigint, -): MTPSignatureVerificationError { - const message = rawErrorMessage(error).toLowerCase(); - if (message.includes("unknown") && message.includes("signature suite")) { - return new MTPSignatureVerificationError("unsupported-suite", signerId); - } - if (message.includes("policy")) { - return new MTPSignatureVerificationError("policy-rejected", signerId); - } - return new MTPSignatureVerificationError("invalid-signature", signerId); -} - -/** Select the most useful sanitized error after trying key history. */ -export function signatureVerificationFailure( - errors: readonly unknown[], - signerId?: bigint, -): MTPSignatureVerificationError { - const classified = errors.map((error) => - error instanceof MTPSignatureVerificationError - ? error - : classifySignatureVerificationFailure(error, signerId), - ); - const preferredCode = [ - "unsupported-suite", - "policy-rejected", - "invalid-signature", - ].find((code) => - classified.some((error) => error.code === code), - ) as MTPSignatureVerificationErrorCode | undefined; - return new MTPSignatureVerificationError( - preferredCode ?? "invalid-signature", - signerId, - ); -} - -export function signerKeysUnavailable( - signerId?: bigint, -): MTPSignatureVerificationError { - return new MTPSignatureVerificationError( - "signer-keys-unavailable", - signerId, - ); -} diff --git a/src/sdk/timeout.ts b/src/sdk/timeout.ts deleted file mode 100644 index b4c3ddc..0000000 --- a/src/sdk/timeout.ts +++ /dev/null @@ -1,27 +0,0 @@ -export async function withTimeout( - promise: Promise, - timeoutMs: number | undefined, - message: string, - cancel?: () => void, -): Promise { - if (!timeoutMs) { - return await promise; - } - - let timeoutId: ReturnType | undefined; - try { - return await Promise.race([ - promise, - new Promise((_resolve, reject) => { - timeoutId = setTimeout(() => { - cancel?.(); - reject(new Error(message)); - }, timeoutMs); - }), - ]); - } finally { - if (timeoutId !== undefined) { - clearTimeout(timeoutId); - } - } -} diff --git a/src/sdk/utils.ts b/src/sdk/utils.ts deleted file mode 100644 index 14c02d1..0000000 --- a/src/sdk/utils.ts +++ /dev/null @@ -1,33 +0,0 @@ -export function utf8Encode(text: string): Uint8Array { - if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(text); - if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(text, "utf-8")); - const bytes = new Uint8Array(text.length * 4); - let len = 0; - for (let i = 0; i < text.length; i += 1) { - const code = text.codePointAt(i) as number; - if (code < 0x80) bytes[len++] = code; - else if (code < 0x800) { bytes[len++] = 0xc0 | (code >> 6); bytes[len++] = 0x80 | (code & 0x3f); } - else if (code < 0x10000) { bytes[len++] = 0xe0 | (code >> 12); bytes[len++] = 0x80 | ((code >> 6) & 0x3f); bytes[len++] = 0x80 | (code & 0x3f); } - else { bytes[len++] = 0xf0 | (code >> 18); bytes[len++] = 0x80 | ((code >> 12) & 0x3f); bytes[len++] = 0x80 | ((code >> 6) & 0x3f); bytes[len++] = 0x80 | (code & 0x3f); i += 1; } - } - return bytes.subarray(0, len); -} - -/** Return the current Unix time in milliseconds for MTP protocol fields. */ -export function unixTimeMillis(): bigint { - return BigInt(Date.now()); -} - -export function writeU64BE(value: bigint): Uint8Array { - if (value < 0n || value > 0xffff_ffff_ffff_ffffn) throw new Error("u64 value out of range"); - const out = new Uint8Array(8); - for (let i = 7; i >= 0; i -= 1) { out[i] = Number(value & 0xffn); value >>= 8n; } - return out; -} - -export function concatBytes(parts: Uint8Array[]): Uint8Array { - const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); - let offset = 0; - for (const part of parts) { out.set(part, offset); offset += part.length; } - return out; -} diff --git a/src/sdk/wasm-init.ts b/src/sdk/wasm-init.ts deleted file mode 100644 index 8c843eb..0000000 --- a/src/sdk/wasm-init.ts +++ /dev/null @@ -1,27 +0,0 @@ -import initWasm from "mtp/raw"; - -type WasmInitInput = Parameters[0]; -type WasmExports = Awaited>; -type WasmInitializer = (input?: WasmInitInput) => Promise; - -export function createWasmInitializer( - initialize: WasmInitializer = initWasm, -): WasmInitializer { - let wasmInitPromise: Promise | undefined; - - /** - * Keep the successful WASM singleton, but make a failed attempt retryable. - * A rejected promise is never retained in the module cache. - */ - return (input?: WasmInitInput): Promise => { - if (!wasmInitPromise) { - wasmInitPromise = initialize(input).catch((error) => { - wasmInitPromise = undefined; - throw error; - }); - } - return wasmInitPromise; - } -} - -export const initWasmOnce = createWasmInitializer(); diff --git a/src/type-map/reserved.ts b/src/type-map/reserved.ts deleted file mode 100644 index 6b5d26a..0000000 --- a/src/type-map/reserved.ts +++ /dev/null @@ -1,13 +0,0 @@ -import reserved from "../../type-map/reserved.json" with { type: "json" }; - -export const FIRST_USER_TYPE_ID = reserved.firstUserTypeId; -export const RESERVED_COMMUNICATION_TYPES = reserved.communication.map( - ({ name }) => name, -); -export const RESERVED_DATA_TYPES = reserved.data.map(({ name }) => name); -export const RESERVED_COMMUNICATION_TYPE_IDS = Object.fromEntries( - reserved.communication.map(({ name, id }) => [name, id]), -); -export const RESERVED_DATA_TYPE_IDS = Object.fromEntries( - reserved.data.map(({ name, id }) => [name, id]), -); diff --git a/src/vite/index.ts b/src/vite/index.ts index 359d104..a906000 100644 --- a/src/vite/index.ts +++ b/src/vite/index.ts @@ -4,12 +4,6 @@ import { spawn } from "node:child_process"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import YAML from "yaml"; -import { - FIRST_USER_TYPE_ID, - RESERVED_COMMUNICATION_TYPES, - RESERVED_DATA_TYPES, -} from "../type-map/reserved.js"; export interface MTPVitePluginOptions { typeMaps: string; @@ -23,7 +17,6 @@ export interface VitePlugin { config?: (...args: any[]) => unknown; buildStart?: (...args: any[]) => unknown; configureServer?: (...args: any[]) => unknown; - addWatchFile?: (file: string) => void; } const packageRoot = process.env.MTP_PACKAGE_ROOT @@ -33,11 +26,51 @@ const rawEntryName = "mtp_wasm.js"; const wasmEntryName = "mtp_wasm_bg.wasm"; const typeMapEntryName = "mtp_type_map.js"; +const reservedCommunicationTypes = [ + "Identification", + "IdentificationResponse", + "Register", + "RegisterResponse", + "Challenge", + "ChallengeResponse", + "Ping", + "Pong", + "Disconnect", + "Redirect", + "Shutdown", + "Error", + "ErrorParsing", + "ErrorBadVersion", + "BadRequest", + "Unauthorized", + "Forbidden", + "NotFound", + "TooManyRequests", + "InternalServerError", + "BadGateway", + "ServiceUnavailable", + "GatewayTimeout", +]; + +const reservedDataTypes = [ + "Version", + "Id", + "ClientNonce", + "ServerNonce", + "PublicKeys", + "Signature", + "PqSignature", + "Description", + "Connected", + "Timestamp", + "Error", + "ErrorParsing", + "ErrorMessage", +]; + function normalizeOptions(options) { if (!options?.typeMaps) { - throw new Error( - "mtp/vite requires a typeMaps option, for example mtp({ typeMaps: './type-maps.yaml' })", - ); + throw new Error("mtp/vite requires a typeMaps option, for example mtp({ typeMaps: './type-maps.yaml' })"); } return options; @@ -61,12 +94,10 @@ function devServerPath(root: string, filePath: string) { return `/${relativePath.split(path.sep).join("/")}`; } -export async function hashPackageInputs(root = packageRoot) { +async function hashPackageInputs() { const hash = crypto.createHash("sha256"); const inputs = [ - "Cargo.lock", "wasm/Cargo.toml", - "wasm/.cargo/config.toml", "wasm/src", "common/Cargo.toml", "common/src", @@ -76,12 +107,11 @@ export async function hashPackageInputs(root = packageRoot) { "crypto/src", "type-map/Cargo.toml", "type-map/build.rs", - "type-map/reserved.json", "type-map/src", ]; async function addPath(relativePath) { - const absolutePath = path.join(root, relativePath); + const absolutePath = path.join(packageRoot, relativePath); const stat = await fs.stat(absolutePath).catch(() => null); if (!stat) { return; @@ -106,134 +136,70 @@ export async function hashPackageInputs(root = packageRoot) { return hash.digest("hex"); } -function quoteList(values: string[]): string { +function quoteList(values) { return values.length === 0 ? "never" : values.map((value) => JSON.stringify(value)).join(" | "); } -export function parseTypeMapYaml(source: string, filePath: string) { - const document = YAML.parseDocument(source, { prettyErrors: false }); - if (document.errors.length) { - const error = document.errors[0]; - const line = - error.pos?.[0] === undefined - ? 1 - : source.slice(0, error.pos[0]).split("\n").length; - throw new Error(`${filePath}:${line}: ${error.message}`); - } - const root = document.toJS() as { - protocol_version?: unknown; - type_maps?: unknown; - }; - if ( - root === null || - typeof root !== "object" || - Array.isArray(root) || - typeof root.protocol_version !== "string" || - !/^\d+\.\d+$/.test(root.protocol_version) - ) { - throw new Error( - `${filePath}: protocol_version must be a string matching '.'`, - ); - } - if ( - root.type_maps === null || - typeof root.type_maps !== "object" || - Array.isArray(root.type_maps) - ) { - throw new Error(`${filePath}: type_maps must be a mapping`); - } - const typeMaps = root.type_maps as Record; - if (!Object.prototype.hasOwnProperty.call(typeMaps, root.protocol_version)) { - throw new Error( - `${filePath}: protocol_version '${root.protocol_version}' is not defined in type_maps`, - ); +function parseTypeMapYaml(source, filePath) { + const communicationTypes = new Set(reservedCommunicationTypes); + const dataTypes = new Set(reservedDataTypes); + let section = null; + let sectionIndent = -1; + + for (const [index, originalLine] of source.split(/\r?\n/).entries()) { + const withoutComment = originalLine.replace(/\s+#.*$/, ""); + if (!withoutComment.trim()) { + continue; + } + if (/^\t/.test(withoutComment)) { + throw new Error(`${filePath}:${index + 1}: tabs are not supported in type-maps.yaml indentation`); + } + + const indent = withoutComment.match(/^ */)?.[0].length ?? 0; + const trimmed = withoutComment.trim(); + const sectionMatch = trimmed.match(/^(CommunicationTypes|DataTypes):\s*$/); + if (sectionMatch) { + section = sectionMatch[1]; + sectionIndent = indent; + continue; + } + + if (section && indent <= sectionIndent) { + section = null; + } + if (!section) { + continue; + } + + const entryMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*\d+\s*$/); + if (!entryMatch) { + throw new Error(`${filePath}:${index + 1}: expected '${section}' entries as 'Name: numeric_id'`); + } + + if (section === "CommunicationTypes") { + communicationTypes.add(entryMatch[1]); + } else { + dataTypes.add(entryMatch[1]); + } } - const reservedCommunicationTypes = new Set(RESERVED_COMMUNICATION_TYPES); - const reservedDataTypes = new Set(RESERVED_DATA_TYPES); - const communicationTypes = new Set(RESERVED_COMMUNICATION_TYPES); - const dataTypes = new Set(RESERVED_DATA_TYPES); - for (const [version, rawMap] of Object.entries(typeMaps)) { - if (!/^\d+\.\d+$/.test(version)) - throw new Error(`${filePath}: unparseable type-map version '${version}'`); - if ( - rawMap === null || - typeof rawMap !== "object" || - Array.isArray(rawMap) - ) { - throw new Error(`${filePath}: ${version} must be a mapping`); - } - const map = rawMap as { - CommunicationTypes?: unknown; - DataTypes?: unknown; - }; - for (const { section, reserved, selected } of [ - { - section: "CommunicationTypes", - reserved: reservedCommunicationTypes, - selected: communicationTypes, - }, - { - section: "DataTypes", - reserved: reservedDataTypes, - selected: dataTypes, - }, - ]) { - const sectionValue = map[section as "CommunicationTypes" | "DataTypes"]; - if ( - sectionValue !== undefined && - (sectionValue === null || - typeof sectionValue !== "object" || - Array.isArray(sectionValue)) - ) { - throw new Error(`${filePath}: ${version}.${section} must be a mapping`); - } - const ids = new Map(); - for (const [name, value] of Object.entries(sectionValue ?? {})) { - if (reserved.has(name)) { - throw new Error( - `${filePath}: ${version}.${section}.${name} uses a reserved type name`, - ); - } - if (!Number.isInteger(value) || (value as number) < FIRST_USER_TYPE_ID) - throw new Error( - `${filePath}: ${version}.${section}.${name} must use an integer id >= ${FIRST_USER_TYPE_ID}`, - ); - const id = value as number; - const previous = ids.get(id); - if (previous && previous !== name) - throw new Error( - `${filePath}: duplicate type id ${id} in ${section} (${previous} and ${name})`, - ); - ids.set(id, name); - if (version === root.protocol_version) { - selected.add(name); - } - } - } - } return { communicationTypes: [...communicationTypes].sort(), dataTypes: [...dataTypes].sort(), }; } -export function generateTypeMapModule(metadata: { - communicationTypes: string[]; - dataTypes: string[]; -}) { +function generateTypeMapModule(metadata) { const js = `export const communicationTypes = ${JSON.stringify(metadata.communicationTypes, null, 2)};\nexport const dataTypes = ${JSON.stringify(metadata.dataTypes, null, 2)};\n`; const dts = `export type MTPCommunicationType = ${quoteList(metadata.communicationTypes)};\nexport type MTPDataType = ${quoteList(metadata.dataTypes)};\nexport declare const communicationTypes: readonly MTPCommunicationType[];\nexport declare const dataTypes: readonly MTPDataType[];\n`; return { js, dts }; } -export async function writeTypeMapModule(outDir, typeMapsPath) { +async function writeTypeMapModule(outDir, typeMapsPath) { const source = await fs.readFile(typeMapsPath, "utf8").catch((error) => { - throw new Error( - `Failed to read type map '${typeMapsPath}': ${error.message}`, - ); + throw new Error(`Failed to read type map '${typeMapsPath}': ${error.message}`); }); const metadata = parseTypeMapYaml(source, typeMapsPath); const module = generateTypeMapModule(metadata); @@ -244,7 +210,7 @@ export async function writeTypeMapModule(outDir, typeMapsPath) { return source; } -export async function copyWasmBuildInputs(buildRoot) { +async function copyWasmBuildInputs(buildRoot) { const inputs = [ "Cargo.lock", "wasm", @@ -256,7 +222,7 @@ export async function copyWasmBuildInputs(buildRoot) { for (const input of inputs) { const source = path.join(packageRoot, input); - if (!(await pathExists(source))) { + if (!await pathExists(source)) { continue; } @@ -289,9 +255,7 @@ async function runWasmPack({ outDir, typeMapsPath, release, wasmPackArgs }) { env: { ...process.env, MTP_TYPE_MAPS: typeMapsPath, - RUSTFLAGS: [process.env.RUSTFLAGS, "--cfg web_sys_unstable_apis"] - .filter(Boolean) - .join(" "), + RUSTFLAGS: [process.env.RUSTFLAGS, "--cfg web_sys_unstable_apis"].filter(Boolean).join(" "), }, stdio: ["ignore", "pipe", "pipe"], }); @@ -306,11 +270,7 @@ async function runWasmPack({ outDir, typeMapsPath, release, wasmPackArgs }) { }); child.on("error", (error) => { if ((error as NodeJS.ErrnoException).code === "ENOENT") { - reject( - new Error( - "Failed to run wasm-pack. Install wasm-pack or enter the project Nix dev shell, then retry.", - ), - ); + reject(new Error("Failed to run wasm-pack. Install wasm-pack or enter the project Nix dev shell, then retry.")); } else { reject(error); } @@ -319,11 +279,7 @@ async function runWasmPack({ outDir, typeMapsPath, release, wasmPackArgs }) { if (code === 0) { resolve(); } else { - reject( - new Error( - `wasm-pack failed with exit code ${code}.\n${stdout}${stderr}`.trim(), - ), - ); + reject(new Error(`wasm-pack failed with exit code ${code}.\n${stdout}${stderr}`.trim())); } }); }); @@ -338,60 +294,37 @@ async function buildIfNeeded(state, force = false) { } state.buildPromise = (async () => { - const typeMapSource = await writeTypeMapModule( - state.outDir, - state.typeMapsPath, - ); + const typeMapSource = await writeTypeMapModule(state.outDir, state.typeMapsPath); const packageInputs = await hashPackageInputs(); const fingerprint = crypto .createHash("sha256") - .update( - JSON.stringify({ - packageRoot, - packageInputs, - typeMapsPath: state.typeMapsPath, - typeMapSource, - release: state.release, - wasmPackArgs: state.wasmPackArgs, - }), - ) + .update(JSON.stringify({ + packageRoot, + packageInputs, + typeMapsPath: state.typeMapsPath, + typeMapSource, + release: state.release, + wasmPackArgs: state.wasmPackArgs, + })) .digest("hex"); const stampPath = path.join(state.outDir, ".mtp-build.json"); const rawEntryPath = path.join(state.outDir, rawEntryName); const wasmPath = path.join(state.outDir, wasmEntryName); let previousFingerprint = null; try { - previousFingerprint = JSON.parse( - await fs.readFile(stampPath, "utf8"), - ).fingerprint; + previousFingerprint = JSON.parse(await fs.readFile(stampPath, "utf8")).fingerprint; } catch { previousFingerprint = null; } - if ( - !force && - previousFingerprint === fingerprint && - (await pathExists(rawEntryPath)) && - (await pathExists(wasmPath)) - ) { + if (!force && previousFingerprint === fingerprint && await pathExists(rawEntryPath) && await pathExists(wasmPath)) { return; } - console.info( - "\x1b[1m\x1b[35mmtp\x1b[0m compiling wasm... (this could take a minute)", - ); + console.info("\x1b[1m\x1b[35mmtp\x1b[0m compiling wasm... (this could take a minute)"); await runWasmPack(state); - console.log( - "\x1b[1m\x1b[35mmtp\x1b[0m \x1b[32mcompilation finished.\x1b[0m", - ); - await fs.writeFile( - stampPath, - JSON.stringify( - { fingerprint, builtAt: new Date().toISOString() }, - null, - 2, - ), - ); + console.log("\x1b[1m\x1b[35mmtp\x1b[0m \x1b[32mcompilation finished.\x1b[0m"); + await fs.writeFile(stampPath, JSON.stringify({ fingerprint, builtAt: new Date().toISOString() }, null, 2)); })().finally(() => { state.buildPromise = null; }); @@ -401,7 +334,7 @@ async function buildIfNeeded(state, force = false) { export function mtp(options: MTPVitePluginOptions): VitePlugin { const normalized = normalizeOptions(options); - const state: any = { + const state = { outDir: null, typeMapsPath: null, release: true, @@ -414,28 +347,16 @@ export function mtp(options: MTPVitePluginOptions): VitePlugin { async config(config, env) { const root = path.resolve(config.root ?? process.cwd()); state.typeMapsPath = path.resolve(root, normalized.typeMaps); - state.outDir = path.resolve( - root, - normalized.outDir ?? path.join("node_modules", ".vite", "mtp"), - ); + state.outDir = path.resolve(root, normalized.outDir ?? path.join("node_modules", ".vite", "mtp")); state.release = normalized.release ?? env.command === "build"; - if (!(await pathExists(state.typeMapsPath))) { - throw new Error( - `mtp/vite could not find typeMaps file: ${state.typeMapsPath}`, - ); + if (!await pathExists(state.typeMapsPath)) { + throw new Error(`mtp/vite could not find typeMaps file: ${state.typeMapsPath}`); } await buildIfNeeded(state); return { - // The generated wasm-bindgen JavaScript imports its sibling `.wasm` - // by a relative URL. Prebundling it independently lets Vite retain an - // older wrapper while the plugin has rebuilt the wasm binary, which - // produces missing closure-export errors at runtime. - optimizeDeps: { - exclude: ["mtp", "mtp/raw", "mtp/type-map"], - }, resolve: { preserveSymlinks: true, alias: { @@ -446,27 +367,20 @@ export function mtp(options: MTPVitePluginOptions): VitePlugin { }; }, buildStart() { - (this as any).addWatchFile(state.typeMapsPath); + this.addWatchFile(state.typeMapsPath); }, - async configureServer(server) { + configureServer(server) { const wasmPath = path.join(state.outDir, wasmEntryName); const wasmUrl = devServerPath(server.config.root, wasmPath); if (wasmUrl) { server.middlewares.use(async (req, res, next) => { - if ( - !req.url || - new URL(req.url, "http://localhost").pathname !== wasmUrl - ) { + if (!req.url || new URL(req.url, "http://localhost").pathname !== wasmUrl) { next(); return; } try { res.setHeader("Content-Type", "application/wasm"); - res.setHeader( - "Cache-Control", - "no-cache, no-store, must-revalidate", - ); res.end(await fs.readFile(wasmPath)); } catch (error) { next(error); @@ -474,55 +388,19 @@ export function mtp(options: MTPVitePluginOptions): VitePlugin { }); } - const sourceWatchPaths = [ - "wasm/src", - "common/src", - "codec/src", - "crypto/src", - "type-map/src", - "type-map/reserved.json", - ].map((rel) => path.join(packageRoot, rel)); - server.watcher.add(state.typeMapsPath); - for (const sourcePath of sourceWatchPaths) { - if (await pathExists(sourcePath)) { - server.watcher.add(sourcePath); - } - } - - let rebuildTimer: ReturnType | null = null; - const scheduleRebuild = (changedPath: string) => { - const resolved = path.resolve(changedPath); - const isTypeMap = resolved === state.typeMapsPath; - const isSource = sourceWatchPaths.some( - (sourcePath) => - resolved === sourcePath || - resolved.startsWith(`${sourcePath}${path.sep}`), - ); - if (!isTypeMap && !isSource) { + server.watcher.on("change", async (changedPath) => { + if (path.resolve(changedPath) !== state.typeMapsPath) { return; } - - if (rebuildTimer) { - clearTimeout(rebuildTimer); + try { + await buildIfNeeded(state, true); + server.moduleGraph.invalidateAll(); + server.ws.send({ type: "full-reload" }); + } catch (error) { + server.config.logger.error(error instanceof Error ? error.message : String(error)); } - - rebuildTimer = setTimeout(() => { - rebuildTimer = null; - void (async () => { - try { - await server.restart(); - } catch (error) { - server.config.logger.error( - error instanceof Error ? error.message : String(error), - ); - } - })(); - }, 200); - }; - - server.watcher.on("change", scheduleRebuild); - server.watcher.on("add", scheduleRebuild); + }); }, }; } diff --git a/test/e2ee.mjs b/test/e2ee.mjs deleted file mode 100644 index 201053e..0000000 --- a/test/e2ee.mjs +++ /dev/null @@ -1,2789 +0,0 @@ -import { initSync } from "../dist/raw/index.js"; -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const wasmPath = path.resolve(__dirname, "../wasm/pkg/mtp_wasm_bg.wasm"); -const wasmBytes = fs.readFileSync(wasmPath); -const wasmModule = new WebAssembly.Module(wasmBytes); -initSync({ module: wasmModule }); - -const relayCreatedAtMillis = 1720000000123n; - -const sdk = await import("../dist/sdk/index.js"); -const { MTPRatchet } = await import("../dist/sdk/ratchet.js"); -const { - serializeEncryptedMessage, - parseEncryptedMessage, - encryptPayload, - decryptPayload, - FLAG_INIT, - MTP_E2EE_VERSION, -} = await import("../dist/sdk/encrypted-message.js"); -const { - MTPSessionManager, - InMemorySessionStorage, - deriveSessionKeys, - derivePeerSessionId, - buildSessionTranscript, -} = await import("../dist/sdk/session.js"); - -const bindings = sdk.raw; - -class MemoryEndpoint { - constructor(pipeId) { - this.pipeId = pipeId; - this.queue = []; - this.waiters = []; - this.peer = null; - this.closed = false; - } - - write(data) { - if (this.closed || !this.peer || this.peer.closed) { - return Promise.reject(new Error("memory pipe is closed")); - } - const chunk = data.slice(); - const waiter = this.peer.waiters.shift(); - if (waiter) waiter(chunk); - else this.peer.queue.push(chunk); - return Promise.resolve(); - } - - read() { - if (this.queue.length > 0) return Promise.resolve(this.queue.shift()); - if (this.closed) return Promise.resolve(null); - return new Promise((resolve) => this.waiters.push(resolve)); - } - - close() { - this.closed = true; - for (const resolve of this.waiters.splice(0)) resolve(null); - if (this.peer) { - this.peer.closed = true; - for (const resolve of this.peer.waiters.splice(0)) resolve(null); - } - return Promise.resolve(); - } - - abort() { - this.closed = true; - for (const resolve of this.waiters.splice(0)) resolve(null); - if (this.peer) { - this.peer.closed = true; - for (const resolve of this.peer.waiters.splice(0)) resolve(null); - } - } -} - -function memoryDuplexPair(pipeId = 77) { - const left = new MemoryEndpoint(pipeId); - const right = new MemoryEndpoint(pipeId); - left.peer = right; - right.peer = left; - return [left, right]; -} - -function publicBundle(keyring) { - const keys = sdk.crypto.keyringToKeys(keyring); - return concat( - new Uint8Array([keys.kemPublicKey.length >> 8, keys.kemPublicKey.length & 0xff]), - keys.kemPublicKey, - new Uint8Array([keys.sigPqPublicKey.length >> 8, keys.sigPqPublicKey.length & 0xff]), - keys.sigPqPublicKey, - new Uint8Array([keys.sigClPublicKey.length >> 8, keys.sigClPublicKey.length & 0xff]), - keys.sigClPublicKey, - ); -} - -function ed25519OnlyEncryptionKeyring(keyring) { - const keys = sdk.crypto.keyringToKeys(keyring); - const fields = [ - keys.kemPublicKey, - keys.kemSecretKey, - new Uint8Array(0), - new Uint8Array(0), - keys.sigClPublicKey, - keys.sigClSecretKey, - ]; - return concat( - ...fields.flatMap((field) => [ - new Uint8Array([field.length >> 8, field.length & 0xff]), - field, - ]), - ); -} - -function concat(...arrays) { - const totalLen = arrays.reduce((sum, a) => sum + a.length, 0); - const result = new Uint8Array(totalLen); - let offset = 0; - for (const a of arrays) { - result.set(a, offset); - offset += a.length; - } - return result; -} - -function setupSessions(sharedSecret, aliceId = 1n, bobId = 2n) { - const aliceStorage = new InMemorySessionStorage(); - const aliceManager = new MTPSessionManager(aliceStorage); - const bobStorage = new InMemorySessionStorage(); - const bobManager = new MTPSessionManager(bobStorage); - const sessionId = derivePeerSessionId(aliceId, bobId); - - return { - aliceManager, - aliceStorage, - bobManager, - bobStorage, - async initSessions() { - const transcript = new Uint8Array([0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e]); - const { initiatorSend, initiatorRecv } = await deriveSessionKeys( - sharedSecret, - transcript, - ); - const aliceSession = await aliceManager.createSession({ - sessionId, - localId: aliceId, - remoteId: bobId, - remotePublicKey: new Uint8Array(32), - sharedSecret, - role: "initiator", - transcript, - }); - const bobSession = await bobManager.createSession({ - sessionId, - localId: bobId, - remoteId: aliceId, - remotePublicKey: new Uint8Array(32), - sharedSecret, - role: "receiver", - transcript, - }); - return { aliceSession, bobSession, initiatorSend, initiatorRecv }; - }, - }; -} - -await describe("E2EE Session Derivation", async () => { - await it("Both sides derive same shared secret", async () => { - const sharedSecret = sdk.crypto.sha256(new Uint8Array([1, 2, 3, 4, 5])); - const transcript = new Uint8Array(0); - - const aliceKeys = await deriveSessionKeys(sharedSecret, transcript); - const bobKeys = await deriveSessionKeys(sharedSecret, transcript); - - // Deterministic: same inputs → same outputs - assert.deepEqual(aliceKeys.initiatorSend, bobKeys.initiatorSend); - assert.deepEqual(aliceKeys.initiatorRecv, bobKeys.initiatorRecv); - - // Init and recv keys are different - assert.notDeepEqual(aliceKeys.initiatorSend, aliceKeys.initiatorRecv); - }); - - await it("Session manager assigns correct chain keys per role", async () => { - const ss = sdk.crypto.sha256(new Uint8Array([1])); - const { initSessions } = setupSessions(ss); - const { aliceSession, bobSession, initiatorSend, initiatorRecv } = - await initSessions(); - - // Alice (initiator): send = initiatorSend, recv = initiatorRecv - assert.deepEqual(aliceSession.sendChainKey, initiatorSend); - assert.deepEqual(aliceSession.recvChainKey, initiatorRecv); - - // Bob (receiver): send = initiatorRecv, recv = initiatorSend - assert.deepEqual(bobSession.sendChainKey, initiatorRecv); - assert.deepEqual(bobSession.recvChainKey, initiatorSend); - - // Alice's send chain = Bob's recv chain - assert.deepEqual(aliceSession.sendChainKey, bobSession.recvChainKey); - // Alice's recv chain = Bob's send chain - assert.deepEqual(aliceSession.recvChainKey, bobSession.sendChainKey); - }); - - await it("Different transcripts produce different keys", async () => { - const ss = sdk.crypto.sha256(new Uint8Array([99])); - const aliceKeys1 = await deriveSessionKeys(ss, new Uint8Array(0)); - const aliceKeys2 = await deriveSessionKeys( - ss, - sdk.crypto.sha256(new Uint8Array([42])), - ); - assert.notDeepEqual(aliceKeys1.initiatorSend, aliceKeys2.initiatorSend); - }); -}); - -await describe("MTP Session Transcript", async () => { - const base = { - sessionId: "session-a", - initiatorId: 1n, - recipientId: 2n, - recipientPublicKey: new Uint8Array([1, 2, 3]), - kemCiphertext: new Uint8Array([4, 5, 6]), - }; - - await it("binds generic session identity and MTP key-establishment values", () => { - const transcript = buildSessionTranscript(base); - assert.notDeepEqual( - transcript, - buildSessionTranscript({ ...base, sessionId: "session-b" }), - ); - assert.notDeepEqual( - transcript, - buildSessionTranscript({ ...base, initiatorId: 3n }), - ); - assert.notDeepEqual( - transcript, - buildSessionTranscript({ - ...base, - recipientPublicKey: new Uint8Array([1, 2, 4]), - }), - ); - }); - - await it("hashes opaque application context into the transcript", () => { - const withoutContext = buildSessionTranscript(base); - const withContext = buildSessionTranscript({ - ...base, - applicationContext: new Uint8Array([0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74]), - }); - assert.notDeepEqual(withoutContext, withContext); - }); -}); - -await describe("E2EE Ratchet", async () => { - await it("Repeated sends produce different message keys", async () => { - const chainKey = sdk.crypto.sha256(new Uint8Array([42])); - const step1 = await MTPRatchet.step(chainKey); - const step2 = await MTPRatchet.step(step1.chainKey); - const step3 = await MTPRatchet.step(step2.chainKey); - - assert.notDeepEqual(step1.key, step2.key); - assert.notDeepEqual(step2.key, step3.key); - assert.notDeepEqual(step1.key, step3.key); - assert.notDeepEqual(chainKey, step1.chainKey); - }); - - await it("Receiver can decrypt messages sent by sender in order", async () => { - const chainKey = sdk.crypto.sha256(new Uint8Array([7])); - - const send1 = await MTPRatchet.step(chainKey); - const send2 = await MTPRatchet.step(send1.chainKey); - const send3 = await MTPRatchet.step(send2.chainKey); - - const recv1 = await MTPRatchet.step(chainKey); - const recv2 = await MTPRatchet.step(recv1.chainKey); - const recv3 = await MTPRatchet.step(recv2.chainKey); - - assert.deepEqual(send1.key, recv1.key); - assert.deepEqual(send2.key, recv2.key); - assert.deepEqual(send3.key, recv3.key); - }); -}); - -await describe("E2EE Serialization", async () => { - await it("Roundtrips a basic message", () => { - const msg = { - header: { - version: 1, - flags: 0, - senderId: 0x1234567890abcdefn, - recipientId: 0xfedcba0987654321n, - messageNumber: 42, - }, - aeadPayload: new Uint8Array([1, 2, 3, 4, 5]), - }; - const bytes = serializeEncryptedMessage(msg); - const parsed = parseEncryptedMessage(bytes); - assert.equal(parsed.header.version, 1); - assert.equal(parsed.header.flags, 0); - assert.equal(parsed.header.senderId, msg.header.senderId); - assert.equal(parsed.header.recipientId, msg.header.recipientId); - assert.equal(parsed.header.messageNumber, 42); - assert.equal(parsed.header.kemCiphertext, undefined); - assert.deepEqual(parsed.aeadPayload, msg.aeadPayload); - }); - - await it("Roundtrips an init message with KEM ciphertext", () => { - const msg = { - header: { - version: 1, - flags: FLAG_INIT, - senderId: 1n, - recipientId: 2n, - messageNumber: 0, - kemCiphertext: new Uint8Array([0xde, 0xad, 0xbe, 0xef]), - }, - aeadPayload: new Uint8Array([10, 20, 30]), - }; - const bytes = serializeEncryptedMessage(msg); - const parsed = parseEncryptedMessage(bytes); - assert.equal(parsed.header.flags & FLAG_INIT, FLAG_INIT); - assert.deepEqual(parsed.header.kemCiphertext, msg.header.kemCiphertext); - }); - - await it("Roundtrip: serialize(parse(x)) === x", () => { - const msg = { - header: { - version: 1, - flags: 0, - senderId: 0xaaaabbbbccccddddn, - recipientId: 0xffff000011112222n, - messageNumber: 65535, - }, - aeadPayload: new Uint8Array(100).fill(0x42), - }; - const bytes = serializeEncryptedMessage(msg); - const parsed = parseEncryptedMessage(bytes); - const bytes2 = serializeEncryptedMessage(parsed); - assert.deepEqual(bytes, bytes2); - }); - - await it("Rejects malformed payloads", () => { - assert.throws(() => parseEncryptedMessage(new Uint8Array(0))); - assert.throws(() => parseEncryptedMessage(new Uint8Array([0x01]))); - assert.throws(() => - parseEncryptedMessage( - new Uint8Array([0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), - ), - ); - }); - - await it("Rejects unsupported version", () => { - const msg = { - header: { - version: 1, - flags: 0, - senderId: 0n, - recipientId: 0n, - messageNumber: 0, - }, - aeadPayload: new Uint8Array([1]), - }; - const bytes = serializeEncryptedMessage(msg); - bytes[0] = 99; - assert.throws(() => parseEncryptedMessage(bytes)); - }); - - await it("Rejects trailing data", () => { - const msg = { - header: { - version: 1, - flags: 0, - senderId: 0n, - recipientId: 0n, - messageNumber: 0, - }, - aeadPayload: new Uint8Array([1]), - }; - const bytes = concat( - serializeEncryptedMessage(msg), - new Uint8Array([0xff]), - ); - assert.throws(() => parseEncryptedMessage(bytes)); - }); -}); - -await describe("E2EE Encrypt/Decrypt", async () => { - await it("Alice encrypts and Bob decrypts successfully", async () => { - const keyring = sdk.crypto.generateKeyring(); - const bobKeys = sdk.crypto.keyringToKeys(keyring); - - // Alice encapsulates to Bob's KEM public key - const enc = sdk.crypto.encapsulate(bobKeys.kemPublicKey); - // Bob decapsulates the ciphertext - const bobSS = sdk.crypto.decapsulate(bobKeys.kemSecretKey, enc.ciphertext); - assert.deepEqual(bobSS, enc.shared_secret); - - const ss = enc.shared_secret; - const { initSessions } = setupSessions(ss); - const { aliceSession, bobSession } = await initSessions(); - - // Alice encrypts a message to Bob - const plaintext = sdk.codec.encode( - "Ping", - { Version: "hello from Alice" }, - { sender: 1n, receiver: 2n }, - ); - - const { payload, session: aliceNewSession } = await encryptPayload({ - plaintext, - session: aliceSession, - kemCiphertext: enc.ciphertext, - }); - - // Bob decrypts the message - const { plaintext: decrypted, session: bobNewSession } = - await decryptPayload({ - payload, - session: bobSession, - }); - - const frame = sdk.codec.decode(decrypted); - assert.equal(frame.type, "Ping"); - assert.equal(frame.data["Version"], "hello from Alice"); - - // Chain keys advanced correctly - assert.deepEqual(aliceNewSession.sendChainKey, bobNewSession.recvChainKey); - assert.equal(aliceNewSession.sendCount, 1); - assert.equal(bobNewSession.recvCount, 1); - assert.notDeepEqual( - aliceNewSession.sendChainKey, - aliceSession.sendChainKey, - ); - }); - - await it("Encrypt-decrypt multiple messages with chain advance", async () => { - const ss = sdk.crypto.sha256(new Uint8Array([1, 2, 3])); - const { initSessions } = setupSessions(ss); - let { aliceSession, bobSession } = await initSessions(); - - // Message 1 - const { payload: p1, session: aliceAfter1 } = await encryptPayload({ - plaintext: sdk.codec.encode("Ping", { Version: "msg1" }), - session: aliceSession, - }); - const { plaintext: d1, session: bobAfter1 } = await decryptPayload({ - payload: p1, - session: bobSession, - }); - assert.equal(sdk.codec.decode(d1).data["Version"], "msg1"); - assert.equal(bobAfter1.recvCount, 1); - - // Message 2 - const { payload: p2, session: aliceAfter2 } = await encryptPayload({ - plaintext: sdk.codec.encode("Ping", { Version: "msg2" }), - session: aliceAfter1, - }); - const { plaintext: d2, session: bobAfter2 } = await decryptPayload({ - payload: p2, - session: bobAfter1, - }); - assert.equal(sdk.codec.decode(d2).data["Version"], "msg2"); - assert.equal(bobAfter2.recvCount, 2); - - // Chain keys match after two messages - assert.deepEqual(aliceAfter2.sendChainKey, bobAfter2.recvChainKey); - assert.equal(aliceAfter2.sendCount, 2); - }); - - await it("decrypts messages delivered out of order exactly once", async () => { - const ss = sdk.crypto.sha256(new Uint8Array([4, 5, 6])); - const { initSessions } = setupSessions(ss); - let { aliceSession, bobSession } = await initSessions(); - - const sent = []; - for (const label of ["first", "second", "third"]) { - const encrypted = await encryptPayload({ - plaintext: sdk.codec.encode("Ping", { Version: label }), - session: aliceSession, - }); - sent.push(encrypted.payload); - aliceSession = encrypted.session; - } - - const third = await decryptPayload({ payload: sent[2], session: bobSession }); - bobSession = third.session; - assert.equal(sdk.codec.decode(third.plaintext).data["Version"], "third"); - assert.equal(bobSession.recvCount, 3); - assert.deepEqual( - bobSession.skippedMessageKeys.map(({ messageNumber }) => messageNumber), - [0, 1], - ); - - const first = await decryptPayload({ payload: sent[0], session: bobSession }); - bobSession = first.session; - assert.equal(sdk.codec.decode(first.plaintext).data["Version"], "first"); - assert.deepEqual( - bobSession.skippedMessageKeys.map(({ messageNumber }) => messageNumber), - [1], - ); - - const second = await decryptPayload({ payload: sent[1], session: bobSession }); - bobSession = second.session; - assert.equal(sdk.codec.decode(second.plaintext).data["Version"], "second"); - assert.equal(bobSession.skippedMessageKeys.length, 0); - await assert.rejects( - decryptPayload({ payload: sent[0], session: bobSession }), - /replay/, - ); - }); -}); - -await describe("E2EE Public Key Bundle", async () => { - await it("parses public key bundles from GetUserData.PublicKey", () => { - const keyring = sdk.crypto.generateKeyring(); - const keys = sdk.crypto.keyringToKeys(keyring); - const bundle = concat( - new Uint8Array([keys.kemPublicKey.length >> 8, keys.kemPublicKey.length & 0xff]), - keys.kemPublicKey, - new Uint8Array([keys.sigPqPublicKey.length >> 8, keys.sigPqPublicKey.length & 0xff]), - keys.sigPqPublicKey, - new Uint8Array([keys.sigClPublicKey.length >> 8, keys.sigClPublicKey.length & 0xff]), - keys.sigClPublicKey, - ); - - const parsed = sdk.crypto.publicKeyBundleToKeys(bundle); - assert.deepEqual(parsed.kemPublicKey, keys.kemPublicKey); - assert.deepEqual(parsed.sigPqPublicKey, keys.sigPqPublicKey); - assert.deepEqual(parsed.sigClPublicKey, keys.sigClPublicKey); - }); -}); - -await describe("Protected-value policy", async () => { - await it("enforces the receiver-selected signature suite", () => { - const keyring = sdk.crypto.generateKeyring(); - const bundle = publicBundle(keyring); - const value = bindings.encode_data_value("policy-check"); - const edSigned = bindings.sign_data_value_with_keyring( - value, - 7n, - 0x40, - keyring, - bindings.mtp_protection_signature_suite_ed25519(), - ); - assert.doesNotThrow(() => - bindings.verify_data_value_with_policy( - edSigned, - bundle, - 7n, - 0x40, - bindings.mtp_protection_signature_suite_ed25519(), - ), - ); - assert.throws(() => - bindings.verify_data_value_with_policy( - edSigned, - bundle, - 7n, - 0x40, - bindings.mtp_protection_signature_suite_dual(), - ), - ); - - const dualSigned = bindings.sign_data_value_with_keyring( - value, - 7n, - 0x40, - keyring, - bindings.mtp_protection_signature_suite_dual(), - ); - assert.doesNotThrow(() => - bindings.verify_data_value_with_policy( - dualSigned, - bundle, - 7n, - 0x40, - bindings.mtp_protection_signature_suite_dual(), - ), - ); - }); -}); - -await describe("Relay recipient separation", async () => { - await it("lets a metadata recipient open metadata but not content", async () => { - const senderKeyring = sdk.crypto.generateKeyring(); - const metadataKeyring = sdk.crypto.generateKeyring(); - const finalKeyring = sdk.crypto.generateKeyring(); - const metadataRecipients = [ - publicBundle(metadataKeyring), - publicBundle(finalKeyring), - ]; - const contentRecipients = [publicBundle(finalKeyring)]; - - const frameBytes = bindings.build_encrypted_relay_frame_with_keyring( - "ProtectedMessage", - { ExampleType: "hello" }, - 11n, - 42n, - 7n, - "message-1", - 123n, - bindings.encode_data_value({ ExampleType: "metadata" }), - senderKeyring, - bindings.mtp_protection_signature_suite_dual(), - metadataRecipients, - contentRecipients, - ); - const frame = bindings.parse_frame(frameBytes); - assert.equal(frame.type, "Relay"); - assert.equal(frame.sender, undefined); - assert.equal(frame.receiver, 7n); - - const metadataBytes = frame.data.encoded; - const openedMetadata = bindings.decrypt_data_value( - metadataBytes, - metadataKeyring, - bindings.mtp_relay_metadata_encryption_purpose(), - ); - const metadata = bindings.parse_data_value(openedMetadata); - assert.equal(metadata.kind, "signed"); - assert.equal(BigInt(metadata.value.RelayVersion), 1n); - assert.equal(BigInt(metadata.value.FinalRecipientId), 42n); - assert.equal(metadata.value.Metadata.ExampleType, "metadata"); - - const metadataClient = await sdk.MTPClient.create({ - url: "https://example.invalid", - credentials: { clientId: 7n, keyring: metadataKeyring }, - pings: false, - }); - const verifiedMetadata = await metadataClient.openRelayMetadata(frame, { - expectedSignerId: 11n, - resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], - signaturePolicy: "dual", - }); - assert.equal(verifiedMetadata.relayVersion, 1); - assert.equal(verifiedMetadata.finalRecipientId, 42n); - assert.deepEqual(verifiedMetadata.metadata, { - ExampleType: "metadata", - }); - await assert.rejects( - () => metadataClient.openRelayContent(verifiedMetadata), - /different final recipient/, - ); - - const encryptedContent = metadata.value.Content.encoded; - assert.throws(() => - bindings.decrypt_data_value( - encryptedContent, - metadataKeyring, - bindings.mtp_relay_content_encryption_purpose(), - ), - ); - - const forwardedBytes = bindings.forward_encrypted_relay_frame(frameBytes, 42n); - const forwarded = bindings.parse_frame(forwardedBytes); - assert.deepEqual(forwarded.data.encoded, frame.data.encoded); - const finalMetadata = bindings.parse_data_value( - bindings.decrypt_data_value( - forwarded.data.encoded, - finalKeyring, - bindings.mtp_relay_metadata_encryption_purpose(), - ), - ); - assert.equal(BigInt(finalMetadata.value.RelayVersion), 1n); - const content = bindings.parse_data_value( - bindings.decrypt_data_value( - finalMetadata.value.Content.encoded, - finalKeyring, - bindings.mtp_relay_content_encryption_purpose(), - ), - ); - assert.equal(content.kind, "signed"); - assert.equal(content.value.MessageType, "ProtectedMessage"); - assert.equal(content.value.Content.ExampleType, "hello"); - }); - - await it("opens forwarded relay data with explicit identities", async () => { - const senderKeyring = sdk.crypto.generateKeyring(); - const metadataKeyring = sdk.crypto.generateKeyring(); - const finalKeyring = sdk.crypto.generateKeyring(); - const frameBytes = bindings.build_encrypted_relay_frame_with_keyring( - "ProtectedMessage", - { ExampleType: "explicit-identity" }, - 11n, - 42n, - 7n, - "message-explicit-identity", - 456n, - bindings.encode_data_value({ ExampleType: "forwarded" }), - senderKeyring, - bindings.mtp_protection_signature_suite_dual(), - [publicBundle(metadataKeyring), publicBundle(finalKeyring)], - [publicBundle(finalKeyring)], - ); - const forwarded = bindings.parse_frame( - bindings.forward_encrypted_relay_frame(frameBytes, 99n), - ); - const verification = { - expectedSignerId: 11n, - resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], - signaturePolicy: "dual", - }; - - const metadataClient = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const metadata = await metadataClient.openRelayMetadata(forwarded, { - ...verification, - recipient: { keyring: metadataKeyring }, - }); - assert.equal(metadata.finalRecipientId, 42n); - assert.deepEqual(metadata.metadata, { ExampleType: "forwarded" }); - - const wrongKeyring = sdk.crypto.generateKeyring(); - await assert.rejects( - () => - metadataClient.openRelayMetadata(forwarded, { - ...verification, - recipient: { keyring: wrongKeyring }, - }), - ); - - const registeredClient = await sdk.MTPClient.create({ - url: "https://example.invalid", - credentials: { clientId: 7n, keyring: metadataKeyring }, - pings: false, - }); - const finalIdentity = { id: 42n, keyring: finalKeyring }; - const finalMetadata = await registeredClient.openRelayMetadata( - forwarded, - { - ...verification, - recipient: finalIdentity, - }, - ); - const content = await registeredClient.openRelayContent(finalMetadata, { - ...verification, - recipient: finalIdentity, - }); - assert.equal(content.signerId, 11n); - assert.equal(content.finalRecipientId, 42n); - assert.equal(content.data.ExampleType, "explicit-identity"); - - await assert.rejects( - () => - registeredClient.openRelayContent(finalMetadata, { - ...verification, - recipient: finalIdentity, - expectedFinalRecipientId: 99n, - }), - /different final recipient/, - ); - }); -}); - -await describe("Relay recipient key history", async () => { - function verificationOptions(recipient) { - return { - recipient, - expectedSignerId: 11n, - resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], - signaturePolicy: "dual", - }; - } - - let senderKeyring; - - await it("uses the current keyring and accepts an empty history", async () => { - senderKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const frame = bindings.parse_frame( - bindings.build_encrypted_relay_frame_with_keyring( - "ProtectedMessage", - { ExampleType: "current-key" }, - 11n, - 42n, - 42n, - "message-current-key", - 1n, - null, - senderKeyring, - bindings.mtp_protection_signature_suite_dual(), - [publicBundle(recipientKeyring)], - [publicBundle(recipientKeyring)], - ), - ); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const options = verificationOptions({ - id: 42n, - keyring: recipientKeyring, - keyringHistory: [], - }); - - const metadata = await client.openRelayMetadata(frame, options); - const content = await client.openRelayContent(metadata, options); - assert.equal(content.data.ExampleType, "current-key"); - }); - - await it( - "tries rotated metadata and content recipients newest to oldest without duplicates", - async () => { - senderKeyring = sdk.crypto.generateKeyring(); - const currentKeyring = sdk.crypto.generateKeyring(); - const newerPreviousKeyring = sdk.crypto.generateKeyring(); - const previousKeyring = sdk.crypto.generateKeyring(); - const history = [ - newerPreviousKeyring, - previousKeyring, - previousKeyring, - ]; - const historySnapshot = history.map((keyring) => keyring.slice()); - const frame = bindings.parse_frame( - bindings.build_encrypted_relay_frame_with_keyring( - "ProtectedMessage", - { ExampleType: "rotated-key" }, - 11n, - 42n, - 7n, - "message-rotated-key", - 2n, - bindings.encode_data_value({ ExampleType: "rotated-metadata" }), - senderKeyring, - bindings.mtp_protection_signature_suite_dual(), - [publicBundle(previousKeyring)], - [publicBundle(previousKeyring)], - ), - ); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const options = verificationOptions({ - id: 42n, - keyring: currentKeyring, - keyringHistory: history, - }); - - const metadata = await client.openRelayMetadata(frame, options); - const content = await client.openRelayContent(metadata, options); - assert.equal(content.data.ExampleType, "rotated-key"); - assert.equal(metadata.matchedSignerKeyIndex, 0); - assert.equal(metadata.signerPublicKeys.length, 1); - assert.deepEqual( - metadata.matchedSignerPublicKey, - publicBundle(senderKeyring), - ); - assert.deepEqual(history, historySnapshot); - }, - ); - - await it("does not expose individual key failures when all keyrings fail", async () => { - senderKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const wrongCurrentKeyring = sdk.crypto.generateKeyring(); - const wrongPreviousKeyring = sdk.crypto.generateKeyring(); - const frame = bindings.parse_frame( - bindings.build_encrypted_relay_frame_with_keyring( - "ProtectedMessage", - { ExampleType: "wrong-key" }, - 11n, - 42n, - 42n, - "message-wrong-key", - 3n, - null, - senderKeyring, - bindings.mtp_protection_signature_suite_dual(), - [publicBundle(recipientKeyring)], - [publicBundle(recipientKeyring)], - ), - ); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - - await assert.rejects( - () => - client.openRelayMetadata(frame, { - ...verificationOptions({ - keyring: wrongCurrentKeyring, - keyringHistory: [wrongPreviousKeyring], - }), - }), - (error) => - error.message === - "Unable to decrypt protected value with supplied recipient keyrings", - ); - }); -}); - -await describe("Protected send APIs", async () => { - function captureSend(client) { - const frames = []; - client.raw.client.send = async (frame) => { - frames.push(frame.slice()); - }; - return frames; - } - - await it("round trips with interoperable Ed25519 defaults", async () => { - const signerKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const frames = captureSend(client); - const signerPublicKey = publicBundle(signerKeyring); - const recipientPublicKey = publicBundle(recipientKeyring); - - await client.sendProtected( - "ProtectedMessage", - { ExampleType: "default-direct" }, - { - receiverId: 22n, - identity: { signerId: 11n, keyring: signerKeyring }, - recipients: [recipientPublicKey], - signaturePurpose: 0x40, - encryptionPurpose: 0x41, - }, - ); - const directFrame = sdk.codec.decode(frames.shift()); - const direct = await client.openProtected(directFrame, { - recipient: { id: 22n, keyring: recipientKeyring }, - expectedSignerId: 11n, - expectedReceiverId: 22n, - resolveSignerPublicKeys: () => [signerPublicKey], - signaturePurpose: 0x40, - encryptionPurpose: 0x41, - }); - assert.equal(direct.data.ExampleType, "default-direct"); - - await client.sendSealedRelay( - "ProtectedMessage", - { ExampleType: "default-relay" }, - { - finalRecipientId: 22n, - nextHopId: 7n, - identity: { signerId: 11n, keyring: signerKeyring }, - metadataRecipients: [recipientPublicKey], - contentRecipients: [recipientPublicKey], - metadata: { ExampleType: "default-metadata" }, - }, - ); - const relayFrame = sdk.codec.decode(frames.shift()); - const relayOptions = { - recipient: { id: 22n, keyring: recipientKeyring }, - expectedSignerId: 11n, - resolveSignerPublicKeys: () => [signerPublicKey], - }; - const metadata = await client.openRelayMetadata(relayFrame, relayOptions); - const content = await client.openRelayContent(metadata, relayOptions); - assert.equal(metadata.metadata.ExampleType, "default-metadata"); - assert.equal(content.data.ExampleType, "default-relay"); - metadata.dispose(); - }); - - await it("builds direct protected frames without a Relay hop", async () => { - const signerKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const frames = captureSend(client); - - await client.sendProtected( - "ProtectedMessage", - { ExampleType: "direct" }, - { - receiverId: 22n, - id: 123, - identity: { signerId: 11n, keyring: signerKeyring }, - recipients: [publicBundle(recipientKeyring)], - signaturePurpose: 0x40, - encryptionPurpose: 0x41, - signatureSuite: "dual", - exposeSender: true, - }, - ); - - assert.equal(frames.length, 1); - const frame = bindings.parse_frame(frames[0]); - assert.equal(frame.type, "ProtectedMessage"); - assert.equal(frame.id, 123); - assert.equal(frame.sender, 11n); - assert.equal(frame.receiver, 22n); - assert.equal(frame.data.kind, "encrypted"); - - const signedBytes = bindings.decrypt_data_value( - frame.data.encoded, - recipientKeyring, - 0x41, - ); - const signed = bindings.parse_data_value(signedBytes); - assert.equal(signed.kind, "signed"); - assert.equal(signed.signerId, 11n); - assert.equal(signed.value.Content.ExampleType, "direct"); - assert.equal(signed.value.MessageType, "ProtectedMessage"); - assert.equal(BigInt(signed.value.FinalRecipientId), 22n); - assert.equal(typeof signed.value.MessageId, "string"); - assert.equal(BigInt(signed.value.CreatedAt) > 0n, true); - bindings.verify_data_value_with_policy( - signedBytes, - publicBundle(signerKeyring), - 11n, - 0x40, - bindings.mtp_protection_signature_suite_dual(), - ); - - await assert.rejects( - () => - client.sendProtected("Ping", { ExampleType: "reserved" }, { - receiverId: 22n, - identity: { signerId: 11n, keyring: signerKeyring }, - recipients: [publicBundle(recipientKeyring)], - signaturePurpose: 0x40, - encryptionPurpose: 0x41, - }), - /control communication type/, - ); - }); - - await it("builds sealed relay frames from exact generic recipient sets", async () => { - const signerKeyring = sdk.crypto.generateKeyring(); - const metadataKeyring = sdk.crypto.generateKeyring(); - const contentKeyring = sdk.crypto.generateKeyring(); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const frames = captureSend(client); - - await client.sendSealedRelay( - "ProtectedMessage", - { ExampleType: "content" }, - { - finalRecipientId: 42n, - nextHopId: 7n, - identity: { signerId: 11n, keyring: signerKeyring }, - metadataRecipients: [publicBundle(metadataKeyring)], - contentRecipients: [publicBundle(contentKeyring)], - metadata: { ExampleType: "metadata" }, - signatureSuite: "dual", - }, - ); - - assert.equal(frames.length, 1); - const frame = bindings.parse_frame(frames[0]); - assert.equal(frame.type, "Relay"); - assert.equal(frame.sender, undefined); - assert.equal(frame.receiver, 7n); - - const metadataBytes = bindings.decrypt_data_value( - frame.data.encoded, - metadataKeyring, - bindings.mtp_relay_metadata_encryption_purpose(), - ); - const metadata = bindings.parse_data_value(metadataBytes); - assert.equal(metadata.kind, "signed"); - assert.equal(metadata.signerId, 11n); - assert.equal(BigInt(metadata.value.FinalRecipientId), 42n); - assert.equal(metadata.value.Metadata.ExampleType, "metadata"); - assert.equal(metadata.value.Content.kind, "encrypted"); - - assert.throws(() => - bindings.decrypt_data_value( - metadata.value.Content.encoded, - metadataKeyring, - bindings.mtp_relay_content_encryption_purpose(), - ), - ); - const contentBytes = bindings.decrypt_data_value( - metadata.value.Content.encoded, - contentKeyring, - bindings.mtp_relay_content_encryption_purpose(), - ); - const content = bindings.parse_data_value(contentBytes); - assert.equal(content.kind, "signed"); - assert.equal(content.value.Content.ExampleType, "content"); - - await assert.rejects( - () => - client.sendSealedRelay("Relay", { ExampleType: "reserved" }, { - finalRecipientId: 42n, - nextHopId: 7n, - identity: { signerId: 11n, keyring: signerKeyring }, - metadataRecipients: [publicBundle(metadataKeyring)], - contentRecipients: [publicBundle(contentKeyring)], - }), - /control communication type/, - ); - }); - - await it("round trips generic relay content and metadata presence", async () => { - const signerKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const frames = captureSend(client); - const openOptions = { - recipient: { id: 42n, keyring: recipientKeyring }, - expectedSignerId: 11n, - resolveSignerPublicKeys: () => [publicBundle(signerKeyring)], - signaturePolicy: "dual", - }; - - const sendAndOpen = async (data, metadata, includeMetadata) => { - const options = { - finalRecipientId: 42n, - nextHopId: 42n, - identity: { signerId: 11n, keyring: signerKeyring }, - metadataRecipients: [publicBundle(recipientKeyring)], - contentRecipients: [publicBundle(recipientKeyring)], - signatureSuite: "dual", - ...(includeMetadata ? { metadata } : {}), - }; - await client.sendSealedRelay("ProtectedMessage", data, options); - const frame = sdk.codec.decode(frames.pop()); - const verifiedMetadata = await client.openRelayMetadata( - frame, - openOptions, - ); - const content = await client.openRelayContent( - verifiedMetadata, - openOptions, - ); - return { verifiedMetadata, content }; - }; - - const explicitNull = await sendAndOpen( - new Uint8Array([1, 2, 3]), - null, - true, - ); - assert.equal(explicitNull.verifiedMetadata.metadata, null); - assert.deepEqual(explicitNull.content.data, new Uint8Array([1, 2, 3])); - - const absent = await sendAndOpen( - ["array", 7, false], - undefined, - false, - ); - assert.equal(absent.verifiedMetadata.metadata, undefined); - assert.deepEqual(absent.content.data, ["array", 7, false]); - - const scalar = await sendAndOpen("scalar content", "scalar metadata", true); - assert.equal(scalar.verifiedMetadata.metadata, "scalar metadata"); - assert.equal(scalar.content.data, "scalar content"); - }); - - await it("opens an unauthenticated connection without discarding credentials", async () => { - const keyring = sdk.crypto.generateKeyring(); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - hostPublicKey: new Uint8Array([1]), - credentials: { clientId: 99n, keyring }, - pings: false, - }); - let connectCalls = 0; - let authCalls = 0; - client.raw.client.connect = async () => { - connectCalls += 1; - }; - client.raw.client.auth_connect = async () => { - authCalls += 1; - return 99n; - }; - - await client.connectUnauthenticated(); - - assert.equal(connectCalls, 1); - assert.equal(authCalls, 0); - assert.equal(client.credentials.clientId, 99n); - }); - - await it("opens an authenticated connection when transport credentials are available", async () => { - const transportKeyring = sdk.crypto.generateKeyring(); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - hostPublicKey: new Uint8Array([1]), - credentials: { clientId: 99n, keyring: transportKeyring }, - pings: false, - }); - let connectCalls = 0; - let authCalls = 0; - client.raw.client.connect = async () => { - connectCalls += 1; - }; - client.raw.client.auth_connect = async ( - _config, - hostPublicKey, - keyring, - clientId, - ) => { - authCalls += 1; - assert.deepEqual(hostPublicKey, new Uint8Array([1])); - assert.deepEqual(keyring, transportKeyring); - assert.equal(clientId, 99n); - return 99n; - }; - - await client.connect(); - - assert.equal(connectCalls, 0); - assert.equal(authCalls, 1); - assert.equal(client.credentials.clientId, 99n); - }); - - await it("keeps a protected signer independent from transport authentication", async () => { - const transportKeyring = sdk.crypto.generateKeyring(); - const signerKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - hostPublicKey: new Uint8Array([1]), - credentials: { clientId: 99n, keyring: transportKeyring }, - pings: false, - }); - client.raw.client.auth_connect = async () => 99n; - await client.connect(); - const frames = captureSend(client); - - await client.sendProtected( - "ProtectedMessage", - { ExampleType: "transport-independent" }, - { - receiverId: 22n, - identity: { signerId: 11n, keyring: signerKeyring }, - recipients: [publicBundle(recipientKeyring)], - signaturePurpose: 0x40, - encryptionPurpose: 0x41, - signatureSuite: "dual", - }, - ); - - const frame = bindings.parse_frame(frames[0]); - assert.equal(client.credentials.clientId, 99n); - assert.equal(frame.sender, undefined); - const signedBytes = bindings.decrypt_data_value( - frame.data.encoded, - recipientKeyring, - 0x41, - ); - const signed = bindings.parse_data_value(signedBytes); - assert.equal(signed.kind, "signed"); - assert.equal(signed.signerId, 11n); - assert.equal(signed.value.Content.ExampleType, "transport-independent"); - }); -}); - -await describe("Protected receive APIs", async () => { - async function buildDirectFrame({ - signerKeyring, - recipientKeyring, - data = { ExampleType: "direct-receive" }, - exposeSender = true, - receiverId = 22n, - signaturePurpose = 0x40, - encryptionPurpose = 0x41, - signatureSuite = "dual", - }) { - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const frames = []; - client.raw.client.send = async (frame) => frames.push(frame.slice()); - await client.sendProtected( - "ProtectedMessage", - data, - { - receiverId, - identity: { signerId: 11n, keyring: signerKeyring }, - recipients: [publicBundle(recipientKeyring)], - signaturePurpose, - encryptionPurpose, - signatureSuite, - exposeSender, - }, - ); - return frames[0]; - } - - function openOptions(signerKeyring, recipientKeyring, overrides = {}) { - return { - recipient: { id: 22n, keyring: recipientKeyring }, - expectedSignerId: 11n, - expectedReceiverId: 22n, - resolveSignerPublicKeys: (signerId) => { - assert.equal(signerId, 11n); - return [publicBundle(signerKeyring)]; - }, - signaturePolicy: "dual", - signaturePurpose: 0x40, - encryptionPurpose: 0x41, - ...overrides, - }; - } - - await it("opens exposed and hidden outer senders", async () => { - const signerKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - - const exposed = await client.openProtected( - await buildDirectFrame({ signerKeyring, recipientKeyring }), - openOptions(signerKeyring, recipientKeyring), - ); - assert.equal(exposed.type, "ProtectedMessage"); - assert.equal(exposed.protectedVersion, 1); - assert.equal(exposed.signerId, 11n); - assert.equal(exposed.finalRecipientId, 22n); - assert.equal(exposed.receiver, 22n); - assert.equal(exposed.outerSender, 11n); - assert.equal(typeof exposed.messageId, "string"); - assert.equal(typeof exposed.createdAt, "bigint"); - assert.deepEqual(exposed.data, { - ExampleType: "direct-receive", - }); - - const hidden = await client.openProtected( - await buildDirectFrame({ - signerKeyring, - recipientKeyring, - exposeSender: false, - }), - openOptions(signerKeyring, recipientKeyring), - ); - assert.equal(hidden.outerSender, undefined); - assert.equal(hidden.signerId, 11n); - }); - - await it("authenticates direct routing fields and rejects direct replays", async () => { - const signerKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const frame = await buildDirectFrame({ signerKeyring, recipientKeyring }); - - const changedType = frame.slice(); - changedType[4] = 0; - changedType[5] = 33; - await assert.rejects( - () => - client.openProtected( - changedType, - openOptions(signerKeyring, recipientKeyring), - ), - /protected message type does not match outer routing/, - ); - - const parsed = bindings.parse_frame(frame); - const changedSender = bindings.build_frame_with_payload( - "ProtectedMessage", - parsed.data.encoded, - { receiver: 22n, sender: 12n }, - ); - await assert.rejects( - () => - client.openProtected( - changedSender, - openOptions(signerKeyring, recipientKeyring), - ), - /protected frame sender does not match authenticated signer/, - ); - - const changedReceiver = await buildDirectFrame({ - signerKeyring, - recipientKeyring, - exposeSender: false, - }); - // The frame has an ID and receiver but no sender, so the receiver's final - // byte is at offset 18 in the MTP wire header. - changedReceiver[18] ^= 1; - await assert.rejects( - () => - client.openProtected( - changedReceiver, - openOptions(signerKeyring, recipientKeyring, { - recipient: { keyring: recipientKeyring }, - expectedReceiverId: undefined, - }), - ), - /protected final recipient does not match outer routing receiver/, - ); - - const replayOptions = openOptions(signerKeyring, recipientKeyring); - await client.openProtected(frame, replayOptions); - await assert.rejects( - () => client.openProtected(frame, replayOptions), - (error) => error instanceof sdk.MTPReplayError, - ); - }); - - await it("snapshots parsed direct frames before async signer resolution", async () => { - const signerKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const serialized = await buildDirectFrame({ - signerKeyring, - recipientKeyring, - }); - const parsed = bindings.parse_frame(serialized); - delete parsed.raw; - - let releaseKeys; - const keysReady = new Promise((resolve) => { - releaseKeys = resolve; - }); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const opening = client.openProtected(parsed, { - recipient: { id: 22n, keyring: recipientKeyring }, - resolveSignerPublicKeys: async () => { - await keysReady; - return [publicBundle(signerKeyring)]; - }, - signaturePolicy: "dual", - signaturePurpose: 0x40, - encryptionPurpose: 0x41, - }); - - parsed.receiver = 23n; - releaseKeys(); - const message = await opening; - assert.equal(message.receiver, 22n); - }); - - await it("validates the expected signer and outer receiver", async () => { - const signerKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const frame = await buildDirectFrame({ signerKeyring, recipientKeyring }); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - - let resolverCalls = 0; - await assert.rejects( - () => - client.openProtected( - frame, - openOptions(signerKeyring, recipientKeyring, { - expectedSignerId: 99n, - resolveSignerPublicKeys: () => { - resolverCalls += 1; - return [publicBundle(signerKeyring)]; - }, - }), - ), - /protected signer ID mismatch/, - ); - assert.equal( - resolverCalls, - 0, - "expected signer mismatch must precede signer-key resolution", - ); - await assert.rejects( - () => - client.openProtected( - frame, - openOptions(signerKeyring, recipientKeyring, { - expectedReceiverId: 23n, - }), - ), - /protected frame receiver ID mismatch/, - ); - }); - - await it("uses current and historical recipient keyrings", async () => { - const signerKeyring = sdk.crypto.generateKeyring(); - const currentRecipientKeyring = sdk.crypto.generateKeyring(); - const historicalRecipientKeyring = sdk.crypto.generateKeyring(); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - - const currentFrame = await buildDirectFrame({ - signerKeyring, - recipientKeyring: currentRecipientKeyring, - }); - const current = await client.openProtected( - currentFrame, - openOptions(signerKeyring, currentRecipientKeyring), - ); - assert.equal(current.data.ExampleType, "direct-receive"); - - const historicalFrame = await buildDirectFrame({ - signerKeyring, - recipientKeyring: historicalRecipientKeyring, - }); - const historical = await client.openProtected( - historicalFrame, - openOptions(signerKeyring, currentRecipientKeyring, { - recipient: { - id: 22n, - keyring: currentRecipientKeyring, - keyringHistory: [historicalRecipientKeyring], - }, - }), - ); - assert.equal(historical.data.ExampleType, "direct-receive"); - - await assert.rejects( - () => - client.openProtected( - historicalFrame, - openOptions(signerKeyring, sdk.crypto.generateKeyring()), - ), - /Unable to decrypt protected value with supplied recipient keyrings/, - ); - }); - - await it("round trips non-container application DataValues", async () => { - const signerKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const options = openOptions(signerKeyring, recipientKeyring); - - const stringMessage = await client.openProtected( - await buildDirectFrame({ - signerKeyring, - recipientKeyring, - data: "direct string", - }), - options, - ); - assert.equal(stringMessage.data, "direct string"); - - const arrayMessage = await client.openProtected( - await buildDirectFrame({ - signerKeyring, - recipientKeyring, - data: ["direct array", 7, false], - }), - options, - ); - assert.deepEqual(arrayMessage.data, ["direct array", 7, false]); - - const bytesMessage = await client.openProtected( - await buildDirectFrame({ - signerKeyring, - recipientKeyring, - data: new Uint8Array([3, 1, 4]), - }), - options, - ); - assert.deepEqual(bytesMessage.data, new Uint8Array([3, 1, 4])); - - const largeIntegerMessage = await client.openProtected( - await buildDirectFrame({ - signerKeyring, - recipientKeyring, - data: 9_007_199_254_740_992n, - }), - options, - ); - assert.equal(largeIntegerMessage.data, 9_007_199_254_740_992n); - - const safeIntegerMessage = await client.openProtected( - await buildDirectFrame({ - signerKeyring, - recipientKeyring, - data: 9_007_199_254_740_991, - }), - options, - ); - assert.equal(safeIntegerMessage.data, 9_007_199_254_740_991); - - const largeSignedMessage = await client.openProtected( - await buildDirectFrame({ - signerKeyring, - recipientKeyring, - data: -9_007_199_254_740_992n, - }), - options, - ); - assert.equal(largeSignedMessage.data, -9_007_199_254_740_992n); - - const largeUnsignedMessage = await client.openProtected( - await buildDirectFrame({ - signerKeyring, - recipientKeyring, - data: 18_446_744_073_709_551_615n, - }), - options, - ); - assert.equal(largeUnsignedMessage.data, 18_446_744_073_709_551_615n); - - await assert.rejects( - () => - client.sendProtected( - "ProtectedMessage", - 9_007_199_254_740_992, - { - receiverId: 22n, - identity: { signerId: 11n, keyring: signerKeyring }, - recipients: [publicBundle(recipientKeyring)], - signaturePurpose: 0x40, - encryptionPurpose: 0x41, - signatureSuite: "dual", - }, - ), - /unsafe integral MTP DataValue inputs must use bigint/, - ); - }); - - await it("requires the configured purposes and verifies the protected signature", async () => { - const signerKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const frame = await buildDirectFrame({ signerKeyring, recipientKeyring }); - - await assert.rejects( - () => - client.openProtected( - frame, - openOptions(signerKeyring, recipientKeyring, { - signaturePurpose: 0x42, - }), - ), - ); - await assert.rejects( - () => - client.openProtected( - frame, - openOptions(signerKeyring, recipientKeyring, { - encryptionPurpose: 0x42, - }), - ), - ); - - const parsed = bindings.parse_frame(frame); - const signedBytes = bindings.decrypt_data_value( - parsed.data.encoded, - recipientKeyring, - 0x41, - ); - const tamperedSigned = signedBytes.slice(); - tamperedSigned[1 + 4 + 1 + 1 + 8] ^= 0xff; - const tamperedPayload = bindings.encrypt_data_value_for_recipients( - tamperedSigned, - [publicBundle(recipientKeyring)], - 0x41, - ); - const tamperedFrame = bindings.build_frame_with_payload( - "ProtectedMessage", - tamperedPayload, - { receiver: 22n, sender: 11n }, - ); - await assert.rejects(() => - client.openProtected( - tamperedFrame, - openOptions(signerKeyring, recipientKeyring), - ), - ); - }); - - await it("resolves policy independently from recipient signing capabilities", async () => { - const signerKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const edOnlyRecipientKeyring = ed25519OnlyEncryptionKeyring(recipientKeyring); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - defaultSignatureVerificationPolicy: "dual", - pings: false, - }); - const dualFrame = await buildDirectFrame({ - signerKeyring, - recipientKeyring, - signatureSuite: "dual", - }); - const defaultOpened = await client.openProtected( - dualFrame, - openOptions(signerKeyring, edOnlyRecipientKeyring, { - signaturePolicy: undefined, - }), - ); - assert.equal(defaultOpened.data.ExampleType, "direct-receive"); - - const edFrame = await buildDirectFrame({ - signerKeyring, - recipientKeyring, - signatureSuite: "ed25519", - }); - const overridden = await client.openProtected( - edFrame, - openOptions(signerKeyring, edOnlyRecipientKeyring, { - signaturePolicy: "ed25519", - }), - ); - assert.equal(overridden.data.ExampleType, "direct-receive"); - - await assert.rejects( - () => - client.openProtected( - edFrame, - openOptions(signerKeyring, edOnlyRecipientKeyring, { - signaturePolicy: undefined, - }), - ), - (error) => - error instanceof sdk.MTPSignatureVerificationError && - error.code === "policy-rejected", - ); - - const libraryDefaultClient = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const libraryDefault = await libraryDefaultClient.openProtected( - edFrame, - openOptions(signerKeyring, edOnlyRecipientKeyring, { - signaturePolicy: undefined, - }), - ); - assert.equal(libraryDefaultClient.defaultSignatureVerificationPolicy, "ed25519"); - assert.equal(libraryDefault.data.ExampleType, "direct-receive"); - }); - - await it("reports unavailable signer keys and invalid signatures separately", async () => { - const signerKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const frame = await buildDirectFrame({ signerKeyring, recipientKeyring }); - - await assert.rejects( - () => - client.openProtected( - frame, - openOptions(signerKeyring, recipientKeyring, { - resolveSignerPublicKeys: () => [], - }), - ), - (error) => - error instanceof sdk.MTPSignatureVerificationError && - error.code === "signer-keys-unavailable", - ); - - await assert.rejects( - () => - client.openProtected( - frame, - openOptions(signerKeyring, recipientKeyring, { - resolveSignerPublicKeys: async () => { - throw new Error("signer key store unavailable"); - }, - }), - ), - (error) => - error instanceof sdk.MTPSignatureVerificationError && - error.code === "signer-keys-unavailable", - ); - - const parsed = bindings.parse_frame(frame); - const signedBytes = bindings.decrypt_data_value( - parsed.data.encoded, - recipientKeyring, - 0x41, - ); - const tamperedSigned = signedBytes.slice(); - tamperedSigned[1 + 4 + 1 + 1 + 8] ^= 0xff; - const tamperedPayload = bindings.encrypt_data_value_for_recipients( - tamperedSigned, - [publicBundle(recipientKeyring)], - 0x41, - ); - const tamperedFrame = bindings.build_frame_with_payload( - "ProtectedMessage", - tamperedPayload, - { receiver: 22n, sender: 11n }, - ); - await assert.rejects( - () => - client.openProtected( - tamperedFrame, - openOptions(signerKeyring, recipientKeyring), - ), - (error) => - error instanceof sdk.MTPSignatureVerificationError && - error.code === "invalid-signature", - ); - }); - - await it("rejects an unresolved communication type", async () => { - const signerKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const frame = await buildDirectFrame({ signerKeyring, recipientKeyring }); - const unknownTypeFrame = frame.slice(); - unknownTypeFrame[4] = 0; - unknownTypeFrame[5] = 0xff; - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - - await assert.rejects( - () => - client.openProtected( - unknownTypeFrame, - openOptions(signerKeyring, recipientKeyring), - ), - /Unknown communication type/, - ); - }); - - await it("uses the shared opening path for subscriptions", async () => { - const signerKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const frame = bindings.parse_frame( - await buildDirectFrame({ signerKeyring, recipientKeyring }), - ); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const originalSubscribe = client.raw.client.subscribe; - const originalUnsubscribe = client.raw.client.unsubscribe; - let subscribedType; - let subscribedHandler; - let unsubscribedId; - client.raw.client.subscribe = (type, handler) => { - subscribedType = type; - subscribedHandler = handler; - return 23; - }; - client.raw.client.unsubscribe = (id) => { - unsubscribedId = id; - return true; - }; - - try { - const received = []; - const unsubscribe = client.subscribeProtected( - "ProtectedMessage", - (message, receivedFrame) => received.push({ message, receivedFrame }), - openOptions(signerKeyring, recipientKeyring), - ); - assert.equal(subscribedType, "ProtectedMessage"); - await subscribedHandler(frame); - assert.equal(received.length, 1); - assert.equal(received[0].message.signerId, 11n); - assert.equal(received[0].receivedFrame.type, "ProtectedMessage"); - unsubscribe(); - assert.equal(unsubscribedId, 23); - } finally { - client.raw.client.subscribe = originalSubscribe; - client.raw.client.unsubscribe = originalUnsubscribe; - } - }); - - await it("keeps protected subscription replay guards independent", async () => { - const signerKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const frame = bindings.parse_frame( - await buildDirectFrame({ - signerKeyring, - recipientKeyring, - signatureSuite: "dual", - }), - ); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const originalSubscribe = client.raw.client.subscribe; - const originalUnsubscribe = client.raw.client.unsubscribe; - const subscriptions = []; - client.raw.client.subscribe = (type, handler) => { - const id = subscriptions.length + 1; - subscriptions.push({ type, handler, id }); - return id; - }; - client.raw.client.unsubscribe = () => true; - - try { - const received = [0, 0]; - const options = openOptions(signerKeyring, recipientKeyring); - client.subscribeProtected( - "ProtectedMessage", - () => { - received[0] += 1; - }, - options, - ); - client.subscribeProtected( - "ProtectedMessage", - () => { - received[1] += 1; - }, - options, - ); - - assert.equal(subscriptions.length, 2); - await subscriptions[0].handler(frame); - await subscriptions[1].handler(frame); - assert.deepEqual(received, [1, 1]); - } finally { - client.raw.client.subscribe = originalSubscribe; - client.raw.client.unsubscribe = originalUnsubscribe; - } - }); -}); - -await describe("E2EE Session Manager", async () => { - await it("derivePeerSessionId is consistent regardless of order", () => { - const id1 = derivePeerSessionId(5n, 10n); - const id2 = derivePeerSessionId(10n, 5n); - assert.equal(id1, id2); - }); - - await it("stores independent sessions by explicit session ID", async () => { - const ss = sdk.crypto.sha256(new Uint8Array([1, 2, 3])); - const storage = new InMemorySessionStorage(); - const manager = new MTPSessionManager(storage); - const sessionId = "independent-session"; - - assert.equal(await manager.getSession(sessionId), null); - - const session = await manager.createSession({ - sessionId, - localId: 1n, - remoteId: 2n, - remotePublicKey: new Uint8Array(32), - sharedSecret: ss, - role: "initiator", - transcriptContext: { - sessionId, - initiatorId: 1n, - recipientId: 2n, - recipientPublicKey: new Uint8Array(32), - kemCiphertext: new Uint8Array([1]), - applicationContext: new Uint8Array([2]), - }, - }); - assert.equal(session.version, 1); - assert.equal(session.sendCount, 0); - assert.equal(session.recvCount, 0); - - await manager.saveSession(session); - const retrieved = await manager.getSession(sessionId); - assert.notEqual(retrieved, null); - assert.equal(retrieved.sessionId, session.sessionId); - - const otherSession = await manager.createSession({ - sessionId: "another-session", - localId: 1n, - remoteId: 2n, - remotePublicKey: new Uint8Array(32), - sharedSecret: ss, - role: "initiator", - transcript: new Uint8Array([2]), - }); - await manager.saveSession(otherSession); - assert.notEqual(await manager.getSession(sessionId), null); - assert.notEqual(await manager.getSession(otherSession.sessionId), null); - - await manager.deleteSession(sessionId); - assert.equal(await manager.getSession(sessionId), null); - assert.notEqual(await manager.getSession(otherSession.sessionId), null); - }); -}); - -await describe("E2EE Full Flow: KEM + Session + Ratchet + AEAD", async () => { - await it("Alice encapsulates to Bob, both derive matching sessions, encrypt-decrypt works", async () => { - // Bob generates keyring - const bobKeyring = sdk.crypto.generateKeyring(); - const bobKeys = sdk.crypto.keyringToKeys(bobKeyring); - - // Alice encapsulates to Bob's KEM public key - const enc = sdk.crypto.encapsulate(bobKeys.kemPublicKey); - - // Bob decapsulates - const bobSharedSecret = sdk.crypto.decapsulate( - bobKeys.kemSecretKey, - enc.ciphertext, - ); - assert.deepEqual(bobSharedSecret, enc.shared_secret); - - const ss = enc.shared_secret; - const { initSessions } = setupSessions(ss); - const { aliceSession, bobSession } = await initSessions(); - - // Alice sends encrypted init message with KEM ciphertext - const msg1 = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" - const { payload: p1, session: aliceAfter1 } = await encryptPayload({ - plaintext: msg1, - session: aliceSession, - kemCiphertext: enc.ciphertext, - }); - - // Bob receives and decrypts - const { plaintext: d1, session: bobAfter1 } = await decryptPayload({ - payload: p1, - session: bobSession, - }); - assert.deepEqual(d1, msg1); - assert.deepEqual(aliceAfter1.sendChainKey, bobAfter1.recvChainKey); - assert.equal(aliceAfter1.sendCount, 1); - assert.equal(bobAfter1.recvCount, 1); - - // Second message (no KEM ciphertext) - const msg2 = new Uint8Array([0x57, 0x6f, 0x72, 0x6c, 0x64]); // "World" - const { payload: p2, session: aliceAfter2 } = await encryptPayload({ - plaintext: msg2, - session: aliceAfter1, - }); - - const { plaintext: d2, session: bobAfter2 } = await decryptPayload({ - payload: p2, - session: bobAfter1, - }); - assert.deepEqual(d2, msg2); - assert.deepEqual(aliceAfter2.sendChainKey, bobAfter2.recvChainKey); - assert.equal(aliceAfter2.sendCount, 2); - assert.equal(bobAfter2.recvCount, 2); - }); -}); - -await describe("E2EE Tamper Detection", async () => { - await it("Rejects modified ciphertext", async () => { - const ss = sdk.crypto.sha256(new Uint8Array([42])); - const { initSessions } = setupSessions(ss); - const { aliceSession, bobSession } = await initSessions(); - - const plaintext = new Uint8Array([0x01, 0x02, 0x03]); - const { payload } = await encryptPayload({ - plaintext, - session: aliceSession, - }); - - // Tamper with AEAD payload - const tampered = new Uint8Array(payload); - tampered[tampered.length - 1] ^= 0xff; - - await assert.rejects( - () => decryptPayload({ payload: tampered, session: bobSession }), - /decrypt failed/, - ); - }); - - await it("Rejects out-of-order message numbers", async () => { - const ss = sdk.crypto.sha256(new Uint8Array([7])); - const { initSessions } = setupSessions(ss); - const { aliceSession, bobSession } = await initSessions(); - - // Send two messages - const { payload: p1, session: aliceAfter1 } = await encryptPayload({ - plaintext: sdk.codec.encode("Ping", { Version: "a" }), - session: aliceSession, - }); - await encryptPayload({ - plaintext: sdk.codec.encode("Ping", { Version: "b" }), - session: aliceAfter1, - }); - - // Bob decrypts p1 - const { session: bobAfter1 } = await decryptPayload({ - payload: p1, - session: bobSession, - }); - - // Now bob expects msgNumber 1, but we try to replay msgNumber 0 - await assert.rejects( - () => decryptPayload({ payload: p1, session: bobAfter1 }), - /replay|out of order/, - ); - }); -}); - -await describe("Encrypted Pipe", async () => { - await it("rejects MTP-reserved application purposes", () => { - assert.throws( - () => new sdk.MTPPipeProtectionContext(new Uint8Array([1]), 0x30, 0), - /reserved/, - ); - }); - - await it("authenticates records and requires a final record", async () => { - const [writerPipe, readerPipe] = memoryDuplexPair(); - const context = new sdk.MTPPipeProtectionContext( - new Uint8Array([1, 2, 3]), - 0x40, - 0, - ); - const writer = new sdk.MTPEncryptedPipeWriter( - writerPipe, - new Uint8Array(32).fill(7), - context, - ); - const reader = new sdk.MTPEncryptedPipeReader( - readerPipe, - new Uint8Array(32).fill(7), - context, - ); - - await writer.writeRecord(new Uint8Array([1, 2, 3])); - assert.deepEqual(await reader.readRecord(), new Uint8Array([1, 2, 3])); - await writer.close(); - assert.equal(await reader.readRecord(), null); - assert.equal(await reader.readRecord(), null); - }); - - await it("poisons the reader after a truncated stream", async () => { - const [writerPipe, readerPipe] = memoryDuplexPair(); - const context = new sdk.MTPPipeProtectionContext( - new Uint8Array([4, 5, 6]), - 0x40, - 0, - ); - const writer = new sdk.MTPEncryptedPipeWriter( - writerPipe, - new Uint8Array(32).fill(8), - context, - ); - const reader = new sdk.MTPEncryptedPipeReader( - readerPipe, - new Uint8Array(32).fill(8), - context, - ); - await writer.writeRecord(new Uint8Array([9])); - writerPipe.abort(); - assert.deepEqual(await reader.readRecord(), new Uint8Array([9])); - await assert.rejects(() => reader.readRecord(), /final|truncated/i); - await assert.rejects(() => reader.readRecord(), /state|readable/i); - }); - - await it("poisons the writer after a transport write failure", async () => { - const [writerPipe] = memoryDuplexPair(); - const context = new sdk.MTPPipeProtectionContext( - new Uint8Array([7, 8, 9]), - 0x40, - 0, - ); - const writer = new sdk.MTPEncryptedPipeWriter( - writerPipe, - new Uint8Array(32).fill(6), - context, - ); - writerPipe.abort(); - await assert.rejects(() => writer.writeRecord(new Uint8Array([1])), /write|closed/i); - await assert.rejects(() => writer.writeRecord(new Uint8Array([2])), /state|writable/i); - }); - - await it("establishes a forward-secure authenticated duplex session", async () => { - const senderKeyring = sdk.crypto.generateKeyring(); - const currentSenderKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const senderBundle = publicBundle(senderKeyring); - const currentSenderBundle = publicBundle(currentSenderKeyring); - const recipientBundle = publicBundle(recipientKeyring); - const [initiatorPipe, responderPipe] = memoryDuplexPair(91); - const sessionId = new Uint8Array(32).fill(0x42); - const params = { - sessionId, - pipeId: 91, - senderId: 11n, - recipientId: 22n, - purpose: 0x40, - direction: 0, - }; - - const responder = sdk.acceptMTPForwardSecurePipeSession( - responderPipe, - { - pipeId: params.pipeId, - senderId: params.senderId, - recipientId: params.recipientId, - purpose: params.purpose, - direction: params.direction, - }, - recipientKeyring, - [currentSenderBundle, senderBundle], - "dual", - "dual", - ); - const initiator = await sdk.initiateMTPForwardSecurePipeSession( - initiatorPipe, - params, - senderKeyring, - recipientBundle, - "dual", - "dual", - ); - const receiver = await responder; - await initiator.writeRecord(new Uint8Array([0xaa, 0xbb])); - assert.deepEqual(await receiver.readRecord(), new Uint8Array([0xaa, 0xbb])); - await initiator.close(); - assert.equal(await receiver.readRecord(), null); - }); - - await it("uses interoperable Ed25519 defaults for pipe handshakes", async () => { - const senderKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const [initiatorPipe, responderPipe] = memoryDuplexPair(93); - const params = { - sessionId: new Uint8Array([9, 3]), - pipeId: 93, - senderId: 11n, - recipientId: 22n, - purpose: 0x40, - direction: 0, - }; - - const responder = sdk.acceptMTPForwardSecurePipeSession( - responderPipe, - params, - recipientKeyring, - publicBundle(senderKeyring), - ); - const initiator = await sdk.initiateMTPForwardSecurePipeSession( - initiatorPipe, - params, - senderKeyring, - publicBundle(recipientKeyring), - ); - const receiver = await responder; - await initiator.writeRecord(new Uint8Array([0x55])); - assert.deepEqual(await receiver.readRecord(), new Uint8Array([0x55])); - await initiator.close(); - assert.equal(await receiver.readRecord(), null); - }); - - await it("accepts a dual sender with an Ed25519-only recipient keyring", async () => { - const senderKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const recipientEncryptionKeyring = ed25519OnlyEncryptionKeyring( - recipientKeyring, - ); - const [writerPipe, readerPipe] = memoryDuplexPair(92); - const params = { - sessionId: new Uint8Array([9, 2]), - pipeId: 92, - senderId: 11n, - recipientId: 22n, - purpose: 0x40, - direction: 0, - }; - const readerPromise = sdk.acceptMTPPipeSession( - readerPipe, - params, - recipientEncryptionKeyring, - publicBundle(senderKeyring), - "dual", - ); - const writer = await sdk.initiateMTPPipeSession( - writerPipe, - params, - senderKeyring, - publicBundle(recipientKeyring), - "dual", - ); - const reader = await readerPromise; - await writer.writeRecord(new Uint8Array([0x11])); - assert.deepEqual(await reader.readRecord(), new Uint8Array([0x11])); - }); -}); - -await describe("Relay API invariants", async () => { - await it("exposes stable codes for raw relay operation errors", () => { - const ping = bindings.build_ping_frame( - 7n, - "not-a-relay", - 1n, - new Uint8Array(), - ); - assert.throws( - () => bindings.forward_encrypted_relay_frame(ping, 9n), - (error) => error?.code === "not-relay", - ); - - assert.throws( - () => bindings.forward_encrypted_relay_frame(new Uint8Array([0xff]), 9n), - (error) => error?.code === "invalid-frame", - ); - }); - - await it("uses the client default for metadata and content independently of recipient PQ keys", async () => { - const senderKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const frame = sdk.codec.decode( - bindings.build_encrypted_relay_frame_with_keyring( - "ProtectedMessage", - { ExampleType: "default-policy" }, - 11n, - 42n, - 42n, - "message-default-policy", - 456n, - null, - senderKeyring, - bindings.mtp_protection_signature_suite_ed25519(), - [publicBundle(recipientKeyring)], - [publicBundle(recipientKeyring)], - ), - ); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - defaultSignatureVerificationPolicy: "ed25519", - pings: false, - }); - const options = { - recipient: { id: 42n, keyring: recipientKeyring }, - expectedSignerId: 11n, - resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], - }; - const metadata = await client.openRelayMetadata(frame, options); - assert.equal(metadata.signaturePolicy, "ed25519"); - const content = await client.openRelayContent(metadata, options); - assert.equal(content.data.ExampleType, "default-policy"); - }); - - await it("inherits the authenticated metadata policy for relay content", async () => { - const senderKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const frame = sdk.codec.decode( - bindings.build_encrypted_relay_frame_with_keyring( - "ProtectedMessage", - { ExampleType: "inherited-policy" }, - 11n, - 42n, - 42n, - "message-inherited-policy", - 456n, - null, - senderKeyring, - bindings.mtp_protection_signature_suite_dual(), - [publicBundle(recipientKeyring)], - [publicBundle(recipientKeyring)], - ), - ); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - defaultSignatureVerificationPolicy: "ed25519", - pings: false, - }); - const metadataOptions = { - recipient: { id: 42n, keyring: recipientKeyring }, - expectedSignerId: 11n, - resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], - signaturePolicy: "dual", - }; - const metadata = await client.openRelayMetadata(frame, metadataOptions); - const content = await client.openRelayContent(metadata, { - recipient: metadataOptions.recipient, - expectedSignerId: metadataOptions.expectedSignerId, - resolveSignerPublicKeys: metadataOptions.resolveSignerPublicKeys, - }); - assert.equal(content.data.ExampleType, "inherited-policy"); - }); - - await it("preserves relay CreatedAt milliseconds as bigint", async () => { - const senderKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const frame = sdk.codec.decode( - bindings.build_encrypted_relay_frame_with_keyring( - "ProtectedMessage", - { ExampleType: "generic" }, - 11n, - 42n, - 42n, - "message-generic", - relayCreatedAtMillis, - bindings.encode_data_value({ ExampleType: "opaque-to-content-only-relays" }), - senderKeyring, - bindings.mtp_protection_signature_suite_dual(), - [publicBundle(recipientKeyring)], - [publicBundle(recipientKeyring)], - ), - ); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const options = { - recipient: { id: 42n, keyring: recipientKeyring }, - expectedSignerId: 11n, - resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], - signaturePolicy: "dual", - }; - - const metadata = await client.openRelayMetadata(frame, options); - assert.ok(metadata); - assert.equal(metadata.signerId, 11n); - assert.equal(metadata.finalRecipientId, 42n); - assert.equal(metadata.messageId, "message-generic"); - assert.equal(metadata.createdAt, relayCreatedAtMillis); - assert.deepEqual(metadata.metadata, { - ExampleType: "opaque-to-content-only-relays", - }); - - const content = await client.openRelayContent(metadata, options); - assert.deepEqual(content, { - type: "ProtectedMessage", - data: { ExampleType: "generic" }, - signerId: 11n, - finalRecipientId: 42n, - messageId: "message-generic", - createdAt: relayCreatedAtMillis, - metadata: { ExampleType: "opaque-to-content-only-relays" }, - }); - }); - - await it("preserves scalar metadata and byte content values", async () => { - const senderKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const frame = sdk.codec.decode( - bindings.build_encrypted_relay_frame_with_keyring( - "ProtectedMessage", - new Uint8Array([0xde, 0xad, 0xbe, 0xef]), - 11n, - 42n, - 42n, - "message-scalar-values", - 456n, - bindings.encode_data_value("opaque metadata"), - senderKeyring, - bindings.mtp_protection_signature_suite_dual(), - [publicBundle(recipientKeyring)], - [publicBundle(recipientKeyring)], - ), - ); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const options = { - recipient: { id: 42n, keyring: recipientKeyring }, - expectedSignerId: 11n, - resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], - signaturePolicy: "dual", - }; - - const metadata = await client.openRelayMetadata(frame, options); - assert.equal(metadata.metadata, "opaque metadata"); - const content = await client.openRelayContent(metadata, options); - assert.deepEqual(content.data, new Uint8Array([0xde, 0xad, 0xbe, 0xef])); - }); - - await it("subscribes through the explicit sealed-relay API", async () => { - const senderKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const frame = sdk.codec.decode( - bindings.build_encrypted_relay_frame_with_keyring( - "ProtectedMessage", - { ExampleType: "subscription" }, - 11n, - 42n, - 42n, - "message-subscription", - 789n, - bindings.encode_data_value({ ExampleType: "subscription" }), - senderKeyring, - bindings.mtp_protection_signature_suite_dual(), - [publicBundle(recipientKeyring)], - [publicBundle(recipientKeyring)], - ), - ); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const originalSubscribe = client.raw.client.subscribe; - const originalUnsubscribe = client.raw.client.unsubscribe; - let subscribedType; - let subscribedHandler; - let unsubscribedId; - client.raw.client.subscribe = (type, handler) => { - subscribedType = type; - subscribedHandler = handler; - return 17; - }; - client.raw.client.unsubscribe = (id) => { - unsubscribedId = id; - }; - - try { - const received = []; - const unsubscribe = client.subscribeSealedRelay( - "ProtectedMessage", - (content, parsedFrame) => { - received.push({ content, parsedFrame }); - }, - { - recipient: { id: 42n, keyring: recipientKeyring }, - expectedSignerId: 11n, - resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], - signaturePolicy: "dual", - }, - ); - - assert.equal(subscribedType, "Relay"); - assert.equal(typeof subscribedHandler, "function"); - await subscribedHandler(frame); - assert.equal(received.length, 1); - assert.equal(received[0].content.messageId, "message-subscription"); - assert.deepEqual(received[0].content.metadata, { - ExampleType: "subscription", - }); - assert.equal(received[0].parsedFrame.type, "ProtectedMessage"); - assert.deepEqual(received[0].parsedFrame.data, { - ExampleType: "subscription", - }); - await subscribedHandler(frame); - assert.equal(received.length, 1); - - unsubscribe(); - assert.equal(unsubscribedId, 17); - } finally { - client.raw.client.subscribe = originalSubscribe; - client.raw.client.unsubscribe = originalUnsubscribe; - } - }); - - await it("keeps relay subscription replay guards independent", async () => { - const senderKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const frame = sdk.codec.decode( - bindings.build_encrypted_relay_frame_with_keyring( - "ProtectedMessage", - { ExampleType: "fanout" }, - 11n, - 42n, - 42n, - "message-relay-fanout", - 789n, - bindings.encode_data_value({ ExampleType: "fanout-metadata" }), - senderKeyring, - bindings.mtp_protection_signature_suite_dual(), - [publicBundle(recipientKeyring)], - [publicBundle(recipientKeyring)], - ), - ); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const originalSubscribe = client.raw.client.subscribe; - const originalUnsubscribe = client.raw.client.unsubscribe; - const subscriptions = []; - client.raw.client.subscribe = (type, handler) => { - const id = subscriptions.length + 1; - subscriptions.push({ type, handler, id }); - return id; - }; - client.raw.client.unsubscribe = () => true; - - try { - const options = { - recipient: { id: 42n, keyring: recipientKeyring }, - expectedSignerId: 11n, - resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], - signaturePolicy: "dual", - }; - let mismatched = 0; - let matched = 0; - let metadataOnly = 0; - client.subscribeSealedRelay( - "AlternateMessage", - () => { - mismatched += 1; - }, - options, - ); - client.subscribeSealedRelay( - "ProtectedMessage", - () => { - matched += 1; - }, - options, - ); - client.subscribeRelayMetadata( - () => { - metadataOnly += 1; - }, - options, - ); - - const relaySubscriptions = subscriptions.filter( - ({ type }) => type === "Relay", - ); - assert.equal(relaySubscriptions.length, 3); - for (const subscription of relaySubscriptions) { - await subscription.handler(frame); - } - assert.equal(mismatched, 0); - assert.equal(matched, 1); - assert.equal(metadataOnly, 1); - } finally { - client.raw.client.subscribe = originalSubscribe; - client.raw.client.unsubscribe = originalUnsubscribe; - } - }); - - await it("consumes authenticated relay IDs through the caller's replay guard", async () => { - const senderKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const frame = sdk.codec.decode( - bindings.build_encrypted_relay_frame_with_keyring( - "ProtectedMessage", - { ExampleType: "replay" }, - 11n, - 42n, - 42n, - "message-replay", - 123n, - null, - senderKeyring, - bindings.mtp_protection_signature_suite_dual(), - [publicBundle(recipientKeyring)], - [publicBundle(recipientKeyring)], - ), - ); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - credentials: { clientId: 42n, keyring: recipientKeyring }, - pings: false, - }); - const accepted = new Set(); - const replayGuard = { - accept(signerId, messageId) { - const key = `${signerId}:${messageId}`; - if (accepted.has(key)) return false; - accepted.add(key); - return true; - }, - }; - const options = { - expectedSignerId: 11n, - resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], - signaturePolicy: "dual", - replayGuard, - }; - const metadata = await client.openRelayMetadata(frame, options); - assert.equal(metadata.messageId, "message-replay"); - await assert.rejects( - () => client.openRelayMetadata(frame, options), - (error) => error instanceof sdk.MTPReplayError, - ); - metadata.dispose(); - metadata.free(); - await assert.rejects( - () => client.openRelayContent(metadata, options), - /disposed/, - ); - }); - - await it("disposes metadata after relay metadata subscription handlers complete", async () => { - const senderKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const frame = sdk.codec.decode( - bindings.build_encrypted_relay_frame_with_keyring( - "ProtectedMessage", - { ExampleType: "metadata-subscription" }, - 11n, - 42n, - 42n, - "message-metadata-subscription", - 123n, - null, - senderKeyring, - bindings.mtp_protection_signature_suite_dual(), - [publicBundle(recipientKeyring)], - [publicBundle(recipientKeyring)], - ), - ); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const originalSubscribe = client.raw.client.subscribe; - let subscribedHandler; - client.raw.client.subscribe = (_type, handler) => { - subscribedHandler = handler; - return 19; - }; - - try { - let receivedMetadata; - client.subscribeRelayMetadata( - (metadata) => { - receivedMetadata = metadata; - assert.equal(metadata.messageId, "message-metadata-subscription"); - }, - { - recipient: { id: 42n, keyring: recipientKeyring }, - expectedSignerId: 11n, - resolveSignerPublicKeys: () => [publicBundle(senderKeyring)], - signaturePolicy: "dual", - }, - ); - - await subscribedHandler(frame); - assert.throws(() => receivedMetadata.messageId, /disposed/); - } finally { - client.raw.client.subscribe = originalSubscribe; - } - }); - - await it("rejects relay content opening when metadata is disposed during resolution", async () => { - const senderKeyring = sdk.crypto.generateKeyring(); - const recipientKeyring = sdk.crypto.generateKeyring(); - const frame = sdk.codec.decode( - bindings.build_encrypted_relay_frame_with_keyring( - "ProtectedMessage", - { ExampleType: "dispose-race" }, - 11n, - 42n, - 42n, - "message-dispose-race", - 123n, - null, - senderKeyring, - bindings.mtp_protection_signature_suite_dual(), - [publicBundle(recipientKeyring)], - [publicBundle(recipientKeyring)], - ), - ); - const client = await sdk.MTPClient.create({ - url: "https://example.invalid", - pings: false, - }); - const signerPublicKey = publicBundle(senderKeyring); - const metadata = await client.openRelayMetadata(frame, { - recipient: { id: 42n, keyring: recipientKeyring }, - expectedSignerId: 11n, - resolveSignerPublicKeys: () => [signerPublicKey], - signaturePolicy: "dual", - }); - - let signalResolverStarted; - let releaseResolver; - const resolverStarted = new Promise((resolve) => { - signalResolverStarted = resolve; - }); - const resolverRelease = new Promise((resolve) => { - releaseResolver = resolve; - }); - const opening = client.openRelayContent(metadata, { - recipient: { id: 42n, keyring: recipientKeyring }, - expectedSignerId: 11n, - resolveSignerPublicKeys: async () => { - signalResolverStarted(); - await resolverRelease; - return [signerPublicKey]; - }, - signaturePolicy: "dual", - }); - - await resolverStarted; - metadata.dispose(); - releaseResolver(); - await assert.rejects(opening, /relay metadata has been disposed/); - }); - - await it("does not permit callers to construct verified metadata", () => { - assert.throws( - () => new sdk.MTPVerifiedRelayMetadata(Symbol(), {}), - /authenticated opening/, - ); - }); -}); diff --git a/test/encrypted-secret.mjs b/test/encrypted-secret.mjs deleted file mode 100644 index 85291ef..0000000 --- a/test/encrypted-secret.mjs +++ /dev/null @@ -1,95 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import { test } from "node:test"; -import { InMemoryEncryptedSecretProvider } from "../dist/sdk/encrypted-secret.js"; - -function record(id, bytes, updatedAt = 2) { - return { - id, - encryptedSecret: new Uint8Array(bytes), - formatVersion: 1, - wrappingScheme: "test-wrap-v1", - wrappingKeyId: "wrapping-key-1", - createdAt: 1, - updatedAt, - }; -} - -test("encrypted secret provider stores, reads, replaces, and deletes", async () => { - const provider = new InMemoryEncryptedSecretProvider(); - const initial = record("session-secret:alpha", [1, 2, 3]); - - await provider.set(initial); - initial.encryptedSecret[0] = 99; - - assert.deepEqual(await provider.get("session-secret:alpha"), record( - "session-secret:alpha", - [1, 2, 3], - )); - - const replacement = record("session-secret:alpha", [4, 5], 3); - await provider.set(replacement); - assert.deepEqual( - await provider.get("session-secret:alpha"), - replacement, - ); - - const fetched = await provider.get("session-secret:alpha"); - fetched.encryptedSecret[0] = 88; - assert.deepEqual( - await provider.get("session-secret:alpha"), - replacement, - ); - - await provider.delete("session-secret:alpha"); - assert.equal(await provider.get("session-secret:alpha"), null); - await provider.delete("session-secret:missing"); -}); - -test("encrypted secret provider isolates unrelated opaque IDs", async () => { - const provider = new InMemoryEncryptedSecretProvider(); - const session = record("session-secret:one", [1]); - const pipe = record("pipe-secret:one", [2]); - - await provider.set(session); - await provider.set(pipe); - - assert.deepEqual(await provider.get(session.id), session); - assert.deepEqual(await provider.get(pipe.id), pipe); - assert.equal(await provider.get("identity-secret:one"), null); -}); - -test("encrypted secret provider has no application identity hierarchy", async () => { - const source = await readFile( - new URL("../src/sdk/encrypted-secret.ts", import.meta.url), - "utf8", - ); - - const forbiddenFields = [ - ["user", "Id"], - ["device", "Id"], - ["chat", "Id"], - ["conversation", "Id"], - ].map(([prefix, suffix]) => `${prefix}${suffix}`); - for (const field of forbiddenFields) { - assert.doesNotMatch(source, new RegExp(`\\b${field}\\b`)); - } -}); - -test("encrypted secret provider validates IDs and records", async () => { - const provider = new InMemoryEncryptedSecretProvider(); - - await assert.rejects(() => provider.get(""), /non-empty string/); - await assert.rejects(() => provider.delete(""), /non-empty string/); - await assert.rejects( - () => provider.set(record("", [1])), - /non-empty string/, - ); - await assert.rejects( - () => provider.set({ - ...record("invalid", [1]), - encryptedSecret: new Uint8Array(), - }), - /non-empty encryptedSecret bytes/, - ); -}); diff --git a/test/package-boundary.mjs b/test/package-boundary.mjs deleted file mode 100644 index be24f4d..0000000 --- a/test/package-boundary.mjs +++ /dev/null @@ -1,81 +0,0 @@ -import { access, readFile } from "node:fs/promises"; -import assert from "node:assert/strict"; -import path from "node:path"; -import { test } from "node:test"; -import { fileURLToPath } from "node:url"; - -const repositoryRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "..", -); - -async function requireFile(relativePath) { - try { - await access(path.join(repositoryRoot, relativePath)); - } catch { - assert.fail(`missing release file: ${relativePath}`); - } -} - -async function readJson(relativePath) { - return JSON.parse( - await readFile(path.join(repositoryRoot, relativePath), "utf8"), - ); -} - -test("published package boundary contains all release entry points", async () => { - const packageJson = await readJson("package.json"); - const wasmPackageJson = await readJson("wasm/pkg/package.json"); - - for (const relativePath of [ - "dist/raw/index.js", - "dist/raw/index.d.ts", - "dist/sdk/index.js", - "dist/sdk/index.d.ts", - "dist/type-map/index.js", - "dist/type-map/index.d.ts", - "dist/vite/index.js", - "dist/vite/index.d.ts", - "wasm/pkg/mtp_wasm.js", - "wasm/pkg/mtp_wasm.d.ts", - "wasm/pkg/mtp_wasm_bg.wasm", - ]) { - await requireFile(relativePath); - } - - let wasmIgnoreExists = true; - try { - await access(path.join(repositoryRoot, "wasm/pkg/.gitignore")); - } catch { - wasmIgnoreExists = false; - } - assert.equal( - wasmIgnoreExists, - false, - "wasm/pkg/.gitignore would cause npm pack to omit the WASM files", - ); - - assert.equal( - packageJson.version, - wasmPackageJson.version, - "package and WASM versions must match", - ); - - const rawModule = await readFile( - path.join(repositoryRoot, "dist/raw/index.js"), - "utf8", - ); - assert.match( - rawModule, - /\.\.\/\.\.\/wasm\/pkg\/mtp_wasm\.js/, - "dist/raw must point at the packaged WASM module", - ); - - for (const target of Object.values(packageJson.exports)) { - const entry = typeof target === "string" ? target : target.import; - await requireFile(entry.replace(/^\.\//, "")); - if (typeof target === "object" && target.types) { - await requireFile(target.types.replace(/^\.\//, "")); - } - } -}); diff --git a/test/task8-options.type-test.ts b/test/task8-options.type-test.ts deleted file mode 100644 index a1b6ace..0000000 --- a/test/task8-options.type-test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { - MTPDataValueInput, - MTPClientOptions, - MTPOpenRelayMetadataOptions, - MTPOpenRelayContentOptions, - MTPClient, - MTPSendProtectedOptions, - MTPSendSealedRelayOptions, -} from "../src/sdk/index.js"; - -const recipients = [new Uint8Array([1])]; - -const clientOptions: MTPClientOptions = { - url: "https://example.invalid", - credentials: { - clientId: 1, - keyring: recipients[0], - // @ts-expect-error keyringBytes was removed from the stable credential API - keyringBytes: recipients[0], - }, -}; - -const protectedOptions: MTPSendProtectedOptions = { - receiverId: 2, - recipients, - signaturePurpose: 32, - encryptionPurpose: 33, - // @ts-expect-error sendProtected has only receiverId - receiver: 3, -}; - -const relayOptions: MTPSendSealedRelayOptions = { - finalRecipientId: 2, - nextHopId: 3, - metadataRecipients: recipients, - contentRecipients: recipients, - // @ts-expect-error sealed relay frames never expose an outer sender - sender: 1, -}; - -const metadataValues: MTPDataValueInput[] = [ - null, - true, - 42, - 42n, - "metadata", - new Uint8Array([1, 2]), - ["nested", false], - { Metadata: "typed container" }, -]; - -const relayReceiveOptions: MTPOpenRelayMetadataOptions = { - resolveSignerPublicKeys: () => recipients, -}; - -const contentReceiveOptions: MTPOpenRelayContentOptions = { - // @ts-expect-error replayGuard belongs to metadata opening or subscriptions - replayGuard: { accept: () => true }, -}; - -declare const client: MTPClient; -void client.sendProtected("ProtectedMessage", "scalar protected content", protectedOptions); -void client.sendSealedRelay("ProtectedMessage", new Uint8Array([1, 2, 3]), relayOptions); - -// @ts-expect-error sealed relay subscriptions accept application type names, not numeric IDs -client.subscribeSealedRelay(32, () => {}); - -// @ts-expect-error relay receive uses the key-history resolver API -relayReceiveOptions.senderPublicKey = recipients[0]; -// @ts-expect-error relay receive selects a verification policy, not a signature suite -relayReceiveOptions.signatureSuite = "dual"; - -void protectedOptions; -void relayOptions; -void metadataValues; -void relayReceiveOptions; -void contentReceiveOptions; -void clientOptions; diff --git a/test/vite-type-map.mjs b/test/vite-type-map.mjs deleted file mode 100644 index 5492f76..0000000 --- a/test/vite-type-map.mjs +++ /dev/null @@ -1,243 +0,0 @@ -import assert from "node:assert/strict"; -import { execFile } from "node:child_process"; -import { - chmod, - mkdir, - mkdtemp, - readdir, - readFile, - rename, - rm, - symlink, - writeFile, -} from "node:fs/promises"; -import { existsSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { promisify } from "node:util"; -import { fileURLToPath } from "node:url"; -import { - copyWasmBuildInputs, - generateTypeMapModule, - hashPackageInputs, - parseTypeMapYaml, -} from "../dist/vite/index.js"; - -const run = promisify(execFile); -const repositoryRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "..", -); - -const repeatedNames = ` -protocol_version: "2.0" -type_maps: - "1.0": - CommunicationTypes: - Message: 32 - LegacyMessage: 33 - DataTypes: - Payload: 32 - LegacyPayload: 33 - "2.0": - CommunicationTypes: - Message: 35 - CurrentMessage: 36 - DataTypes: - Payload: 35 - CurrentPayload: 36 -`; - -const selected = parseTypeMapYaml(repeatedNames, "repeated.yaml"); -assert.ok(selected.communicationTypes.includes("Message")); -assert.ok(selected.communicationTypes.includes("CurrentMessage")); -assert.ok(!selected.communicationTypes.includes("LegacyMessage")); -assert.ok(selected.dataTypes.includes("Payload")); -assert.ok(selected.dataTypes.includes("CurrentPayload")); -assert.ok(!selected.dataTypes.includes("LegacyPayload")); -assert.ok(selected.communicationTypes.includes("Ping")); -assert.ok(selected.dataTypes.includes("Version")); - -const generated = generateTypeMapModule(selected); -assert.match(generated.dts, /"Message"/); -assert.match(generated.dts, /"CurrentMessage"/); -assert.doesNotMatch(generated.dts, /LegacyMessage/); -assert.doesNotMatch(generated.dts, /LegacyPayload/); - -assert.throws( - () => - parseTypeMapYaml( - `protocol_version: "1.0"\ntype_maps:\n "1.0":\n CommunicationTypes:\n Ping: 32\n`, - "reserved.yaml", - ), - /uses a reserved type name/, -); -assert.throws( - () => - parseTypeMapYaml( - `protocol_version: "2.0"\ntype_maps:\n "1.0": {}\n`, - "missing-version.yaml", - ), - /is not defined in type_maps/, -); -assert.throws( - () => parseTypeMapYaml(`protocol_version: "1.0"\ntype_maps: {}`, "empty.yaml"), - /is not defined in type_maps/, -); -assert.throws( - () => - parseTypeMapYaml( - `protocol_version: "latest"\ntype_maps:\n "latest": {}\n`, - "invalid-version.yaml", - ), - /protocol_version must be a string/, -); - -const temporaryBuildRoot = await mkdtemp(path.join(os.tmpdir(), "mtp-vite-inputs-")); -try { - await copyWasmBuildInputs(temporaryBuildRoot); - for (const requiredInput of [ - "Cargo.lock", - "wasm/Cargo.toml", - "wasm/src/lib.rs", - "common/Cargo.toml", - "codec/Cargo.toml", - "crypto/Cargo.toml", - "type-map/Cargo.toml", - "type-map/build.rs", - "type-map/reserved.json", - ]) { - assert.equal( - existsSync(path.join(temporaryBuildRoot, requiredInput)), - true, - `temporary WASM build input is missing: ${requiredInput}`, - ); - } - assert.equal( - await readFile(path.join(temporaryBuildRoot, "type-map/reserved.json"), "utf8"), - await readFile(path.join(repositoryRoot, "type-map/reserved.json"), "utf8"), - ); - assert.match( - await readFile(path.join(temporaryBuildRoot, "type-map/build.rs"), "utf8"), - /include_str!\("reserved\.json"\)/, - ); -} finally { - await rm(temporaryBuildRoot, { recursive: true, force: true }); -} - -const hashTestRoot = await mkdtemp(path.join(os.tmpdir(), "mtp-vite-hash-")); -try { - await mkdir(path.join(hashTestRoot, "type-map"), { recursive: true }); - const manifestPath = path.join(hashTestRoot, "type-map/reserved.json"); - await writeFile(manifestPath, "{\"version\":1}"); - const firstHash = await hashPackageInputs(hashTestRoot); - await writeFile(manifestPath, "{\"version\":2}"); - const secondHash = await hashPackageInputs(hashTestRoot); - assert.notEqual(firstHash, secondHash); -} finally { - await rm(hashTestRoot, { recursive: true, force: true }); -} - -const viteCandidates = [ - path.join(repositoryRoot, "example/web-client/node_modules/.bin/vite"), - path.join(repositoryRoot, "node_modules/.bin/vite"), -]; -const viteBin = viteCandidates.find((candidate) => existsSync(candidate)); -if (!viteBin) { - console.warn("Skipping packed Vite smoke test: Vite is not installed"); -} else { - const temporaryPackageRoot = await mkdtemp(path.join(os.tmpdir(), "mtp-vite-package-")); - try { - await run( - "npm", - ["pack", "--pack-destination", temporaryPackageRoot], - { - cwd: repositoryRoot, - env: { - ...process.env, - npm_config_cache: path.join(temporaryPackageRoot, "npm-cache"), - }, - }, - ); - const packageName = (await readdir(temporaryPackageRoot)).find((entry) => - entry.endsWith(".tgz"), - ); - assert.ok(packageName, "npm pack did not report a tarball"); - - const extractedRoot = path.join(temporaryPackageRoot, "extracted"); - const appRoot = path.join(temporaryPackageRoot, "app"); - await mkdir(extractedRoot, { recursive: true }); - await run("tar", ["-xzf", path.join(temporaryPackageRoot, packageName), "-C", extractedRoot]); - await mkdir(path.join(appRoot, "node_modules"), { recursive: true }); - await rename(path.join(extractedRoot, "package"), path.join(appRoot, "node_modules/mtp")); - await symlink( - path.join(repositoryRoot, "node_modules/yaml"), - path.join(appRoot, "node_modules/yaml"), - "dir", - ); - await symlink( - path.join(path.dirname(path.dirname(viteBin)), "vite"), - path.join(appRoot, "node_modules/vite"), - "dir", - ); - - await writeFile( - path.join(appRoot, "package.json"), - JSON.stringify({ type: "module", private: true }), - ); - await writeFile( - path.join(appRoot, "index.html"), - '', - ); - await writeFile( - path.join(appRoot, "main.js"), - 'import { communicationTypes } from "mtp/type-map";\n' + - 'if (!communicationTypes.includes("CurrentMessage") || communicationTypes.includes("LegacyMessage")) throw new Error("wrong browser type-map selection");\n', - ); - await writeFile( - path.join(appRoot, "type-maps.yaml"), - repeatedNames, - ); - await writeFile( - path.join(appRoot, "vite.config.mjs"), - 'import { defineConfig } from "vite";\n' + - 'import { mtp } from "mtp/vite";\n' + - 'export default defineConfig({ plugins: [mtp({ typeMaps: "./type-maps.yaml", release: false })] });\n', - ); - - const fakeBin = path.join(temporaryPackageRoot, "bin"); - const fakeWasmPack = path.join(fakeBin, "wasm-pack"); - await mkdir(fakeBin, { recursive: true }); - await writeFile( - fakeWasmPack, - `#!/usr/bin/env node -const fs = require("node:fs"); -const path = require("node:path"); -const args = process.argv.slice(2); -const outIndex = args.indexOf("--out-dir"); -if (outIndex < 0) throw new Error("fake wasm-pack did not receive --out-dir"); -if (!fs.existsSync(path.join(process.cwd(), "type-map", "reserved.json"))) { - throw new Error("temporary wasm build is missing type-map/reserved.json"); -} -const outDir = args[outIndex + 1]; -fs.mkdirSync(outDir, { recursive: true }); -fs.writeFileSync(path.join(outDir, "mtp_wasm.js"), "export default function init() {}\\n"); -fs.writeFileSync(path.join(outDir, "mtp_wasm_bg.wasm"), Buffer.from([0, 97, 115, 109, 1, 0, 0, 0])); -`, - ); - await chmod(fakeWasmPack, 0o755); - - await run(viteBin, ["build", "--config", "vite.config.mjs"], { - cwd: appRoot, - env: { - ...process.env, - PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`, - }, - }); - assert.equal(existsSync(path.join(appRoot, "dist/index.html")), true); - } finally { - await rm(temporaryPackageRoot, { recursive: true, force: true }); - } -} - -console.log("Vite type-map tests passed"); diff --git a/test/wasm-init.mjs b/test/wasm-init.mjs deleted file mode 100644 index c7057ee..0000000 --- a/test/wasm-init.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { createWasmInitializer } from "../dist/sdk/wasm-init.js"; - -test("WASM initialization can retry after a rejected attempt", async () => { - let attempts = 0; - const expected = { initialized: true }; - const init = createWasmInitializer(async () => { - attempts += 1; - if (attempts === 1) { - throw new Error("initialization failed"); - } - return expected; - }); - - await assert.rejects(init(), /initialization failed/); - assert.equal(await init(), expected); - assert.equal(await init(), expected); - assert.equal(attempts, 2); -}); diff --git a/transport/Cargo.lock b/transport/Cargo.lock index 91fc1e0..fb10a6a 100644 --- a/transport/Cargo.lock +++ b/transport/Cargo.lock @@ -4,4 +4,4 @@ version = 4 [[package]] name = "transport" -version = "0.2.0" +version = "0.1.0" diff --git a/transport/Cargo.toml b/transport/Cargo.toml index 563320f..7a60a2a 100644 --- a/transport/Cargo.toml +++ b/transport/Cargo.toml @@ -1,12 +1,11 @@ [package] name = "mtp-transport" -version = "0.3.0" +version = "0.1.0" edition = "2024" [dependencies] -mtp-codec = { version = "0.3.0", path = "../codec" } -mtp-common = { version = "0.3.0", path = "../common" } -mtp-crypto = { version = "0.3.0", path = "../crypto" } +mtp-codec = { path = "../codec" } +mtp-common = { path = "../common" } wtransport = { version = "0.7.1", default-features = false, features = [ "aws-lc-rs", "quinn", @@ -15,27 +14,16 @@ wtransport = { version = "0.7.1", default-features = false, features = [ rustls = { version = "0.23.41" } tokio = { version = "1", features = ["full"] } rustls-native-certs = "0.8.4" -rcgen = "0.14" -tracing = "0.1" -async-trait = "0.1" -sha2 = "0.11" -rand = "0.10.2" -zeroize = "1.9" +log = "0.4" [dev-dependencies] +rcgen = "0.14" [[test]] name = "integration" -required-features = ["host", "insecure-tls"] +required-features = ["host"] [features] +default = [] # Enables hosting a MTP server host = [] - -pipes = ["mtp-codec/pipes", "mtp-codec/crypto"] - -# Compiles the insecure certificate verifier (NoopCertVerifier). -# Even with this feature enabled, the verifier requires the environment -# variable MTP_INSECURE_TLS=1 at runtime. Intended for local development -# only; never enable in release builds. -insecure-tls = [] diff --git a/transport/src/client.rs b/transport/src/client.rs index dc9666f..115b37e 100644 --- a/transport/src/client.rs +++ b/transport/src/client.rs @@ -1,206 +1,45 @@ use std::sync::Arc; -use std::time::Instant; use mtp_common::CommunicationError; use rustls::{ClientConfig as RustlsClientConfig, RootCertStore, pki_types::pem::PemObject}; -use wtransport::{ClientConfig as WTransportClientConfig, Endpoint}; +use wtransport::{ClientConfig, Endpoint}; use crate::{ConnectionHandle, Policy, Receiver, Sender}; -#[cfg(feature = "insecure-tls")] -mod noop_verifier { - use rustls::{ - DigitallySignedStruct, SignatureScheme, - client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, - pki_types::{CertificateDer, ServerName, UnixTime}, - }; - - #[derive(Debug)] - pub(super) struct NoopCertVerifier; - - impl ServerCertVerifier for NoopCertVerifier { - fn verify_server_cert( - &self, - _end_entity: &CertificateDer<'_>, - _intermediates: &[CertificateDer<'_>], - _server_name: &ServerName<'_>, - _ocsp_response: &[u8], - _now: UnixTime, - ) -> Result { - Ok(ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> Result { - Ok(HandshakeSignatureValid::assertion()) - } - - fn verify_tls13_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> Result { - Ok(HandshakeSignatureValid::assertion()) - } - - fn supported_verify_schemes(&self) -> Vec { - vec![ - SignatureScheme::RSA_PKCS1_SHA1, - SignatureScheme::RSA_PKCS1_SHA256, - SignatureScheme::RSA_PKCS1_SHA384, - SignatureScheme::RSA_PKCS1_SHA512, - SignatureScheme::ECDSA_NISTP256_SHA256, - SignatureScheme::ECDSA_NISTP384_SHA384, - SignatureScheme::RSA_PSS_SHA256, - SignatureScheme::RSA_PSS_SHA384, - SignatureScheme::RSA_PSS_SHA512, - SignatureScheme::ED25519, - ] - } - } -} - -/// TLS and transport settings for a native client connection. -/// -/// Certificate verification uses system roots by default. Disabling verification -/// requires an explicit call to [`Self::with_insecure_certificate_verification`] -/// and the `insecure-tls` compile-time feature. -pub struct ClientConfig { - server_cert: Option>, - pinned_hash: Option<[u8; 32]>, - insecure_certificate_verification: bool, - policy: Policy, -} - -impl ClientConfig { - pub fn new(policy: Policy) -> Self { - Self { - server_cert: None, - pinned_hash: None, - insecure_certificate_verification: false, - policy, - } - } - - pub fn with_server_certificate(mut self, cert_pem: Vec) -> Self { - self.server_cert = Some(cert_pem); - self.insecure_certificate_verification = false; - self.pinned_hash = None; - self - } - - /// Pin the connection to a specific SPKI SHA-256 hash. - /// - /// The client will only accept server certificates whose DER-encoded Subject - /// Public Key Info matches the given 32-byte hash. This is the recommended - /// approach for trusting self-signed certificates without disabling - /// verification entirely. - pub fn with_pinned_certificate_hash(mut self, hash: [u8; 32]) -> Self { - self.pinned_hash = Some(hash); - self.server_cert = None; - self.insecure_certificate_verification = false; - self - } - - /// Disable server certificate verification. - /// - /// Requires the `insecure-tls` feature at compile time and the environment - /// variable `MTP_INSECURE_TLS=1` at runtime. Returns an error if either - /// condition is not met. - /// - /// Intended only for local development with a self-signed host. - #[cfg(feature = "insecure-tls")] - pub fn with_insecure_certificate_verification(mut self) -> Self { - self.server_cert = None; - self.pinned_hash = None; - self.insecure_certificate_verification = true; - self - } -} - pub async fn connect( url: &str, server_cert: Option>, policy: Policy, ) -> Result<(Sender, Receiver), CommunicationError> { - let config = match server_cert { - Some(cert_pem) => ClientConfig::new(policy).with_server_certificate(cert_pem), - None => ClientConfig::new(policy), - }; - connect_with_config(url, config).await -} + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); -/// Connect using explicit TLS and transport configuration. -pub async fn connect_with_config( - url: &str, - config: ClientConfig, -) -> Result<(Sender, Receiver), CommunicationError> { - let connect_started = Instant::now(); - mtp_crypto::ensure_crypto_provider(); - - let config_started = Instant::now(); - let client_config = if config.insecure_certificate_verification { - #[cfg(feature = "insecure-tls")] - { - let env_val = std::env::var("MTP_INSECURE_TLS") - .map(|v| v == "1") - .unwrap_or(false); - if !env_val { - return Err(CommunicationError::Other( - "insecure TLS requires MTP_INSECURE_TLS=1 in the environment".into(), - )); - } - client_config_insecure(&config.policy)? - } - #[cfg(not(feature = "insecure-tls"))] - { - unreachable!( - "insecure_certificate_verification is only set when \ - the insecure-tls feature is enabled" - ) - } - } else if let Some(hash) = config.pinned_hash { - crate::pinning::configure_client_pinned_hash(hash, &config.policy)? - } else if let Some(cert_pem) = config.server_cert { - configure_client_with_cert(cert_pem, &config.policy)? + let client_config = if let Some(cert_pem) = server_cert { + configure_client_with_cert(cert_pem, &policy)? } else { - configure_client_system_roots(&config.policy)? + configure_client_system_roots(&policy)? }; - tracing::debug!(elapsed = ?config_started.elapsed(), "client connect: configure TLS"); - let endpoint_started = Instant::now(); let endpoint = Endpoint::client(client_config) .map_err(|e| CommunicationError::Other(format!("Endpoint creation failed: {}", e)))?; - tracing::debug!(elapsed = ?endpoint_started.elapsed(), "client connect: create endpoint"); - let transport_connect_started = Instant::now(); let connection = endpoint .connect(url) .await .map_err(|e| CommunicationError::ConnectingError(e.to_string()))?; - tracing::debug!(elapsed = ?transport_connect_started.elapsed(), "client connect: establish WebTransport session"); - let handle = Arc::new(ConnectionHandle::with_remote_addr( - connection.quic_connection().remote_address(), - )); - let policy = Arc::new(config.policy); + let handle = Arc::new(ConnectionHandle::new()); + let policy = Arc::new(policy); let sender = Sender::new(connection.clone(), handle.clone(), policy.clone()); let receiver = Receiver::new(connection, handle, policy); - tracing::debug!(elapsed = ?connect_started.elapsed(), "client connect: complete"); Ok((sender, receiver)) } fn configure_client_with_cert( server_cert: Vec, policy: &Policy, -) -> Result { +) -> Result { let mut root_store = RootCertStore::empty(); let certs = rustls::pki_types::CertificateDer::pem_slice_iter(&server_cert) @@ -216,31 +55,10 @@ fn configure_client_with_cert( client_config_from_roots(root_store, policy) } -#[cfg(feature = "insecure-tls")] -fn client_config_insecure(policy: &Policy) -> Result { - use noop_verifier::NoopCertVerifier; - - let mut tls_config = RustlsClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(NoopCertVerifier)) - .with_no_client_auth(); - - tls_config.alpn_protocols = vec![b"h3".to_vec()]; - - Ok(WTransportClientConfig::builder() - .with_bind_default() - .with_custom_tls(tls_config) - .keep_alive_interval(policy.keep_alive_interval) - .max_idle_timeout(policy.max_idle_timeout) - .map_err(|e| CommunicationError::Other(e.to_string()))? - .build()) -} - -fn configure_client_system_roots( - policy: &Policy, -) -> Result { +fn configure_client_system_roots(policy: &Policy) -> Result { let mut root_store = RootCertStore::empty(); + // Load native certs let certs = rustls_native_certs::load_native_certs().certs; for cert in certs { @@ -253,14 +71,14 @@ fn configure_client_system_roots( fn client_config_from_roots( root_store: RootCertStore, policy: &Policy, -) -> Result { +) -> Result { let mut tls_config = RustlsClientConfig::builder() .with_root_certificates(root_store) .with_no_client_auth(); tls_config.alpn_protocols = vec![b"h3".to_vec()]; - Ok(WTransportClientConfig::builder() + Ok(ClientConfig::builder() .with_bind_default() .with_custom_tls(tls_config) .keep_alive_interval(policy.keep_alive_interval) diff --git a/transport/src/connection.rs b/transport/src/connection.rs index 7db09e8..21f8121 100644 --- a/transport/src/connection.rs +++ b/transport/src/connection.rs @@ -1,99 +1,23 @@ use crate::ConnectionHandle; -use crate::framing::RetryClassifier; -#[cfg(feature = "pipes")] -use crate::pipe::PipeReader; -use mtp_codec::{CommunicationValue, DecodeError, DecodeLimits, EncodeLimits, TypeMap}; +use mtp_codec::CommunicationValue; use mtp_common::CommunicationError; -#[cfg(feature = "pipes")] -use mtp_common::{FirstFrameDisposition, classify_first_frame}; -#[cfg(feature = "pipes")] -use std::collections::HashSet; -use std::ops::Deref; use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; -use tokio::sync::{Mutex, Notify, RwLock, Semaphore, mpsc}; -use tokio::time::{Duration, Instant, sleep, timeout, timeout_at}; -use tracing::{debug, info, instrument, trace, warn}; +use tokio::sync::{Mutex, mpsc}; +use tokio::time::{Duration, sleep, timeout}; use wtransport::Connection; -#[cfg(feature = "pipes")] -#[derive(Debug)] -pub enum TransportEvent { - Message(CommunicationValue), - Pipe(PipeReader), -} - const APPLICATION_CLOSE_REASON: &str = "mtp-close"; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum DecodeRejectionClass { - Malformed, - ResourceLimit, - DuplicateField, -} - -pub fn classify_decode_error(error: &DecodeError) -> DecodeRejectionClass { - match error { - DecodeError::MalformedEncoding => DecodeRejectionClass::Malformed, - DecodeError::DepthLimit - | DecodeError::ValueCountLimit - | DecodeError::BlobLimit - | DecodeError::AllocationLimit - | DecodeError::RecipientLimit => DecodeRejectionClass::ResourceLimit, - DecodeError::DuplicateField => DecodeRejectionClass::DuplicateField, - } -} - -#[derive(Debug, Default)] -pub struct DecodeRejectionCounters { - malformed: AtomicU64, - resource_limit: AtomicU64, - duplicate_field: AtomicU64, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct DecodeRejectionCounts { - pub malformed: u64, - pub resource_limit: u64, - pub duplicate_field: u64, -} - -impl DecodeRejectionCounters { - pub(crate) fn record(&self, error: &DecodeError) { - match classify_decode_error(error) { - DecodeRejectionClass::Malformed => { - self.malformed.fetch_add(1, Ordering::Relaxed); - } - DecodeRejectionClass::ResourceLimit => { - self.resource_limit.fetch_add(1, Ordering::Relaxed); - } - DecodeRejectionClass::DuplicateField => { - self.duplicate_field.fetch_add(1, Ordering::Relaxed); - } - } - } - - pub(crate) fn snapshot(&self) -> DecodeRejectionCounts { - DecodeRejectionCounts { - malformed: self.malformed.load(Ordering::Relaxed), - resource_limit: self.resource_limit.load(Ordering::Relaxed), - duplicate_field: self.duplicate_field.load(Ordering::Relaxed), - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SendMode { PersistentStream, SingleStreamPerMessage, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone)] pub struct Policy { pub send_mode: SendMode, pub max_message_size: u64, - /// Receive limit used until the application-level handshake completes. - pub handshake_max_message_size: u64, pub close_frame_len: u32, pub application_close_code: u32, pub open_stream_timeout: Duration, @@ -103,33 +27,28 @@ pub struct Policy { pub keep_alive_interval: Option, pub max_idle_timeout: Option, pub force_close_delay: Duration, - pub persistent_stream_max_retries: usize, - pub persistent_stream_retry_backoff: Duration, + pub max_transient_recv_errors: usize, + pub transient_recv_backoff: Duration, pub receiver_queue_capacity: usize, - pub max_concurrent_stream_tasks: usize, - pub max_frames_per_stream: Option, } impl Default for Policy { fn default() -> Self { Self { send_mode: SendMode::PersistentStream, - max_message_size: 16 * 1024 * 1024, - handshake_max_message_size: 64 * 1024, + max_message_size: 1_000_000_000, close_frame_len: u32::MAX, application_close_code: 0, open_stream_timeout: Duration::from_millis(2_000), write_timeout: Duration::from_millis(2_000), - accept_stream_timeout: Duration::from_millis(500), + accept_stream_timeout: Duration::from_millis(10_000), read_timeout: Duration::from_millis(30_000), keep_alive_interval: Some(Duration::from_secs(3)), max_idle_timeout: Some(Duration::from_secs(30)), force_close_delay: Duration::from_millis(300), - persistent_stream_max_retries: 4, - persistent_stream_retry_backoff: Duration::from_millis(20), + max_transient_recv_errors: 20, + transient_recv_backoff: Duration::from_millis(100), receiver_queue_capacity: 1000, - max_concurrent_stream_tasks: 128, - max_frames_per_stream: None, } } } @@ -145,11 +64,6 @@ impl Policy { self } - pub fn with_handshake_max_message_size(mut self, max_message_size: u64) -> Self { - self.handshake_max_message_size = max_message_size; - self - } - pub fn with_timeouts( mut self, open_stream_timeout: Duration, @@ -176,51 +90,6 @@ impl Policy { self.receiver_queue_capacity = receiver_queue_capacity; self } - - pub fn with_persistent_stream_retries( - mut self, - persistent_stream_max_retries: usize, - persistent_stream_retry_backoff: Duration, - ) -> Self { - self.persistent_stream_max_retries = persistent_stream_max_retries; - self.persistent_stream_retry_backoff = persistent_stream_retry_backoff; - self - } - - pub fn with_max_concurrent_stream_tasks(mut self, max_concurrent_stream_tasks: usize) -> Self { - self.max_concurrent_stream_tasks = max_concurrent_stream_tasks; - self - } - - pub fn with_max_frames_per_stream(mut self, max_frames_per_stream: Option) -> Self { - self.max_frames_per_stream = max_frames_per_stream; - self - } -} - -/// A validated policy snapshot used after a public [`Policy`] crosses into a -/// transport implementation. `Policy` intentionally remains a plain public -/// struct for source compatibility, so callers can construct it directly and -/// bypass builder methods. Every transport constructor takes this snapshot -/// before creating channels or semaphores. -#[derive(Debug, Clone, Copy)] -pub(crate) struct RuntimePolicy(Policy); - -impl RuntimePolicy { - pub(crate) fn from_public(policy: &Policy) -> Self { - let mut policy = *policy; - policy.receiver_queue_capacity = policy.receiver_queue_capacity.max(1); - policy.max_concurrent_stream_tasks = policy.max_concurrent_stream_tasks.max(1); - Self(policy) - } -} - -impl Deref for RuntimePolicy { - type Target = Policy; - - fn deref(&self) -> &Self::Target { - &self.0 - } } enum ReceivedFrame { @@ -229,80 +98,50 @@ enum ReceivedFrame { Idle, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SenderState { - Open, - Closing, - Closed, -} - -#[derive(Clone)] pub struct Sender { - send_guard: Arc>, + send_guard: Mutex<()>, stream_guard: Arc>>, - state: Arc>, handle: Arc, connection: Connection, - policy: Arc, - type_map: Arc>, + policy: Arc, } impl Sender { pub fn new(connection: Connection, handle: Arc, policy: Arc) -> Self { - let policy = Arc::new(RuntimePolicy::from_public(&policy)); Self { - send_guard: Arc::new(Mutex::new(())), + send_guard: Mutex::new(()), stream_guard: Arc::new(Mutex::new(None)), - state: Arc::new(Mutex::new(SenderState::Open)), handle, connection, policy, - type_map: Arc::new(RwLock::new(TypeMap::latest())), } } - /// Bind control frames created by this sender to the negotiated protocol map. - pub async fn set_type_map(&self, type_map: &TypeMap) { - *self.type_map.write().await = type_map.clone(); - } - - #[instrument(skip(stream, data, policy), level = "trace")] async fn write_frame( stream: &mut wtransport::SendStream, data: &CommunicationValue, policy: &Policy, ) -> Result<(), CommunicationError> { - let bytes = data - .to_bytes_with_limits(EncodeLimits::for_transport_message_size( - policy.max_message_size, - )) - .map_err(|_| CommunicationError::Encode)?; + let bytes = data.to_bytes().map_err(|_| CommunicationError::Encode)?; if bytes.len() as u64 > policy.max_message_size || bytes.len() as u64 >= policy.close_frame_len as u64 { return Err(CommunicationError::MessageTooLarge); } - let write_result = async { - stream.write_all(&bytes).await?; - Ok::<(), wtransport::error::StreamWriteError>(()) - }; + use tokio::io::AsyncWriteExt; - match timeout(policy.write_timeout, write_result).await { - Ok(Ok(())) => Ok(()), - Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => { - warn!("[Sender] write failed: peer sent STOP_SENDING (error code {code})"); - Err(CommunicationError::DeliveryUnknown) - } - Ok(Err(other)) => { - warn!("[Sender] write failed: {other}"); - Err(CommunicationError::DeliveryUnknown) - } - Err(_) => { - warn!("[Sender] write timed out (len={})", bytes.len()); - Err(CommunicationError::DeliveryUnknown) - } - } + timeout(policy.write_timeout, stream.write_u32(bytes.len() as u32)) + .await + .map_err(|_| CommunicationError::StreamError)? + .map_err(|_| CommunicationError::StreamError)?; + + timeout(policy.write_timeout, stream.write_all(&bytes)) + .await + .map_err(|_| CommunicationError::StreamError)? + .map_err(CommunicationError::from)?; + + Ok(()) } fn normalize_send_error(error: CommunicationError) -> CommunicationError { @@ -316,7 +155,6 @@ impl Sender { } } - #[instrument(skip(conn, policy), level = "trace")] async fn open_uni_stream( conn: &Connection, policy: &Policy, @@ -349,7 +187,6 @@ impl Sender { } } - #[instrument(skip(conn, stream_opt, data, policy), level = "trace")] async fn send_on_persistent_stream( conn: &Connection, stream_opt: &mut Option, @@ -362,34 +199,26 @@ impl Sender { return Err(CommunicationError::StreamClosed); } - let res = match Self::ensure_stream(conn, stream_opt, policy).await { - Ok(stream) => Self::write_frame(stream, data, policy).await, - Err(e) => Err(e), + let res = { + let stream = Self::ensure_stream(conn, stream_opt, policy).await?; + Self::write_frame(stream, data, policy).await }; if res.is_ok() { return Ok(()); } - let err = match res { - Ok(()) => return Ok(()), - Err(error) => error, - }; - if !RetryClassifier::retry_persistent_stream(&err) { - return Err(err); - } *stream_opt = None; tries += 1; - if tries > policy.persistent_stream_max_retries { - return Err(err); + if tries >= 4 { + let stream = Self::ensure_stream(conn, stream_opt, policy).await?; + return Self::write_frame(stream, data, policy).await; } - let backoff = policy.persistent_stream_retry_backoff * tries as u32; - tokio::time::sleep(backoff).await; + tokio::time::sleep(std::time::Duration::from_millis(20 * tries as u64)).await; } } - #[instrument(skip(conn, data, policy), level = "trace")] async fn send_on_single_stream( conn: &Connection, data: &CommunicationValue, @@ -398,84 +227,53 @@ impl Sender { let mut stream = Self::open_uni_stream(conn, policy).await?; Self::write_frame(&mut stream, data, policy).await?; - match timeout(policy.write_timeout, stream.finish()).await { - Ok(Ok(())) => Ok(()), - Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => { - warn!("[Sender] finish failed: peer sent STOP_SENDING (error code {code})"); - Err(CommunicationError::DeliveryUnknown) - } - Ok(Err(other)) => { - warn!("[Sender] finish failed: {other}"); - Err(CommunicationError::DeliveryUnknown) - } - Err(_) => { - warn!("[Sender] finish timed out"); - Err(CommunicationError::DeliveryUnknown) - } - } - } + timeout(policy.write_timeout, stream.finish()) + .await + .map_err(|_| CommunicationError::StreamError)? + .map_err(|_| CommunicationError::StreamError)?; - #[instrument(skip(conn, policy), level = "trace")] + Ok(()) + } async fn send_close_frame( conn: &Connection, policy: &Policy, ) -> Result<(), CommunicationError> { let mut stream = Self::open_uni_stream(conn, policy).await?; - let len_bytes = policy.close_frame_len.to_be_bytes(); - match timeout(policy.write_timeout, stream.write_all(&len_bytes)).await { - Ok(Ok(())) => {} - Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => { - warn!( - "[Sender] close frame write failed: peer sent STOP_SENDING (error code {code})" - ); - } - Ok(Err(other)) => { - warn!("[Sender] close frame write failed: {other}"); - } - Err(_) => { - warn!("[Sender] close frame write timed out"); - } - } + use tokio::io::AsyncWriteExt; + timeout( + policy.write_timeout, + stream.write_u32(policy.close_frame_len), + ) + .await + .map_err(|_| CommunicationError::StreamError)? + .map_err(|_| CommunicationError::StreamError)?; - match timeout(policy.write_timeout, stream.finish()).await { - Ok(Ok(())) => {} - Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => { - warn!( - "[Sender] close frame finish failed: peer sent STOP_SENDING (error code {code})" - ); - } - Ok(Err(other)) => { - warn!("[Sender] close frame finish failed: {other}"); - } - Err(_) => { - warn!("[Sender] close frame finish timed out"); - } + if let Err(e) = timeout(policy.write_timeout, stream.finish()) + .await + .map_err(|_| CommunicationError::StreamError)? + { + log::warn!("[Sender] close frame finish failed: {e}"); } Ok(()) } - #[instrument(skip(self, data), level = "trace")] pub async fn send(&self, data: &CommunicationValue) -> Result<(), CommunicationError> { - let _send_lock = self.send_guard.lock().await; - - { - let state = self.state.lock().await; - if *state != SenderState::Open { - return Err(self - .handle - .close_reason() - .unwrap_or(CommunicationError::StreamClosed)); - } + if self.handle.is_closed() { + return Err(self + .handle + .close_reason() + .unwrap_or(CommunicationError::UseAfterClosed)); } + let _send_lock = self.send_guard.lock().await; + if self.connection.quic_connection().close_reason().is_some() { let reason = self .handle .close_reason() .unwrap_or(CommunicationError::StreamClosed); - *self.state.lock().await = SenderState::Closed; self.handle.close(Some(reason.clone())); return Err(reason); } @@ -508,7 +306,6 @@ impl Sender { if self.connection.quic_connection().close_reason().is_some() || matches!(normalized, CommunicationError::StreamClosed) { - *self.state.lock().await = SenderState::Closed; self.handle.close(Some(normalized.clone())); } @@ -517,32 +314,14 @@ impl Sender { } } - #[instrument(skip(self), level = "trace")] pub async fn finish_stream(&self) -> Result<(), CommunicationError> { let _send_lock = self.send_guard.lock().await; - if *self.state.lock().await != SenderState::Open { - return Err(self - .handle - .close_reason() - .unwrap_or(CommunicationError::StreamClosed)); - } let mut stream_opt = self.stream_guard.lock().await; if let Some(mut stream) = stream_opt.take() { - match timeout(self.policy.write_timeout, stream.finish()).await { - Ok(Ok(())) => {} - Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => { - warn!("[Sender] finish_stream: peer sent STOP_SENDING (error code {code})"); - return Err(CommunicationError::StreamClosed); - } - Ok(Err(other)) => { - warn!("[Sender] finish_stream failed: {other}"); - return Err(CommunicationError::StreamError); - } - Err(_) => { - warn!("[Sender] finish_stream timed out"); - return Err(CommunicationError::StreamError); - } - } + timeout(self.policy.write_timeout, stream.finish()) + .await + .map_err(|_| CommunicationError::StreamError)? + .map_err(|_| CommunicationError::StreamError)?; } Ok(()) } @@ -551,96 +330,30 @@ impl Sender { &self.handle } - #[cfg(feature = "pipes")] - #[instrument(skip(self, description), level = "trace")] - pub async fn open_pipe( - &self, - pipe_id: u32, - description: &str, - ) -> Result { - let _send_lock = self.send_guard.lock().await; - if *self.state.lock().await != SenderState::Open { - return Err(self - .handle - .close_reason() - .unwrap_or(CommunicationError::StreamClosed)); - } - - if self.connection.quic_connection().close_reason().is_some() { - let reason = self - .handle - .close_reason() - .unwrap_or(CommunicationError::StreamClosed); - self.handle.close(Some(reason.clone())); - return Err(reason); - } - - let mut stream = Self::open_uni_stream(&self.connection, &self.policy).await?; - - let type_map = self.type_map.read().await.clone(); - let request = CommunicationValue::new_with_type_map( - mtp_codec::CommunicationType::PipeRequest, - &type_map, - ) - .with_id(pipe_id) - .add_typed_default( - mtp_codec::DataType::Description, - mtp_codec::DataValue::Str(description.to_string()), - ); - - Self::write_frame(&mut stream, &request, &self.policy).await?; - - Ok(crate::pipe::PipeWriter { stream }) - } - - #[instrument(skip(self), level = "trace")] - pub fn close_immediate(&self) { - info!(target = "mtp.transport", "fire-and-forget close requested"); + pub fn close(&self) { let connection = self.connection.clone(); let handle = self.handle.clone(); let policy = self.policy.clone(); let stream_guard = self.stream_guard.clone(); - let send_guard = self.send_guard.clone(); - let state = self.state.clone(); tokio::spawn(async move { - let _send_lock = send_guard.lock().await; - { - let mut sender_state = state.lock().await; - if *sender_state != SenderState::Open { - return; - } - *sender_state = SenderState::Closing; - } - if connection.quic_connection().close_reason().is_some() || handle.is_closed() { - *state.lock().await = SenderState::Closed; handle.close(Some(CommunicationError::StreamClosed)); return; } - { - if let Some(mut stream) = stream_guard.lock().await.take() { - match timeout(policy.write_timeout, stream.finish()).await { - Ok(Ok(())) => {} - Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => warn!( - "[Sender] persistent stream finish failed: peer sent STOP_SENDING (error code {code})" - ), - Ok(Err(e)) => { - warn!("[Sender] persistent stream finish failed: {e}") - } - Err(_) => warn!("[Sender] persistent stream finish timed out"), - } + if let Some(mut stream) = stream_guard.lock().await.take() { + match timeout(policy.write_timeout, stream.finish()).await { + Ok(Ok(())) => {} + Ok(Err(e)) => log::warn!("[Sender] persistent stream finish failed: {e}"), + Err(_) => log::warn!("[Sender] persistent stream finish timed out"), } } let _ = Self::send_close_frame(&connection, &policy).await; - *state.lock().await = SenderState::Closed; handle.close(Some(CommunicationError::StreamClosed)); - info!(target = "mtp.transport", "connection closed"); - drop(_send_lock); sleep(policy.force_close_delay).await; if connection.quic_connection().close_reason().is_none() { connection.quic_connection().close( @@ -651,64 +364,6 @@ impl Sender { }); } - #[instrument(skip(self), level = "trace")] - /// Initiate a best-effort graceful close and wait for the configured force-close delay. - pub async fn close(&self) { - info!(target = "mtp.transport", "graceful close initiated"); - let connection = self.connection.clone(); - let handle = self.handle.clone(); - let policy = self.policy.clone(); - let _send_lock = self.send_guard.lock().await; - { - let mut state = self.state.lock().await; - if *state != SenderState::Open { - return; - } - *state = SenderState::Closing; - } - - if connection.quic_connection().close_reason().is_some() || handle.is_closed() { - *self.state.lock().await = SenderState::Closed; - handle.close(Some(CommunicationError::StreamClosed)); - return; - } - - { - let mut stream_opt = self.stream_guard.lock().await; - if let Some(mut stream) = stream_opt.take() { - let close_bytes = policy.close_frame_len.to_be_bytes(); - let close_write = async { - stream.write_all(&close_bytes).await?; - stream.finish().await - }; - - match timeout(policy.write_timeout, close_write).await { - Ok(Ok(())) => {} - Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => { - warn!("[Sender] close failed: peer sent STOP_SENDING (error code {code})") - } - Ok(Err(e)) => warn!("[Sender] close failed: {e}"), - Err(_) => warn!("[Sender] close timed out"), - } - } else { - let _ = Self::send_close_frame(&connection, &policy).await; - } - } - - *self.state.lock().await = SenderState::Closed; - handle.close(Some(CommunicationError::StreamClosed)); - info!(target = "mtp.transport", "connection closed"); - - drop(_send_lock); - sleep(policy.force_close_delay).await; - if connection.quic_connection().close_reason().is_none() { - connection.quic_connection().close( - policy.application_close_code.into(), - APPLICATION_CLOSE_REASON.as_bytes(), - ); - } - } - pub fn is_open(&self) -> bool { self.handle.is_open() } @@ -722,104 +377,26 @@ impl Sender { } } -/// Framed message receiver. -/// -/// When the `pipes` feature is disabled, `receive()` is intended to be driven -/// by one task at a time. When `pipes` is enabled, an internal dispatcher task -/// consumes events from the channel; applications should use the -/// `MTPConnection::receive()` and `MTPConnection::receive_pipe()` methods -/// instead of calling `receiver.receive()` directly. -/// -/// The type is cheaply cloneable: all clones share the same internal channel. pub struct Receiver { - inner: Arc, -} - -struct ReceiverInner { - #[cfg(feature = "pipes")] - msg_rx: Mutex>>, - #[cfg(feature = "pipes")] - pipe_rx: Mutex>, - #[cfg(not(feature = "pipes"))] rx: Mutex>>, _accept_task: tokio::task::JoinHandle<()>, handle: Arc, - ping_control: Arc>, - queue_notify: Arc, - max_message_size: Arc, - type_map: Arc>, - decode_rejections: Arc, - #[cfg(feature = "pipes")] - expected_pipes: Arc>>, -} - -impl Clone for Receiver { - fn clone(&self) -> Self { - Self { - inner: self.inner.clone(), - } - } } impl Drop for Receiver { fn drop(&mut self) { - if Arc::strong_count(&self.inner) == 1 { - self.inner._accept_task.abort(); - } - } -} - -#[derive(Clone, Default)] -struct PingControl { - pong_sender: Option, - pong_observer: Option>, - expected_pong_id: Option, -} - -impl PingControl { - fn accepts_pong(&mut self, id: Option) -> bool { - if self.expected_pong_id == id && id.is_some() { - self.expected_pong_id = None; - true - } else { - false - } + // The accept loop holds clones of the connection and the shared + // ConnectionHandle. Without this, dropping a Receiver without first + // closing the connection would leave that task running forever. Abort + // it directly rather than closing the shared handle, so a still-live + // Sender on the same connection is unaffected. abort() is a no-op if + // the task already finished (e.g. the connection was closed). + self._accept_task.abort(); } } impl Receiver { pub fn new(connection: Connection, handle: Arc, policy: Arc) -> Self { - let policy = Arc::new(RuntimePolicy::from_public(&policy)); - let max_message_size = policy.max_message_size; - Self::new_with_max_message_size(connection, handle, policy, max_message_size) - } - - #[cfg(feature = "host")] - pub(crate) fn new_for_handshake( - connection: Connection, - handle: Arc, - policy: Arc, - ) -> Self { - let policy = Arc::new(RuntimePolicy::from_public(&policy)); - let initial_max = policy - .handshake_max_message_size - .min(policy.max_message_size); - Self::new_with_max_message_size(connection, handle, policy, initial_max) - } - - fn new_with_max_message_size( - connection: Connection, - handle: Arc, - policy: Arc, - initial_max_message_size: u64, - ) -> Self { - #[cfg(feature = "pipes")] - let (msg_tx, msg_rx) = mpsc::channel::>( - policy.receiver_queue_capacity, - ); - #[cfg(feature = "pipes")] - let (pipe_tx, pipe_rx) = mpsc::channel::(policy.receiver_queue_capacity); - #[cfg(not(feature = "pipes"))] let (tx, rx) = mpsc::channel::>( policy.receiver_queue_capacity, ); @@ -827,60 +404,11 @@ impl Receiver { let conn_handle = handle.clone(); let accept_connection = connection.clone(); let accept_policy = policy.clone(); - let ping_control = Arc::new(RwLock::new(PingControl::default())); - let accept_ping_control = ping_control.clone(); - let queue_notify = Arc::new(Notify::new()); - let accept_queue_notify = queue_notify.clone(); - let max_message_size = Arc::new(AtomicU64::new(initial_max_message_size)); - let accept_max_message_size = max_message_size.clone(); - let type_map = Arc::new(RwLock::new(TypeMap::latest())); - let accept_type_map = type_map.clone(); - let decode_rejections = Arc::new(DecodeRejectionCounters::default()); - let accept_decode_rejections = decode_rejections.clone(); - #[cfg(feature = "pipes")] - let expected_pipes = Arc::new(std::sync::Mutex::new(HashSet::new())); - #[cfg(feature = "pipes")] - let accept_expected_pipes = expected_pipes.clone(); - let stream_limit = Arc::new(Semaphore::new(policy.max_concurrent_stream_tasks.max(1))); - let accept_stream_limit = stream_limit.clone(); - debug!( - target = "mtp.transport", - max_concurrent_stream_tasks = policy.max_concurrent_stream_tasks, - receiver_queue_capacity = policy.receiver_queue_capacity, - "receiver accept loop started" - ); - info!( - target = "mtp.transport", - max_concurrent_stream_tasks = policy.max_concurrent_stream_tasks, - receiver_queue_capacity = policy.receiver_queue_capacity, - "connection accepted" - ); let accept_task = tokio::spawn(async move { let mut close_rx = conn_handle.subscribe_close(); loop { - #[cfg(feature = "pipes")] - let cap_full = msg_tx.capacity() == 0 || pipe_tx.capacity() == 0; - #[cfg(not(feature = "pipes"))] - let cap_full = tx.capacity() == 0; - - if cap_full { - trace!( - target = "mtp.transport", - "accept loop paused: receiver queue full" - ); - tokio::select! { - _ = close_rx.changed() => { - if close_rx.borrow().is_some() { - break; - } - } - _ = accept_queue_notify.notified() => {} - } - continue; - } - tokio::select! { _ = close_rx.changed() => { if close_rx.borrow().is_some() { @@ -894,175 +422,21 @@ impl Receiver { ) => { match accepted { Ok(Ok(stream)) => { - let permit = match accept_stream_limit.clone().acquire_owned().await { - Ok(permit) => permit, - Err(_) => break, - }; - #[cfg(feature = "pipes")] - let msg_tx_stream = msg_tx.clone(); - #[cfg(feature = "pipes")] - let pipe_tx_stream = pipe_tx.clone(); - #[cfg(not(feature = "pipes"))] let tx_stream = tx.clone(); let stream_handle = conn_handle.clone(); let stream_policy = accept_policy.clone(); - let stream_ping_control = accept_ping_control.clone(); - let stream_max_message_size = accept_max_message_size.clone(); - let stream_type_map = accept_type_map.clone(); - let stream_decode_rejections = accept_decode_rejections.clone(); - #[cfg(feature = "pipes")] - let stream_expected_pipes = accept_expected_pipes.clone(); tokio::spawn(async move { - let _permit = permit; let mut s = stream; - let mut frame_count = 0usize; loop { - if let Some(max_frames) = stream_policy.max_frames_per_stream - && frame_count >= max_frames - { - let close_error = CommunicationError::StreamError; - #[cfg(feature = "pipes")] - let _ = msg_tx_stream.send(Err(close_error.clone())).await; - #[cfg(not(feature = "pipes"))] - let _ = tx_stream.send(Err(close_error.clone())).await; - stream_handle.close(Some(close_error)); - break; - } - - let frame_limit = stream_max_message_size.load(Ordering::Relaxed); - match Self::read_one_frame( - &mut s, - &stream_policy, - frame_limit, - &stream_decode_rejections, - ) - .await - { - Ok(ReceivedFrame::Message(mut msg)) => { - let negotiated_type_map = - stream_type_map.read().await.clone(); - msg.set_type_map(&negotiated_type_map); - frame_count += 1; - - #[cfg(feature = "pipes")] - { - if frame_count == 1 { - let is_pipe_request = msg.is_type( - mtp_codec::CommunicationType::PipeRequest, - ); - let pipe_id = msg.id().filter(|id| *id != 0); - let pipe_is_expected = is_pipe_request && pipe_id.is_some_and(|pipe_id| { - stream_expected_pipes - .lock() - .is_ok_and(|mut expected| expected.remove(&pipe_id)) - }); - let disposition = match classify_first_frame( - is_pipe_request, - msg.id(), - pipe_is_expected, - ) { - Ok(disposition) => disposition, - Err(error) => { - let _ = msg_tx_stream - .send(Err(error.clone())) - .await; - stream_handle.close(Some(error)); - break; - } - }; - - if let FirstFrameDisposition::Pipe(pipe_id) = disposition { - let description = msg - .get_str(mtp_codec::DataType::Description) - .unwrap_or("") - .to_string(); - - let pipe_reader = crate::pipe::PipeReader { - stream: s, - description, - pipe_id, - }; - - if pipe_tx_stream - .send(pipe_reader) - .await - .is_err() - { - stream_handle.close(Some( - CommunicationError::StreamClosed, - )); - } - break; - } - } - } - - let pong_sender = if msg.is_type(mtp_codec::CommunicationType::Ping) { - stream_ping_control - .read() - .await - .pong_sender - .clone() - } else { - None - }; - - if let Some(sender) = pong_sender { - let mut pong = CommunicationValue::new_with_type_map( - mtp_codec::CommunicationType::Pong, - &negotiated_type_map, - ); - if let Some(id) = msg.id() { - pong = pong.with_id(id); - } else { - pong = pong.without_id(); - } - if let Some(timestamp) = msg.get_data(mtp_codec::DataType::Timestamp) { - pong = pong.add_typed_default( - mtp_codec::DataType::Timestamp, - timestamp.clone(), - ); - } - if let Err(e) = sender.send(&pong).await { - warn!("[Receiver] failed to send Pong: {e}"); - } - continue; - } - - if msg.is_type(mtp_codec::CommunicationType::Pong) { - let observer = { - let mut control = stream_ping_control.write().await; - if control.accepts_pong(msg.id()) { - control.pong_observer.clone() - } else { - None - } - }; - if let Some(observer) = observer { - let _ = observer.try_send(msg); - } - continue; - } - - #[cfg(feature = "pipes")] - if msg_tx_stream - .send(Ok(msg)) - .await - .is_err() - { - break; - } - #[cfg(not(feature = "pipes"))] + match Self::read_one_frame(&mut s, &stream_policy).await { + Ok(ReceivedFrame::Message(msg)) => { if tx_stream.send(Ok(msg)).await.is_err() { break; } } Ok(ReceivedFrame::ClosedByPeer) => { let close_error = CommunicationError::StreamClosed; - #[cfg(feature = "pipes")] - let _ = msg_tx_stream.send(Err(close_error.clone())).await; - #[cfg(not(feature = "pipes"))] let _ = tx_stream.send(Err(close_error.clone())).await; stream_handle.close(Some(close_error)); break; @@ -1080,9 +454,6 @@ impl Receiver { other => other, }; - #[cfg(feature = "pipes")] - let _ = msg_tx_stream.send(Err(close_error.clone())).await; - #[cfg(not(feature = "pipes"))] let _ = tx_stream.send(Err(close_error.clone())).await; stream_handle.close(Some(close_error)); break; @@ -1095,9 +466,6 @@ impl Receiver { Ok(Err(_e)) => { // A connection error from accept_uni means the connection is permanently closed. let close_error = CommunicationError::StreamClosed; - #[cfg(feature = "pipes")] - let _ = msg_tx.send(Err(close_error.clone())).await; - #[cfg(not(feature = "pipes"))] let _ = tx.send(Err(close_error.clone())).await; conn_handle.close(Some(close_error)); break; @@ -1106,9 +474,6 @@ impl Receiver { Err(_) => { if accept_connection.quic_connection().close_reason().is_some() { let close_error = CommunicationError::StreamClosed; - #[cfg(feature = "pipes")] - let _ = msg_tx.send(Err(close_error.clone())).await; - #[cfg(not(feature = "pipes"))] let _ = tx.send(Err(close_error.clone())).await; conn_handle.close(Some(close_error)); break; @@ -1125,361 +490,102 @@ impl Receiver { }); Self { - inner: Arc::new(ReceiverInner { - #[cfg(feature = "pipes")] - msg_rx: Mutex::new(msg_rx), - #[cfg(feature = "pipes")] - pipe_rx: Mutex::new(pipe_rx), - #[cfg(not(feature = "pipes"))] - rx: Mutex::new(rx), - _accept_task: accept_task, - handle, - ping_control, - queue_notify, - max_message_size, - type_map, - decode_rejections, - #[cfg(feature = "pipes")] - expected_pipes, - }), + rx: Mutex::new(rx), + _accept_task: accept_task, + handle, } } - /// Change the receive cap for subsequently parsed frames. - pub fn set_max_message_size(&self, max_message_size: u64) { - self.inner - .max_message_size - .store(max_message_size, Ordering::Relaxed); - } - - /// Bind subsequently decoded frames to the negotiated protocol version. - pub async fn set_type_map(&self, type_map: &TypeMap) { - *self.inner.type_map.write().await = type_map.clone(); - } - - #[cfg(feature = "pipes")] - pub fn expect_pipe(&self, pipe_id: u32) -> Result<(), CommunicationError> { - if pipe_id == 0 { - return Err(CommunicationError::Other("pipe id must be non-zero".into())); - } - self.inner - .expected_pipes - .lock() - .map_err(|_| CommunicationError::Other("expected pipe state is unavailable".into()))? - .insert(pipe_id); - Ok(()) - } - - #[cfg(feature = "pipes")] - pub fn cancel_expected_pipe(&self, pipe_id: u32) { - if let Ok(mut expected) = self.inner.expected_pipes.lock() { - expected.remove(&pipe_id); - } - } - - /// Return local counts for frames rejected by the structured decoder. - /// - /// These counters are intentionally local-only; peers continue to receive - /// the generic protocol parse failure. - pub fn decode_rejection_counts(&self) -> DecodeRejectionCounts { - self.inner.decode_rejections.snapshot() - } - - /* Respond to reserved Ping frames without exposing them to application I/O. */ - pub fn respond_to_pings(&self, sender: Sender) { - if let Ok(mut control) = self.inner.ping_control.try_write() { - control.pong_sender = Some(sender); - } else { - warn!("[Receiver] could not register Ping responder: control lock busy"); - } - } - - /* Route only the currently expected reserved Pong through a bounded observer. */ - pub async fn observe_pongs_bounded(&self, observer: mpsc::Sender) { - self.inner.ping_control.write().await.pong_observer = Some(observer); - } - - pub async fn set_expected_pong_id(&self, expected_pong_id: Option) { - self.inner.ping_control.write().await.expected_pong_id = expected_pong_id; - } - - #[instrument(skip(stream, policy, decode_rejections), level = "trace")] async fn read_one_frame( stream: &mut wtransport::RecvStream, - policy: &RuntimePolicy, - max_message_size: u64, - decode_rejections: &DecodeRejectionCounters, + policy: &Policy, ) -> Result { - use wtransport::error::{StreamReadError, StreamReadExactError}; + use std::io::ErrorKind; + use tokio::io::AsyncReadExt; - let mut len_buf = [0u8; 4]; - match timeout(policy.read_timeout, stream.read_exact(&mut len_buf)).await { - Ok(Ok(())) => {} - Ok(Err(StreamReadExactError::FinishedEarly(0))) => { - return Ok(ReceivedFrame::Idle); + let mut attempts = 0; + let len = loop { + match stream.read_u32().await { + Ok(len) => break len, + Err(e) => { + if e.kind() == ErrorKind::Interrupted && attempts < 3 { + attempts += 1; + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + continue; + } + if e.kind() == ErrorKind::UnexpectedEof { + return Ok(ReceivedFrame::Idle); + } + log::warn!("[Receiver] read_u32 failed: {e}"); + return Err(CommunicationError::StreamError); + } } - Ok(Err(StreamReadExactError::FinishedEarly(n))) => { - warn!( - "[Receiver] length-prefix read ended early ({n}/4 bytes): stream closed by peer" - ); - return Err(CommunicationError::StreamError); - } - Ok(Err(StreamReadExactError::Read(StreamReadError::Reset(code)))) => { - warn!( - "[Receiver] length-prefix read failed: peer sent RESET_STREAM (error code {code})" - ); - return Err(CommunicationError::StreamError); - } - Ok(Err(other)) => { - warn!("[Receiver] length-prefix read failed: {other}"); - return Err(CommunicationError::StreamError); - } - Err(_) => { - return Ok(ReceivedFrame::Idle); - } - } + }; - let len = u32::from_be_bytes(len_buf); if len == policy.close_frame_len { return Ok(ReceivedFrame::ClosedByPeer); } - let deadline = Instant::now() + policy.read_timeout; - let body_len = len as usize; - let frame_len = body_len - .checked_add(4) - .ok_or(CommunicationError::MessageTooLarge)?; - if frame_len as u64 > max_message_size { + let len = len as usize; + if len as u64 > policy.max_message_size { return Err(CommunicationError::MessageTooLarge); } - // The length has already been checked against the admitted frame - // limit, so reserve one bounded framing buffer and decode it without a - // second prefix-plus-body allocation/copy. - let mut frame = Vec::new(); - frame - .try_reserve_exact(frame_len) - .map_err(|_| CommunicationError::MessageTooLarge)?; - frame.extend_from_slice(&len_buf); - frame.resize(frame_len, 0); - let mut body_offset = 4usize; - while body_offset < frame_len { - let chunk_len = (frame_len - body_offset).min(16 * 1024); - match timeout_at( - deadline, - stream.read_exact(&mut frame[body_offset..body_offset + chunk_len]), - ) - .await - { - Ok(Ok(())) => body_offset += chunk_len, - Ok(Err(StreamReadExactError::FinishedEarly(n))) => { - warn!( - "[Receiver] body read ended early ({}/{body_len} bytes): stream closed by peer", - body_offset.saturating_sub(4) + n - ); - return Err(CommunicationError::StreamError); - } - Ok(Err(StreamReadExactError::Read(StreamReadError::Reset(code)))) => { - warn!( - "[Receiver] body read failed: peer sent RESET_STREAM (error code {code})" - ); - return Err(CommunicationError::StreamError); - } - Ok(Err(other)) => { - warn!("[Receiver] body read failed: {other}"); - return Err(CommunicationError::StreamError); - } - Err(_) => { - warn!("[Receiver] body read timed out (len={body_len})"); - return Err(CommunicationError::StreamError); - } + let mut buf = vec![0u8; len]; + match timeout(policy.read_timeout, stream.read_exact(&mut buf)).await { + Ok(Ok(())) => {} + Ok(Err(e)) => match timeout(policy.read_timeout, stream.read_exact(&mut buf)).await { + Ok(Ok(())) => {} + _ => return Err(e.into()), + }, + Err(_) => { + log::warn!("[Receiver] read_exact timed out (len={})", len); + return Err(CommunicationError::StreamError); } } - let message = match CommunicationValue::try_from_bytes_with_limits( - &frame, - DecodeLimits::for_transport_message_size(max_message_size), - ) { - Ok(message) => message, - Err(error) => { - decode_rejections.record(&error); - warn!( - ?error, - class = ?classify_decode_error(&error), - "[Receiver] rejected frame during bounded decode" - ); - return Err(CommunicationError::ParseCommunicationValue); - } - }; + let message = CommunicationValue::from_bytes(&buf) + .map_err(|_| CommunicationError::ParseCommunicationValue)?; Ok(ReceivedFrame::Message(message)) } - #[instrument(skip(self), level = "trace")] pub async fn receive(&self) -> Result { - let mut close_rx = self.inner.handle.subscribe_close(); - - #[cfg(feature = "pipes")] - { - let mut rx = self.inner.msg_rx.lock().await; - let result = tokio::select! { - biased; - message = rx.recv() => message, - _ = close_rx.changed() => return Err(close_rx - .borrow() - .clone() - .unwrap_or(CommunicationError::StreamClosed)), - }; - match result { - Some(Ok(msg)) => { - self.inner.queue_notify.notify_one(); - Ok(msg) - } - Some(Err(e)) => Err(e), - None => Err(self - .inner - .handle - .close_reason() - .unwrap_or(CommunicationError::StreamClosed)), - } - } - #[cfg(not(feature = "pipes"))] - { - let mut rx = self.inner.rx.lock().await; - let result = tokio::select! { - biased; - message = rx.recv() => message, - _ = close_rx.changed() => return Err(close_rx - .borrow() - .clone() - .unwrap_or(CommunicationError::StreamClosed)), - }; - match result { - Some(result) => { - self.inner.queue_notify.notify_one(); - result - } - None => Err(self - .inner - .handle - .close_reason() - .unwrap_or(CommunicationError::StreamClosed)), - } - } - } - - #[cfg(feature = "pipes")] - #[instrument(skip(self), level = "trace")] - pub async fn receive_event(&self) -> Result { - let mut close_rx = self.inner.handle.subscribe_close(); - let mut msg_rx = self.inner.msg_rx.lock().await; - let mut pipe_rx = self.inner.pipe_rx.lock().await; - tokio::select! { - biased; - msg = msg_rx.recv() => { - match msg { - Some(Ok(val)) => { - self.inner.queue_notify.notify_one(); - Ok(TransportEvent::Message(val)) - } - Some(Err(e)) => Err(e), - None => Err(self - .inner - .handle - .close_reason() - .unwrap_or(CommunicationError::StreamClosed)), - } - } - pipe = pipe_rx.recv() => { - match pipe { - Some(reader) => { - self.inner.queue_notify.notify_one(); - Ok(TransportEvent::Pipe(reader)) - } - None => Err(self - .inner - .handle - .close_reason() - .unwrap_or(CommunicationError::StreamClosed)), - } - } - _ = close_rx.changed() => Err(close_rx - .borrow() - .clone() - .unwrap_or(CommunicationError::StreamClosed)), - } - } - - #[cfg(feature = "pipes")] - #[instrument(skip(self), level = "trace")] - pub async fn receive_pipe(&self) -> Result { - if self.inner.handle.is_closed() { + if self.handle.is_closed() { return Err(self - .inner .handle .close_reason() .unwrap_or(CommunicationError::StreamClosed)); } - let mut rx = self.inner.pipe_rx.lock().await; + let mut rx = self.rx.lock().await; match rx.recv().await { - Some(reader) => { - self.inner.queue_notify.notify_one(); - Ok(reader) - } - None => Err(self - .inner + Some(result) => result, + _ => Err(self .handle .close_reason() .unwrap_or(CommunicationError::StreamClosed)), } } - #[cfg(feature = "pipes")] - pub fn try_receive_pipe(&self) -> Result, CommunicationError> { - if self.inner.handle.is_closed() { - return Err(self - .inner - .handle - .close_reason() - .unwrap_or(CommunicationError::StreamClosed)); - } - - match self.inner.pipe_rx.try_lock() { - Ok(mut rx) => match rx.try_recv() { - Ok(reader) => { - self.inner.queue_notify.notify_one(); - Ok(Some(reader)) - } - Err(mpsc::error::TryRecvError::Empty) => Ok(None), - Err(mpsc::error::TryRecvError::Disconnected) => Err(self - .inner - .handle - .close_reason() - .unwrap_or(CommunicationError::StreamClosed)), - }, - Err(_) => Ok(None), - } - } - pub fn handle(&self) -> &Arc { - &self.inner.handle + &self.handle } pub fn close(&self) { - self.inner.handle.close(None); + self.handle.close(None); } pub fn is_open(&self) -> bool { - self.inner.handle.is_open() + self.handle.is_open() } pub fn is_closed(&self) -> bool { - self.inner.handle.is_closed() + self.handle.is_closed() } pub fn close_reason(&self) -> Option { - self.inner.handle.close_reason() + self.handle.close_reason() } } @@ -1494,10 +600,29 @@ mod tests { assert_ne!(SendMode::PersistentStream, SendMode::SingleStreamPerMessage); } + #[test] + fn test_policy_default_values() { + let p = Policy::default(); + assert_eq!(p.send_mode, SendMode::PersistentStream); + assert_eq!(p.max_message_size, 1_000_000_000); + assert_eq!(p.close_frame_len, u32::MAX); + assert_eq!(p.application_close_code, 0); + assert_eq!(p.open_stream_timeout, Duration::from_millis(2_000)); + assert_eq!(p.write_timeout, Duration::from_millis(2_000)); + assert_eq!(p.accept_stream_timeout, Duration::from_millis(10_000)); + assert_eq!(p.read_timeout, Duration::from_millis(30_000)); + assert_eq!(p.keep_alive_interval, Some(Duration::from_secs(3))); + assert_eq!(p.max_idle_timeout, Some(Duration::from_secs(30))); + assert_eq!(p.force_close_delay, Duration::from_millis(300)); + assert_eq!(p.max_transient_recv_errors, 20); + assert_eq!(p.transient_recv_backoff, Duration::from_millis(100)); + assert_eq!(p.receiver_queue_capacity, 1000); + } + #[test] fn test_policy_clone() { let p = Policy::default(); - let cloned = p; + let cloned = p.clone(); assert_eq!(p.send_mode, cloned.send_mode); } @@ -1507,63 +632,4 @@ mod tests { let debug_str = format!("{:?}", p); assert!(debug_str.contains("Policy")); } - - #[test] - fn runtime_policy_normalizes_zero_channel_and_task_limits() { - let policy = Policy { - receiver_queue_capacity: 0, - max_concurrent_stream_tasks: 0, - ..Policy::default() - }; - - let runtime = RuntimePolicy::from_public(&policy); - - assert_eq!(runtime.receiver_queue_capacity, 1); - assert_eq!(runtime.max_concurrent_stream_tasks, 1); - assert_eq!(policy.receiver_queue_capacity, 0); - assert_eq!(policy.max_concurrent_stream_tasks, 0); - } - - #[test] - fn ping_control_accepts_only_the_current_expected_id() { - let mut control = PingControl { - expected_pong_id: Some(7), - ..PingControl::default() - }; - - assert!(!control.accepts_pong(Some(6))); - assert_eq!(control.expected_pong_id, Some(7)); - assert!(control.accepts_pong(Some(7))); - assert_eq!(control.expected_pong_id, None); - assert!(!control.accepts_pong(Some(7))); - } - - #[test] - fn decode_rejection_classes_are_stable_and_counted() { - assert_eq!( - classify_decode_error(&DecodeError::MalformedEncoding), - DecodeRejectionClass::Malformed - ); - assert_eq!( - classify_decode_error(&DecodeError::AllocationLimit), - DecodeRejectionClass::ResourceLimit - ); - assert_eq!( - classify_decode_error(&DecodeError::DuplicateField), - DecodeRejectionClass::DuplicateField - ); - - let counters = DecodeRejectionCounters::default(); - counters.record(&DecodeError::MalformedEncoding); - counters.record(&DecodeError::DepthLimit); - counters.record(&DecodeError::DuplicateField); - assert_eq!( - counters.snapshot(), - DecodeRejectionCounts { - malformed: 1, - resource_limit: 1, - duplicate_field: 1, - } - ); - } } diff --git a/transport/src/connection_handle.rs b/transport/src/connection_handle.rs index 3b1e839..c1bb3c4 100644 --- a/transport/src/connection_handle.rs +++ b/transport/src/connection_handle.rs @@ -1,49 +1,27 @@ use mtp_common::CommunicationError; -use std::net::SocketAddr; use std::sync::{ Arc, - atomic::{AtomicBool, AtomicU64, Ordering}, + atomic::{AtomicBool, Ordering}, }; use tokio::sync::watch; #[derive(Debug)] pub struct ConnectionHandle { - connection_id: u64, closed: AtomicBool, close_tx: watch::Sender>, close_rx: watch::Receiver>, - remote_addr: Option, } -static NEXT_CONNECTION_ID: AtomicU64 = AtomicU64::new(1); - impl ConnectionHandle { pub fn new() -> Self { let (close_tx, close_rx) = watch::channel(None); Self { - connection_id: NEXT_CONNECTION_ID.fetch_add(1, Ordering::Relaxed).max(1), closed: AtomicBool::new(false), close_tx, close_rx, - remote_addr: None, } } - pub fn with_remote_addr(remote_addr: SocketAddr) -> Self { - let mut handle = Self::new(); - handle.remote_addr = Some(remote_addr); - handle - } - - pub fn remote_addr(&self) -> Option { - self.remote_addr - } - - /// Stable process-local identifier for authentication-rate-limit scopes. - pub fn connection_id(&self) -> u64 { - self.connection_id - } - pub fn is_open(&self) -> bool { !self.closed.load(Ordering::SeqCst) } diff --git a/transport/src/encrypted_pipe.rs b/transport/src/encrypted_pipe.rs deleted file mode 100644 index d4bd7fd..0000000 --- a/transport/src/encrypted_pipe.rs +++ /dev/null @@ -1,1512 +0,0 @@ -//! Endpoint-to-endpoint authenticated encryption for MTP pipes. -//! -//! Pipe negotiation and QUIC/WebTransport remain transport primitives. This -//! module adds the application-facing record layer that callers can place on -//! top of an accepted [`PipeWriter`] or [`PipeReader`], plus an explicit -//! signed/KEM session-offer helper. The raw stream adapter does not infer -//! application identities or derive keys from clear pipe metadata. - -use mtp_codec::{ - DataValue, DecodeLimits, MtpProtectionPurpose, ProtectionError, ProtectionPolicy, - ProtectionPurpose, -}; -use mtp_crypto::{ - AeadDecrypt, AeadEncrypt, DualSigner, Ed25519Signer, KemPublicKey, Keyring, PublicKeyBundle, - SignatureScheme, XChaCha20Poly1305, -}; -use rand::RngExt; -use std::fmt; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -use zeroize::{Zeroize, Zeroizing}; - -const PIPE_E2EE_DOMAIN: &[u8] = b"MTP-PIPE-E2EE-1"; -const PIPE_RECORD_KDF_DOMAIN: &[u8] = b"MTP-PIPE-E2EE-1/KEY"; -const PIPE_TRANSCRIPT_DOMAIN: &[u8] = b"MTP-PIPE-TRANSCRIPT-1"; -const PIPE_RECORD_MESSAGE_LABEL: &[u8] = b"/message"; -const PIPE_RECORD_NEXT_LABEL: &[u8] = b"/next"; -const SESSION_ID_MAX_LEN: usize = 1024; -const RECORD_LENGTH_BYTES: usize = 4; -const RECORD_TYPE_BYTES: usize = 1; -const RECORD_TYPE_DATA: u8 = 0; -const RECORD_TYPE_FINAL: u8 = 1; -const XCHACHA_OVERHEAD: usize = - mtp_crypto::aead::XCHACHA20POLY1305_NONCE_LEN + mtp_crypto::aead::AUTH_TAG_LEN; - -/// Maximum encoded ciphertext size of one encrypted pipe record. -pub const MAX_ENCRYPTED_PIPE_RECORD: usize = 16 * 1024 * 1024; - -/// Purpose authenticated by the signed session-key offer. -pub const PIPE_SESSION_SIGNATURE_PURPOSE: u8 = MtpProtectionPurpose::PipeSessionSignature.value(); -/// Generic purpose authenticated by the encrypted session-key offer. -pub const PIPE_SESSION_ENCRYPTION_PURPOSE: u8 = MtpProtectionPurpose::PipeSessionEncryption.value(); -/// Maximum serialized size of a session-key offer. -pub const MAX_PIPE_SESSION_OFFER: usize = 64 * 1024; -const PIPE_SESSION_OFFER_DOMAIN: &str = "MTP-PIPE-SESSION-1"; -const FS_INIT_DOMAIN: &str = "MTP-PIPE-FS-INIT-1"; -const FS_RESPONSE_DOMAIN: &str = "MTP-PIPE-FS-RESPONSE-1"; -const FS_FINISH_DOMAIN: &str = "MTP-PIPE-FS-FINISH-1"; -const FS_ROOT_INFO: &[u8] = b"MTP-PIPE-FS-ROOT-1"; - -/// The context authenticated by every encrypted pipe record. -#[derive(Clone, PartialEq, Eq)] -pub struct PipeProtectionContext { - session_id: Vec, - purpose: u8, - direction: u8, - transcript_hash: [u8; 32], -} - -impl fmt::Debug for PipeProtectionContext { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("PipeProtectionContext") - .field("session_id_len", &self.session_id.len()) - .field("purpose", &self.purpose) - .field("direction", &self.direction) - .field("transcript_hash", &"[REDACTED]") - .finish() - } -} - -impl PipeProtectionContext { - /// Create a context shared by both endpoints of one logical pipe stream. - /// - /// `session_id` must identify the authenticated pipe/session and should - /// include both endpoint identities and the pipe identity. `direction` - /// is a protocol-defined value that must be identical at both endpoints; - /// use different values for the two directions of a bidirectional design. - pub fn new( - session_id: impl AsRef<[u8]>, - purpose: u8, - direction: u8, - ) -> Result { - let session_id = session_id.as_ref(); - if session_id.is_empty() || session_id.len() > SESSION_ID_MAX_LEN { - return Err(EncryptedPipeError::InvalidContext); - } - if MtpProtectionPurpose::is_reserved(purpose) { - return Err(EncryptedPipeError::InvalidContext); - } - Ok(Self { - session_id: session_id.to_vec(), - purpose, - direction, - transcript_hash: base_transcript_hash(session_id, purpose, direction), - }) - } - - fn from_parameters(parameters: &PipeSessionParameters) -> Self { - Self { - session_id: parameters.session_id.clone(), - purpose: parameters.purpose, - direction: parameters.direction, - transcript_hash: parameters.transcript_hash(), - } - } - - pub fn session_id(&self) -> &[u8] { - &self.session_id - } - - pub fn purpose(&self) -> u8 { - self.purpose - } - - pub fn direction(&self) -> u8 { - self.direction - } - - pub fn transcript_hash(&self) -> &[u8; 32] { - &self.transcript_hash - } -} - -/// Endpoint and stream metadata that a pipe-session key must bind. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PipeSessionParameters { - session_id: Vec, - pipe_id: u32, - sender_id: u64, - recipient_id: u64, - purpose: u8, - direction: u8, -} - -impl PipeSessionParameters { - pub fn new( - session_id: impl AsRef<[u8]>, - pipe_id: u32, - sender_id: u64, - recipient_id: u64, - purpose: u8, - direction: u8, - ) -> Result { - if pipe_id == 0 { - return Err(PipeSessionError::InvalidParameters( - "pipe id must be non-zero", - )); - } - let session_id = session_id.as_ref().to_vec(); - PipeProtectionContext::new(&session_id, purpose, direction) - .map_err(|_| PipeSessionError::InvalidParameters("invalid session id"))?; - Ok(Self { - session_id, - pipe_id, - sender_id, - recipient_id, - purpose, - direction, - }) - } - - pub fn session_id(&self) -> &[u8] { - &self.session_id - } - - pub fn pipe_id(&self) -> u32 { - self.pipe_id - } - - pub fn sender_id(&self) -> u64 { - self.sender_id - } - - pub fn recipient_id(&self) -> u64 { - self.recipient_id - } - - pub fn purpose(&self) -> u8 { - self.purpose - } - - pub fn direction(&self) -> u8 { - self.direction - } - - fn context(&self) -> PipeProtectionContext { - PipeProtectionContext::from_parameters(self) - } - - fn transcript_hash(&self) -> [u8; 32] { - let mut transcript = Vec::with_capacity(64 + self.session_id.len()); - transcript.extend_from_slice(PIPE_TRANSCRIPT_DOMAIN); - append_transcript_field(&mut transcript, &self.session_id); - transcript.extend_from_slice(&self.pipe_id.to_be_bytes()); - transcript.extend_from_slice(&self.sender_id.to_be_bytes()); - transcript.extend_from_slice(&self.recipient_id.to_be_bytes()); - transcript.push(self.purpose); - transcript.push(self.direction); - mtp_crypto::sha256(&transcript) - } -} - -fn append_transcript_field(out: &mut Vec, value: &[u8]) { - out.extend_from_slice(&(value.len() as u32).to_be_bytes()); - out.extend_from_slice(value); -} - -fn base_transcript_hash(session_id: &[u8], purpose: u8, direction: u8) -> [u8; 32] { - let mut transcript = Vec::with_capacity(32 + session_id.len()); - transcript.extend_from_slice(PIPE_TRANSCRIPT_DOMAIN); - append_transcript_field(&mut transcript, session_id); - transcript.push(purpose); - transcript.push(direction); - mtp_crypto::sha256(&transcript) -} - -/// Errors returned while establishing an encrypted pipe session. -#[derive(Debug)] -pub enum PipeSessionError { - InvalidParameters(&'static str), - InvalidOffer, - OfferTooLarge(usize), - UnexpectedEof, - Io(std::io::Error), - Codec(mtp_common::CodecError), - Protection(ProtectionError), - Crypto(mtp_crypto::CryptoError), -} - -impl fmt::Display for PipeSessionError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidParameters(message) => { - write!(f, "invalid pipe session parameters: {message}") - } - Self::InvalidOffer => f.write_str("invalid pipe session offer"), - Self::OfferTooLarge(length) => { - write!(f, "pipe session offer is too large: {length} bytes") - } - Self::UnexpectedEof => f.write_str("truncated pipe session offer"), - Self::Io(error) => write!(f, "pipe session I/O error: {error}"), - Self::Codec(error) => write!(f, "pipe session codec error: {error}"), - Self::Protection(error) => write!(f, "pipe session protection error: {error}"), - Self::Crypto(error) => write!(f, "pipe session crypto error: {error}"), - } - } -} - -impl std::error::Error for PipeSessionError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io(error) => Some(error), - Self::Codec(error) => Some(error), - Self::Protection(error) => Some(error), - Self::Crypto(error) => Some(error), - _ => None, - } - } -} - -impl From for PipeSessionError { - fn from(error: std::io::Error) -> Self { - Self::Io(error) - } -} - -impl From for PipeSessionError { - fn from(error: mtp_common::CodecError) -> Self { - Self::Codec(error) - } -} - -impl From for PipeSessionError { - fn from(error: ProtectionError) -> Self { - Self::Protection(error) - } -} - -impl From for PipeSessionError { - fn from(error: mtp_crypto::CryptoError) -> Self { - Self::Crypto(error) - } -} - -fn session_offer_value(params: &PipeSessionParameters, key: [u8; 32]) -> DataValue { - DataValue::Array(vec![ - DataValue::Str(PIPE_SESSION_OFFER_DOMAIN.to_owned()), - DataValue::Bytes(params.session_id.clone()), - DataValue::UnsignedNumber(params.pipe_id as u128), - DataValue::UnsignedNumber(params.sender_id as u128), - DataValue::UnsignedNumber(params.recipient_id as u128), - DataValue::UnsignedNumber(params.purpose as u128), - DataValue::UnsignedNumber(params.direction as u128), - DataValue::Bytes(key.to_vec()), - ]) -} - -fn fs_common_fields(params: &PipeSessionParameters) -> Vec { - vec![ - DataValue::Bytes(params.session_id.clone()), - DataValue::UnsignedNumber(params.pipe_id as u128), - DataValue::UnsignedNumber(params.sender_id as u128), - DataValue::UnsignedNumber(params.recipient_id as u128), - DataValue::UnsignedNumber(params.purpose as u128), - DataValue::UnsignedNumber(params.direction as u128), - ] -} - -fn fs_init_value(params: &PipeSessionParameters, nonce: [u8; 32]) -> DataValue { - let mut fields = vec![DataValue::Str(FS_INIT_DOMAIN.to_owned())]; - fields.extend(fs_common_fields(params)); - fields.push(DataValue::Bytes(nonce.to_vec())); - DataValue::Array(fields) -} - -fn fs_response_value( - params: &PipeSessionParameters, - init_hash: [u8; 32], - ephemeral_public_key: &[u8], -) -> DataValue { - let mut fields = vec![DataValue::Str(FS_RESPONSE_DOMAIN.to_owned())]; - fields.extend(fs_common_fields(params)); - fields.push(DataValue::Bytes(init_hash.to_vec())); - fields.push(DataValue::Bytes(ephemeral_public_key.to_vec())); - DataValue::Array(fields) -} - -fn fs_finish_value( - params: &PipeSessionParameters, - response_hash: [u8; 32], - ciphertext: &[u8], -) -> DataValue { - let mut fields = vec![DataValue::Str(FS_FINISH_DOMAIN.to_owned())]; - fields.extend(fs_common_fields(params)); - fields.push(DataValue::Bytes(response_hash.to_vec())); - fields.push(DataValue::Bytes(ciphertext.to_vec())); - DataValue::Array(fields) -} - -fn validate_fs_common( - fields: &[DataValue], - expected: &PipeSessionParameters, - expected_domain: &str, - expected_len: usize, -) -> Result<(), PipeSessionError> { - if fields.len() != expected_len - || fields.first().and_then(DataValue::as_str) != Some(expected_domain) - || offer_field(fields, 1)?.as_bytes_slice() != Some(expected.session_id()) - || u32::try_from(unsigned_field(fields, 2)?).ok() != Some(expected.pipe_id) - || u64::try_from(unsigned_field(fields, 3)?).ok() != Some(expected.sender_id) - || u64::try_from(unsigned_field(fields, 4)?).ok() != Some(expected.recipient_id) - || u8::try_from(unsigned_field(fields, 5)?).ok() != Some(expected.purpose) - || u8::try_from(unsigned_field(fields, 6)?).ok() != Some(expected.direction) - { - return Err(PipeSessionError::InvalidOffer); - } - Ok(()) -} - -fn derive_forward_secure_chain_key( - shared_secret: &[u8], - handshake_transcript: &[u8; 32], -) -> Result<[u8; 32], PipeSessionError> { - let key = mtp_crypto::hkdf_expand(shared_secret, handshake_transcript, FS_ROOT_INFO, 32)?; - key.try_into().map_err(|_| PipeSessionError::InvalidOffer) -} - -fn forward_secure_context( - params: &PipeSessionParameters, - handshake_transcript: &[u8; 32], -) -> PipeProtectionContext { - let mut transcript = Vec::with_capacity(PIPE_TRANSCRIPT_DOMAIN.len() + 64); - transcript.extend_from_slice(PIPE_TRANSCRIPT_DOMAIN); - transcript.extend_from_slice(¶ms.transcript_hash()); - transcript.extend_from_slice(handshake_transcript); - let hash: [u8; 32] = mtp_crypto::sha256(&transcript); - PipeProtectionContext { - session_id: params.session_id.clone(), - purpose: params.purpose, - direction: params.direction, - transcript_hash: hash, - } -} - -enum PipeSigner { - Ed25519(Ed25519Signer), - Dual(DualSigner), -} - -impl SignatureScheme for PipeSigner { - fn algorithm(&self) -> u8 { - match self { - Self::Ed25519(signer) => signer.algorithm(), - Self::Dual(signer) => signer.algorithm(), - } - } - - fn sign(&self, message: &[u8]) -> Result, mtp_crypto::CryptoError> { - match self { - Self::Ed25519(signer) => signer.sign(message), - Self::Dual(signer) => signer.sign(message), - } - } - - fn verify(&self, message: &[u8], signature: &[u8]) -> Result<(), mtp_crypto::CryptoError> { - match self { - Self::Ed25519(signer) => signer.verify(message, signature), - Self::Dual(signer) => signer.verify(message, signature), - } - } -} - -fn pipe_signer_for_keyring(sender_keyring: &Keyring) -> Result { - match ( - sender_keyring.sig_pq_secret_key.as_bytes().is_empty(), - sender_keyring.sig_pq_public_key.as_bytes().is_empty(), - ) { - (true, true) => { - sender_keyring.validate_ed25519_signing()?; - Ok(PipeSigner::Ed25519(Ed25519Signer::new( - &sender_keyring.sig_cl_secret_key, - )?)) - } - (false, false) => { - sender_keyring.validate_dual_signing()?; - Ok(PipeSigner::Dual(DualSigner::new( - &sender_keyring.sig_cl_secret_key, - &sender_keyring.sig_pq_secret_key, - &sender_keyring.sig_pq_public_key, - )?)) - } - _ => Err(PipeSessionError::InvalidParameters( - "incomplete ML-DSA key pair", - )), - } -} - -fn pipe_signature_policy(keyring: &Keyring) -> Result { - match ( - keyring.sig_pq_secret_key.as_bytes().is_empty(), - keyring.sig_pq_public_key.as_bytes().is_empty(), - ) { - (true, true) => Ok(ProtectionPolicy::from(mtp_codec::SignaturePolicy::Ed25519)), - (false, false) => Ok(ProtectionPolicy::from(mtp_codec::SignaturePolicy::Dual)), - _ => Err(PipeSessionError::InvalidParameters( - "incomplete ML-DSA key pair", - )), - } -} - -fn build_session_offer( - params: &PipeSessionParameters, - sender_keyring: &Keyring, - recipient_public_keys: &[PublicKeyBundle], - key: [u8; 32], -) -> Result, PipeSessionError> { - if recipient_public_keys.is_empty() { - return Err(PipeSessionError::InvalidParameters( - "at least one pipe-session recipient is required", - )); - } - for recipient_public_key in recipient_public_keys { - recipient_public_key.validate()?; - } - let signer = pipe_signer_for_keyring(sender_keyring)?; - let signed = session_offer_value(params, key).sign( - params.sender_id, - ProtectionPurpose::from(PIPE_SESSION_SIGNATURE_PURPOSE), - &signer, - )?; - let encrypted = signed.encrypt_for( - recipient_public_keys, - ProtectionPurpose::from(PIPE_SESSION_ENCRYPTION_PURPOSE), - )?; - let offer = encrypted.to_bytes()?; - if offer.len() > MAX_PIPE_SESSION_OFFER { - return Err(PipeSessionError::OfferTooLarge(offer.len())); - } - Ok(offer) -} - -async fn write_session_offer( - stream: &mut S, - offer: &[u8], -) -> Result<(), PipeSessionError> { - let length = - u32::try_from(offer.len()).map_err(|_| PipeSessionError::OfferTooLarge(offer.len()))?; - stream.write_all(&length.to_be_bytes()).await?; - stream.write_all(offer).await?; - stream.flush().await?; - Ok(()) -} - -async fn read_session_offer( - stream: &mut R, -) -> Result, PipeSessionError> { - let mut length_bytes = [0u8; 4]; - stream - .read_exact(&mut length_bytes) - .await - .map_err(|error| { - if error.kind() == std::io::ErrorKind::UnexpectedEof { - PipeSessionError::UnexpectedEof - } else { - PipeSessionError::Io(error) - } - })?; - let length = u32::from_be_bytes(length_bytes) as usize; - if length == 0 || length > MAX_PIPE_SESSION_OFFER { - return Err(PipeSessionError::OfferTooLarge(length)); - } - let mut offer = vec![0u8; length]; - stream.read_exact(&mut offer).await.map_err(|error| { - if error.kind() == std::io::ErrorKind::UnexpectedEof { - PipeSessionError::UnexpectedEof - } else { - PipeSessionError::Io(error) - } - })?; - Ok(offer) -} - -fn offer_field(fields: &[DataValue], index: usize) -> Result<&DataValue, PipeSessionError> { - fields.get(index).ok_or(PipeSessionError::InvalidOffer) -} - -fn unsigned_field(fields: &[DataValue], index: usize) -> Result { - offer_field(fields, index)? - .as_unsigned_number() - .ok_or(PipeSessionError::InvalidOffer) -} - -fn validate_session_offer( - decrypted: DataValue, - expected: &PipeSessionParameters, - sender_public_key: &PublicKeyBundle, - policy: ProtectionPolicy, -) -> Result<[u8; 32], PipeSessionError> { - validate_session_offer_with_keys( - decrypted, - expected, - std::slice::from_ref(sender_public_key), - policy, - ) -} - -fn validate_session_offer_with_keys( - decrypted: DataValue, - expected: &PipeSessionParameters, - sender_public_keys: &[PublicKeyBundle], - policy: ProtectionPolicy, -) -> Result<[u8; 32], PipeSessionError> { - let signed = decrypted - .as_signed() - .ok_or(PipeSessionError::InvalidOffer)?; - if signed.signer_id != expected.sender_id { - return Err(PipeSessionError::Protection( - ProtectionError::SignerIdMismatch { - expected: expected.sender_id, - actual: signed.signer_id, - }, - )); - } - for sender_public_key in sender_public_keys { - sender_public_key.validate()?; - } - signed.verify_with_key_history( - expected.sender_id, - sender_public_keys, - ProtectionPurpose::from(PIPE_SESSION_SIGNATURE_PURPOSE), - policy, - )?; - - let fields = signed - .value - .as_array_slice() - .ok_or(PipeSessionError::InvalidOffer)?; - if fields.len() != 8 - || offer_field(fields, 0)?.as_str() != Some(PIPE_SESSION_OFFER_DOMAIN) - || offer_field(fields, 1)?.as_bytes_slice() != Some(expected.session_id()) - || u32::try_from(unsigned_field(fields, 2)?).ok() != Some(expected.pipe_id) - || u64::try_from(unsigned_field(fields, 3)?).ok() != Some(expected.sender_id) - || u64::try_from(unsigned_field(fields, 4)?).ok() != Some(expected.recipient_id) - || u8::try_from(unsigned_field(fields, 5)?).ok() != Some(expected.purpose) - || u8::try_from(unsigned_field(fields, 6)?).ok() != Some(expected.direction) - { - return Err(PipeSessionError::InvalidOffer); - } - let key = offer_field(fields, 7)? - .as_bytes_slice() - .ok_or(PipeSessionError::InvalidOffer)?; - key.try_into().map_err(|_| PipeSessionError::InvalidOffer) -} - -/// Establish an encrypted writer by sending a signed, recipient-encrypted -/// session-key offer over the raw pipe, then return the authenticated record -/// layer for subsequent bytes. -pub async fn initiate_pipe_session( - stream: S, - params: PipeSessionParameters, - sender_keyring: &Keyring, - recipient_public_key: &PublicKeyBundle, -) -> Result, PipeSessionError> { - let recipients = [recipient_public_key.clone()]; - initiate_group_pipe_session(stream, params, sender_keyring, &recipients).await -} - -/// Establish a pipe session for a group by encrypting one fresh session key -/// to every current member. Membership changes must create a fresh session -/// offer with the new recipient set; do not reuse the old record key for a -/// newly added member or continue sending it to a removed member. -pub async fn initiate_group_pipe_session( - mut stream: S, - params: PipeSessionParameters, - sender_keyring: &Keyring, - recipient_public_keys: &[PublicKeyBundle], -) -> Result, PipeSessionError> { - let mut key = [0u8; 32]; - rand::rng().fill(&mut key); - let offer = build_session_offer(¶ms, sender_keyring, recipient_public_keys, key)?; - write_session_offer(&mut stream, &offer).await?; - Ok(EncryptedPipeWriter::new(stream, key, params.context())) -} - -/// Accept and authenticate a signed, recipient-encrypted session-key offer, -/// then return the record layer for subsequent bytes. -pub async fn accept_pipe_session( - stream: R, - expected: &PipeSessionParameters, - recipient_keyring: &Keyring, - sender_public_key: &PublicKeyBundle, -) -> Result, PipeSessionError> { - let policy = pipe_signature_policy(recipient_keyring)?; - accept_pipe_session_with_policy( - stream, - expected, - recipient_keyring, - sender_public_key, - policy, - ) - .await -} - -/// Policy-aware counterpart to [`accept_pipe_session`]. -pub async fn accept_pipe_session_with_policy( - mut stream: R, - expected: &PipeSessionParameters, - recipient_keyring: &Keyring, - sender_public_key: &PublicKeyBundle, - policy: ProtectionPolicy, -) -> Result, PipeSessionError> { - let offer = read_session_offer(&mut stream).await?; - let encrypted = DataValue::from_bytes_with_limits( - &offer, - DecodeLimits { - max_blob_size: MAX_PIPE_SESSION_OFFER, - ..DecodeLimits::default() - }, - ) - .ok_or(PipeSessionError::InvalidOffer)?; - let signed = encrypted.decrypt( - recipient_keyring, - ProtectionPurpose::from(PIPE_SESSION_ENCRYPTION_PURPOSE), - )?; - let key = validate_session_offer(signed, expected, sender_public_key, policy)?; - Ok(EncryptedPipeReader::new(stream, key, expected.context())) -} - -/// Accept a pipe session against a trusted signing-key history. Historical -/// keys are local resolver state and never become visible in the offer. -pub async fn accept_pipe_session_with_key_history( - mut stream: R, - expected: &PipeSessionParameters, - recipient_keyring: &Keyring, - sender_public_keys: &[PublicKeyBundle], - policy: ProtectionPolicy, -) -> Result, PipeSessionError> { - if sender_public_keys.is_empty() { - return Err(PipeSessionError::InvalidParameters( - "at least one sender verification key is required", - )); - } - let offer = read_session_offer(&mut stream).await?; - let encrypted = DataValue::from_bytes_with_limits( - &offer, - DecodeLimits { - max_blob_size: MAX_PIPE_SESSION_OFFER, - ..DecodeLimits::default() - }, - ) - .ok_or(PipeSessionError::InvalidOffer)?; - let signed = encrypted.decrypt( - recipient_keyring, - ProtectionPurpose::from(PIPE_SESSION_ENCRYPTION_PURPOSE), - )?; - let key = validate_session_offer_with_keys(signed, expected, sender_public_keys, policy)?; - Ok(EncryptedPipeReader::new(stream, key, expected.context())) -} - -fn sign_forward_secure_value( - value: DataValue, - signer_id: u64, - keyring: &Keyring, -) -> Result, PipeSessionError> { - let signer = pipe_signer_for_keyring(keyring)?; - value - .sign( - signer_id, - ProtectionPurpose::from(PIPE_SESSION_SIGNATURE_PURPOSE), - &signer, - )? - .to_bytes() - .map_err(PipeSessionError::Codec) -} - -fn verify_forward_secure_value( - bytes: &[u8], - expected_signer_id: u64, - signer_public_key: &PublicKeyBundle, - policy: ProtectionPolicy, -) -> Result { - verify_forward_secure_value_with_keys( - bytes, - expected_signer_id, - std::slice::from_ref(signer_public_key), - policy, - ) -} - -fn verify_forward_secure_value_with_keys( - bytes: &[u8], - expected_signer_id: u64, - signer_public_keys: &[PublicKeyBundle], - policy: ProtectionPolicy, -) -> Result { - if signer_public_keys.is_empty() { - return Err(PipeSessionError::InvalidParameters( - "at least one sender verification key is required", - )); - } - let value = DataValue::from_bytes_with_limits( - bytes, - DecodeLimits { - max_blob_size: MAX_PIPE_SESSION_OFFER, - ..DecodeLimits::default() - }, - ) - .ok_or(PipeSessionError::InvalidOffer)?; - for signer_public_key in signer_public_keys { - signer_public_key.validate()?; - } - let signed = value.as_signed().ok_or(PipeSessionError::InvalidOffer)?; - signed.verify_with_key_history( - expected_signer_id, - signer_public_keys, - ProtectionPurpose::from(PIPE_SESSION_SIGNATURE_PURPOSE), - policy, - )?; - Ok((*signed.value).clone()) -} - -fn handshake_hash(parts: &[&[u8]]) -> [u8; 32] { - let total = parts.iter().map(|part| part.len()).sum(); - let mut transcript = Vec::with_capacity(total); - for part in parts { - append_transcript_field(&mut transcript, part); - } - mtp_crypto::sha256(&transcript) -} - -/// Forward-secret duplex handshake. -/// -/// Unlike the one-way session offer, this API requires a bidirectional stream: -/// the responder contributes an ephemeral KEM key, the initiator encapsulates -/// to it, and both sides derive record keys from the authenticated transcript. -/// Long-term KEM keys are not used, so later compromise of those keys cannot -/// recover recorded sessions. Long-term signing keys still authenticate the -/// exchange. -pub async fn initiate_forward_secure_pipe_session( - mut stream: S, - params: PipeSessionParameters, - sender_keyring: &Keyring, - recipient_public_key: &PublicKeyBundle, - policy: ProtectionPolicy, -) -> Result, PipeSessionError> { - recipient_public_key.validate()?; - let mut nonce = [0u8; 32]; - rand::rng().fill(&mut nonce); - let init_bytes = sign_forward_secure_value( - fs_init_value(¶ms, nonce), - params.sender_id, - sender_keyring, - )?; - write_session_offer(&mut stream, &init_bytes).await?; - - let response_bytes = read_session_offer(&mut stream).await?; - let response = verify_forward_secure_value( - &response_bytes, - params.recipient_id, - recipient_public_key, - policy, - )?; - let response_fields = response - .as_array_slice() - .ok_or(PipeSessionError::InvalidOffer)?; - validate_fs_common(response_fields, ¶ms, FS_RESPONSE_DOMAIN, 9)?; - let init_hash = handshake_hash(&[&init_bytes]); - if response_fields[7].as_bytes_slice() != Some(init_hash.as_slice()) { - return Err(PipeSessionError::InvalidOffer); - } - let ephemeral_public = response_fields[8] - .as_bytes_slice() - .ok_or(PipeSessionError::InvalidOffer)?; - let encapsulated = - mtp_crypto::HybridKem::encapsulate(&KemPublicKey::new(ephemeral_public.to_vec()))?; - let finish_bytes = sign_forward_secure_value( - fs_finish_value( - ¶ms, - handshake_hash(&[&response_bytes]), - &encapsulated.ciphertext, - ), - params.sender_id, - sender_keyring, - )?; - write_session_offer(&mut stream, &finish_bytes).await?; - - let transcript = handshake_hash(&[&init_bytes, &response_bytes, &finish_bytes]); - let chain_key = derive_forward_secure_chain_key(&encapsulated.shared_secret, &transcript)?; - Ok(EncryptedPipeWriter::new( - stream, - chain_key, - forward_secure_context(¶ms, &transcript), - )) -} - -/// Responder side of [`initiate_forward_secure_pipe_session`]. -pub async fn accept_forward_secure_pipe_session( - stream: S, - expected: &PipeSessionParameters, - recipient_keyring: &Keyring, - sender_public_key: &PublicKeyBundle, - policy: ProtectionPolicy, -) -> Result, PipeSessionError> { - accept_forward_secure_pipe_session_with_key_history( - stream, - expected, - recipient_keyring, - std::slice::from_ref(sender_public_key), - policy, - ) - .await -} - -/// Responder side of the forward-secure handshake with a local signing-key -/// history. Historical public keys remain local resolver state and are never -/// included in the handshake. -pub async fn accept_forward_secure_pipe_session_with_key_history< - S: AsyncRead + AsyncWrite + Unpin, ->( - mut stream: S, - expected: &PipeSessionParameters, - recipient_keyring: &Keyring, - sender_public_keys: &[PublicKeyBundle], - policy: ProtectionPolicy, -) -> Result, PipeSessionError> { - let init_bytes = read_session_offer(&mut stream).await?; - let init = verify_forward_secure_value_with_keys( - &init_bytes, - expected.sender_id, - sender_public_keys, - policy, - )?; - let init_fields = init - .as_array_slice() - .ok_or(PipeSessionError::InvalidOffer)?; - validate_fs_common(init_fields, expected, FS_INIT_DOMAIN, 8)?; - let nonce = init_fields[7] - .as_bytes_slice() - .ok_or(PipeSessionError::InvalidOffer)?; - if nonce.len() != 32 { - return Err(PipeSessionError::InvalidOffer); - } - - let (ephemeral_secret, ephemeral_public) = mtp_crypto::HybridKem::generate_keypair(); - let response_bytes = sign_forward_secure_value( - fs_response_value( - expected, - handshake_hash(&[&init_bytes]), - ephemeral_public.as_bytes(), - ), - expected.recipient_id, - recipient_keyring, - )?; - write_session_offer(&mut stream, &response_bytes).await?; - - let finish_bytes = read_session_offer(&mut stream).await?; - let finish = verify_forward_secure_value_with_keys( - &finish_bytes, - expected.sender_id, - sender_public_keys, - policy, - )?; - let finish_fields = finish - .as_array_slice() - .ok_or(PipeSessionError::InvalidOffer)?; - validate_fs_common(finish_fields, expected, FS_FINISH_DOMAIN, 9)?; - let response_hash = handshake_hash(&[&response_bytes]); - if finish_fields[7].as_bytes_slice() != Some(response_hash.as_slice()) { - return Err(PipeSessionError::InvalidOffer); - } - let ciphertext = finish_fields[8] - .as_bytes_slice() - .ok_or(PipeSessionError::InvalidOffer)?; - let shared_secret = mtp_crypto::HybridKem::decapsulate(&ephemeral_secret, ciphertext)?; - let transcript = handshake_hash(&[&init_bytes, &response_bytes, &finish_bytes]); - let chain_key = derive_forward_secure_chain_key(&shared_secret, &transcript)?; - Ok(EncryptedPipeReader::new( - stream, - chain_key, - forward_secure_context(expected, &transcript), - )) -} - -/// Errors produced by the encrypted pipe record layer. -#[derive(Debug)] -pub enum EncryptedPipeError { - InvalidContext, - InvalidRecordLength(usize), - InvalidRecordType(u8), - InvalidState, - SequenceExhausted, - FinalRecordRequired, - UnexpectedEof, - Io(std::io::Error), - Crypto(mtp_crypto::CryptoError), -} - -impl fmt::Display for EncryptedPipeError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidContext => write!(f, "invalid encrypted pipe context"), - Self::InvalidRecordLength(length) => { - write!(f, "invalid encrypted pipe record length: {length}") - } - Self::InvalidRecordType(record_type) => { - write!(f, "invalid encrypted pipe record type: {record_type}") - } - Self::InvalidState => f.write_str("encrypted pipe is no longer usable"), - Self::SequenceExhausted => write!(f, "encrypted pipe sequence exhausted"), - Self::FinalRecordRequired => f.write_str("encrypted pipe ended without a final record"), - Self::UnexpectedEof => write!(f, "truncated encrypted pipe record"), - Self::Io(error) => write!(f, "encrypted pipe I/O error: {error}"), - Self::Crypto(error) => write!(f, "encrypted pipe authentication failed: {error}"), - } - } -} - -impl std::error::Error for EncryptedPipeError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io(error) => Some(error), - Self::Crypto(error) => Some(error), - _ => None, - } - } -} - -impl From for EncryptedPipeError { - fn from(error: std::io::Error) -> Self { - Self::Io(error) - } -} - -impl From for EncryptedPipeError { - fn from(error: mtp_crypto::CryptoError) -> Self { - Self::Crypto(error) - } -} - -fn checked_record_length(plaintext_len: usize) -> Result { - let length = plaintext_len - .checked_add(XCHACHA_OVERHEAD) - .ok_or(EncryptedPipeError::InvalidRecordLength(usize::MAX))?; - if length > MAX_ENCRYPTED_PIPE_RECORD || length > u32::MAX as usize { - return Err(EncryptedPipeError::InvalidRecordLength(length)); - } - Ok(length) -} - -fn record_aad( - context: &PipeProtectionContext, - sequence: u64, - record_len: usize, - record_type: u8, -) -> Vec { - let mut aad = Vec::with_capacity(PIPE_E2EE_DOMAIN.len() + 2 + 32 + 8 + 4); - aad.extend_from_slice(PIPE_E2EE_DOMAIN); - aad.push(context.purpose); - aad.push(context.direction); - aad.extend_from_slice(context.transcript_hash()); - aad.extend_from_slice(&sequence.to_be_bytes()); - aad.extend_from_slice(&(record_len as u32).to_be_bytes()); - aad.push(record_type); - aad -} - -fn record_key_info(context: &PipeProtectionContext, sequence: u64, label: &[u8]) -> Vec { - let mut info = Vec::with_capacity(PIPE_RECORD_KDF_DOMAIN.len() + 2 + 32 + 8 + label.len()); - info.extend_from_slice(PIPE_RECORD_KDF_DOMAIN); - info.push(context.purpose); - info.push(context.direction); - info.extend_from_slice(context.transcript_hash()); - info.extend_from_slice(&sequence.to_be_bytes()); - info.extend_from_slice(label); - info -} - -fn derive_record_keys( - chain_key: &[u8; 32], - context: &PipeProtectionContext, - sequence: u64, -) -> Result<([u8; 32], [u8; 32]), EncryptedPipeError> { - let message_key = mtp_crypto::hkdf_expand( - chain_key, - context.transcript_hash(), - &record_key_info(context, sequence, PIPE_RECORD_MESSAGE_LABEL), - 32, - )?; - let next_chain_key = mtp_crypto::hkdf_expand( - chain_key, - context.transcript_hash(), - &record_key_info(context, sequence, PIPE_RECORD_NEXT_LABEL), - 32, - )?; - Ok(( - message_key - .try_into() - .map_err(|_| EncryptedPipeError::InvalidContext)?, - next_chain_key - .try_into() - .map_err(|_| EncryptedPipeError::InvalidContext)?, - )) -} - -/// Writer for ordered, authenticated encrypted pipe records. -pub struct EncryptedPipeWriter { - stream: S, - chain_key: Zeroizing<[u8; 32]>, - context: PipeProtectionContext, - sequence: u64, - state: PipeStreamState, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PipeStreamState { - Open, - Finalized, - Failed, -} - -impl EncryptedPipeWriter { - pub fn new(stream: S, key: [u8; 32], context: PipeProtectionContext) -> Self { - Self { - stream, - chain_key: Zeroizing::new(key), - context, - sequence: 0, - state: PipeStreamState::Open, - } - } - - pub fn sequence(&self) -> u64 { - self.sequence - } - - pub fn into_inner(self) -> S { - self.stream - } -} - -impl EncryptedPipeWriter { - /// Encrypt and append one record. Record boundaries are preserved by the - /// four-byte length prefix and are authenticated as associated data. - pub async fn write_record(&mut self, plaintext: &[u8]) -> Result<(), EncryptedPipeError> { - if self.state != PipeStreamState::Open { - return Err(EncryptedPipeError::InvalidState); - } - let result = self.write_record_inner(plaintext, RECORD_TYPE_DATA).await; - if result.is_err() { - self.poison(); - } - result - } - - async fn write_record_inner( - &mut self, - plaintext: &[u8], - record_type: u8, - ) -> Result<(), EncryptedPipeError> { - let sequence = self.sequence; - if sequence == u64::MAX { - return Err(EncryptedPipeError::SequenceExhausted); - } - let record_len = checked_record_length(plaintext.len())?; - let aad = record_aad(&self.context, sequence, record_len, record_type); - let (message_key, next_chain_key) = - derive_record_keys(&self.chain_key, &self.context, sequence)?; - let next_chain_key = Zeroizing::new(next_chain_key); - let cipher = XChaCha20Poly1305::new(message_key); - let ciphertext = cipher.encrypt(plaintext, &aad)?; - if ciphertext.len() != record_len { - return Err(EncryptedPipeError::InvalidRecordLength(ciphertext.len())); - } - - self.stream - .write_all(&(record_len as u32).to_be_bytes()) - .await?; - self.stream.write_all(&[record_type]).await?; - self.stream.write_all(&ciphertext).await?; - self.stream.flush().await?; - self.chain_key = next_chain_key; - self.sequence = sequence - .checked_add(1) - .ok_or(EncryptedPipeError::SequenceExhausted)?; - Ok(()) - } - - fn poison(&mut self) { - self.chain_key.zeroize(); - self.state = PipeStreamState::Failed; - } - - /// Authenticate stream completion with a final empty record before - /// closing the underlying transport. - pub async fn finish(mut self) -> Result<(), EncryptedPipeError> { - if self.state != PipeStreamState::Open { - return Err(EncryptedPipeError::InvalidState); - } - if let Err(error) = self.write_record_inner(&[], RECORD_TYPE_FINAL).await { - self.poison(); - return Err(error); - } - self.state = PipeStreamState::Finalized; - if let Err(error) = self.stream.shutdown().await { - self.poison(); - return Err(error.into()); - } - Ok(()) - } -} - -/// Reader for ordered, authenticated encrypted pipe records. -pub struct EncryptedPipeReader { - stream: R, - chain_key: Zeroizing<[u8; 32]>, - context: PipeProtectionContext, - sequence: u64, - state: PipeStreamState, -} - -impl EncryptedPipeReader { - pub fn new(stream: R, key: [u8; 32], context: PipeProtectionContext) -> Self { - Self { - stream, - chain_key: Zeroizing::new(key), - context, - sequence: 0, - state: PipeStreamState::Open, - } - } - - pub fn sequence(&self) -> u64 { - self.sequence - } - - pub fn into_inner(self) -> R { - self.stream - } -} - -impl EncryptedPipeReader { - /// Read and authenticate the next record. `None` is returned only after a - /// valid authenticated final record; transport EOF alone is truncation. - pub async fn read_record(&mut self) -> Result>, EncryptedPipeError> { - if self.state == PipeStreamState::Finalized { - return Ok(None); - } - if self.state == PipeStreamState::Failed { - return Err(EncryptedPipeError::InvalidState); - } - let result = self.read_record_inner().await; - if result.is_err() { - self.poison(); - } - result - } - - async fn read_record_inner(&mut self) -> Result>, EncryptedPipeError> { - if self.sequence == u64::MAX { - return Err(EncryptedPipeError::SequenceExhausted); - } - let mut prefix = [0u8; RECORD_LENGTH_BYTES]; - self.stream.read_exact(&mut prefix).await.map_err(|error| { - if error.kind() == std::io::ErrorKind::UnexpectedEof { - EncryptedPipeError::FinalRecordRequired - } else { - EncryptedPipeError::Io(error) - } - })?; - - let record_len = u32::from_be_bytes(prefix) as usize; - if !(XCHACHA_OVERHEAD..=MAX_ENCRYPTED_PIPE_RECORD).contains(&record_len) { - return Err(EncryptedPipeError::InvalidRecordLength(record_len)); - } - let mut record_type = [0u8; RECORD_TYPE_BYTES]; - self.stream - .read_exact(&mut record_type) - .await - .map_err(|error| { - if error.kind() == std::io::ErrorKind::UnexpectedEof { - EncryptedPipeError::UnexpectedEof - } else { - EncryptedPipeError::Io(error) - } - })?; - if !matches!(record_type[0], RECORD_TYPE_DATA | RECORD_TYPE_FINAL) { - return Err(EncryptedPipeError::InvalidRecordType(record_type[0])); - } - - let mut ciphertext = vec![0u8; record_len]; - self.stream - .read_exact(&mut ciphertext) - .await - .map_err(|error| { - if error.kind() == std::io::ErrorKind::UnexpectedEof { - EncryptedPipeError::UnexpectedEof - } else { - EncryptedPipeError::Io(error) - } - })?; - let sequence = self.sequence; - let aad = record_aad(&self.context, sequence, record_len, record_type[0]); - let (message_key, next_chain_key) = - derive_record_keys(&self.chain_key, &self.context, sequence)?; - let next_chain_key = Zeroizing::new(next_chain_key); - let cipher = XChaCha20Poly1305::new(message_key); - let plaintext = cipher.decrypt(&ciphertext, &aad)?; - self.chain_key = next_chain_key; - self.sequence = sequence - .checked_add(1) - .ok_or(EncryptedPipeError::SequenceExhausted)?; - if record_type[0] == RECORD_TYPE_FINAL { - if !plaintext.is_empty() { - return Err(EncryptedPipeError::InvalidRecordLength(plaintext.len())); - } - self.state = PipeStreamState::Finalized; - return Ok(None); - } - Ok(Some(plaintext)) - } - - fn poison(&mut self) { - self.chain_key.zeroize(); - self.state = PipeStreamState::Failed; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tokio::io::duplex; - - #[test] - fn application_pipe_context_rejects_mtp_purposes() { - assert!(matches!( - PipeProtectionContext::new(b"application", PIPE_SESSION_SIGNATURE_PURPOSE, 0), - Err(EncryptedPipeError::InvalidContext) - )); - } - - #[tokio::test] - async fn records_roundtrip_and_bind_context() { - let (left, right) = duplex(4096); - let context = - PipeProtectionContext::new(b"pipe-session/client/peer", 0x41, 0).expect("context"); - let writer_context = context.clone(); - let reader_context = context.clone(); - let writer = tokio::spawn(async move { - let mut writer = EncryptedPipeWriter::new(left, [7u8; 32], writer_context); - writer.write_record(b"first").await.expect("first record"); - writer.write_record(b"second").await.expect("second record"); - writer.finish().await.expect("finish"); - }); - - let mut reader = EncryptedPipeReader::new(right, [7u8; 32], reader_context); - assert_eq!( - reader.read_record().await.expect("read").as_deref(), - Some(b"first".as_slice()) - ); - assert_eq!( - reader.read_record().await.expect("read").as_deref(), - Some(b"second".as_slice()) - ); - assert!(reader.read_record().await.expect("eof").is_none()); - writer.await.expect("writer task"); - } - - #[tokio::test] - async fn wrong_context_fails_authentication() { - let (left, right) = duplex(4096); - let writer_context = PipeProtectionContext::new(b"session-a", 1, 0).expect("context"); - let reader_context = PipeProtectionContext::new(b"session-b", 1, 0).expect("context"); - let writer = tokio::spawn(async move { - let mut writer = EncryptedPipeWriter::new(left, [9u8; 32], writer_context); - writer.write_record(b"secret").await.expect("write"); - }); - let mut reader = EncryptedPipeReader::new(right, [9u8; 32], reader_context); - assert!(matches!( - reader.read_record().await, - Err(EncryptedPipeError::Crypto(_)) - )); - assert!(matches!( - reader.read_record().await, - Err(EncryptedPipeError::InvalidState) - )); - writer.await.expect("writer task"); - } - - #[tokio::test] - async fn transport_eof_without_final_record_is_truncation() { - let (left, right) = duplex(4096); - let context = PipeProtectionContext::new(b"session", 0x40, 0).expect("context"); - let mut writer = EncryptedPipeWriter::new(left, [3u8; 32], context.clone()); - writer.write_record(b"not finished").await.expect("record"); - let stream = writer.into_inner(); - drop(stream); - - let mut reader = EncryptedPipeReader::new(right, [3u8; 32], context); - assert_eq!( - reader.read_record().await.expect("record").as_deref(), - Some(b"not finished".as_slice()) - ); - assert!(matches!( - reader.read_record().await, - Err(EncryptedPipeError::FinalRecordRequired) - )); - assert!(matches!( - reader.read_record().await, - Err(EncryptedPipeError::InvalidState) - )); - } - - #[test] - fn record_key_schedule_is_context_and_chain_bound() { - let context = PipeProtectionContext::new(b"session-a", 1, 0).expect("context"); - let first = derive_record_keys(&[7u8; 32], &context, 0).expect("first keys"); - let second = derive_record_keys(&first.1, &context, 1).expect("second keys"); - let repeated = derive_record_keys(&[7u8; 32], &context, 1).expect("repeated keys"); - let other_context = PipeProtectionContext::new(b"session-b", 1, 0).expect("context"); - let other = derive_record_keys(&first.1, &other_context, 1).expect("other keys"); - - assert_ne!(first.0, second.0); - assert_ne!(second.0, repeated.0); - assert_ne!(second.0, other.0); - assert_ne!(second.1, other.1); - } - - #[tokio::test] - async fn signed_session_offer_establishes_the_record_layer() { - let sender = Keyring::generate(); - let recipient = Keyring::generate(); - let sender_public = sender.public_key_bundle(); - let recipient_public = recipient.public_key_bundle(); - let params = PipeSessionParameters::new(b"session/client/peer/pipe-7", 7, 41, 99, 0x40, 0) - .expect("parameters"); - let writer_params = params.clone(); - let (left, right) = duplex(128 * 1024); - let writer_task = tokio::spawn(async move { - let mut writer = initiate_pipe_session(left, writer_params, &sender, &recipient_public) - .await - .expect("session offer"); - writer - .write_record(b"authenticated pipe data") - .await - .expect("record"); - writer.finish().await.expect("finish"); - }); - - let mut reader = accept_pipe_session(right, ¶ms, &recipient, &sender_public) - .await - .expect("session accept"); - assert_eq!( - reader.read_record().await.expect("record").as_deref(), - Some(b"authenticated pipe data".as_slice()) - ); - assert!(reader.read_record().await.expect("eof").is_none()); - writer_task.await.expect("writer task"); - } - - #[tokio::test] - async fn session_offer_accepts_a_trusted_historical_signing_key() { - let historical_sender = Keyring::generate(); - let current_sender = Keyring::generate(); - let recipient = Keyring::generate(); - let recipient_public = recipient.public_key_bundle(); - let historical_public = historical_sender.public_key_bundle(); - let current_public = current_sender.public_key_bundle(); - let params = PipeSessionParameters::new(b"historical-session", 8, 41, 99, 0x40, 0) - .expect("parameters"); - let writer_params = params.clone(); - let (left, right) = duplex(128 * 1024); - let writer_task = tokio::spawn(async move { - initiate_pipe_session(left, writer_params, &historical_sender, &recipient_public).await - }); - let reader = accept_pipe_session_with_key_history( - right, - ¶ms, - &recipient, - &[current_public, historical_public], - ProtectionPolicy::from(mtp_codec::SignaturePolicy::Dual), - ) - .await - .expect("historical session offer"); - let writer = writer_task.await.expect("writer task").expect("writer"); - drop(writer); - // The successful setup is the assertion; no application record is - // needed to prove that the historical signature key was selected. - assert_eq!(reader.sequence(), 0); - } - - #[tokio::test] - async fn forward_secure_duplex_handshake_accepts_signing_key_history() { - let historical_sender = Keyring::generate(); - let current_sender = Keyring::generate(); - let recipient = Keyring::generate(); - let historical_public = historical_sender.public_key_bundle(); - let current_public = current_sender.public_key_bundle(); - let recipient_public = recipient.public_key_bundle(); - let params = PipeSessionParameters::new(b"forward-secure-session", 17, 41, 99, 0x40, 0) - .expect("parameters"); - let responder_params = params.clone(); - let (left, right) = duplex(256 * 1024); - let responder = tokio::spawn(async move { - accept_forward_secure_pipe_session_with_key_history( - right, - &responder_params, - &recipient, - &[current_public, historical_public], - ProtectionPolicy::from(mtp_codec::SignaturePolicy::Dual), - ) - .await - }); - let mut writer = initiate_forward_secure_pipe_session( - left, - params, - &historical_sender, - &recipient_public, - ProtectionPolicy::from(mtp_codec::SignaturePolicy::Dual), - ) - .await - .expect("forward-secure initiator"); - writer - .write_record(b"forward secret") - .await - .expect("record"); - writer.finish().await.expect("finish"); - let mut reader = responder.await.expect("responder task").expect("reader"); - assert_eq!( - reader.read_record().await.expect("record").as_deref(), - Some(b"forward secret".as_slice()) - ); - assert!(reader.read_record().await.expect("final").is_none()); - } - - #[test] - fn group_session_offer_is_decryptable_by_each_current_member_only() { - let sender = Keyring::generate(); - let first = Keyring::generate(); - let second = Keyring::generate(); - let outsider = Keyring::generate(); - let params = - PipeSessionParameters::new(b"group-session", 11, 41, 99, 0x40, 0).expect("parameters"); - let key = [8u8; 32]; - let offer = build_session_offer( - ¶ms, - &sender, - &[first.public_key_bundle(), second.public_key_bundle()], - key, - ) - .expect("offer"); - let encrypted = DataValue::from_bytes(&offer).expect("encrypted offer"); - for member in [&first, &second] { - let opened = encrypted - .decrypt( - member, - ProtectionPurpose::from(PIPE_SESSION_ENCRYPTION_PURPOSE), - ) - .expect("member decrypt"); - let fields = opened - .as_signed() - .and_then(|value| value.value.as_array_slice()) - .expect("signed fields"); - assert_eq!(fields[7].as_bytes_slice(), Some(key.as_slice())); - } - assert!(matches!( - encrypted.decrypt( - &outsider, - ProtectionPurpose::from(PIPE_SESSION_ENCRYPTION_PURPOSE), - ), - Err(ProtectionError::NoMatchingRecipient) - )); - } -} diff --git a/transport/src/framing.rs b/transport/src/framing.rs deleted file mode 100644 index ac4e005..0000000 --- a/transport/src/framing.rs +++ /dev/null @@ -1,109 +0,0 @@ -use crate::{Policy, TransportSendStream}; -use mtp_codec::{CommunicationValue, EncodeLimits}; -use mtp_common::CommunicationError; - -/// Classifies failures that may be recovered by replacing a persistent -/// application stream. Encoding and frame-size failures are deterministic and -/// must reach the caller without opening more streams. -pub(crate) struct RetryClassifier; - -impl RetryClassifier { - pub(crate) fn retry_persistent_stream(error: &CommunicationError) -> bool { - matches!( - error, - CommunicationError::StreamError | CommunicationError::StreamClosed - ) - } -} - -/// Writes the canonical self-framed MTP value used by every transport. -/// -/// `CommunicationValue` already begins with the four-byte body length. The -/// transport writes that representation directly so a frame does not carry a -/// redundant outer length prefix. -pub(crate) async fn write_frame( - stream: &mut S, - value: &CommunicationValue, - policy: &Policy, -) -> Result<(), CommunicationError> { - let bytes = value - .to_bytes_with_limits(EncodeLimits::for_transport_message_size( - policy.max_message_size, - )) - .map_err(|_| CommunicationError::Encode)?; - if bytes.len() as u64 > policy.max_message_size - || bytes.len() as u64 >= policy.close_frame_len as u64 - { - return Err(CommunicationError::MessageTooLarge); - } - stream.write_all(&bytes).await -} - -#[cfg(test)] -mod tests { - use super::*; - use async_trait::async_trait; - use mtp_codec::{CommunicationType, DataValue}; - use std::pin::Pin; - use std::task::{Context, Poll}; - use tokio::io::AsyncWrite; - - #[derive(Default)] - struct BufferStream { - bytes: Vec, - } - - impl AsyncWrite for BufferStream { - fn poll_write( - mut self: Pin<&mut Self>, - _cx: &mut Context<'_>, - buf: &[u8], - ) -> Poll> { - self.bytes.extend_from_slice(buf); - Poll::Ready(Ok(buf.len())) - } - - fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - } - - #[async_trait] - impl TransportSendStream for BufferStream { - async fn write_all(&mut self, buf: &[u8]) -> Result<(), CommunicationError> { - self.bytes.extend_from_slice(buf); - Ok(()) - } - - async fn finish(&mut self) -> Result<(), CommunicationError> { - Ok(()) - } - - fn reset(&mut self, _code: u32) -> Result<(), CommunicationError> { - Ok(()) - } - } - - #[tokio::test] - async fn framing_preserves_a_generic_payload() { - let payload = DataValue::Array(vec![ - DataValue::Str("payload".into()), - DataValue::Bytes(vec![1, 2, 3]), - ]); - let frame = CommunicationValue::new(CommunicationType::Pong).with_payload(payload.clone()); - let mut stream = BufferStream::default(); - - write_frame(&mut stream, &frame, &Policy::default()) - .await - .unwrap(); - - let body_len = u32::from_be_bytes(stream.bytes[..4].try_into().unwrap()) as usize; - assert_eq!(body_len, stream.bytes.len() - 4); - let decoded = CommunicationValue::from_bytes(&stream.bytes).unwrap(); - assert_eq!(decoded.into_payload(), payload); - } -} diff --git a/transport/src/generic_connection.rs b/transport/src/generic_connection.rs deleted file mode 100644 index 4361855..0000000 --- a/transport/src/generic_connection.rs +++ /dev/null @@ -1,684 +0,0 @@ -//! Transport-neutral MTP framing. -//! -//! These types are used by non-wtransport backends. The established -//! [`crate::Sender`] and [`crate::Receiver`] remain source compatible native -//! wrappers while the framing implementation below is shared by adapters. - -use crate::{ - Policy, TransportConnection, TransportRecvStream, TransportSendStream, - connection::{DecodeRejectionCounters, RuntimePolicy, classify_decode_error}, - framing::{RetryClassifier, write_frame}, -}; -use mtp_codec::{CommunicationValue, DataType, DecodeLimits, TypeMap}; -use mtp_common::{CommunicationError, FirstFrameDisposition, classify_first_frame}; -#[cfg(feature = "pipes")] -use std::collections::HashSet; -use std::sync::Arc; -#[cfg(feature = "pipes")] -use std::sync::Mutex as StdMutex; -use std::sync::atomic::{AtomicU64, Ordering}; -use tokio::sync::{Mutex, Notify, RwLock, Semaphore, mpsc}; -use tokio::time::{Instant, timeout, timeout_at}; - -#[cfg(feature = "pipes")] -use crate::pipe::{PipeReader, PipeWriter}; - -pub struct GenericSender { - connection: C, - policy: Arc, - persistent: Arc>>, - send_lock: Arc>, - type_map: Arc>, -} - -impl Clone for GenericSender { - fn clone(&self) -> Self { - Self { - connection: self.connection.clone(), - policy: self.policy.clone(), - persistent: self.persistent.clone(), - send_lock: self.send_lock.clone(), - type_map: self.type_map.clone(), - } - } -} - -impl GenericSender { - pub fn new(connection: C, policy: Arc) -> Self { - let policy = Arc::new(RuntimePolicy::from_public(&policy)); - Self { - connection, - policy, - persistent: Arc::new(Mutex::new(None)), - send_lock: Arc::new(Mutex::new(())), - type_map: Arc::new(RwLock::new(TypeMap::latest())), - } - } - - /// Bind control frames created by this sender to the negotiated protocol map. - pub async fn set_type_map(&self, type_map: &TypeMap) { - *self.type_map.write().await = type_map.clone(); - } - - async fn open(&self) -> Result { - timeout(self.policy.open_stream_timeout, self.connection.open_uni()) - .await - .map_err(|_| CommunicationError::StreamError)? - } - - pub async fn send(&self, value: &CommunicationValue) -> Result<(), CommunicationError> { - let _lock = self.send_lock.lock().await; - if self.connection.close_reason().is_some() { - return Err(CommunicationError::StreamClosed); - } - if let Some(version) = value.get_str(DataType::Version) { - tracing::debug!( - message_type = ?value.get_type(), - version, - connected = ?value.get_data(DataType::Connected), - client_id = ?value.get_data(DataType::Id), - "sending MTP handshake response frame" - ); - } - match self.policy.send_mode { - crate::SendMode::SingleStreamPerMessage => { - let mut stream = self.open().await?; - timeout( - self.policy.write_timeout, - write_frame(&mut stream, value, &self.policy), - ) - .await - .map_err(|_| CommunicationError::StreamError)??; - match timeout(self.policy.write_timeout, stream.finish()).await { - Ok(Ok(())) => Ok(()), - Ok(Err(_)) | Err(_) => Err(CommunicationError::DeliveryUnknown), - } - } - crate::SendMode::PersistentStream => { - let mut stream = self.persistent.lock().await; - let mut attempts = 0; - loop { - if stream.is_none() { - *stream = Some(self.open().await?); - } - let result = match stream.as_mut() { - Some(stream) => timeout( - self.policy.write_timeout, - write_frame(stream, value, &self.policy), - ) - .await - .map_err(|_| CommunicationError::StreamError) - .and_then(|result| result), - None => Err(CommunicationError::StreamError), - }; - if result.is_ok() { - return Ok(()); - } - let error = match result { - Ok(()) => return Ok(()), - Err(error) => error, - }; - if !RetryClassifier::retry_persistent_stream(&error) { - return Err(error); - } - *stream = None; - attempts += 1; - if attempts > self.policy.persistent_stream_max_retries { - return Err(error); - } - tokio::time::sleep( - self.policy.persistent_stream_retry_backoff * attempts as u32, - ) - .await; - } - } - } - } - - #[cfg(feature = "pipes")] - pub async fn open_pipe( - &self, - pipe_id: u32, - description: &str, - ) -> Result, CommunicationError> { - let _send_lock = self.send_lock.lock().await; - if self.connection.close_reason().is_some() { - return Err(CommunicationError::StreamClosed); - } - - let mut stream = self.open().await?; - - let type_map = self.type_map.read().await.clone(); - let request = CommunicationValue::new_with_type_map( - mtp_codec::CommunicationType::PipeRequest, - &type_map, - ) - .with_id(pipe_id) - .add_typed_default( - mtp_codec::DataType::Description, - mtp_codec::DataValue::Str(description.to_string()), - ); - - timeout( - self.policy.write_timeout, - write_frame(&mut stream, &request, &self.policy), - ) - .await - .map_err(|_| CommunicationError::StreamError)??; - - Ok(PipeWriter { stream }) - } - - pub fn close(&self) { - self.connection - .close(self.policy.application_close_code, b"mtp-close"); - } - - /// Finish the current persistent stream. - /// - /// This is used by hosts that put the opening/authentication exchange on - /// a persistent stream and then transition to application streams. - pub async fn finish_stream(&self) -> Result<(), CommunicationError> { - let _lock = self.send_lock.lock().await; - let mut stream = self.persistent.lock().await; - let Some(mut stream) = stream.take() else { - return Ok(()); - }; - timeout(self.policy.write_timeout, stream.finish()) - .await - .map_err(|_| CommunicationError::StreamError)? - } - pub fn is_closed(&self) -> bool { - self.connection.close_reason().is_some() - } - pub fn is_open(&self) -> bool { - !self.is_closed() - } - pub fn close_reason(&self) -> Option { - self.connection.close_reason() - } -} - -pub struct GenericReceiver { - incoming: Arc>>>, - #[cfg(feature = "pipes")] - pipes: Arc>>>, - connection: C, - ping_sender: Arc>>>, - max_message_size: Arc, - type_map: Arc>, - queue_notify: Arc, - decode_rejections: Arc, - #[cfg(feature = "pipes")] - expected_pipes: Arc>>, - _accept_task: Arc>, -} - -impl Clone for GenericReceiver { - fn clone(&self) -> Self { - Self { - incoming: self.incoming.clone(), - #[cfg(feature = "pipes")] - pipes: self.pipes.clone(), - connection: self.connection.clone(), - ping_sender: self.ping_sender.clone(), - max_message_size: self.max_message_size.clone(), - type_map: self.type_map.clone(), - queue_notify: self.queue_notify.clone(), - decode_rejections: self.decode_rejections.clone(), - #[cfg(feature = "pipes")] - expected_pipes: self.expected_pipes.clone(), - _accept_task: self._accept_task.clone(), - } - } -} - -impl Drop for GenericReceiver { - fn drop(&mut self) { - if Arc::strong_count(&self._accept_task) == 1 { - self._accept_task.abort(); - } - } -} - -impl GenericReceiver { - pub fn new(connection: C, policy: Arc) -> Self { - let policy = Arc::new(RuntimePolicy::from_public(&policy)); - let (tx, rx) = mpsc::channel(policy.receiver_queue_capacity); - #[cfg(feature = "pipes")] - let (pipe_tx, pipe_rx) = mpsc::channel(policy.receiver_queue_capacity); - let ping_sender: Arc>>> = Arc::new(RwLock::new(None)); - let max_message_size = Arc::new(AtomicU64::new( - policy - .handshake_max_message_size - .min(policy.max_message_size), - )); - let task_ping_sender = ping_sender.clone(); - let task_connection = connection.clone(); - let task_policy = policy.clone(); - let task_max_message_size = max_message_size.clone(); - let type_map = Arc::new(RwLock::new(TypeMap::latest())); - let task_type_map = type_map.clone(); - let queue_notify = Arc::new(Notify::new()); - let task_queue_notify = queue_notify.clone(); - let decode_rejections = Arc::new(DecodeRejectionCounters::default()); - let task_decode_rejections = decode_rejections.clone(); - #[cfg(feature = "pipes")] - let expected_pipes = Arc::new(StdMutex::new(HashSet::new())); - #[cfg(feature = "pipes")] - let task_expected_pipes = expected_pipes.clone(); - let task_accept_task_tx = tx.clone(); - #[cfg(feature = "pipes")] - let task_accept_task_pipe_tx = pipe_tx.clone(); - let accept_task = tokio::spawn(async move { - let limit = Arc::new(Semaphore::new( - task_policy.max_concurrent_stream_tasks.max(1), - )); - loop { - // Backpressure: stop accepting new streams if the output queue is full. - #[cfg(feature = "pipes")] - let cap_full = - task_accept_task_tx.capacity() == 0 || task_accept_task_pipe_tx.capacity() == 0; - #[cfg(not(feature = "pipes"))] - let cap_full = task_accept_task_tx.capacity() == 0; - - let notified = task_queue_notify.notified(); - tokio::pin!(notified); - if cap_full { - notified.await; - continue; - } - - let stream = match tokio::time::timeout( - task_policy.accept_stream_timeout, - task_connection.accept_uni(), - ) - .await - { - Ok(Ok(stream)) => stream, - Ok(Err(error)) => { - let _ = task_accept_task_tx.send(Err(error)).await; - break; - } - Err(_) => { - if task_connection.close_reason().is_some() { - let _ = task_accept_task_tx - .send(Err(CommunicationError::StreamClosed)) - .await; - break; - } else { - continue; - } - } - }; - // Acquire semaphore permit BEFORE spawning the task. - let permit = match limit.clone().acquire_owned().await { - Ok(permit) => permit, - Err(_) => break, - }; - let tx = task_accept_task_tx.clone(); - #[cfg(feature = "pipes")] - let pipe_tx = task_accept_task_pipe_tx.clone(); - let policy = task_policy.clone(); - let max_message_size = task_max_message_size.clone(); - let ping_sender = task_ping_sender.clone(); - let connection = task_connection.clone(); - let type_map = task_type_map.clone(); - let decode_rejections = task_decode_rejections.clone(); - #[cfg(feature = "pipes")] - let expected_pipes = task_expected_pipes.clone(); - tokio::spawn(async move { - let _permit = permit; - let mut stream = stream; - let mut frames = 0usize; - 'stream: loop { - if policy - .max_frames_per_stream - .is_some_and(|max| frames >= max) - { - let close_error = CommunicationError::StreamError; - let _ = tx.send(Err(close_error.clone())).await; - connection.close(policy.application_close_code, b"max frames exceeded"); - break; - } - let mut len = [0; 4]; - match tokio::time::timeout(policy.read_timeout, stream.read_exact(&mut len)) - .await - { - Ok(Ok(())) => {} - Ok(Err(CommunicationError::StreamClosed)) => break, - Ok(Err(error)) => { - tracing::warn!(%error, "MTP receive stream failed while reading frame header"); - let _ = tx.send(Err(error)).await; - connection.close( - policy.application_close_code, - b"frame header read error", - ); - break 'stream; - } - Err(_) => { - if frames == 0 { - tracing::warn!( - timeout = ?policy.read_timeout, - "MTP receive stream timed out before its first complete frame" - ); - } else { - tracing::debug!( - frames, - timeout = ?policy.read_timeout, - "MTP receive stream idle timeout" - ); - } - break; - } - } - let len = u32::from_be_bytes(len); - if len == policy.close_frame_len { - break; - } - let deadline = Instant::now() + policy.read_timeout; - let frame_limit = max_message_size.load(Ordering::Relaxed); - let body_len = len as usize; - let frame_len = match body_len.checked_add(4) { - Some(frame_len) => frame_len, - None => { - let _ = tx.send(Err(CommunicationError::MessageTooLarge)).await; - connection.close(policy.application_close_code, b"frame too large"); - break; - } - }; - if frame_len as u64 > frame_limit { - tracing::warn!(frame_len, "MTP receive stream frame is too large"); - let _ = tx.send(Err(CommunicationError::MessageTooLarge)).await; - connection.close(policy.application_close_code, b"frame too large"); - break; - } - let mut frame = Vec::new(); - if frame.try_reserve_exact(frame_len).is_err() { - tracing::warn!(frame_len, "MTP receive stream could not reserve frame"); - let _ = tx.send(Err(CommunicationError::MessageTooLarge)).await; - connection - .close(policy.application_close_code, b"frame allocation failed"); - break; - } - frame.extend_from_slice(&len.to_be_bytes()); - frame.resize(frame_len, 0); - let mut body_offset = 4usize; - while body_offset < frame_len { - let chunk_len = (frame_len - body_offset).min(16 * 1024); - let body_read = timeout_at( - deadline, - stream.read_exact(&mut frame[body_offset..body_offset + chunk_len]), - ) - .await; - if !matches!(&body_read, Ok(Ok(()))) { - if matches!(&body_read, Ok(Err(CommunicationError::StreamClosed))) { - break 'stream; - } - tracing::warn!( - pipe_chunk_len = chunk_len, - ?body_read, - "MTP receive stream failed while reading frame body" - ); - let _ = tx.send(Err(CommunicationError::StreamError)).await; - connection - .close(policy.application_close_code, b"frame body read error"); - break 'stream; - } - body_offset += chunk_len; - } - frames += 1; - let mut message = match CommunicationValue::try_from_bytes_with_limits( - &frame, - DecodeLimits::for_transport_message_size(frame_limit), - ) { - Ok(message) => message, - Err(error) => { - tracing::warn!( - ?error, - class = ?classify_decode_error(&error), - "MTP receive stream rejected by bounded decode" - ); - decode_rejections.record(&error); - let _ = tx - .send(Err(CommunicationError::ParseCommunicationValue)) - .await; - connection.close(policy.application_close_code, b"invalid frame"); - break; - } - }; - tracing::debug!( - frames, - frame_len, - message_type = ?message.get_type(), - "decoded MTP receive frame" - ); - let negotiated_type_map = type_map.read().await.clone(); - message.set_type_map(&negotiated_type_map); - - #[cfg(feature = "pipes")] - { - if frames == 1 { - let is_pipe_request = - message.is_type(mtp_codec::CommunicationType::PipeRequest); - let pipe_id = message.id().filter(|id| *id != 0); - let pipe_is_expected = is_pipe_request - && pipe_id.is_some_and(|pipe_id| { - expected_pipes - .lock() - .is_ok_and(|mut expected| expected.remove(&pipe_id)) - }); - let disposition = match classify_first_frame( - is_pipe_request, - message.id(), - pipe_is_expected, - ) { - Ok(disposition) => disposition, - Err(error) => { - let _ = tx.send(Err(error.clone())).await; - connection.close( - policy.application_close_code, - b"pipe request missing id", - ); - break; - } - }; - - if let FirstFrameDisposition::Pipe(pipe_id) = disposition { - let description = message - .get_str(mtp_codec::DataType::Description) - .unwrap_or("") - .to_string(); - - let pipe_reader = PipeReader { - stream, - description, - pipe_id, - }; - - tracing::debug!(pipe_id, description = %pipe_reader.description, "classified incoming pipe stream"); - - if pipe_tx.send(pipe_reader).await.is_err() { - break; - } - return; - } - } - } - - if message.is_type(mtp_codec::CommunicationType::Ping) { - if let Some(sender) = ping_sender.read().await.clone() { - let mut pong = CommunicationValue::new_with_type_map( - mtp_codec::CommunicationType::Pong, - &negotiated_type_map, - ); - if let Some(id) = message.id() { - pong = pong.with_id(id); - } else { - pong = pong.without_id(); - } - if let Some(timestamp) = - message.get_data(mtp_codec::DataType::Timestamp) - { - pong = pong.add_typed_default( - mtp_codec::DataType::Timestamp, - timestamp.clone(), - ); - } - let _ = sender.send(&pong).await; - } - continue; - } - if tx.send(Ok(message)).await.is_err() { - break; - } - } - }); - } - }); - Self { - incoming: Arc::new(Mutex::new(rx)), - #[cfg(feature = "pipes")] - pipes: Arc::new(Mutex::new(pipe_rx)), - connection, - ping_sender, - max_message_size, - type_map, - queue_notify, - decode_rejections, - #[cfg(feature = "pipes")] - expected_pipes, - _accept_task: Arc::new(accept_task), - } - } - pub async fn respond_to_pings(&self, sender: GenericSender) { - *self.ping_sender.write().await = Some(sender); - } - - #[cfg(feature = "pipes")] - pub fn expect_pipe(&self, pipe_id: u32) -> Result<(), CommunicationError> { - if pipe_id == 0 { - return Err(CommunicationError::Other("pipe id must be non-zero".into())); - } - self.expected_pipes - .lock() - .map_err(|_| CommunicationError::Other("expected pipe state is unavailable".into()))? - .insert(pipe_id); - Ok(()) - } - - #[cfg(feature = "pipes")] - pub fn cancel_expected_pipe(&self, pipe_id: u32) { - if let Ok(mut expected) = self.expected_pipes.lock() { - expected.remove(&pipe_id); - } - } - - /// Switch from the handshake frame limit to the application frame limit. - pub fn set_max_message_size(&self, max_message_size: u64) { - self.max_message_size - .store(max_message_size, Ordering::Relaxed); - } - - /// Bind subsequently decoded frames to the negotiated protocol version. - pub async fn set_type_map(&self, type_map: &TypeMap) { - *self.type_map.write().await = type_map.clone(); - } - - /// Return local counts for frames rejected by the structured decoder. - pub fn decode_rejection_counts(&self) -> crate::DecodeRejectionCounts { - self.decode_rejections.snapshot() - } - pub async fn receive(&self) -> Result { - let result = self - .incoming - .lock() - .await - .recv() - .await - .unwrap_or(Err(CommunicationError::StreamClosed)); - if result.is_ok() { - self.queue_notify.notify_one(); - } - result - } - - #[cfg(feature = "pipes")] - pub async fn receive_event( - &self, - ) -> Result, CommunicationError> { - let mut incoming = self.incoming.lock().await; - let mut pipes = self.pipes.lock().await; - tokio::select! { - msg = incoming.recv() => { - match msg { - Some(Ok(val)) => { - self.queue_notify.notify_one(); - Ok(crate::TransportEvent::Message(val)) - } - Some(Err(e)) => Err(e), - None => Err(self - .connection - .close_reason() - .unwrap_or(CommunicationError::StreamClosed)), - } - } - pipe = pipes.recv() => { - match pipe { - Some(reader) => { - self.queue_notify.notify_one(); - Ok(crate::TransportEvent::Pipe(reader)) - } - None => Err(self - .connection - .close_reason() - .unwrap_or(CommunicationError::StreamClosed)), - } - } - } - } - - #[cfg(feature = "pipes")] - pub async fn receive_pipe(&self) -> Result, CommunicationError> { - let result = self - .pipes - .lock() - .await - .recv() - .await - .ok_or(CommunicationError::StreamClosed); - if result.is_ok() { - self.queue_notify.notify_one(); - } - result - } - - #[cfg(feature = "pipes")] - pub fn try_receive_pipe( - &self, - ) -> Result>, CommunicationError> { - match self.pipes.try_lock() { - Ok(mut rx) => match rx.try_recv() { - Ok(reader) => { - self.queue_notify.notify_one(); - Ok(Some(reader)) - } - Err(mpsc::error::TryRecvError::Empty) => Ok(None), - Err(mpsc::error::TryRecvError::Disconnected) => { - Err(CommunicationError::StreamClosed) - } - }, - Err(_) => Ok(None), - } - } - - pub fn is_closed(&self) -> bool { - self.connection.close_reason().is_some() - } - pub fn is_open(&self) -> bool { - !self.is_closed() - } - pub fn close_reason(&self) -> Option { - self.connection.close_reason() - } -} diff --git a/transport/src/host.rs b/transport/src/host.rs index a2f8f1f..0927aa0 100644 --- a/transport/src/host.rs +++ b/transport/src/host.rs @@ -3,56 +3,8 @@ use mtp_common::CommunicationError; use rustls::pki_types::{PrivateKeyDer, pem::PemObject}; use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; -use std::time::Instant; -use tracing::debug; use wtransport::{Connection as WTConnection, Endpoint, ServerConfig}; -fn generate_self_signed_cert() -> Result<(Vec, Vec), CommunicationError> { - let key_pair = rcgen::KeyPair::generate().map_err(|e| { - CommunicationError::Other(format!("failed to generate self-signed key pair: {e}")) - })?; - let params = rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()]) - .map_err(|e| { - CommunicationError::Other(format!( - "failed to build self-signed certificate params: {e}" - )) - })?; - let cert = params - .self_signed(&key_pair) - .map_err(|e| CommunicationError::Other(format!("failed to self-sign certificate: {e}")))?; - let cert_pem = cert.pem(); - let key_pem = key_pair.serialize_pem(); - Ok((cert_pem.into_bytes(), key_pem.into_bytes())) -} - -enum HostCredentials { - Pem { cert_pem: Vec, key_pem: Vec }, - SelfSigned, -} - -/// TLS and transport settings for a native host. -pub struct HostConfig { - credentials: HostCredentials, - policy: Policy, -} - -impl HostConfig { - pub fn new(cert_pem: Vec, key_pem: Vec, policy: Policy) -> Self { - Self { - credentials: HostCredentials::Pem { cert_pem, key_pem }, - policy, - } - } - - /// Generate a self-signed certificate for local development. - pub fn self_signed(policy: Policy) -> Self { - Self { - credentials: HostCredentials::SelfSigned, - policy, - } - } -} - pub struct Host { incoming: tokio::sync::mpsc::Receiver<(Sender, Receiver)>, local_addr: std::net::SocketAddr, @@ -92,23 +44,9 @@ pub async fn host( key_pem: Vec, policy: Policy, ) -> Result { - host_with_config(ip, port, HostConfig::new(cert_pem, key_pem, policy)).await -} + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); -/// Start a host using explicit TLS and transport configuration. -pub async fn host_with_config( - ip: IpAddr, - port: u16, - config: HostConfig, -) -> Result { - mtp_crypto::ensure_crypto_provider(); - - let (cert_pem, key_pem) = match config.credentials { - HostCredentials::Pem { cert_pem, key_pem } => (cert_pem, key_pem), - HostCredentials::SelfSigned => generate_self_signed_cert()?, - }; - - let server_config = configure_server(ip, port, cert_pem, key_pem, &config.policy).await?; + let server_config = configure_server(ip, port, cert_pem, key_pem, &policy).await?; let endpoint = Endpoint::server(server_config) .map_err(|e| CommunicationError::Other(format!("Endpoint creation failed: {}", e)))?; @@ -118,42 +56,34 @@ pub async fn host_with_config( let (incoming_tx, incoming_rx) = tokio::sync::mpsc::channel(16); - let policy = Arc::new(config.policy); + let policy = Arc::new(policy); let task = tokio::spawn(async move { loop { - let accept_started = Instant::now(); let incoming_session = endpoint.accept().await; - tracing::debug!(elapsed = ?accept_started.elapsed(), "host accept loop: received QUIC connection"); + + let request = match incoming_session.await { + Ok(req) => req, + Err(e) => { + log::debug!("incoming WebTransport session failed: {e}"); + continue; + } + }; + + let connection = match request + .accept_with_headers([("sec-webtransport-http3-draft02", "1")]) + .await + { + Ok(conn) => conn, + Err(e) => { + log::debug!("WebTransport request accept failed: {e}"); + continue; + } + }; let incoming_tx = incoming_tx.clone(); - let policy = Arc::clone(&policy); - tokio::spawn(async move { - let session_started = Instant::now(); - let request = match incoming_session.await { - Ok(req) => req, - Err(e) => { - debug!("incoming WebTransport session failed: {e}"); - return; - } - }; - tracing::debug!(elapsed = ?session_started.elapsed(), "host accept loop: complete WebTransport handshake"); - - let request_accept_started = Instant::now(); - let connection = match request - .accept_with_headers([("sec-webtransport-http3-draft02", "1")]) - .await - { - Ok(conn) => conn, - Err(e) => { - debug!("WebTransport request accept failed: {e}"); - return; - } - }; - tracing::debug!(elapsed = ?request_accept_started.elapsed(), "host accept loop: accept WebTransport request"); - - handle_connection(connection, incoming_tx, policy).await; - }); + let policy = policy.clone(); + tokio::spawn(handle_connection(connection, incoming_tx, policy)); } }); @@ -169,16 +99,11 @@ async fn handle_connection( tx: tokio::sync::mpsc::Sender<(Sender, Receiver)>, policy: Arc, ) { - let setup_started = Instant::now(); - let handle = Arc::new(ConnectionHandle::with_remote_addr( - connection.quic_connection().remote_address(), - )); + let handle = Arc::new(ConnectionHandle::new()); let sender = Sender::new(connection.clone(), handle.clone(), policy.clone()); - let receiver = Receiver::new_for_handshake(connection, handle, policy); - if tx.send((sender, receiver)).await.is_ok() { - tracing::debug!(elapsed = ?setup_started.elapsed(), "host accept loop: hand connection to authentication"); - } + let receiver = Receiver::new(connection, handle, policy); + let _ = tx.send((sender, receiver)).await; } async fn configure_server( diff --git a/transport/src/lib.rs b/transport/src/lib.rs index 15fc78d..707e74e 100644 --- a/transport/src/lib.rs +++ b/transport/src/lib.rs @@ -1,41 +1,13 @@ pub mod client; pub mod connection; pub mod connection_handle; -mod framing; -pub mod generic_connection; -pub mod pinning; -pub mod transport_traits; -#[cfg(feature = "pipes")] -pub mod encrypted_pipe; -#[cfg(feature = "pipes")] -pub mod pipe; +pub use connection::{Policy, Receiver, SendMode, Sender}; -pub use connection::{ - DecodeRejectionClass, DecodeRejectionCounters, DecodeRejectionCounts, Policy, Receiver, - SendMode, Sender, classify_decode_error, -}; -pub use generic_connection::{GenericReceiver, GenericSender}; - -#[cfg(feature = "pipes")] -pub use connection::TransportEvent; -#[cfg(feature = "pipes")] -pub use encrypted_pipe::{ - EncryptedPipeError, EncryptedPipeReader, EncryptedPipeWriter, MAX_ENCRYPTED_PIPE_RECORD, - MAX_PIPE_SESSION_OFFER, PIPE_SESSION_ENCRYPTION_PURPOSE, PIPE_SESSION_SIGNATURE_PURPOSE, - PipeProtectionContext, PipeSessionError, PipeSessionParameters, - accept_forward_secure_pipe_session, accept_forward_secure_pipe_session_with_key_history, - accept_pipe_session, accept_pipe_session_with_key_history, accept_pipe_session_with_policy, - initiate_forward_secure_pipe_session, initiate_group_pipe_session, initiate_pipe_session, -}; -#[cfg(feature = "pipes")] -pub use pipe::{PipeReader, PipeWriter}; - -pub use client::{ClientConfig, connect, connect_with_config}; +pub use client::connect; pub use connection_handle::ConnectionHandle; -pub use transport_traits::{TransportConnection, TransportRecvStream, TransportSendStream}; #[cfg(feature = "host")] pub mod host; #[cfg(feature = "host")] -pub use host::{Host, HostConfig, host, host_with_config}; +pub use host::{Host, host}; diff --git a/transport/src/pinning.rs b/transport/src/pinning.rs deleted file mode 100644 index 6aab1e6..0000000 --- a/transport/src/pinning.rs +++ /dev/null @@ -1,175 +0,0 @@ -use rustls::{ - ClientConfig as RustlsClientConfig, DigitallySignedStruct, SignatureScheme, - client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, - pki_types::{CertificateDer, ServerName, UnixTime}, -}; -use sha2::{Digest, Sha256}; -use wtransport::ClientConfig as WTransportClientConfig; - -use crate::Policy; - -use mtp_common::CommunicationError; -use std::sync::Arc; - -/// A certificate verifier that pins a connection to a specific SPKI -/// (Subject Public Key Info) SHA-256 hash. The client will only accept -/// server certificates whose DER-encoded SPKI matches the provided hash. -#[derive(Debug)] -pub struct PinnedCertVerifier { - expected_hash: [u8; 32], -} - -impl PinnedCertVerifier { - pub fn new(expected_hash: [u8; 32]) -> Self { - Self { expected_hash } - } -} - -impl ServerCertVerifier for PinnedCertVerifier { - fn verify_server_cert( - &self, - end_entity: &CertificateDer<'_>, - _intermediates: &[CertificateDer<'_>], - _server_name: &ServerName<'_>, - _ocsp_response: &[u8], - _now: UnixTime, - ) -> Result { - let der = end_entity.as_ref(); - - let spki = extract_spki(der).map_err(|_| { - rustls::Error::General("failed to extract SPKI from certificate".into()) - })?; - - let computed = Sha256::digest(&spki); - - if computed.as_slice() != self.expected_hash.as_slice() { - return Err(rustls::Error::General(format!( - "certificate SPKI hash mismatch: expected {:02x?}, got {:02x?}", - self.expected_hash, computed - ))); - } - - Ok(ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> Result { - Ok(HandshakeSignatureValid::assertion()) - } - - fn verify_tls13_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> Result { - Ok(HandshakeSignatureValid::assertion()) - } - - fn supported_verify_schemes(&self) -> Vec { - vec![ - SignatureScheme::RSA_PKCS1_SHA256, - SignatureScheme::ECDSA_NISTP256_SHA256, - SignatureScheme::RSA_PSS_SHA256, - SignatureScheme::ED25519, - ] - } -} - -/// Extract the Subject Public Key Info (SPKI) field from a DER-encoded X.509 -/// certificate. Returns the raw bytes of the SPKI sequence. -fn extract_spki(der: &[u8]) -> Result, ()> { - let (_, outer) = parse_der_sequence(der).map_err(|_| ())?; - - let (_, tbs) = parse_der_sequence(outer).map_err(|_| ())?; - - // Skip version (context [0]), serial number, signature algorithm, issuer, - // validity, subject to reach subjectPublicKeyInfo (index 6). - let mut offset = 0; - let mut element_index = 0; - - while offset < tbs.len() && element_index < 6 { - let (len, _) = parse_der_element(&tbs[offset..]).map_err(|_| ())?; - offset += len; - element_index += 1; - } - - if element_index != 6 { - return Err(()); - } - - let (spki_len, spki) = parse_der_element(&tbs[offset..]).map_err(|_| ())?; - if spki_len == 0 { - return Err(()); - } - - Ok(spki.to_vec()) -} - -fn parse_der_element(data: &[u8]) -> Result<(usize, &[u8]), ()> { - if data.len() < 2 { - return Err(()); - } - - let mut offset = 1; - - let len_byte = data[offset]; - offset += 1; - - let content_len = if len_byte & 0x80 == 0 { - len_byte as usize - } else { - let num_bytes = (len_byte & 0x7F) as usize; - if offset + num_bytes > data.len() { - return Err(()); - } - let mut len = 0usize; - for i in 0..num_bytes { - len = (len << 8) | data[offset + i] as usize; - } - offset += num_bytes; - len - }; - - if offset + content_len > data.len() { - return Err(()); - } - - let total_len = offset + content_len; - Ok((total_len, &data[offset..offset + content_len])) -} - -fn parse_der_sequence(data: &[u8]) -> Result<(usize, &[u8]), ()> { - if data.is_empty() || data[0] != 0x30 { - return Err(()); - } - parse_der_element(data) -} - -/// Build a [`WTransportClientConfig`] that verifies the server certificate -/// against a pinned SPKI SHA-256 hash. -pub fn configure_client_pinned_hash( - expected_hash: [u8; 32], - policy: &Policy, -) -> Result { - let verifier = PinnedCertVerifier::new(expected_hash); - - let mut tls_config = RustlsClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(verifier)) - .with_no_client_auth(); - - tls_config.alpn_protocols = vec![b"h3".to_vec()]; - - Ok(WTransportClientConfig::builder() - .with_bind_default() - .with_custom_tls(tls_config) - .keep_alive_interval(policy.keep_alive_interval) - .max_idle_timeout(policy.max_idle_timeout) - .map_err(|e| CommunicationError::Other(e.to_string()))? - .build()) -} diff --git a/transport/src/pipe.rs b/transport/src/pipe.rs deleted file mode 100644 index 9c17f71..0000000 --- a/transport/src/pipe.rs +++ /dev/null @@ -1,86 +0,0 @@ -use std::pin::Pin; -use std::task::{Context, Poll}; -use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; -use tracing::warn; - -#[derive(Debug)] -pub struct PipeWriter { - pub(crate) stream: S, -} - -impl PipeWriter { - pub async fn finish(mut self) -> Result<(), mtp_common::CommunicationError> { - self.stream.finish().await.map_err(|e| { - warn!("[PipeWriter] finish failed: {e}"); - mtp_common::CommunicationError::StreamWriteError(e) - }) - } - - pub fn abort(&mut self) -> Result<(), wtransport::error::ClosedStream> { - self.stream.reset(wtransport::VarInt::from_u32(0)) - } -} - -impl PipeWriter { - pub fn into_inner(self) -> S { - self.stream - } - - pub async fn finish_async(mut self) -> Result<(), mtp_common::CommunicationError> { - tokio::io::AsyncWriteExt::shutdown(&mut self) - .await - .map_err(|e| { - warn!("[PipeWriter] finish_async failed: {e}"); - mtp_common::CommunicationError::StreamError - }) - } -} - -impl AsyncWrite for PipeWriter { - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &[u8], - ) -> Poll> { - Pin::new(&mut self.stream).poll_write(cx, buf) - } - - fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.stream).poll_flush(cx) - } - - fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.stream).poll_shutdown(cx) - } -} - -#[derive(Debug)] -pub struct PipeReader { - pub(crate) stream: R, - pub(crate) description: String, - pub(crate) pipe_id: u32, -} - -impl PipeReader { - pub fn into_inner(self) -> R { - self.stream - } - - pub fn description(&self) -> &str { - &self.description - } - - pub fn pipe_id(&self) -> u32 { - self.pipe_id - } -} - -impl AsyncRead for PipeReader { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - Pin::new(&mut self.stream).poll_read(cx, buf) - } -} diff --git a/transport/src/transport_traits.rs b/transport/src/transport_traits.rs deleted file mode 100644 index 70395af..0000000 --- a/transport/src/transport_traits.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! Transport-neutral primitives used by alternative MTP hosts. -//! -//! The existing public `Sender` and `Receiver` remain backed by -//! `wtransport`. These traits are deliberately introduced separately so new -//! QUIC/WebTransport backends can be added without changing that API in one -//! breaking step. - -use async_trait::async_trait; -use mtp_common::CommunicationError; - -/// A writable unidirectional stream suitable for MTP frames. -/// -/// Implementors must also implement [`tokio::io::AsyncWrite`] so that the -/// stream can back a raw pipe via [`crate::PipeWriter`] when the `pipes` -/// feature is enabled. -#[async_trait] -pub trait TransportSendStream: tokio::io::AsyncWrite + Send + Sync { - async fn write_all(&mut self, buf: &[u8]) -> Result<(), CommunicationError>; - async fn finish(&mut self) -> Result<(), CommunicationError>; - fn reset(&mut self, code: u32) -> Result<(), CommunicationError>; -} - -/// A readable unidirectional stream suitable for MTP frames. -/// -/// Implementors must also implement [`tokio::io::AsyncRead`] so that the -/// stream can back a raw pipe via [`crate::PipeReader`] when the `pipes` -/// feature is enabled. -#[async_trait] -pub trait TransportRecvStream: tokio::io::AsyncRead + Send + Sync { - async fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), CommunicationError>; - async fn read_chunk(&mut self, max: usize) -> Result>, CommunicationError>; - fn stop(self, code: u32) -> Result<(), CommunicationError> - where - Self: Sized; -} - -/// A QUIC/WebTransport connection that provides MTP's unidirectional streams. -#[async_trait] -pub trait TransportConnection: Clone + Send + Sync + 'static { - type SendStream: TransportSendStream + 'static; - type RecvStream: TransportRecvStream + 'static; - - async fn open_uni(&self) -> Result; - async fn accept_uni(&self) -> Result; - fn close_reason(&self) -> Option; - fn close(&self, code: u32, reason: &[u8]); -} - -#[async_trait] -impl TransportSendStream for wtransport::SendStream { - async fn write_all(&mut self, buf: &[u8]) -> Result<(), CommunicationError> { - wtransport::SendStream::write_all(self, buf) - .await - .map_err(|_| CommunicationError::DeliveryUnknown) - } - - async fn finish(&mut self) -> Result<(), CommunicationError> { - wtransport::SendStream::finish(self) - .await - .map_err(|_| CommunicationError::StreamError) - } - - fn reset(&mut self, code: u32) -> Result<(), CommunicationError> { - wtransport::SendStream::reset(self, wtransport::VarInt::from_u32(code)) - .map_err(|_| CommunicationError::StreamClosed) - } -} - -#[async_trait] -impl TransportRecvStream for wtransport::RecvStream { - async fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), CommunicationError> { - match wtransport::RecvStream::read_exact(self, buf).await { - Ok(()) => Ok(()), - Err(wtransport::error::StreamReadExactError::FinishedEarly(0)) => { - Err(CommunicationError::StreamClosed) - } - Err(_) => Err(CommunicationError::StreamError), - } - } - - async fn read_chunk(&mut self, max: usize) -> Result>, CommunicationError> { - let mut buf = vec![0; max]; - match wtransport::RecvStream::read(self, &mut buf).await { - Ok(Some(size)) => { - buf.truncate(size); - Ok(Some(buf)) - } - Ok(None) => Ok(None), - Err(_) => Err(CommunicationError::StreamError), - } - } - - fn stop(self, code: u32) -> Result<(), CommunicationError> { - wtransport::RecvStream::stop(self, wtransport::VarInt::from_u32(code)); - Ok(()) - } -} - -#[async_trait] -impl TransportConnection for wtransport::Connection { - type SendStream = wtransport::SendStream; - type RecvStream = wtransport::RecvStream; - - async fn open_uni(&self) -> Result { - let opening = wtransport::Connection::open_uni(self) - .await - .map_err(CommunicationError::ConnectionError)?; - opening.await.map_err(|_| CommunicationError::StreamError) - } - - async fn accept_uni(&self) -> Result { - wtransport::Connection::accept_uni(self) - .await - .map_err(|_| CommunicationError::StreamError) - } - - fn close_reason(&self) -> Option { - self.quic_connection() - .close_reason() - .map(|_| CommunicationError::StreamClosed) - } - - fn close(&self, code: u32, reason: &[u8]) { - self.quic_connection().close(code.into(), reason); - } -} diff --git a/transport/tests/generic_pipe.rs b/transport/tests/generic_pipe.rs deleted file mode 100644 index 7dfb538..0000000 --- a/transport/tests/generic_pipe.rs +++ /dev/null @@ -1,336 +0,0 @@ -#![cfg(feature = "pipes")] - -use async_trait::async_trait; -use mtp_codec::CommunicationValue; -use mtp_common::CommunicationError; -use mtp_transport::{ - GenericReceiver, GenericSender, Policy, SendMode, TransportConnection, TransportEvent, - TransportRecvStream, TransportSendStream, -}; -use std::sync::Arc; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, DuplexStream, duplex}; -use tokio::sync::{Mutex, mpsc}; - -struct MockSendStream { - inner: DuplexStream, -} - -impl AsyncWrite for MockSendStream { - fn poll_write( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - buf: &[u8], - ) -> std::task::Poll> { - std::pin::Pin::new(&mut self.inner).poll_write(cx, buf) - } - - fn poll_flush( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - std::pin::Pin::new(&mut self.inner).poll_flush(cx) - } - - fn poll_shutdown( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - std::pin::Pin::new(&mut self.inner).poll_shutdown(cx) - } -} - -#[async_trait] -impl TransportSendStream for MockSendStream { - async fn write_all(&mut self, buf: &[u8]) -> Result<(), CommunicationError> { - AsyncWriteExt::write_all(&mut self.inner, buf) - .await - .map_err(|_| CommunicationError::StreamError) - } - - async fn finish(&mut self) -> Result<(), CommunicationError> { - self.inner - .shutdown() - .await - .map_err(|_| CommunicationError::StreamError) - } - - fn reset(&mut self, _code: u32) -> Result<(), CommunicationError> { - Ok(()) - } -} - -struct MockRecvStream { - inner: DuplexStream, -} - -impl AsyncRead for MockRecvStream { - fn poll_read( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - buf: &mut tokio::io::ReadBuf<'_>, - ) -> std::task::Poll> { - std::pin::Pin::new(&mut self.inner).poll_read(cx, buf) - } -} - -#[async_trait] -impl TransportRecvStream for MockRecvStream { - async fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), CommunicationError> { - AsyncReadExt::read_exact(&mut self.inner, buf) - .await - .map(|_| ()) - .map_err(|_| CommunicationError::StreamError) - } - - async fn read_chunk(&mut self, max: usize) -> Result>, CommunicationError> { - let mut buf = vec![0u8; max]; - match AsyncReadExt::read(&mut self.inner, &mut buf).await { - Ok(0) => Ok(None), - Ok(n) => { - buf.truncate(n); - Ok(Some(buf)) - } - Err(_) => Err(CommunicationError::StreamError), - } - } - - fn stop(self, _code: u32) -> Result<(), CommunicationError> { - Ok(()) - } -} - -#[derive(Clone)] -struct MockTransportConnection { - pair_tx: mpsc::Sender, - pair_rx: Arc>>, -} - -impl MockTransportConnection { - fn pair() -> (Self, Self) { - let (tx_a, rx_a) = mpsc::channel(16); - let (tx_b, rx_b) = mpsc::channel(16); - ( - Self { - pair_tx: tx_a, - pair_rx: Arc::new(Mutex::new(rx_b)), - }, - Self { - pair_tx: tx_b, - pair_rx: Arc::new(Mutex::new(rx_a)), - }, - ) - } -} - -#[async_trait] -impl TransportConnection for MockTransportConnection { - type SendStream = MockSendStream; - type RecvStream = MockRecvStream; - - async fn open_uni(&self) -> Result { - let (local, remote) = duplex(65536); - self.pair_tx - .send(remote) - .await - .map_err(|_| CommunicationError::StreamError)?; - Ok(MockSendStream { inner: local }) - } - - async fn accept_uni(&self) -> Result { - let remote = self - .pair_rx - .lock() - .await - .recv() - .await - .ok_or(CommunicationError::StreamClosed)?; - Ok(MockRecvStream { inner: remote }) - } - - fn close_reason(&self) -> Option { - None - } - - fn close(&self, _code: u32, _reason: &[u8]) {} -} - -async fn mock_connected_pair() -> (MockTransportConnection, MockTransportConnection) { - MockTransportConnection::pair() -} - -#[tokio::test] -async fn test_open_pipe_and_receive_reader() -> Result<(), Box> { - let (conn_a, conn_b) = mock_connected_pair().await; - let policy = Arc::new(Policy::default()); - let sender = GenericSender::new(conn_a, policy.clone()); - let receiver = GenericReceiver::new(conn_b, policy); - - receiver.expect_pipe(42)?; - let pipe_writer = sender.open_pipe(42, "test-pipe").await?; - - let pipe_reader = receiver.receive_pipe().await?; - assert_eq!(pipe_reader.pipe_id(), 42); - assert_eq!(pipe_reader.description(), "test-pipe"); - - drop(pipe_writer); - Ok(()) -} - -#[tokio::test] -async fn test_pipe_raw_data_roundtrip() -> Result<(), Box> { - let (conn_a, conn_b) = mock_connected_pair().await; - let policy = Arc::new(Policy::default()); - let sender = GenericSender::new(conn_a, policy.clone()); - let receiver = GenericReceiver::new(conn_b, policy); - - receiver.expect_pipe(1)?; - let mut pipe_writer = sender.open_pipe(1, "data-pipe").await?; - - let data = b"hello through the pipe"; - AsyncWriteExt::write_all(&mut pipe_writer, data).await?; - pipe_writer.finish_async().await?; - - let mut pipe_reader = receiver.receive_pipe().await?; - let mut buf = vec![0u8; data.len()]; - AsyncReadExt::read_exact(&mut pipe_reader, &mut buf).await?; - assert_eq!(&buf, data); - - Ok(()) -} - -#[tokio::test] -async fn test_pipe_large_payload() -> Result<(), Box> { - let (conn_a, conn_b) = mock_connected_pair().await; - let policy = Arc::new(Policy::default()); - let sender = GenericSender::new(conn_a, policy.clone()); - let receiver = GenericReceiver::new(conn_b, policy); - - receiver.expect_pipe(7)?; - let mut pipe_writer = sender.open_pipe(7, "big-pipe").await?; - - let data: Vec = (0..256 * 1024).map(|i| (i % 256) as u8).collect(); - let data_clone = data.clone(); - let write_handle = tokio::spawn(async move { - AsyncWriteExt::write_all(&mut pipe_writer, &data_clone) - .await - .map_err(|_| CommunicationError::StreamError)?; - pipe_writer.finish_async().await - }); - - let mut pipe_reader = receiver.receive_pipe().await?; - let mut buf = Vec::new(); - AsyncReadExt::read_to_end(&mut pipe_reader, &mut buf).await?; - assert_eq!(buf, data); - - write_handle.await??; - - Ok(()) -} - -#[tokio::test] -async fn test_receive_event_dispatches_pipe() -> Result<(), Box> { - let (conn_a, conn_b) = mock_connected_pair().await; - let policy = Arc::new(Policy::default()); - let sender = GenericSender::new(conn_a, policy.clone()); - let receiver = GenericReceiver::new(conn_b, policy); - - receiver.expect_pipe(99)?; - let mut pipe_writer = sender.open_pipe(99, "event-pipe").await?; - - match receiver.receive_event().await? { - TransportEvent::Pipe(mut reader) => { - assert_eq!(reader.pipe_id(), 99); - assert_eq!(reader.description(), "event-pipe"); - - let data = b"event dispatch test"; - AsyncWriteExt::write_all(&mut pipe_writer, data).await?; - pipe_writer.finish_async().await?; - - let mut buf = vec![0u8; data.len()]; - AsyncReadExt::read_exact(&mut reader, &mut buf).await?; - assert_eq!(&buf, data); - } - TransportEvent::Message(_) => panic!("expected Pipe event, got Message"), - } - - Ok(()) -} - -#[tokio::test] -async fn test_try_receive_pipe_returns_none_when_empty() -> Result<(), Box> { - let (conn_a, conn_b) = mock_connected_pair().await; - let policy = Arc::new(Policy::default()); - let _sender = GenericSender::new(conn_a, policy.clone()); - let receiver = GenericReceiver::new(conn_b, policy); - - let result = receiver.try_receive_pipe()?; - assert!(result.is_none()); - - Ok(()) -} - -#[tokio::test] -async fn test_regular_messages_still_work_alongside_pipes() -> Result<(), Box> -{ - let (conn_a, conn_b) = mock_connected_pair().await; - let policy = Arc::new(Policy::default().with_send_mode(SendMode::SingleStreamPerMessage)); - let sender = GenericSender::new(conn_a, policy.clone()); - let receiver = GenericReceiver::new(conn_b, policy); - - let request = CommunicationValue::new(mtp_codec::CommunicationType::PipeRequest) - .with_id(1) - .add_typed_default( - mtp_codec::DataType::Description, - mtp_codec::DataValue::Str("mixed-pipe".into()), - ); - sender.send(&request).await?; - - let received = receiver.receive().await?; - assert!(received.is_type(mtp_codec::CommunicationType::PipeRequest)); - - receiver.expect_pipe(1)?; - let _pipe_writer = sender.open_pipe(1, "mixed-pipe").await?; - - let pipe_reader = receiver.receive_pipe().await?; - assert_eq!(pipe_reader.pipe_id(), 1); - - Ok(()) -} - -#[tokio::test] -async fn test_multiple_pipes() -> Result<(), Box> { - let (conn_a, conn_b) = mock_connected_pair().await; - let policy = Arc::new(Policy::default()); - let sender = GenericSender::new(conn_a, policy.clone()); - let receiver = GenericReceiver::new(conn_b, policy); - - receiver.expect_pipe(10)?; - receiver.expect_pipe(20)?; - let mut pw1 = sender.open_pipe(10, "first").await?; - let mut pw2 = sender.open_pipe(20, "second").await?; - - let r1 = receiver.receive_pipe().await?; - assert_eq!(r1.pipe_id(), 10); - let r2 = receiver.receive_pipe().await?; - assert_eq!(r2.pipe_id(), 20); - - let data1 = b"pipe-one-data"; - AsyncWriteExt::write_all(&mut pw1, data1).await?; - pw1.finish_async().await?; - - let data2 = b"pipe-two-data"; - AsyncWriteExt::write_all(&mut pw2, data2).await?; - pw2.finish_async().await?; - - let mut buf1 = vec![0u8; data1.len()]; - let mut reader1 = r1; - AsyncReadExt::read_exact(&mut reader1, &mut buf1).await?; - assert_eq!(&buf1, data1); - - let mut buf2 = vec![0u8; data2.len()]; - let mut reader2 = r2; - AsyncReadExt::read_exact(&mut reader2, &mut buf2).await?; - assert_eq!(&buf2, data2); - - Ok(()) -} diff --git a/transport/tests/integration.rs b/transport/tests/integration.rs index e8bfb4c..1099b9e 100644 --- a/transport/tests/integration.rs +++ b/transport/tests/integration.rs @@ -1,63 +1,50 @@ use std::net::{IpAddr, Ipv4Addr}; use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap}; -use mtp_transport::{ - ClientConfig as TransportClientConfig, Host, HostConfig as TransportHostConfig, Policy, - Receiver, Sender, connect, connect_with_config, host, host_with_config, -}; +use mtp_transport::{Host, Policy, Receiver, Sender, connect, host}; fn generate_self_signed_cert() -> (Vec, Vec) { - let key_pair = rcgen::KeyPair::generate().expect("failed to generate self-signed key pair"); - let params = rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()]) - .expect("failed to build self-signed certificate params"); - let cert = params - .self_signed(&key_pair) - .expect("failed to self-sign certificate"); + let key_pair = rcgen::KeyPair::generate().unwrap(); + let params = + rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()]).unwrap(); + let cert = params.self_signed(&key_pair).unwrap(); let cert_pem = cert.pem(); let key_pem = key_pair.serialize_pem(); (cert_pem.into_bytes(), key_pem.into_bytes()) } -async fn start_test_host( - cert_pem: Vec, - key_pem: Vec, -) -> Result> { - Ok(host( +async fn start_test_host(cert_pem: Vec, key_pem: Vec) -> Host { + host( IpAddr::V4(Ipv4Addr::LOCALHOST), 0, cert_pem, key_pem, Policy::default(), ) - .await?) + .await + .unwrap() } -async fn connect_to_host( - h: &Host, - cert_pem: Vec, -) -> Result<(Sender, Receiver), Box> { +async fn connect_to_host(h: &Host, cert_pem: Vec) -> (Sender, Receiver) { let url = format!("https://127.0.0.1:{}", h.local_addr().port()); - Ok(connect(&url, Some(cert_pem), Policy::default()).await?) + connect(&url, Some(cert_pem), Policy::default()) + .await + .unwrap() } -async fn connected_pair() --> Result<(Host, Sender, Receiver, Sender, Receiver), Box> { +async fn connected_pair() -> (Host, Sender, Receiver, Sender, Receiver) { let (cert_pem, key_pem) = generate_self_signed_cert(); - let mut h = start_test_host(cert_pem.clone(), key_pem).await?; - let (client_tx, client_rx) = connect_to_host(&h, cert_pem).await?; - let (host_tx, host_rx) = h.next().await.ok_or("host did not accept connection")?; - Ok((h, client_tx, client_rx, host_tx, host_rx)) + let mut h = start_test_host(cert_pem.clone(), key_pem).await; + let (client_tx, client_rx) = connect_to_host(&h, cert_pem).await; + let (host_tx, host_rx) = h.next().await.unwrap(); + (h, client_tx, client_rx, host_tx, host_rx) } fn numbered_message(comm_type: CommunicationType, value: u128, tm: &TypeMap) -> CommunicationValue { - CommunicationValue::new(comm_type) - .add_data( - DataType::PqSignature - .try_to_id(tm) - .expect("test type must be mapped"), - DataValue::UnsignedNumber(value), - ) - .expect("numbered message must have a container payload") + CommunicationValue::new(comm_type).add_data( + DataType::PqSignature.to_id(tm), + DataValue::UnsignedNumber(value), + ) } fn assert_numbered_message( @@ -66,167 +53,111 @@ fn assert_numbered_message( value: u128, tm: &TypeMap, ) { + assert_eq!(message.get_type(), comm_type.to_id(tm)); assert_eq!( - message.get_type(), - comm_type.try_to_id(tm).expect("test type must be mapped") - ); - assert_eq!( - message.get_data(DataType::PqSignature), - Some(&DataValue::UnsignedNumber(value)) + message.get_data(DataType::PqSignature).clone(), + DataValue::UnsignedNumber(value) ); } #[tokio::test] -async fn test_host_start_and_stop() -> Result<(), Box> { +async fn test_host_start_and_stop() { let (cert_pem, key_pem) = generate_self_signed_cert(); - let h = start_test_host(cert_pem, key_pem).await?; + let h = start_test_host(cert_pem, key_pem).await; let addr = h.local_addr(); // Port should be non-zero (OS-assigned) assert!(addr.port() > 0); - Ok(()) } #[tokio::test] -async fn test_explicit_development_tls() -> Result<(), Box> { - // The insecure-tls feature requires MTP_INSECURE_TLS=1 at runtime. - // SAFETY: test is single-threaded; no concurrent readers of this env var. - unsafe { - std::env::set_var("MTP_INSECURE_TLS", "1"); - } - - let mut h = host_with_config( - IpAddr::V4(Ipv4Addr::LOCALHOST), - 0, - TransportHostConfig::self_signed(Policy::default()), - ) - .await?; - let url = format!("https://127.0.0.1:{}", h.local_addr().port()); - let client_config = - TransportClientConfig::new(Policy::default()).with_insecure_certificate_verification(); - - let (client_tx, _client_rx) = connect_with_config(&url, client_config).await?; - let (_host_tx, _host_rx) = h.next().await.ok_or("host did not accept connection")?; - - client_tx.close().await; - h.shutdown(); - Ok(()) -} - -#[tokio::test] -async fn test_send_receive_roundtrip() -> Result<(), Box> { - let (_h, client_tx, client_rx, host_tx, host_rx) = connected_pair().await?; +async fn test_send_receive_roundtrip() { + let (_h, client_tx, client_rx, host_tx, host_rx) = connected_pair().await; let tm = TypeMap::latest(); // Client sends a simple message let msg = numbered_message(CommunicationType::Ping, 42, &tm); - client_tx.send(&msg).await?; + client_tx.send(&msg).await.unwrap(); // Host receives it - let received = host_rx.receive().await?; + let received = host_rx.receive().await.unwrap(); assert_numbered_message(&received, CommunicationType::Ping, 42, &tm); // Host sends a response - let resp = numbered_message(CommunicationType::BadRequest, 99, &tm); - host_tx.send(&resp).await?; + let resp = numbered_message(CommunicationType::Pong, 99, &tm); + host_tx.send(&resp).await.unwrap(); // Client receives it - let client_received = client_rx.receive().await?; - assert_numbered_message(&client_received, CommunicationType::BadRequest, 99, &tm); + let client_received = client_rx.receive().await.unwrap(); + assert_numbered_message(&client_received, CommunicationType::Pong, 99, &tm); // Close both sides - client_tx.close().await; - host_tx.close().await; - Ok(()) + client_tx.close(); + host_tx.close(); } #[tokio::test] -async fn test_generic_payload_roundtrip() -> Result<(), Box> { - let (_h, client_tx, _client_rx, host_tx, host_rx) = connected_pair().await?; - let payload = DataValue::Array(vec![ - DataValue::Str("generic payload".into()), - DataValue::Bytes(vec![1, 2, 3]), - ]); - let message = - CommunicationValue::new(CommunicationType::BadRequest).with_payload(payload.clone()); - - client_tx.send(&message).await?; - let received = host_rx.receive().await?; - - assert_eq!(received.into_payload(), payload); - client_tx.close().await; - host_tx.close().await; - Ok(()) -} - -#[tokio::test] -async fn test_concurrent_messages() -> Result<(), Box> { - let (_h, client_tx, _client_rx, _host_tx, host_rx) = connected_pair().await?; +async fn test_concurrent_messages() { + let (_h, client_tx, _client_rx, _host_tx, host_rx) = connected_pair().await; let tm = TypeMap::latest(); // Send 5 messages in sequence for i in 0..5u128 { let msg = numbered_message(CommunicationType::Ping, i, &tm); - client_tx.send(&msg).await?; + client_tx.send(&msg).await.unwrap(); } // Receive all 5 in order for i in 0..5u128 { - let received = host_rx.receive().await?; + let received = host_rx.receive().await.unwrap(); assert_numbered_message(&received, CommunicationType::Ping, i, &tm); } // Send 3 responses back for i in 0..3u128 { - let msg = numbered_message(CommunicationType::BadRequest, i * 10, &tm); - client_tx.send(&msg).await?; + let msg = numbered_message(CommunicationType::Pong, i * 10, &tm); + client_tx.send(&msg).await.unwrap(); } for i in 0..3u128 { - let received = host_rx.receive().await?; - assert_numbered_message(&received, CommunicationType::BadRequest, i * 10, &tm); + let received = host_rx.receive().await.unwrap(); + assert_numbered_message(&received, CommunicationType::Pong, i * 10, &tm); } - client_tx.close().await; - Ok(()) + client_tx.close(); } #[tokio::test] -async fn test_close_detection() -> Result<(), Box> { - let (_h, client_tx, _client_rx, _host_tx, host_rx) = connected_pair().await?; +async fn test_close_detection() { + let (_h, client_tx, _client_rx, _host_tx, host_rx) = connected_pair().await; // Send a message then close let msg = CommunicationValue::new(CommunicationType::Ping); - client_tx.send(&msg).await?; + client_tx.send(&msg).await.unwrap(); + client_tx.close(); // Host should still receive the message let tm = TypeMap::latest(); - let received = host_rx.receive().await?; - assert_eq!( - received.get_type(), - CommunicationType::Ping - .try_to_id(&tm) - .expect("test type must be mapped") - ); - - client_tx.close().await; + let received = host_rx.receive().await.unwrap(); + assert_eq!(received.get_type(), CommunicationType::Ping.to_id(&tm)); // Host should get an error or closed signal on next receive let result = host_rx.receive().await; assert!(result.is_err()); - Ok(()) } #[tokio::test] -async fn test_host_shutdown_stops_accepting() -> Result<(), Box> { +async fn test_host_shutdown_stops_accepting() { let (cert_pem, key_pem) = generate_self_signed_cert(); - let mut h = start_test_host(cert_pem.clone(), key_pem).await?; + let mut h = start_test_host(cert_pem.clone(), key_pem).await; let url = format!("https://127.0.0.1:{}", h.local_addr().port()); // A connection succeeds while the host is accepting. - let (_c_tx, _c_rx) = connect(&url, Some(cert_pem.clone()), Policy::default()).await?; - let _accepted = h.next().await.ok_or("host did not accept connection")?; + let (_c_tx, _c_rx) = connect(&url, Some(cert_pem.clone()), Policy::default()) + .await + .unwrap(); + let _accepted = h.next().await.unwrap(); // After shutdown the accept task is aborted and its endpoint is dropped, so // new connections no longer succeed. Guard with a timeout so a hung connect @@ -242,17 +173,16 @@ async fn test_host_shutdown_stops_accepting() -> Result<(), Box Result<(), Box> { - let (_h, client_tx, client_rx, host_tx, host_rx) = connected_pair().await?; +async fn test_drop_receiver_keeps_sender_alive() { + let (_h, client_tx, client_rx, host_tx, host_rx) = connected_pair().await; // Client sends a message the host receives. let msg = CommunicationValue::new(CommunicationType::Ping); - client_tx.send(&msg).await?; - let _ = host_rx.receive().await?; + client_tx.send(&msg).await.unwrap(); + let _ = host_rx.receive().await.unwrap(); // Dropping the host Receiver aborts only its accept task; the Sender shares // the same connection and must keep working. @@ -260,148 +190,12 @@ async fn test_drop_receiver_keeps_sender_alive() -> Result<(), Box Result<(), Box> { - let (_h, client_tx, client_rx, host_tx, host_rx) = connected_pair().await?; - - let tm = TypeMap::latest(); - let msg1 = numbered_message(CommunicationType::Ping, 11, &tm); - client_tx.send(&msg1).await?; - let received1 = host_rx.receive().await?; - assert_numbered_message(&received1, CommunicationType::Ping, 11, &tm); - - client_tx.finish_stream().await?; - - let msg2 = numbered_message(CommunicationType::BadRequest, 22, &tm); - client_tx.send(&msg2).await?; - let received2 = host_rx.receive().await?; - assert_numbered_message(&received2, CommunicationType::BadRequest, 22, &tm); - - client_tx.close().await; - host_tx.close().await; - drop(client_rx); - Ok(()) -} - -#[tokio::test] -async fn test_receiver_backpressure_with_small_queue() -> Result<(), Box> { - let (cert_pem, key_pem) = generate_self_signed_cert(); - let mut h = start_test_host(cert_pem.clone(), key_pem).await?; - let url = format!("https://127.0.0.1:{}", h.local_addr().port()); - let policy = Policy::default().with_receiver_queue_capacity(1); - let (client_tx, client_rx) = connect(&url, Some(cert_pem), policy).await?; - let (_host_tx, host_rx) = h.next().await.ok_or("host did not accept connection")?; - - let tm = TypeMap::latest(); - for i in 0..8u128 { - client_tx - .send(&numbered_message(CommunicationType::Ping, i, &tm)) - .await?; - } - - for i in 0..8u128 { - let received = - tokio::time::timeout(std::time::Duration::from_secs(5), host_rx.receive()).await??; - assert_numbered_message(&received, CommunicationType::Ping, i, &tm); - } - - client_tx.close().await; - drop(client_rx); - h.shutdown(); - Ok(()) -} - -#[tokio::test] -async fn test_max_frames_per_stream_enforced() -> Result<(), Box> { - let (cert_pem, key_pem) = generate_self_signed_cert(); - let policy = Policy::default().with_max_frames_per_stream(Some(1)); - let mut h = host( - IpAddr::V4(Ipv4Addr::LOCALHOST), - 0, - cert_pem.clone(), - key_pem, - policy, - ) - .await?; - let url = format!("https://127.0.0.1:{}", h.local_addr().port()); - let (client_tx, _client_rx) = connect(&url, Some(cert_pem), Policy::default()).await?; - let (_host_tx, host_rx) = h.next().await.ok_or("host did not accept connection")?; - - let tm = TypeMap::latest(); - client_tx - .send(&numbered_message(CommunicationType::Ping, 1, &tm)) - .await - .expect("first frame should be sent"); - let first = host_rx - .receive() - .await - .expect("first frame should be received"); - assert_numbered_message(&first, CommunicationType::Ping, 1, &tm); - - let _ = client_tx - .send(&numbered_message(CommunicationType::Ping, 2, &tm)) - .await; - let second = host_rx.receive().await; - assert!(second.is_err(), "stream should be closed after frame limit"); - - client_tx.close().await; - h.shutdown(); - Ok(()) -} - -#[tokio::test] -async fn test_semaphore_saturation_with_concurrent_streams() --> Result<(), Box> { - let (cert_pem, key_pem) = generate_self_signed_cert(); - let policy = Policy::default() - .with_send_mode(mtp_transport::SendMode::SingleStreamPerMessage) - .with_receiver_queue_capacity(1) - .with_max_concurrent_stream_tasks(1); - let mut h = host( - IpAddr::V4(Ipv4Addr::LOCALHOST), - 0, - cert_pem.clone(), - key_pem, - policy, - ) - .await?; - let url = format!("https://127.0.0.1:{}", h.local_addr().port()); - let (client_tx, _client_rx) = connect(&url, Some(cert_pem), Policy::default()).await?; - let (_host_tx, host_rx) = h.next().await.ok_or("host did not accept connection")?; - - let tm = TypeMap::latest(); - let mut joins = Vec::new(); - for i in 0..6u128 { - let tx = client_tx.clone(); - let msg = numbered_message(CommunicationType::Ping, i, &tm); - joins.push(tokio::spawn(async move { tx.send(&msg).await })); - } - - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - - for join in joins { - join.await??; - } - - for i in 0..6u128 { - let received = - tokio::time::timeout(std::time::Duration::from_secs(5), host_rx.receive()).await??; - assert_numbered_message(&received, CommunicationType::Ping, i, &tm); - } - - client_tx.close().await; - h.shutdown(); - Ok(()) + client_tx.close(); + host_tx.close(); } diff --git a/tsconfig.json b/tsconfig.json index 3b8d1c1..5a697e9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,23 +7,22 @@ "emitDeclarationOnly": false, "outDir": "dist", "rootDir": "src", + "baseUrl": ".", "types": ["node"], "ignoreDeprecations": "6.0", "paths": { - "mtp/raw": ["./src/raw/index.ts"], - "mtp/type-map": ["./src/type-map/index.ts"], + "mtp/raw": ["src/raw/index.ts"], + "mtp/type-map": ["src/type-map/index.ts"] }, - "strict": true, - "noImplicitAny": false, + "strict": false, "skipLibCheck": true, "isolatedModules": true, - "verbatimModuleSyntax": true, - "resolveJsonModule": true, + "verbatimModuleSyntax": true }, "include": [ "src/raw/**/*.ts", "src/sdk/**/*.ts", "src/type-map/**/*.ts", - "src/vite/**/*.ts", - ], + "src/vite/**/*.ts" + ] } diff --git a/tsconfig.type-tests.json b/tsconfig.type-tests.json deleted file mode 100644 index 5325ad8..0000000 --- a/tsconfig.type-tests.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": true, - "rootDir": "." - }, - "include": ["src/**/*.ts", "test/**/*.type-test.ts"] -} diff --git a/type-map/Cargo.lock b/type-map/Cargo.lock index d87a0f3..c301538 100644 --- a/type-map/Cargo.lock +++ b/type-map/Cargo.lock @@ -3,127 +3,5 @@ version = 4 [[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "mtp-type-map" -version = "0.3.0" -dependencies = [ - "serde", - "serde_yaml", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +name = "type-map" +version = "0.1.0" diff --git a/type-map/Cargo.toml b/type-map/Cargo.toml index 1cacaa0..3dac68f 100644 --- a/type-map/Cargo.toml +++ b/type-map/Cargo.toml @@ -1,22 +1,17 @@ [package] name = "mtp-type-map" -version = "0.3.0" +version = "0.1.0" edition = "2024" build = "build.rs" [features] +default = [] # Enables multi-version type-map constructors, builtin_type_maps(), and # the Registry struct for version negotiation. Used by host, not client. registry = [] -pipes = [] - [dependencies] [build-dependencies] serde = { version = "1", features = ["derive"] } serde_yaml = "0.9" - -[package.metadata.cargo-machete] -# cargo-machete does not inspect build.rs, where both build dependencies are used. -ignored = ["serde", "serde_yaml"] diff --git a/type-map/build.rs b/type-map/build.rs index 21f81ff..9a0fafb 100755 --- a/type-map/build.rs +++ b/type-map/build.rs @@ -1,10 +1,6 @@ use serde::Deserialize; use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; -use std::path::{Path, PathBuf}; -use std::sync::OnceLock; - -const DEFAULT_TYPE_MAPS_PATH: &str = "./type-maps.yaml"; #[derive(Deserialize)] struct Config { @@ -22,296 +18,185 @@ struct TypeMapConfig { data_types: BTreeMap, } -#[derive(Deserialize)] -struct ReservedManifest { - #[serde(rename = "firstUserTypeId")] - first_user_type_id: u16, - communication: Vec, - data: Vec, -} - -#[derive(Deserialize)] -struct ManifestEntry { - name: String, +struct ReservedEntry { + name: &'static str, id: u16, } -fn reserved_manifest() -> &'static ReservedManifest { - static MANIFEST: OnceLock = OnceLock::new(); - MANIFEST.get_or_init(|| { - serde_yaml::from_str(include_str!("reserved.json")) - .expect("type-map/reserved.json must be valid JSON/YAML") - }) -} +const RESERVED_COMM_TYPES: &[ReservedEntry] = &[ + ReservedEntry { + name: "Identification", + id: 0, + }, + ReservedEntry { + name: "IdentificationResponse", + id: 1, + }, + ReservedEntry { + name: "Register", + id: 2, + }, + ReservedEntry { + name: "RegisterResponse", + id: 3, + }, + ReservedEntry { + name: "Challenge", + id: 4, + }, + ReservedEntry { + name: "ChallengeResponse", + id: 5, + }, + ReservedEntry { + name: "Ping", + id: 6, + }, + ReservedEntry { + name: "Pong", + id: 7, + }, + ReservedEntry { + name: "Disconnect", + id: 8, + }, + ReservedEntry { + name: "Redirect", + id: 9, + }, + ReservedEntry { + name: "Shutdown", + id: 10, + }, + ReservedEntry { + name: "Error", + id: 11, + }, + ReservedEntry { + name: "ErrorParsing", + id: 12, + }, + ReservedEntry { + name: "ErrorBadVersion", + id: 13, + }, + ReservedEntry { + name: "BadRequest", + id: 14, + }, + ReservedEntry { + name: "Unauthorized", + id: 15, + }, + ReservedEntry { + name: "Forbidden", + id: 16, + }, + ReservedEntry { + name: "NotFound", + id: 17, + }, + ReservedEntry { + name: "TooManyRequests", + id: 18, + }, + ReservedEntry { + name: "InternalServerError", + id: 19, + }, + ReservedEntry { + name: "BadGateway", + id: 20, + }, + ReservedEntry { + name: "ServiceUnavailable", + id: 21, + }, + ReservedEntry { + name: "GatewayTimeout", + id: 22, + }, +]; -fn first_user_type_id() -> u16 { - reserved_manifest().first_user_type_id -} - -fn all_reserved_comm_types() -> &'static [ManifestEntry] { - &reserved_manifest().communication -} - -fn generated_reserved_comm_types() -> Vec<&'static ManifestEntry> { - all_reserved_comm_types() - .iter() - .filter(|entry| cfg!(feature = "pipes") || !entry.name.starts_with("Pipe")) - .collect() -} - -fn all_reserved_data_types() -> &'static [ManifestEntry] { - &reserved_manifest().data -} +const RESERVED_DATA_TYPES: &[ReservedEntry] = &[ + ReservedEntry { + name: "Version", + id: 0, + }, + ReservedEntry { name: "Id", id: 1 }, + ReservedEntry { + name: "ClientNonce", + id: 2, + }, + ReservedEntry { + name: "ServerNonce", + id: 3, + }, + ReservedEntry { + name: "PublicKeys", + id: 4, + }, + ReservedEntry { + name: "Signature", + id: 5, + }, + ReservedEntry { + name: "PqSignature", + id: 6, + }, + ReservedEntry { + name: "Description", + id: 7, + }, + ReservedEntry { + name: "Connected", + id: 8, + }, + ReservedEntry { + name: "Timestamp", + id: 9, + }, + ReservedEntry { + name: "Error", + id: 10, + }, + ReservedEntry { + name: "ErrorParsing", + id: 11, + }, + ReservedEntry { + name: "ErrorMessage", + id: 12, + }, +]; fn main() { - let out = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()); let multi_version = std::env::var("CARGO_FEATURE_REGISTRY").is_ok(); println!("cargo:rerun-if-env-changed=MTP_TYPE_MAPS"); - println!("cargo:rerun-if-changed=reserved.json"); - validate_reserved_manifest(); - let loaded = match std::env::var_os("MTP_TYPE_MAPS") { - Some(config_path) => load_config(&PathBuf::from(config_path)), - None => { - let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); - let default_path = manifest_dir.join(DEFAULT_TYPE_MAPS_PATH); - if default_path.exists() { - load_config(&default_path) - } else { - println!( - "cargo:warning=MTP_TYPE_MAPS not set; generating types with reserved entries only" - ); - LoadedConfig { - config: Config { - protocol_version: String::new(), - type_maps: BTreeMap::new(), - }, - path: None, - content: String::new(), - } + let config = match std::env::var("MTP_TYPE_MAPS") { + Ok(config_path) => { + println!("cargo:rerun-if-changed={}", config_path); + + let content = + std::fs::read_to_string(&config_path).expect("Failed to read type-maps.yaml"); + serde_yaml::from_str(&content).expect("Failed to parse type-maps.yaml") + } + Err(_) => { + eprint!("warning: MTP_TYPE_MAPS not set; generating types with reserved entries only"); + Config { + protocol_version: String::new(), + type_maps: BTreeMap::new(), } } }; - validate_config(&loaded); - let code = generate(&loaded.config, multi_version); + let code = generate(&config, multi_version); std::fs::write(out.join("types.rs"), code).unwrap(); } -fn validate_reserved_manifest() { - assert!(first_user_type_id() > 0); - for (category, entries) in [ - ("communication", all_reserved_comm_types()), - ("data", all_reserved_data_types()), - ] { - let mut names = BTreeSet::new(); - let mut ids = BTreeSet::new(); - for entry in entries { - assert!( - entry.id < first_user_type_id(), - "reserved {category} type {} has a user-range ID {}", - entry.name, - entry.id - ); - assert!( - names.insert(entry.name.as_str()), - "duplicate reserved {category} type name {}", - entry.name - ); - assert!( - ids.insert(entry.id), - "duplicate reserved {category} type ID {}", - entry.id - ); - } - } -} - -struct LoadedConfig { - config: Config, - path: Option, - content: String, -} - -fn load_config(path: &Path) -> LoadedConfig { - let path = path.canonicalize().unwrap_or_else(|error| { - panic!( - "failed to resolve type-map file {}: {error}", - path.display() - ) - }); - println!("cargo:rerun-if-changed={}", path.display()); - - let content = std::fs::read_to_string(&path) - .unwrap_or_else(|error| panic!("failed to read type-map file {}: {error}", path.display())); - let config = serde_yaml::from_str(&content).unwrap_or_else(|error| { - if let Some(location) = error.location() { - panic!( - "{}:{}:{}: failed to parse type-map YAML: {error}", - path.display(), - location.line(), - location.column() - ); - } - panic!("{}: failed to parse type-map YAML: {error}", path.display()); - }); - - LoadedConfig { - config, - path: Some(path), - content, - } -} - -fn validate_config(loaded: &LoadedConfig) { - if loaded.path.is_none() { - return; - } - - validate_version( - loaded, - "protocol_version", - &loaded.config.protocol_version, - &[], - "protocol_version", - ); - for (version, type_map) in &loaded.config.type_maps { - validate_version( - loaded, - "type_maps version", - version, - &["type_maps"], - version, - ); - validate_ids( - loaded, - version, - "CommunicationTypes", - &type_map.communication_types, - all_reserved_comm_types(), - ); - validate_ids( - loaded, - version, - "DataTypes", - &type_map.data_types, - all_reserved_data_types(), - ); - } -} - -fn validate_version( - loaded: &LoadedConfig, - kind: &str, - version: &str, - parents: &[&str], - yaml_key: &str, -) { - if parse_version(version).is_none() { - validation_error( - loaded, - parents, - yaml_key, - format!("invalid {kind} {version:?}; expected '.'"), - ); - } -} - -fn validate_ids( - loaded: &LoadedConfig, - version: &str, - category: &str, - entries: &BTreeMap, - reserved: &[ManifestEntry], -) { - let mut names_by_id = BTreeMap::new(); - for (name, id) in entries { - if reserved.iter().any(|entry| entry.name == *name) { - validation_error( - loaded, - &["type_maps", version, category], - name, - format!( - "{category}.{name} in type-map version {version} uses reserved name {name:?}" - ), - ); - } - if *id < first_user_type_id() { - validation_error( - loaded, - &["type_maps", version, category], - name, - format!( - "{category}.{name} in type-map version {version} uses reserved id {id}; user ids must be {} or greater", - first_user_type_id() - ), - ); - } - - if let Some(previous_name) = names_by_id.insert(*id, name) { - validation_error( - loaded, - &["type_maps", version, category], - name, - format!( - "duplicate id {id} in {category} for type-map version {version}: {previous_name} and {name}" - ), - ); - } - } -} - -fn validation_error(loaded: &LoadedConfig, parents: &[&str], yaml_key: &str, message: String) -> ! { - let path = loaded - .path - .as_deref() - .expect("validated configs have a path"); - let line = yaml_key_line(&loaded.content, parents, yaml_key).unwrap_or(1); - panic!("{}:{line}: {message}", path.display()); -} - -fn yaml_key_line(content: &str, parents: &[&str], key: &str) -> Option { - let mut stack: Vec<(usize, &str)> = Vec::new(); - for (index, line) in content.lines().enumerate() { - let Some((indent, candidate)) = yaml_line_key(line) else { - continue; - }; - while stack.last().is_some_and(|(level, _)| *level >= indent) { - stack.pop(); - } - if candidate == key - && stack.len() == parents.len() - && stack - .iter() - .map(|(_, parent)| *parent) - .eq(parents.iter().copied()) - { - return Some(index + 1); - } - stack.push((indent, candidate)); - } - None -} - -fn yaml_line_key(line: &str) -> Option<(usize, &str)> { - let indent = line.len() - line.trim_start_matches(' ').len(); - let line = line.get(indent..)?.split('#').next()?.trim_end(); - if line.is_empty() { - return None; - } - let (key, _) = line.split_once(':')?; - Some((indent, key.trim().trim_matches(['\'', '"']))) -} - -fn parse_version(version: &str) -> Option<(u16, u16)> { - let (major, minor) = version.split_once('.')?; - if major.is_empty() || minor.is_empty() || minor.contains('.') { - return None; - } - Some((major.parse().ok()?, minor.parse().ok()?)) -} - fn sorted_versions(config: &Config) -> Vec<(String, u16, u16)> { let mut versions: Vec<(String, u16, u16)> = config .type_maps @@ -383,6 +268,7 @@ fn generate(config: &Config, multi_version: bool) -> String { } generate_enum_conversion_methods(&mut out); + generate_reverse_lookups(&mut out, config, &sorted, multi_version); generate_id_display_impls(&mut out); out @@ -412,28 +298,6 @@ fn generate_latest_method(out: &mut String, config: &Config) { writeln!(out, " }}").unwrap(); writeln!(out, "}}").unwrap(); writeln!(out).unwrap(); - - writeln!(out, "impl TypeMap {{").unwrap(); - writeln!( - out, - " pub fn communication_type_name(&self, id: u16) -> Option<&'static str> {{" - ) - .unwrap(); - writeln!( - out, - " self.comm_enum_id(id).map(CommunicationType::name)" - ) - .unwrap(); - writeln!(out, " }}").unwrap(); - writeln!( - out, - " pub fn data_type_name(&self, id: u16) -> Option<&'static str> {{" - ) - .unwrap(); - writeln!(out, " self.data_enum_id(id).map(DataType::name)").unwrap(); - writeln!(out, " }}").unwrap(); - writeln!(out, "}}").unwrap(); - writeln!(out).unwrap(); } fn parse_protocol_version(version: &str) -> (u16, u16) { @@ -453,10 +317,9 @@ fn generate_comm_type_enum(out: &mut String, user_names: &BTreeSet<&str>) { "#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]" ) .unwrap(); - writeln!(out, "#[allow(clippy::enum_variant_names)]").unwrap(); writeln!(out, "pub enum CommunicationType {{").unwrap(); - for entry in generated_reserved_comm_types() { + for entry in RESERVED_COMM_TYPES { writeln!(out, " {},", entry.name).unwrap(); } for name in user_names { @@ -470,7 +333,7 @@ fn generate_comm_type_enum(out: &mut String, user_names: &BTreeSet<&str>) { writeln!(out, " pub fn name(self) -> &'static str {{").unwrap(); writeln!(out, " match self {{").unwrap(); - for entry in generated_reserved_comm_types() { + for entry in RESERVED_COMM_TYPES { writeln!( out, " CommunicationType::{} => \"{}\",", @@ -496,7 +359,7 @@ fn generate_comm_type_enum(out: &mut String, user_names: &BTreeSet<&str>) { writeln!(out, " pub fn from_name(s: &str) -> Option {{").unwrap(); writeln!(out, " match s {{").unwrap(); - for entry in generated_reserved_comm_types() { + for entry in RESERVED_COMM_TYPES { writeln!( out, " \"{}\" => Some(CommunicationType::{}),", @@ -538,10 +401,9 @@ fn generate_data_type_enum(out: &mut String, user_names: &BTreeSet<&str>) { "#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]" ) .unwrap(); - writeln!(out, "#[allow(clippy::enum_variant_names)]").unwrap(); writeln!(out, "pub enum DataType {{").unwrap(); - for entry in all_reserved_data_types() { + for entry in RESERVED_DATA_TYPES { writeln!(out, " {},", entry.name).unwrap(); } for name in user_names { @@ -555,7 +417,7 @@ fn generate_data_type_enum(out: &mut String, user_names: &BTreeSet<&str>) { writeln!(out, " pub fn name(self) -> &'static str {{").unwrap(); writeln!(out, " match self {{").unwrap(); - for entry in all_reserved_data_types() { + for entry in RESERVED_DATA_TYPES { writeln!( out, " DataType::{} => \"{}\",", @@ -576,7 +438,7 @@ fn generate_data_type_enum(out: &mut String, user_names: &BTreeSet<&str>) { writeln!(out, " pub fn from_name(s: &str) -> Option {{").unwrap(); writeln!(out, " match s {{").unwrap(); - for entry in all_reserved_data_types() { + for entry in RESERVED_DATA_TYPES { writeln!( out, " \"{}\" => Some(DataType::{}),", @@ -649,7 +511,7 @@ fn generate_lookup_methods( major, minor ) .unwrap(); - for entry in generated_reserved_comm_types() { + for entry in RESERVED_COMM_TYPES { writeln!( out, " CommunicationType::{} => Some({}),", @@ -688,7 +550,7 @@ fn generate_lookup_methods( major, minor ) .unwrap(); - for entry in all_reserved_data_types() { + for entry in RESERVED_DATA_TYPES { writeln!( out, " DataType::{} => Some({}),", @@ -722,7 +584,7 @@ fn generate_lookup_methods( major, minor ) .unwrap(); - for entry in generated_reserved_comm_types() { + for entry in RESERVED_COMM_TYPES { writeln!( out, " {} => Some(CommunicationType::{}),", @@ -760,7 +622,7 @@ fn generate_lookup_methods( major, minor ) .unwrap(); - for entry in all_reserved_data_types() { + for entry in RESERVED_DATA_TYPES { writeln!( out, " {} => Some(DataType::{}),", @@ -793,7 +655,7 @@ fn generate_single_version_lookup(out: &mut String, config: &Config) { .unwrap(); writeln!(out, " match self.version {{").unwrap(); writeln!(out, " PROTOCOL_VERSION => match ct {{").unwrap(); - for entry in generated_reserved_comm_types() { + for entry in RESERVED_COMM_TYPES { writeln!( out, " CommunicationType::{} => Some({}),", @@ -826,7 +688,7 @@ fn generate_single_version_lookup(out: &mut String, config: &Config) { .unwrap(); writeln!(out, " match self.version {{").unwrap(); writeln!(out, " PROTOCOL_VERSION => match dt {{").unwrap(); - for entry in all_reserved_data_types() { + for entry in RESERVED_DATA_TYPES { writeln!( out, " DataType::{} => Some({}),", @@ -854,7 +716,7 @@ fn generate_single_version_lookup(out: &mut String, config: &Config) { .unwrap(); writeln!(out, " match self.version {{").unwrap(); writeln!(out, " PROTOCOL_VERSION => match id {{").unwrap(); - for entry in generated_reserved_comm_types() { + for entry in RESERVED_COMM_TYPES { writeln!( out, " {} => Some(CommunicationType::{}),", @@ -886,7 +748,7 @@ fn generate_single_version_lookup(out: &mut String, config: &Config) { .unwrap(); writeln!(out, " match self.version {{").unwrap(); writeln!(out, " PROTOCOL_VERSION => match id {{").unwrap(); - for entry in all_reserved_data_types() { + for entry in RESERVED_DATA_TYPES { writeln!( out, " {} => Some(DataType::{}),", @@ -920,24 +782,79 @@ fn generate_builtin_type_maps(out: &mut String, sorted_versions: &[(String, u16, writeln!(out).unwrap(); } +fn generate_reverse_lookups( + out: &mut String, + config: &Config, + sorted_versions: &[(String, u16, u16)], + multi_version: bool, +) { + let mut id_to_comm: BTreeMap = BTreeMap::new(); + for entry in RESERVED_COMM_TYPES { + id_to_comm.insert(entry.id, entry.name.to_string()); + } + if multi_version { + for (version_key, _major, _minor) in sorted_versions { + if let Some(tm_cfg) = config.type_maps.get(version_key) { + for (name, id) in &tm_cfg.communication_types { + id_to_comm.insert(*id, name.clone()); + } + } + } + } else if let Some(tm_cfg) = config.type_maps.get(&config.protocol_version) { + for (name, id) in &tm_cfg.communication_types { + id_to_comm.insert(*id, name.clone()); + } + } + + writeln!( + out, + "pub fn communication_type_name(id: u16) -> Option<&'static str> {{" + ) + .unwrap(); + writeln!(out, " match id {{").unwrap(); + for (id, name) in &id_to_comm { + writeln!(out, " {} => Some(\"{}\"),", id, name).unwrap(); + } + writeln!(out, " _ => None,").unwrap(); + writeln!(out, " }}").unwrap(); + writeln!(out, "}}").unwrap(); + writeln!(out).unwrap(); + + let mut id_to_data: BTreeMap = BTreeMap::new(); + for entry in RESERVED_DATA_TYPES { + id_to_data.insert(entry.id, entry.name.to_string()); + } + if multi_version { + for (version_key, _major, _minor) in sorted_versions { + if let Some(tm_cfg) = config.type_maps.get(version_key) { + for (name, id) in &tm_cfg.data_types { + id_to_data.insert(*id, name.clone()); + } + } + } + } else if let Some(tm_cfg) = config.type_maps.get(&config.protocol_version) { + for (name, id) in &tm_cfg.data_types { + id_to_data.insert(*id, name.clone()); + } + } + + writeln!( + out, + "pub fn data_type_name(id: u16) -> Option<&'static str> {{" + ) + .unwrap(); + writeln!(out, " match id {{").unwrap(); + for (id, name) in &id_to_data { + writeln!(out, " {} => Some(\"{}\"),", id, name).unwrap(); + } + writeln!(out, " _ => None,").unwrap(); + writeln!(out, " }}").unwrap(); + writeln!(out, "}}").unwrap(); + writeln!(out).unwrap(); +} + fn generate_enum_conversion_methods(out: &mut String) { writeln!(out, "impl CommunicationType {{").unwrap(); - writeln!( - out, - " pub fn try_to_id(self, tm: &TypeMap) -> Option {{" - ) - .unwrap(); - writeln!( - out, - " tm.comm_id_enum(self).map(CommunicationTypeId)" - ) - .unwrap(); - writeln!(out, " }}").unwrap(); - writeln!( - out, - " #[deprecated(since = \"0.2.0\", note = \"use try_to_id to handle types absent from a TypeMap version\")]" - ) - .unwrap(); writeln!( out, " pub fn to_id(self, tm: &TypeMap) -> CommunicationTypeId {{" @@ -953,18 +870,6 @@ fn generate_enum_conversion_methods(out: &mut String) { writeln!(out).unwrap(); writeln!(out, "impl DataType {{").unwrap(); - writeln!( - out, - " pub fn try_to_id(self, tm: &TypeMap) -> Option {{" - ) - .unwrap(); - writeln!(out, " tm.data_id_enum(self).map(DataTypeId)").unwrap(); - writeln!(out, " }}").unwrap(); - writeln!( - out, - " #[deprecated(since = \"0.2.0\", note = \"use try_to_id to handle types absent from a TypeMap version\")]" - ) - .unwrap(); writeln!(out, " pub fn to_id(self, tm: &TypeMap) -> DataTypeId {{").unwrap(); writeln!( out, @@ -993,7 +898,7 @@ fn generate_all_types_methods( let tm_cfg = &config.type_maps[version_key]; write!(out, " Version({}, {}) => &[", major, minor).unwrap(); let mut first = true; - for entry in generated_reserved_comm_types() { + for entry in RESERVED_COMM_TYPES { if !first { write!(out, ", ").unwrap(); } @@ -1024,7 +929,7 @@ fn generate_all_types_methods( let tm_cfg = &config.type_maps[version_key]; write!(out, " Version({}, {}) => &[", major, minor).unwrap(); let mut first = true; - for entry in all_reserved_data_types() { + for entry in RESERVED_DATA_TYPES { if !first { write!(out, ", ").unwrap(); } @@ -1060,7 +965,7 @@ fn generate_all_types_methods_single(out: &mut String, config: &Config) { writeln!(out, " match self.version {{").unwrap(); write!(out, " PROTOCOL_VERSION => &[").unwrap(); let mut first = true; - for entry in generated_reserved_comm_types() { + for entry in RESERVED_COMM_TYPES { if !first { write!(out, ", ").unwrap(); } @@ -1090,7 +995,7 @@ fn generate_all_types_methods_single(out: &mut String, config: &Config) { writeln!(out, " match self.version {{").unwrap(); write!(out, " PROTOCOL_VERSION => &[").unwrap(); let mut first = true; - for entry in all_reserved_data_types() { + for entry in RESERVED_DATA_TYPES { if !first { write!(out, ", ").unwrap(); } @@ -1121,7 +1026,14 @@ fn generate_id_display_impls(out: &mut String) { " fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {{" ) .unwrap(); - writeln!(out, " write!(f, \"CommTypeId({{}})\", self.0)").unwrap(); + writeln!(out, " match communication_type_name(self.0) {{").unwrap(); + writeln!(out, " Some(name) => f.write_str(name),").unwrap(); + writeln!( + out, + " None => write!(f, \"CommTypeId({{}})\", self.0)," + ) + .unwrap(); + writeln!(out, " }}").unwrap(); writeln!(out, " }}").unwrap(); writeln!(out, "}}").unwrap(); writeln!(out).unwrap(); @@ -1132,7 +1044,14 @@ fn generate_id_display_impls(out: &mut String) { " fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {{" ) .unwrap(); - writeln!(out, " write!(f, \"DataTypeId({{}})\", self.0)").unwrap(); + writeln!(out, " match data_type_name(self.0) {{").unwrap(); + writeln!(out, " Some(name) => f.write_str(name),").unwrap(); + writeln!( + out, + " None => write!(f, \"DataTypeId({{}})\", self.0)," + ) + .unwrap(); + writeln!(out, " }}").unwrap(); writeln!(out, " }}").unwrap(); writeln!(out, "}}").unwrap(); writeln!(out).unwrap(); diff --git a/type-map/reserved.json b/type-map/reserved.json deleted file mode 100644 index cb49f66..0000000 --- a/type-map/reserved.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "firstUserTypeId": 32, - "communication": [ - { "name": "Identification", "id": 0 }, - { "name": "IdentificationResponse", "id": 1 }, - { "name": "Register", "id": 2 }, - { "name": "RegisterResponse", "id": 3 }, - { "name": "Challenge", "id": 4 }, - { "name": "ChallengeResponse", "id": 5 }, - { "name": "Ping", "id": 6 }, - { "name": "Pong", "id": 7 }, - { "name": "Disconnect", "id": 8 }, - { "name": "Redirect", "id": 9 }, - { "name": "Shutdown", "id": 10 }, - { "name": "Error", "id": 11 }, - { "name": "ErrorParsing", "id": 12 }, - { "name": "ErrorBadVersion", "id": 13 }, - { "name": "BadRequest", "id": 14 }, - { "name": "Unauthorized", "id": 15 }, - { "name": "Forbidden", "id": 16 }, - { "name": "NotFound", "id": 17 }, - { "name": "TooManyRequests", "id": 18 }, - { "name": "InternalServerError", "id": 19 }, - { "name": "BadGateway", "id": 20 }, - { "name": "ServiceUnavailable", "id": 21 }, - { "name": "GatewayTimeout", "id": 22 }, - { "name": "PipeRequest", "id": 23 }, - { "name": "PipeResponse", "id": 24 }, - { "name": "PipeAbort", "id": 25 }, - { "name": "Relay", "id": 26 } - ], - "data": [ - { "name": "Version", "id": 0 }, - { "name": "Id", "id": 1 }, - { "name": "ClientNonce", "id": 2 }, - { "name": "ServerNonce", "id": 3 }, - { "name": "PublicKeys", "id": 4 }, - { "name": "Signature", "id": 5 }, - { "name": "PqSignature", "id": 6 }, - { "name": "Description", "id": 7 }, - { "name": "Connected", "id": 8 }, - { "name": "Timestamp", "id": 9 }, - { "name": "Error", "id": 10 }, - { "name": "ErrorParsing", "id": 11 }, - { "name": "ErrorMessage", "id": 12 }, - { "name": "Accepted", "id": 13 }, - { "name": "RequirePq", "id": 14 }, - { "name": "MessageId", "id": 15 }, - { "name": "FinalRecipientId", "id": 18 }, - { "name": "CreatedAt", "id": 21 }, - { "name": "MessageType", "id": 22 }, - { "name": "Content", "id": 23 }, - { "name": "Metadata", "id": 24 }, - { "name": "RelayVersion", "id": 25 }, - { "name": "ProtectedVersion", "id": 26 } - ] -} diff --git a/type-map/src/lib.rs b/type-map/src/lib.rs index 3691c3b..91bf33e 100644 --- a/type-map/src/lib.rs +++ b/type-map/src/lib.rs @@ -92,51 +92,6 @@ impl TypeMap { include!(concat!(env!("OUT_DIR"), "/types.rs")); -#[cfg(test)] -mod tests { - use super::{DataType, TypeMap}; - - #[test] - fn current_relay_reserved_fields_use_generic_layout() { - let type_map = TypeMap::latest(); - - assert_eq!( - DataType::MessageId.try_to_id(&type_map).map(|id| id.0), - Some(15) - ); - assert_eq!( - DataType::FinalRecipientId - .try_to_id(&type_map) - .map(|id| id.0), - Some(18) - ); - assert_eq!( - DataType::CreatedAt.try_to_id(&type_map).map(|id| id.0), - Some(21) - ); - assert_eq!( - DataType::MessageType.try_to_id(&type_map).map(|id| id.0), - Some(22) - ); - assert_eq!( - DataType::Content.try_to_id(&type_map).map(|id| id.0), - Some(23) - ); - assert_eq!( - DataType::Metadata.try_to_id(&type_map).map(|id| id.0), - Some(24) - ); - assert_eq!( - DataType::RelayVersion.try_to_id(&type_map).map(|id| id.0), - Some(25) - ); - - for tombstoned_id in [16, 17, 19, 20] { - assert_eq!(type_map.data_type_name(tombstoned_id), None); - } - } -} - /* ============================= REGISTRY ============================= */ #[cfg(feature = "registry")] pub use registry::*; @@ -189,10 +144,6 @@ mod registry { self.versions.last_key_value().map(|(_, v)| v) } - pub fn versions(&self) -> impl Iterator { - self.versions.keys() - } - pub fn builtin() -> Self { let mut r = Self::new(); for tm in builtin_type_maps() { @@ -214,17 +165,9 @@ mod registry { use super::*; #[test] - fn builtin_registers_only_the_current_codec_version() { + fn builtin_contains_versions() { let r = Registry::builtin(); - assert!(r.supports(&Version(3, 0))); - for removed_version in [Version(0, 0), Version(1, 0), Version(2, 0)] { - assert!(!r.supports(&removed_version)); - assert_eq!(r.negotiate(&[removed_version]), None); - } - assert_eq!( - r.negotiate(&[Version(3, 0), Version(2, 0)]), - Some(Version(3, 0)) - ); + assert!(r.supports(&Version(0, 0))); } #[test] diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index 36aadd2..bdc68aa 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "mtp-wasm" -version = "0.3.0" +version = "0.1.0" edition = "2024" [package.metadata.cargo-machete] -ignored = ["getrandom"] +ignored = ["getrandom-v02"] [lib] crate-type = ["cdylib"] @@ -14,27 +14,18 @@ wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" futures-channel = "0.3" -futures-util = "0.3" console_error_panic_hook = "0.1" -tracing = "0.1" -wasm-tracing = "2.1" hex = "0.4" -getrandom = { version = "0.2.17", features = ["js"] } -getrandom-v04 = { package = "getrandom", version = "0.4.3", features = ["wasm_js"] } - -mtp-common = { version = "0.3.0", path = "../common" } -mtp-type-map = { version = "0.3.0", path = "../type-map" } -mtp-codec = { version = "0.3.0", path = "../codec", features = ["crypto", "pipes", "registry"] } -mtp-crypto = { version = "0.3.0", path = "../crypto", features = ["wasm", "password-kdf"] } -zeroize = "1.9" -wasm-bindgen-test = "0.3.76" +getrandom = { version = "0.4", features = ["wasm_js"] } +getrandom-v02 = { package = "getrandom", version = "0.2", features = ["js"] } +mtp-common = { path = "../common" } +mtp-type-map = { path = "../type-map" } +mtp-codec = { path = "../codec", features = ["crypto"] } +mtp-crypto = { path = "../crypto", features = ["wasm"] } [dev-dependencies] +wasm-bindgen-test = "0.3" hex = "0.4" - -[features] -default = [] -pipes = [] diff --git a/wasm/src/auth.rs b/wasm/src/auth.rs deleted file mode 100644 index e2e8127..0000000 --- a/wasm/src/auth.rs +++ /dev/null @@ -1,148 +0,0 @@ -use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue}; -use mtp_type_map::CommunicationTypeId; -use wasm_bindgen::prelude::*; - -use crate::error::js_error; - -pub(crate) fn raw_frame_preview(bytes: &[u8]) -> String { - let shown = bytes.len().min(256); - let mut preview = hex::encode(&bytes[..shown]); - if bytes.len() > shown { - preview.push_str("..."); - } - format!("{} bytes, hex={preview}", bytes.len()) -} - -pub(crate) fn unexpected_response_type_error( - context: &str, - expected_type: CommunicationTypeId, - response_type: CommunicationTypeId, - response: &[u8], - parsed: &CommunicationValue, -) -> JsValue { - js_error(format!( - "unexpected response type during {context}: expected {:?}, got {:?}; raw {}; parsed {}", - expected_type, - response_type, - raw_frame_preview(response), - parsed - )) -} - -pub(crate) fn verify_host_challenge( - challenge: &CommunicationValue, - _tm: &mtp_codec::TypeMap, - host_pk: &mtp_crypto::PublicKeyBundle, - id: u64, - server_challenge: u128, - require_pq: bool, -) -> Result<(), JsValue> { - let sig = match challenge.get_data(DataType::Signature) { - Some(DataValue::Bytes(b)) => b.clone(), - _ => return Err(js_error("missing host challenge signature")), - }; - 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(js_error( - "host requires post-quantum authentication but its PQ public key is absent", - )); - } - if require_pq && pq_sig.is_empty() { - return Err(js_error( - "host challenge is missing the required PQ signature", - )); - } - - let payload = mtp_crypto::auth::challenge_payload(id, server_challenge); - mtp_crypto::verify_ed25519(&host_pk.sig_cl_public_key, &payload, &sig) - .map_err(|_| js_error("host challenge signature invalid"))?; - if !pq_sig.is_empty() { - mtp_crypto::verify_ml_dsa(&host_pk.sig_pq_public_key, &payload, &pq_sig) - .map_err(|_| js_error("host challenge PQ signature invalid"))?; - } - Ok(()) -} - -pub(crate) fn verify_host_final( - resp: &CommunicationValue, - _tm: &mtp_codec::TypeMap, - host_pk: &mtp_crypto::PublicKeyBundle, - id: u64, - client_nonce: u128, - server_challenge: u128, - require_pq: bool, -) -> Result<(), JsValue> { - if resp.get_data(DataType::ClientNonce) != Some(&DataValue::UnsignedNumber(client_nonce)) { - return Err(js_error("nonce mismatch")); - } - let host_sig = match resp.get_data(DataType::Signature) { - Some(DataValue::Bytes(b)) => b.clone(), - _ => return Err(js_error("missing host signature")), - }; - let host_pq_sig = match resp.get_data(DataType::PqSignature) { - Some(DataValue::Bytes(b)) => b.clone(), - _ => vec![], - }; - if require_pq && host_pq_sig.is_empty() { - return Err(js_error( - "host confirmation is missing the required PQ signature", - )); - } - - let payload = mtp_crypto::auth::host_final_payload(id, client_nonce, server_challenge); - mtp_crypto::verify_ed25519(&host_pk.sig_cl_public_key, &payload, &host_sig) - .map_err(|_| js_error("host signature invalid"))?; - if !host_pq_sig.is_empty() { - mtp_crypto::verify_ml_dsa(&host_pk.sig_pq_public_key, &payload, &host_pq_sig) - .map_err(|_| js_error("host PQ signature invalid"))?; - } - Ok(()) -} - -pub(crate) fn random_nonce() -> Result { - let mut nonce_bytes = [0u8; 16]; - getrandom_v04::fill(&mut nonce_bytes).map_err(|_| js_error("rng failed"))?; - Ok(u128::from_be_bytes(nonce_bytes)) -} - -pub(crate) fn signed_challenge_response_bytes( - keyring: &mtp_crypto::Keyring, - proof_payload: &[u8], - client_nonce: u128, - type_map: &mtp_codec::TypeMap, -) -> Result, JsValue> { - use mtp_crypto::SignatureScheme; - - let signer = mtp_crypto::Ed25519Signer::new(&keyring.sig_cl_secret_key) - .map_err(|e| js_error(format!("signer creation failed: {}", e)))?; - let signature = signer - .sign(proof_payload) - .map_err(|e| js_error(format!("signature failed: {}", e)))?; - - let mut proof = - CommunicationValue::new_with_type_map(CommunicationType::ChallengeResponse, type_map) - .add_typed_default( - DataType::ClientNonce, - DataValue::UnsignedNumber(client_nonce), - ) - .add_typed_default(DataType::Signature, DataValue::Bytes(signature)); - - if !keyring.sig_pq_secret_key.as_bytes().is_empty() { - let pq_signer = - mtp_crypto::MlDsaSigner::new(&keyring.sig_pq_secret_key, &keyring.sig_pq_public_key) - .map_err(|e| js_error(format!("PQ signer creation failed: {}", e)))?; - let pq_signature = pq_signer - .sign(proof_payload) - .map_err(|e| js_error(format!("PQ signature failed: {}", e)))?; - proof = proof.add_typed_default(DataType::PqSignature, DataValue::Bytes(pq_signature)); - } - - proof - .to_bytes() - .map_err(|e| js_error(format!("encode failed: {}", e))) -} diff --git a/wasm/src/client.rs b/wasm/src/client.rs new file mode 100644 index 0000000..8fdde48 --- /dev/null +++ b/wasm/src/client.rs @@ -0,0 +1,758 @@ +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::rc::Rc; + +use futures_channel::oneshot; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; + +use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, PROTOCOL_VERSION}; +use mtp_type_map::CommunicationTypeId; + +use mtp_crypto::SignatureScheme; + +use crate::config::ConnectionConfig; +use crate::error::js_error; +use crate::transport::WasmTransport; + +struct PendingRequest { + response_type: Option, + sender: oneshot::Sender>, +} + +struct PingTimer { + id: i32, + closure: Closure, +} + +fn frame_property(frame: &JsValue, key: &str) -> Option { + js_sys::Reflect::get(frame, &JsValue::from_str(key)) + .ok() + .filter(|value| !value.is_null() && !value.is_undefined()) +} + +fn frame_id(frame: &JsValue) -> Option { + frame_property(frame, "id") + .and_then(|value| value.as_f64()) + .map(|value| value as u32) +} + +fn frame_type(frame: &JsValue) -> Option { + frame_property(frame, "type").and_then(|value| value.as_string()) +} + +fn route_incoming_frame( + frame: &JsValue, + on_message: &js_sys::Function, + subscriptions: &Rc>>, + pending_requests: &Rc>>, +) { + let message_type = frame_type(frame); + + if let Some(request_id) = frame_id(frame) { + let pending = pending_requests.borrow_mut().remove(&request_id); + if let Some(pending) = pending { + let type_matches = pending + .response_type + .as_ref() + .zip(message_type.as_ref()) + .map(|(expected, actual)| expected == actual) + .unwrap_or(true); + if type_matches { + let _ = pending.sender.send(Ok(frame.clone())); + } else { + let actual = message_type.clone().unwrap_or_else(|| "unknown".into()); + let _ = pending.sender.send(Err(js_error(&format!( + "unexpected response type: expected {}, got {}", + pending.response_type.unwrap_or_else(|| "unknown".into()), + actual + )))); + } + } + } + + let _ = on_message.call1(&JsValue::NULL, frame); + + let Some(message_type) = message_type else { + return; + }; + for (_, (subscription_type, callback)) in subscriptions.borrow().iter() { + if subscription_type == &message_type { + let _ = callback.call1(&JsValue::NULL, frame); + } + } +} + +fn stop_ping_timer(ping_timer: &Rc>>) { + let Some(timer) = ping_timer.borrow_mut().take() else { + return; + }; + if let Ok(clear_interval) = + js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("clearInterval")) + .and_then(|value| value.dyn_into::().map_err(Into::into)) + { + let _ = clear_interval.call1(&JsValue::NULL, &JsValue::from_f64(timer.id as f64)); + } + drop(timer.closure); +} + +fn reject_pending_requests( + pending_requests: &Rc>>, + message: &str, +) { + let pending = std::mem::take(&mut *pending_requests.borrow_mut()); + for (_, pending) in pending { + let _ = pending.sender.send(Err(js_error(message))); + } +} + +fn raw_frame_preview(bytes: &[u8]) -> String { + let shown = bytes.len().min(256); + let mut preview = hex::encode(&bytes[..shown]); + if bytes.len() > shown { + preview.push_str("..."); + } + format!("{} bytes, hex={preview}", bytes.len()) +} + +fn unexpected_response_type_error( + context: &str, + expected_type: CommunicationTypeId, + response_type: CommunicationTypeId, + response: &[u8], + parsed: &CommunicationValue, +) -> JsValue { + js_error(&format!( + "unexpected response type during {context}: expected {:?}, got {:?}; raw {}; parsed {}", + expected_type, + response_type, + raw_frame_preview(response), + parsed + )) +} + +/* + * Verify the host's signature over the challenge it issued (step 2), mirroring + * the native client (`client/src/lib.rs`). `id` is the client id for a login or + * `0` for a registration. The Ed25519 signature is mandatory; the ML-DSA + * signature is verified only when the host included one. + */ +fn verify_host_challenge( + challenge: &CommunicationValue, + _tm: &mtp_codec::TypeMap, + host_pk: &mtp_crypto::PublicKeyBundle, + id: u64, + server_challenge: u128, +) -> Result<(), JsValue> { + let sig = match challenge.get_data(DataType::Signature) { + DataValue::Bytes(b) => b.clone(), + _ => return Err(js_error("missing host challenge signature")), + }; + let pq_sig = match challenge.get_data(DataType::PqSignature) { + DataValue::Bytes(b) => b.clone(), + _ => vec![], + }; + + let payload = mtp_crypto::auth::challenge_payload(id, server_challenge); + mtp_crypto::verify_ed25519(&host_pk.sig_cl_public_key, &payload, &sig) + .map_err(|_| js_error("host challenge signature invalid"))?; + if !pq_sig.is_empty() { + mtp_crypto::verify_ml_dsa(&host_pk.sig_pq_public_key, &payload, &pq_sig) + .map_err(|_| js_error("host challenge PQ signature invalid"))?; + } + Ok(()) +} + +/* + * Verify the host's final confirmation (step 4): the echoed `client_nonce` and + * the host signature over the handshake transcript. `id` is the client id for a + * login and the host-assigned id for a register. + */ +fn verify_host_final( + resp: &CommunicationValue, + _tm: &mtp_codec::TypeMap, + host_pk: &mtp_crypto::PublicKeyBundle, + id: u64, + client_nonce: u128, + server_challenge: u128, +) -> Result<(), JsValue> { + if *resp.get_data(DataType::ClientNonce) != DataValue::UnsignedNumber(client_nonce) { + return Err(js_error("nonce mismatch")); + } + let host_sig = match resp.get_data(DataType::Signature) { + DataValue::Bytes(b) => b.clone(), + _ => return Err(js_error("missing host signature")), + }; + let host_pq_sig = match resp.get_data(DataType::PqSignature) { + DataValue::Bytes(b) => b.clone(), + _ => vec![], + }; + + let payload = mtp_crypto::auth::host_final_payload(id, client_nonce, server_challenge); + mtp_crypto::verify_ed25519(&host_pk.sig_cl_public_key, &payload, &host_sig) + .map_err(|_| js_error("host signature invalid"))?; + if !host_pq_sig.is_empty() { + mtp_crypto::verify_ml_dsa(&host_pk.sig_pq_public_key, &payload, &host_pq_sig) + .map_err(|_| js_error("host PQ signature invalid"))?; + } + Ok(()) +} + +fn random_nonce() -> Result { + let mut nonce_bytes = [0u8; 16]; + getrandom::fill(&mut nonce_bytes).map_err(|_| js_error("rng failed"))?; + Ok(u128::from_be_bytes(nonce_bytes)) +} + +fn signed_challenge_response_bytes( + keyring: &mtp_crypto::Keyring, + proof_payload: &[u8], + client_nonce: u128, +) -> Result, JsValue> { + let signer = mtp_crypto::Ed25519Signer::new(&keyring.sig_cl_secret_key) + .map_err(|e| js_error(&format!("signer creation failed: {}", e)))?; + let signature = signer + .sign(proof_payload) + .map_err(|e| js_error(&format!("signature failed: {}", e)))?; + + let mut proof = CommunicationValue::new(CommunicationType::ChallengeResponse) + .add_typed_default( + DataType::ClientNonce, + DataValue::UnsignedNumber(client_nonce), + ) + .add_typed_default(DataType::Signature, DataValue::Bytes(signature)); + + if !keyring.sig_pq_secret_key.as_bytes().is_empty() { + let pq_signer = + mtp_crypto::MlDsaSigner::new(&keyring.sig_pq_secret_key, &keyring.sig_pq_public_key) + .map_err(|e| js_error(&format!("PQ signer creation failed: {}", e)))?; + let pq_signature = pq_signer + .sign(proof_payload) + .map_err(|e| js_error(&format!("PQ signature failed: {}", e)))?; + proof = proof.add_typed_default(DataType::PqSignature, DataValue::Bytes(pq_signature)); + } + + proof + .to_bytes() + .map_err(|e| js_error(&format!("encode failed: {}", e))) +} + +#[wasm_bindgen] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionState { + Disconnected = 0, + Connecting = 1, + Connected = 2, + Failed = 3, +} + +#[wasm_bindgen] +pub struct WasmClient { + transport: Option, + state: Rc>, + on_state_change: js_sys::Function, + pub(crate) on_message: js_sys::Function, + pub(crate) on_error: js_sys::Function, + subscriptions: Rc>>, + next_subscription_id: Rc>, + pending_requests: Rc>>, + ping_timer: Rc>>, +} + +#[wasm_bindgen] +impl WasmClient { + #[wasm_bindgen(constructor)] + pub fn new( + on_state_change: &js_sys::Function, + on_message: &js_sys::Function, + on_error: &js_sys::Function, + ) -> Self { + Self { + transport: None, + state: Rc::new(Cell::new(ConnectionState::Disconnected)), + on_state_change: on_state_change.clone(), + on_message: on_message.clone(), + on_error: on_error.clone(), + subscriptions: Rc::new(RefCell::new(HashMap::new())), + next_subscription_id: Rc::new(Cell::new(1)), + pending_requests: Rc::new(RefCell::new(HashMap::new())), + ping_timer: Rc::new(RefCell::new(None)), + } + } + + #[wasm_bindgen] + pub fn is_supported() -> bool { + js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("WebTransport")).unwrap_or(false) + } + + #[wasm_bindgen(getter)] + pub fn state(&self) -> u8 { + self.state.get() as u8 + } + + /// Unauthenticated connect (sends basic Identification, enables receive loop). + #[wasm_bindgen] + pub async fn connect(&mut self, config: &ConnectionConfig) -> Result<(), JsValue> { + self.set_state(ConnectionState::Connecting); + let transport = WasmTransport::connect( + &config.url, + config.server_certificate_hashes.clone(), + config.max_message_size, + ) + .await?; + + let version_str = format!("{}", PROTOCOL_VERSION); + let ident = CommunicationValue::new(CommunicationType::Identification) + .add_typed_default(DataType::Version, DataValue::Str(version_str)) + .add_typed_default( + DataType::Id, + DataValue::UnsignedNumber(config.client_id as u128), + ); + let ident_bytes = ident + .to_bytes() + .map_err(|e| js_error(&format!("encode failed: {}", e)))?; + transport.send_frame(&ident_bytes).await?; + + self.start_receive_loop(transport); + Ok(()) + } + + /// Authenticated login with an existing client ID. + /// Exchanges Identification + signatures and verifies the host response. + /// + /// - `host_public_key_bytes`: serialized PublicKeyBundle from the server + /// - `keyring_bytes`: serialized Keyring of this client (must match `client_id`) + /// - `client_id`: previously assigned client ID + /// + /// Returns the confirmed (same) client ID on success. + #[wasm_bindgen] + pub async fn auth_connect( + &mut self, + config: &ConnectionConfig, + host_public_key_bytes: &[u8], + keyring_bytes: &[u8], + client_id: u64, + ) -> Result { + self.set_state(ConnectionState::Connecting); + + let host_pk = mtp_crypto::PublicKeyBundle::from_bytes(host_public_key_bytes) + .map_err(|e| js_error(&format!("invalid host public key: {}", e)))?; + let keyring = mtp_crypto::Keyring::from_bytes(keyring_bytes) + .map_err(|e| js_error(&format!("invalid keyring: {}", e)))?; + + let tm = mtp_codec::TypeMap::latest(); + let version_str = format!("{}", PROTOCOL_VERSION); + + let transport = WasmTransport::connect( + &config.url, + config.server_certificate_hashes.clone(), + config.max_message_size, + ) + .await?; + + // 1. Send the unsigned Identification hello. + let hello = CommunicationValue::new(CommunicationType::Identification) + .add_typed_default(DataType::Version, DataValue::Str(version_str.clone())) + .add_typed_default(DataType::Id, DataValue::UnsignedNumber(client_id as u128)) + .to_bytes() + .map_err(|e| js_error(&format!("encode failed: {}", e)))?; + transport.send_frame(&hello).await?; + + // 2. Receive and verify the host's challenge. + let server_challenge = self + .read_verified_challenge( + &transport, + &tm, + &host_pk, + client_id, + "auth_connect challenge", + ) + .await?; + + // 3. Sign the host's challenge and send the proof. + let client_nonce = random_nonce()?; + + let proof_payload = mtp_crypto::auth::login_proof_payload( + &version_str, + client_id, + server_challenge, + client_nonce, + ); + let proof = signed_challenge_response_bytes(&keyring, &proof_payload, client_nonce)?; + transport.send_frame(&proof).await?; + + // 4. Receive and verify the host's final confirmation. + let response = transport.read_one_frame().await?; + let resp_comm = CommunicationValue::from_bytes(&response) + .map_err(|e| js_error(&format!("parse response: {}", e)))?; + let resp_type = resp_comm.get_type(); + let expected_type = CommunicationType::IdentificationResponse.to_id(&tm); + if resp_type != expected_type { + self.set_state(ConnectionState::Disconnected); + return Err(unexpected_response_type_error( + "auth_connect", + expected_type, + resp_type, + &response, + &resp_comm, + )); + } + + if resp_comm.get_data(DataType::Connected) != &DataValue::BoolTrue { + self.set_state(ConnectionState::Disconnected); + return Err(js_error("host rejected authentication")); + } + + // Verify echoed nonce + host signature (login: id is client_id). + if let Err(e) = verify_host_final( + &resp_comm, + &tm, + &host_pk, + client_id, + client_nonce, + server_challenge, + ) { + self.set_state(ConnectionState::Disconnected); + return Err(e); + } + + // Extract assigned ID + let assigned_id = match resp_comm.get_data(DataType::Id) { + DataValue::UnsignedNumber(n) => *n as u64, + _ => { + self.set_state(ConnectionState::Disconnected); + return Err(js_error("missing assigned ID")); + } + }; + + self.start_receive_loop(transport); + + Ok(assigned_id) + } + + /// Authenticated registration with a fresh keyring. + /// The server assigns a new client ID. + /// + /// - `host_public_key_bytes`: serialized PublicKeyBundle from the server + /// - `keyring_bytes`: serialized Keyring (must include ed25519 secret key) + /// + /// Returns the newly assigned client ID. + #[wasm_bindgen] + pub async fn auth_register( + &mut self, + config: &ConnectionConfig, + host_public_key_bytes: &[u8], + keyring_bytes: &[u8], + ) -> Result { + self.set_state(ConnectionState::Connecting); + + let host_pk = mtp_crypto::PublicKeyBundle::from_bytes(host_public_key_bytes) + .map_err(|e| js_error(&format!("invalid host public key: {}", e)))?; + let keyring = mtp_crypto::Keyring::from_bytes(keyring_bytes) + .map_err(|e| js_error(&format!("invalid keyring: {}", e)))?; + + let tm = mtp_codec::TypeMap::latest(); + let version_str = format!("{}", PROTOCOL_VERSION); + let pk_bytes = keyring.public_key_bundle().as_bytes(); + + let transport = WasmTransport::connect( + &config.url, + config.server_certificate_hashes.clone(), + config.max_message_size, + ) + .await?; + + // 1. Send the unsigned Register hello (version + public-key bundle). + let hello = CommunicationValue::new(CommunicationType::Register) + .add_typed_default(DataType::Version, DataValue::Str(version_str.clone())) + .add_typed_default(DataType::PublicKeys, DataValue::Bytes(pk_bytes.clone())) + .to_bytes() + .map_err(|e| js_error(&format!("encode failed: {}", e)))?; + transport.send_frame(&hello).await?; + + // 2. Receive and verify the host's challenge (register binds id = 0). + let server_challenge = self + .read_verified_challenge(&transport, &tm, &host_pk, 0, "auth_register challenge") + .await?; + + // 3. Sign the host's challenge over the bundle and send the proof. + let client_nonce = random_nonce()?; + + let proof_payload = mtp_crypto::auth::register_proof_payload( + &version_str, + &pk_bytes, + server_challenge, + client_nonce, + ); + let proof = signed_challenge_response_bytes(&keyring, &proof_payload, client_nonce)?; + transport.send_frame(&proof).await?; + + // 4. Receive the host's final confirmation; extract + verify assigned id. + let response = transport.read_one_frame().await?; + let resp_comm = CommunicationValue::from_bytes(&response) + .map_err(|e| js_error(&format!("parse response: {}", e)))?; + let resp_type = resp_comm.get_type(); + let expected_type = CommunicationType::RegisterResponse.to_id(&tm); + if resp_type != expected_type { + self.set_state(ConnectionState::Disconnected); + return Err(unexpected_response_type_error( + "auth_register", + expected_type, + resp_type, + &response, + &resp_comm, + )); + } + + if resp_comm.get_data(DataType::Connected) != &DataValue::BoolTrue { + self.set_state(ConnectionState::Disconnected); + return Err(js_error("host rejected registration")); + } + + let assigned_id = match resp_comm.get_data(DataType::Id) { + DataValue::UnsignedNumber(n) => *n as u64, + _ => { + self.set_state(ConnectionState::Disconnected); + return Err(js_error("missing assigned ID")); + } + }; + + // Verify echoed nonce + host signature (register: id is host-assigned). + if let Err(e) = verify_host_final( + &resp_comm, + &tm, + &host_pk, + assigned_id, + client_nonce, + server_challenge, + ) { + self.set_state(ConnectionState::Disconnected); + return Err(e); + } + + self.start_receive_loop(transport); + + Ok(assigned_id) + } + + #[wasm_bindgen] + pub async fn send(&self, frame: Vec) -> Result<(), JsValue> { + match &self.transport { + Some(t) => t.send_frame(&frame).await, + None => Err(js_error("not connected")), + } + } + + #[wasm_bindgen] + pub async fn request( + &self, + frame: Vec, + response_type: Option, + ) -> Result { + let request = CommunicationValue::from_bytes(&frame) + .map_err(|e| js_error(&format!("parse request: {}", e)))?; + let request_id = request.get_id(); + if request_id == 0 { + return Err(js_error("request frame must have a non-zero id")); + } + + let Some(transport) = self.transport.clone() else { + return Err(js_error("not connected")); + }; + + let (sender, receiver) = oneshot::channel(); + self.pending_requests.borrow_mut().insert( + request_id, + PendingRequest { + response_type, + sender, + }, + ); + + if let Err(error) = transport.send_frame(&frame).await { + self.pending_requests.borrow_mut().remove(&request_id); + return Err(error); + } + + match receiver.await { + Ok(result) => result, + Err(_) => Err(js_error("request cancelled")), + } + } + + #[wasm_bindgen] + pub fn subscribe(&self, message_type: String, callback: js_sys::Function) -> u32 { + let id = self.next_subscription_id.get(); + self.next_subscription_id.set(id.wrapping_add(1).max(1)); + self.subscriptions + .borrow_mut() + .insert(id, (message_type, callback)); + id + } + + #[wasm_bindgen] + pub fn unsubscribe(&self, id: u32) -> bool { + self.subscriptions.borrow_mut().remove(&id).is_some() + } + + #[wasm_bindgen] + pub fn start_protocol_pings(&self, interval_ms: u32, client_id: u64) -> Result<(), JsValue> { + self.stop_protocol_pings(); + let Some(transport) = self.transport.clone() else { + return Err(js_error("not connected")); + }; + let interval_ms = interval_ms.max(1_000) as i32; + let on_error = self.on_error.clone(); + let closure = Closure::wrap(Box::new(move || { + let transport = transport.clone(); + let on_error = on_error.clone(); + wasm_bindgen_futures::spawn_local(async move { + let timestamp = js_sys::Date::now() as u64; + let frame = CommunicationValue::new(CommunicationType::Ping) + .add_typed_default( + DataType::Description, + DataValue::Str("protocol ping".into()), + ) + .add_typed_default( + DataType::Timestamp, + DataValue::UnsignedNumber(timestamp as u128), + ) + .with_sender(client_id) + .to_bytes() + .map_err(|e| js_error(&format!("encode ping failed: {}", e))); + match frame { + Ok(frame) => { + if let Err(error) = transport.send_frame(&frame).await { + let _ = on_error.call1(&JsValue::NULL, &error); + } + } + Err(error) => { + let _ = on_error.call1(&JsValue::NULL, &error); + } + } + }); + }) as Box); + + let set_interval = + js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("setInterval"))? + .dyn_into::()?; + let id = set_interval + .call2( + &JsValue::NULL, + closure.as_ref().unchecked_ref(), + &JsValue::from_f64(interval_ms as f64), + )? + .as_f64() + .ok_or_else(|| js_error("setInterval did not return an id"))? as i32; + *self.ping_timer.borrow_mut() = Some(PingTimer { id, closure }); + Ok(()) + } + + #[wasm_bindgen] + pub fn stop_protocol_pings(&self) { + let Some(timer) = self.ping_timer.borrow_mut().take() else { + return; + }; + if let Ok(clear_interval) = + js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("clearInterval")) + .and_then(|value| value.dyn_into::().map_err(Into::into)) + { + let _ = clear_interval.call1(&JsValue::NULL, &JsValue::from_f64(timer.id as f64)); + } + drop(timer.closure); + } + + #[wasm_bindgen] + pub fn disconnect(&mut self) { + self.stop_protocol_pings(); + if let Some(t) = &self.transport { + t.close(); + } + self.transport = None; + self.subscriptions.borrow_mut().clear(); + self.reject_pending_requests("disconnected"); + self.set_state(ConnectionState::Disconnected); + } + + fn set_state(&self, new_state: ConnectionState) { + self.state.set(new_state); + let _ = self + .on_state_change + .call1(&JsValue::NULL, &JsValue::from(new_state as u8)); + } + + fn start_receive_loop(&mut self, transport: WasmTransport) { + let loop_transport = transport.clone(); + self.transport = Some(transport); + self.set_state(ConnectionState::Connected); + + let state = self.state.clone(); + let on_msg = self.on_message.clone(); + let on_err = self.on_error.clone(); + let subscriptions = self.subscriptions.clone(); + let pending_requests = self.pending_requests.clone(); + let loop_pending_requests = pending_requests.clone(); + let ping_timer = self.ping_timer.clone(); + wasm_bindgen_futures::spawn_local(async move { + let route_frame = Closure::wrap(Box::new(move |frame: JsValue| { + route_incoming_frame(&frame, &on_msg, &subscriptions, &loop_pending_requests); + }) as Box); + loop_transport + .receive_loop( + route_frame + .as_ref() + .unchecked_ref::() + .clone(), + on_err.clone(), + ) + .await; + drop(route_frame); + state.set(ConnectionState::Disconnected); + stop_ping_timer(&ping_timer); + reject_pending_requests(&pending_requests, "disconnected"); + }); + } + + fn reject_pending_requests(&self, message: &str) { + reject_pending_requests(&self.pending_requests, message); + } + + async fn read_verified_challenge( + &self, + transport: &WasmTransport, + tm: &mtp_codec::TypeMap, + host_pk: &mtp_crypto::PublicKeyBundle, + bound_id: u64, + context: &str, + ) -> Result { + let challenge_bytes = transport.read_one_frame().await?; + let challenge = CommunicationValue::from_bytes(&challenge_bytes) + .map_err(|e| js_error(&format!("parse challenge: {}", e)))?; + let expected = CommunicationType::Challenge.to_id(tm); + if challenge.get_type() != expected { + self.set_state(ConnectionState::Disconnected); + return Err(unexpected_response_type_error( + context, + expected, + challenge.get_type(), + &challenge_bytes, + &challenge, + )); + } + + let server_challenge = match challenge.get_data(DataType::ServerNonce) { + DataValue::UnsignedNumber(n) => *n, + _ => { + self.set_state(ConnectionState::Disconnected); + return Err(js_error("missing server challenge")); + } + }; + + if let Err(e) = verify_host_challenge(&challenge, tm, host_pk, bound_id, server_challenge) { + self.set_state(ConnectionState::Disconnected); + return Err(e); + } + + Ok(server_challenge) + } +} diff --git a/wasm/src/client/authentication.rs b/wasm/src/client/authentication.rs deleted file mode 100644 index 73bfc9f..0000000 --- a/wasm/src/client/authentication.rs +++ /dev/null @@ -1,680 +0,0 @@ -use wasm_bindgen::prelude::*; - -use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, PROTOCOL_VERSION}; - -use crate::auth; -use crate::client::{ConnectionState, WasmClient}; -use crate::config::ConnectionConfig; -use crate::error::js_error; -use crate::transport::WasmTransport; - -fn server_rejection_message(outcome: &CommunicationValue) -> Option<&str> { - (outcome.get_data(DataType::Connected) == Some(&DataValue::BoolFalse)).then(|| { - outcome - .get_str(DataType::ErrorMessage) - .unwrap_or("host rejected the connection") - }) -} - -#[wasm_bindgen] -#[allow(deprecated)] -impl WasmClient { - pub async fn connect(&self, config: &ConnectionConfig) -> Result<(), JsValue> { - self.connect_owned(config.clone()).await - } - - #[wasm_bindgen(js_name = connectOwned)] - pub async fn connect_owned(&self, config: ConnectionConfig) -> Result<(), JsValue> { - let generation = self.begin_connection(); - let transport = match WasmTransport::connect_with_limits( - &config.url, - config.server_certificate_hashes.clone(), - config.max_message_size, - self.receive_decode_limits(), - ) - .await - { - Ok(transport) => transport, - Err(error) => { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(error); - } - }; - if !self.install_attempt_transport(&transport, generation) { - return Err(js_error("connection attempt superseded")); - } - - let result = async { - let version_str = format!("{}", PROTOCOL_VERSION); - let opening_codec = mtp_codec::registry::VersionedCodec::for_version( - mtp_codec::registry::Registry::builtin(), - PROTOCOL_VERSION, - ) - .ok_or_else(|| js_error("client protocol version is not registered"))?; - transport.set_type_map(opening_codec.type_map()); - let mut ident = CommunicationValue::new_with_type_map( - CommunicationType::Identification, - opening_codec.type_map(), - ) - .add_typed_default(DataType::Version, DataValue::Str(version_str)) - .add_typed_default( - DataType::Id, - DataValue::UnsignedNumber(config.client_id as u128), - ); - if let Some(desc) = &config.description { - ident = - ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); - } - let ident_bytes = ident - .to_bytes() - .map_err(|e| js_error(format!("encode failed: {}", e)))?; - transport.send_frame(&ident_bytes).await?; - - let outcome_bytes = transport.read_one_frame().await?; - let outcome = CommunicationValue::try_from_bytes_with_type_map_and_limits( - &outcome_bytes, - opening_codec.type_map(), - transport.decode_limits(), - ) - .map_err(|e| js_error(format!("parse handshake outcome: {e}")))?; - if Some(outcome.get_type()) - == CommunicationType::ErrorBadVersion.try_to_id(opening_codec.type_map()) - { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(js_error( - outcome - .get_str(DataType::ErrorMessage) - .unwrap_or("host does not support this protocol version"), - )); - } - - // Generic host rejections are IdentificationResponse frames with - // Connected=false. They intentionally do not carry a negotiated - // Version because negotiation never completed. Check this before - // reading Version, otherwise a useful server error such as an - // authentication timeout is reported as the misleading - // "host omitted a valid negotiated protocol version". - if let Some(message) = server_rejection_message(&outcome) { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(js_error(message)); - } - - let missing_version = || { - js_error(format!( - "host omitted a valid negotiated protocol version (response_type={:?}, connected={:?}, frame_len={})", - outcome.get_type(), - outcome.get_data(DataType::Connected), - outcome_bytes.len(), - )) - }; - - let negotiated_version = match outcome.get_data(DataType::Version) { - Some(DataValue::Str(version)) => mtp_codec::Version::parse(version) - .ok_or_else(|| missing_version())?, - _ => return Err(missing_version()), - }; - if negotiated_version != PROTOCOL_VERSION { - return Err(js_error( - "host selected a protocol version the client did not offer", - )); - } - let codec = mtp_codec::registry::VersionedCodec::for_version( - mtp_codec::registry::Registry::builtin(), - negotiated_version, - ) - .ok_or_else(|| js_error("host returned an unsupported negotiated protocol version"))?; - transport.set_type_map(codec.type_map()); - let outcome = CommunicationValue::try_from_bytes_with_type_map_and_limits( - &outcome_bytes, - codec.type_map(), - transport.decode_limits(), - ) - .map_err(|e| js_error(format!("parse negotiated handshake outcome: {e}")))?; - let tm = codec.type_map(); - let expected = CommunicationType::IdentificationResponse - .try_to_id(&tm) - .ok_or_else(|| js_error("IdentificationResponse is absent from the type map"))?; - if outcome.get_type() != expected - || outcome.get_data(DataType::Connected) != Some(&DataValue::BoolTrue) - { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(js_error( - outcome - .get_str(DataType::ErrorMessage) - .unwrap_or("host rejected the connection"), - )); - } - let assigned_id = match outcome.get_data(DataType::Id) { - Some(DataValue::UnsignedNumber(id)) => { - u64::try_from(*id).map_err(|_| js_error("assigned ID is out of range"))? - } - _ => { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(js_error("host omitted the assigned client ID")); - } - }; - - if !self.start_receive_loop(transport.clone(), generation, assigned_id) { - return Err(js_error("connection attempt superseded")); - } - Ok(()) - } - .await; - if let Err(error) = &result { - self.abort_attempt(&transport, generation); - let _ = error; - } - result - } - - #[wasm_bindgen] - #[deprecated( - note = "use the SDK authentication methods; this raw method remains for compatibility" - )] - pub async fn auth_connect( - &self, - config: &ConnectionConfig, - host_public_key_bytes: &[u8], - keyring_bytes: &[u8], - client_id: u64, - ) -> Result { - self.auth_connect_owned( - config.clone(), - host_public_key_bytes.to_vec(), - keyring_bytes.to_vec(), - client_id, - ) - .await - } - - #[wasm_bindgen(js_name = authConnectOwned)] - pub async fn auth_connect_owned( - &self, - config: ConnectionConfig, - host_public_key_bytes: Vec, - keyring_bytes: Vec, - client_id: u64, - ) -> Result { - let generation = self.begin_connection(); - - let host_pk = match mtp_crypto::PublicKeyBundle::from_bytes(&host_public_key_bytes) { - Ok(value) => value, - Err(error) => { - let error = js_error(format!("invalid host public key: {}", error)); - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(error); - } - }; - let keyring = match mtp_crypto::Keyring::from_bytes(&keyring_bytes) { - Ok(value) => value, - Err(error) => { - let error = js_error(format!("invalid keyring: {}", error)); - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(error); - } - }; - - let handshake_codec = mtp_codec::registry::VersionedCodec::for_version( - mtp_codec::registry::Registry::builtin(), - PROTOCOL_VERSION, - ) - .ok_or_else(|| js_error("client protocol version is not registered"))?; - let tm = handshake_codec.type_map().clone(); - let version_str = format!("{}", PROTOCOL_VERSION); - let public_key_bytes = keyring - .public_key_bundle() - .try_as_bytes() - .map_err(|error| js_error(format!("public key serialization failed: {error}")))?; - - let transport = match WasmTransport::connect_with_limits( - &config.url, - config.server_certificate_hashes.clone(), - config.max_message_size, - self.receive_decode_limits(), - ) - .await - { - Ok(transport) => transport, - Err(error) => { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(error); - } - }; - transport.set_type_map(&tm); - if !self.install_attempt_transport(&transport, generation) { - return Err(js_error("connection attempt superseded")); - } - - let result = async { - let mut hello = - CommunicationValue::new_with_type_map(CommunicationType::Identification, &tm) - .add_typed_default(DataType::Version, DataValue::Str(version_str.clone())) - .add_typed_default(DataType::Id, DataValue::UnsignedNumber(client_id as u128)) - // Mark this as an authentication-capable opening so a - // non-crypto host can reject it explicitly. - .add_typed_default( - DataType::PublicKeys, - DataValue::Bytes(public_key_bytes.clone()), - ); - if let Some(desc) = &config.description { - hello = - hello.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); - } - let hello_bytes = hello - .to_bytes() - .map_err(|e| js_error(format!("encode failed: {}", e)))?; - transport.send_frame(&hello_bytes).await?; - - let server_challenge = self - .read_verified_challenge( - &transport, - &tm, - &host_pk, - client_id, - "auth_connect challenge", - config.require_pq, - !keyring.sig_pq_secret_key.as_bytes().is_empty(), - generation, - ) - .await?; - - let client_nonce = auth::random_nonce()?; - - let proof_payload = mtp_crypto::auth::login_proof_payload( - &version_str, - client_id, - server_challenge, - client_nonce, - ); - let proof = - auth::signed_challenge_response_bytes(&keyring, &proof_payload, client_nonce, &tm)?; - transport.send_frame(&proof).await?; - - let response = transport.read_one_frame().await?; - let resp_comm = CommunicationValue::try_from_bytes_with_type_map_and_limits( - &response, - &tm, - transport.decode_limits(), - ) - .map_err(|e| js_error(format!("parse response: {}", e)))?; - let negotiated_version = match resp_comm.get_data(DataType::Version) { - Some(DataValue::Str(version)) => mtp_codec::Version::parse(version) - .ok_or_else(|| js_error("host returned an invalid negotiated version"))?, - _ => return Err(js_error("host omitted the negotiated version")), - }; - if negotiated_version != PROTOCOL_VERSION { - return Err(js_error( - "host selected a protocol version the client did not offer", - )); - } - let codec = mtp_codec::registry::VersionedCodec::for_version( - mtp_codec::registry::Registry::builtin(), - negotiated_version, - ) - .ok_or_else(|| js_error("host returned an unsupported negotiated version"))?; - transport.set_type_map(codec.type_map()); - let resp_comm = CommunicationValue::try_from_bytes_with_type_map_and_limits( - &response, - codec.type_map(), - transport.decode_limits(), - ) - .map_err(|e| js_error(format!("parse negotiated response: {}", e)))?; - let tm = codec.type_map(); - let resp_type = resp_comm.get_type(); - let expected_type = CommunicationType::IdentificationResponse - .try_to_id(&tm) - .ok_or_else(|| js_error("IdentificationResponse is absent from the type map"))?; - if resp_type != expected_type { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(auth::unexpected_response_type_error( - "auth_connect", - expected_type, - resp_type, - &response, - &resp_comm, - )); - } - - if resp_comm.get_data(DataType::Connected) != Some(&DataValue::BoolTrue) { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(js_error( - resp_comm - .get_str(DataType::ErrorMessage) - .unwrap_or("host rejected authentication"), - )); - } - - if let Err(e) = auth::verify_host_final( - &resp_comm, - &tm, - &host_pk, - client_id, - client_nonce, - server_challenge, - config.require_pq, - ) { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(e); - } - - let assigned_id = match resp_comm.get_data(DataType::Id) { - Some(DataValue::UnsignedNumber(n)) => match u64::try_from(*n) { - Ok(id) => id, - Err(_) => { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(js_error("assigned ID is out of range")); - } - }, - _ => { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(js_error("missing assigned ID")); - } - }; - - if !self.start_receive_loop(transport.clone(), generation, assigned_id) { - return Err(js_error("connection attempt superseded")); - } - - Ok(assigned_id) - } - .await; - if let Err(error) = &result { - self.abort_attempt(&transport, generation); - let _ = error; - } - result - } - - #[wasm_bindgen] - #[deprecated( - note = "use the SDK registration methods; this raw method remains for compatibility" - )] - pub async fn auth_register( - &self, - config: &ConnectionConfig, - host_public_key_bytes: &[u8], - keyring_bytes: &[u8], - ) -> Result { - self.auth_register_owned( - config.clone(), - host_public_key_bytes.to_vec(), - keyring_bytes.to_vec(), - ) - .await - } - - #[wasm_bindgen(js_name = authRegisterOwned)] - pub async fn auth_register_owned( - &self, - config: ConnectionConfig, - host_public_key_bytes: Vec, - keyring_bytes: Vec, - ) -> Result { - let generation = self.begin_connection(); - - let host_pk = match mtp_crypto::PublicKeyBundle::from_bytes(&host_public_key_bytes) { - Ok(value) => value, - Err(error) => { - let error = js_error(format!("invalid host public key: {}", error)); - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(error); - } - }; - let keyring = match mtp_crypto::Keyring::from_bytes(&keyring_bytes) { - Ok(value) => value, - Err(error) => { - let error = js_error(format!("invalid keyring: {}", error)); - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(error); - } - }; - - let handshake_codec = mtp_codec::registry::VersionedCodec::for_version( - mtp_codec::registry::Registry::builtin(), - PROTOCOL_VERSION, - ) - .ok_or_else(|| js_error("client protocol version is not registered"))?; - let tm = handshake_codec.type_map().clone(); - let version_str = format!("{}", PROTOCOL_VERSION); - let pk_bytes = keyring - .public_key_bundle() - .try_as_bytes() - .map_err(|error| js_error(format!("public key serialization failed: {error}")))?; - - let transport = match WasmTransport::connect_with_limits( - &config.url, - config.server_certificate_hashes.clone(), - config.max_message_size, - self.receive_decode_limits(), - ) - .await - { - Ok(transport) => transport, - Err(error) => { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(error); - } - }; - transport.set_type_map(&tm); - if !self.install_attempt_transport(&transport, generation) { - return Err(js_error("connection attempt superseded")); - } - - let result = async { - let mut hello = CommunicationValue::new_with_type_map(CommunicationType::Register, &tm) - .add_typed_default(DataType::Version, DataValue::Str(version_str.clone())) - .add_typed_default(DataType::PublicKeys, DataValue::Bytes(pk_bytes.clone())); - if let Some(desc) = &config.description { - hello = - hello.add_typed_default(DataType::Description, DataValue::Str(desc.clone())); - } - let hello_bytes = hello - .to_bytes() - .map_err(|e| js_error(format!("encode failed: {}", e)))?; - transport.send_frame(&hello_bytes).await?; - - let server_challenge = self - .read_verified_challenge( - &transport, - &tm, - &host_pk, - 0, - "auth_register challenge", - config.require_pq, - !keyring.sig_pq_secret_key.as_bytes().is_empty(), - generation, - ) - .await?; - - let client_nonce = auth::random_nonce()?; - - let proof_payload = mtp_crypto::auth::register_proof_payload( - &version_str, - &pk_bytes, - server_challenge, - client_nonce, - ); - let proof = - auth::signed_challenge_response_bytes(&keyring, &proof_payload, client_nonce, &tm)?; - transport.send_frame(&proof).await?; - - let response = transport.read_one_frame().await?; - let resp_comm = CommunicationValue::try_from_bytes_with_type_map_and_limits( - &response, - &tm, - transport.decode_limits(), - ) - .map_err(|e| js_error(format!("parse response: {}", e)))?; - let negotiated_version = match resp_comm.get_data(DataType::Version) { - Some(DataValue::Str(version)) => mtp_codec::Version::parse(version) - .ok_or_else(|| js_error("host returned an invalid negotiated version"))?, - _ => return Err(js_error("host omitted the negotiated version")), - }; - if negotiated_version != PROTOCOL_VERSION { - return Err(js_error( - "host selected a protocol version the client did not offer", - )); - } - let codec = mtp_codec::registry::VersionedCodec::for_version( - mtp_codec::registry::Registry::builtin(), - negotiated_version, - ) - .ok_or_else(|| js_error("host returned an unsupported negotiated version"))?; - transport.set_type_map(codec.type_map()); - let resp_comm = CommunicationValue::try_from_bytes_with_type_map_and_limits( - &response, - codec.type_map(), - transport.decode_limits(), - ) - .map_err(|e| js_error(format!("parse negotiated response: {}", e)))?; - let tm = codec.type_map(); - let resp_type = resp_comm.get_type(); - let expected_type = CommunicationType::RegisterResponse - .try_to_id(&tm) - .ok_or_else(|| js_error("RegisterResponse is absent from the type map"))?; - if resp_type != expected_type { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(auth::unexpected_response_type_error( - "auth_register", - expected_type, - resp_type, - &response, - &resp_comm, - )); - } - - if resp_comm.get_data(DataType::Connected) != Some(&DataValue::BoolTrue) { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(js_error( - resp_comm - .get_str(DataType::ErrorMessage) - .unwrap_or("host rejected registration"), - )); - } - - let assigned_id = match resp_comm.get_data(DataType::Id) { - Some(DataValue::UnsignedNumber(n)) => match u64::try_from(*n) { - Ok(id) => id, - Err(_) => { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(js_error("assigned ID is out of range")); - } - }, - _ => { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(js_error("missing assigned ID")); - } - }; - - if let Err(e) = auth::verify_host_final( - &resp_comm, - &tm, - &host_pk, - assigned_id, - client_nonce, - server_challenge, - config.require_pq, - ) { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(e); - } - - if !self.start_receive_loop(transport.clone(), generation, assigned_id) { - return Err(js_error("connection attempt superseded")); - } - - Ok(assigned_id) - } - .await; - if let Err(error) = &result { - self.abort_attempt(&transport, generation); - let _ = error; - } - result - } - - async fn read_verified_challenge( - &self, - transport: &WasmTransport, - tm: &mtp_codec::TypeMap, - host_pk: &mtp_crypto::PublicKeyBundle, - bound_id: u64, - context: &str, - require_pq: bool, - client_has_pq_key: bool, - generation: u32, - ) -> Result { - let challenge_bytes = transport.read_one_frame().await?; - let challenge = CommunicationValue::try_from_bytes_with_type_map_and_limits( - &challenge_bytes, - tm, - transport.decode_limits(), - ) - .map_err(|e| js_error(format!("parse challenge: {}", e)))?; - let expected = CommunicationType::Challenge - .try_to_id(tm) - .ok_or_else(|| js_error("Challenge is absent from the type map"))?; - if challenge.get_type() != expected { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(auth::unexpected_response_type_error( - context, - expected, - challenge.get_type(), - &challenge_bytes, - &challenge, - )); - } - - let server_challenge = match challenge.get_data(DataType::ServerNonce) { - Some(DataValue::UnsignedNumber(n)) => *n, - _ => { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(js_error("missing server challenge")); - } - }; - - if challenge.get_data(DataType::RequirePq) == Some(&DataValue::BoolTrue) - && !client_has_pq_key - { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(js_error( - "host requires post-quantum authentication but the client PQ key is absent", - )); - } - - if let Err(e) = auth::verify_host_challenge( - &challenge, - tm, - host_pk, - bound_id, - server_challenge, - require_pq, - ) { - self.set_state_if_current(generation, ConnectionState::Disconnected); - return Err(e); - } - - Ok(server_challenge) - } -} - -#[cfg(test)] -mod tests { - use super::server_rejection_message; - use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue}; - - #[test] - fn reports_rejection_reason_without_a_negotiated_version() { - let response = CommunicationValue::new(CommunicationType::IdentificationResponse) - .add_typed_default(DataType::Connected, DataValue::BoolFalse) - .add_typed_default( - DataType::ErrorMessage, - DataValue::Str("authentication handshake timed out".into()), - ); - - assert_eq!( - server_rejection_message(&response), - Some("authentication handshake timed out") - ); - } -} diff --git a/wasm/src/client/connection.rs b/wasm/src/client/connection.rs deleted file mode 100644 index 261e3f9..0000000 --- a/wasm/src/client/connection.rs +++ /dev/null @@ -1,102 +0,0 @@ -use wasm_bindgen::prelude::*; - -use crate::client::{ConnectionState, WasmClient}; -use crate::client_pipe; -use crate::transport::WasmTransport; - -use super::dispatch::set_shared_state; - -#[wasm_bindgen] -impl WasmClient { - pub fn disconnect(&self) { - self.connection_generation - .set(self.connection_generation.get().wrapping_add(1)); - self.stop_protocol_pings(); - if let Some(t) = self.transport.borrow_mut().take() { - t.close(); - } - if let Some(t) = self.attempt_transport.borrow_mut().take() { - t.close(); - } - self.subscriptions.borrow_mut().clear(); - self.reject_pending_requests("disconnected"); - client_pipe::reject_pending_pipe_creations(&self.pending_pipe_creations, "disconnected"); - self.expired_pipe_creations.borrow_mut().clear(); - client_pipe::reject_pending_pipes(&self.pending_pipes, "disconnected"); - self.connection_client_id.set(0); - self.set_state(ConnectionState::Disconnected); - } - - pub(super) fn set_state(&self, new_state: ConnectionState) { - set_shared_state( - &self.state, - &self.pending_state_callbacks, - self.state_callback.as_ref(), - new_state, - ); - } - - pub(super) fn set_state_if_current(&self, generation: u32, new_state: ConnectionState) { - if self.connection_generation.get() == generation { - self.set_state(new_state); - } - } - - pub(super) fn install_attempt_transport( - &self, - transport: &WasmTransport, - generation: u32, - ) -> bool { - if self.connection_generation.get() != generation { - transport.close(); - return false; - } - *self.attempt_transport.borrow_mut() = Some(transport.clone()); - true - } - - pub(super) fn abort_attempt(&self, transport: &WasmTransport, generation: u32) { - transport.close(); - if self.connection_generation.get() != generation { - return; - } - if let Some(current) = self.attempt_transport.borrow_mut().take() { - current.close(); - } - if let Some(current) = self.transport.borrow_mut().take() { - current.close(); - } - self.stop_protocol_pings(); - self.reject_pending_requests("connection failed"); - client_pipe::reject_pending_pipe_creations( - &self.pending_pipe_creations, - "connection failed", - ); - self.expired_pipe_creations.borrow_mut().clear(); - client_pipe::reject_pending_pipes(&self.pending_pipes, "connection failed"); - self.connection_client_id.set(0); - self.set_state(ConnectionState::Disconnected); - } - - pub(super) fn begin_connection(&self) -> u32 { - let generation = self.connection_generation.get().wrapping_add(1); - self.connection_generation.set(generation); - self.stop_protocol_pings(); - if let Some(transport) = self.transport.borrow_mut().take() { - transport.close(); - } - if let Some(transport) = self.attempt_transport.borrow_mut().take() { - transport.close(); - } - self.reject_pending_requests("connection replaced"); - client_pipe::reject_pending_pipe_creations( - &self.pending_pipe_creations, - "connection replaced", - ); - self.expired_pipe_creations.borrow_mut().clear(); - client_pipe::reject_pending_pipes(&self.pending_pipes, "connection replaced"); - self.connection_client_id.set(0); - self.set_state(ConnectionState::Connecting); - generation - } -} diff --git a/wasm/src/client/dispatch.rs b/wasm/src/client/dispatch.rs deleted file mode 100644 index 92a8cc1..0000000 --- a/wasm/src/client/dispatch.rs +++ /dev/null @@ -1,184 +0,0 @@ -use std::cell::{Cell, RefCell}; -use std::collections::{HashMap, VecDeque}; -use std::rc::Rc; - -use wasm_bindgen::prelude::*; - -use crate::client::ConnectionState; -use crate::client_pipe::{self, PendingRequest}; - -pub(super) struct PingTimer { - pub(super) id: i32, - pub(super) closure: Closure, -} - -pub(super) struct PendingPing { - pub(super) generation: u32, - pub(super) sent_at: f64, -} - -pub(super) fn frame_property(frame: &JsValue, key: &str) -> Option { - js_sys::Reflect::get(frame, &JsValue::from_str(key)) - .ok() - .filter(|value| !value.is_null() && !value.is_undefined()) -} - -pub(super) fn frame_id(frame: &JsValue) -> Option { - frame_property(frame, "id") - .and_then(|value| value.as_f64()) - .filter(|value| { - value.is_finite() && value.fract() == 0.0 && (0.0..=u32::MAX as f64).contains(value) - }) - .and_then(|value| u32::try_from(value as u64).ok()) -} - -pub(super) fn frame_type(frame: &JsValue) -> Option { - frame_property(frame, "type").and_then(|value| value.as_string()) -} - -pub(super) fn route_incoming_frame( - frame: &JsValue, - generation: u32, - on_message: &js_sys::Function, - subscriptions: &Rc>>, - pending_requests: &Rc>>, - expired_requests: &Rc>>, - pending_pings: &Rc>>, - ping_ms: &Rc>>, -) { - let message_type = frame_type(frame); - - if message_type.as_deref() == Some("Pong") - && let Some(ping_id) = frame_id(frame) - { - let sent_at = pending_pings - .borrow() - .get(&ping_id) - .filter(|ping| ping.generation == generation) - .map(|ping| ping.sent_at); - if let Some(sent_at) = sent_at { - pending_pings.borrow_mut().remove(&ping_id); - ping_ms.set(Some(js_sys::Date::now() - sent_at)); - return; - } - } - - if let Some(request_id) = frame_id(frame) { - let pending = { - let mut requests = pending_requests.borrow_mut(); - if requests - .get(&request_id) - .is_some_and(|request| request.generation == generation) - { - requests.remove(&request_id) - } else { - None - } - }; - if let Some(pending) = pending { - let type_matches = pending - .response_type - .as_ref() - .zip(message_type.as_ref()) - .map(|(expected, actual)| expected == actual) - .unwrap_or(true); - if type_matches { - let _ = pending.sender.send(Ok(frame.clone())); - } else { - let actual = message_type.clone().unwrap_or_else(|| "unknown".into()); - let _ = pending.sender.send(Err(crate::error::js_error(format!( - "unexpected response type: expected {}, got {}", - pending.response_type.unwrap_or_else(|| "unknown".into()), - actual - )))); - } - return; - } - if client_pipe::consume_expired_request(expired_requests, request_id) { - return; - } - } - - let _ = on_message.call1(&JsValue::NULL, frame); - let Some(message_type) = message_type else { - return; - }; - let callbacks: Vec = subscriptions - .borrow() - .iter() - .filter(|(_, (t, _))| t == &message_type) - .map(|(_, (_, cb))| cb.clone()) - .collect(); - for callback in callbacks { - let _ = callback.call1(&JsValue::NULL, frame); - } -} - -pub(super) fn stop_ping_timer(ping_timer: &Rc>>) { - let Some(timer) = ping_timer.borrow_mut().take() else { - return; - }; - if let Ok(clear_interval) = - js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("clearInterval")) - .and_then(|value| value.dyn_into::()) - { - let _ = clear_interval.call1(&JsValue::NULL, &JsValue::from_f64(timer.id as f64)); - } - drop(timer.closure); -} - -pub(super) fn reject_pending_requests( - pending_requests: &Rc>>, - message: &str, -) { - let pending = std::mem::take(&mut *pending_requests.borrow_mut()); - for (_, pending) in pending { - let _ = pending.sender.send(Err(crate::error::js_error(message))); - } -} - -pub(super) async fn wait_for_timeout(timeout_ms: u32) -> Result<(), JsValue> { - let promise = js_sys::Promise::new(&mut |resolve, reject| { - let result = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("setTimeout")) - .and_then(|value| value.dyn_into::()) - .and_then(|set_timeout| { - set_timeout.call2( - &JsValue::NULL, - &resolve, - &JsValue::from_f64(timeout_ms as f64), - ) - }); - if let Err(error) = result { - let _ = reject.call1(&JsValue::NULL, &error); - } - }); - wasm_bindgen_futures::JsFuture::from(promise).await?; - Ok(()) -} - -pub(super) fn set_shared_state( - state: &Rc>, - pending_state_callbacks: &Rc>>, - state_callback: &JsValue, - new_state: ConnectionState, -) { - state.set(new_state); - pending_state_callbacks.borrow_mut().push_back(new_state); - - let global = js_sys::global(); - let qmt = js_sys::Reflect::get(&global, &JsValue::from_str("queueMicrotask")) - .and_then(|f| f.dyn_into::()); - let scheduled = qmt - .and_then(|qmt| qmt.call1(&global, state_callback)) - .is_ok(); - if !scheduled - && js_sys::Reflect::get(&global, &JsValue::from_str("setTimeout")) - .and_then(|f| f.dyn_into::()) - .and_then(|set_timeout| { - set_timeout.call2(&global, state_callback, &JsValue::from_f64(0.0)) - }) - .is_err() - { - pending_state_callbacks.borrow_mut().pop_back(); - } -} diff --git a/wasm/src/client/mod.rs b/wasm/src/client/mod.rs deleted file mode 100644 index a0bcd3a..0000000 --- a/wasm/src/client/mod.rs +++ /dev/null @@ -1,299 +0,0 @@ -// WASM client facade. Lifecycle, authentication, receive dispatch, and pipes -// live in private child modules below. -use std::cell::{Cell, RefCell}; -use std::collections::HashMap; -use std::collections::VecDeque; -use std::rc::Rc; - -use futures_channel::oneshot; -use futures_util::{FutureExt, pin_mut, select}; -use wasm_bindgen::prelude::*; - -use mtp_codec::{CommunicationValue, DecodeLimits, EncodeLimits}; - -use crate::client_pipe::{self, PendingRequest}; -use crate::error::js_error; -use crate::transport::WasmTransport; - -mod authentication; -mod connection; -mod dispatch; -mod pipes; -mod receive; -use dispatch::{PendingPing, PingTimer, wait_for_timeout}; - -const DEFAULT_REQUEST_TIMEOUT_MS: u32 = 30_000; -const MAX_SAFE_JS_INTEGER: f64 = 9_007_199_254_740_991.0; - -fn decode_limit(value: &JsValue, key: &str, default: usize) -> Result { - if value.is_null() || value.is_undefined() { - return Ok(default); - } - let value = js_sys::Reflect::get(value, &JsValue::from_str(key))?; - if value.is_null() || value.is_undefined() { - return Ok(default); - } - let Some(number) = value.as_f64() else { - return Err(js_error(format!("{key} must be a number"))); - }; - if !number.is_finite() || number.fract() != 0.0 || number < 0.0 || number > MAX_SAFE_JS_INTEGER - { - return Err(js_error(format!("{key} must be a non-negative integer"))); - } - usize::try_from(number as u64).map_err(|_| js_error(format!("{key} is out of range"))) -} - -pub(crate) fn encode_limits_from_js(value: &JsValue) -> Result { - let defaults = EncodeLimits::default(); - Ok(EncodeLimits { - max_depth: decode_limit(value, "maxDepth", defaults.max_depth)?, - max_values: decode_limit(value, "maxValues", defaults.max_values)?, - max_output_size: decode_limit(value, "maxOutputSize", defaults.max_output_size)?, - }) -} - -pub(crate) fn decode_limits_from_js(value: &JsValue) -> Result { - let defaults = DecodeLimits::default(); - Ok(DecodeLimits { - max_depth: decode_limit(value, "maxDepth", defaults.max_depth)?, - max_values: decode_limit(value, "maxValues", defaults.max_values)?, - max_blob_size: decode_limit(value, "maxBlobSize", defaults.max_blob_size)?, - max_recipients: decode_limit(value, "maxRecipients", defaults.max_recipients)?, - max_allocated_bytes: decode_limit( - value, - "maxAllocatedBytes", - defaults.max_allocated_bytes, - )?, - }) -} - -#[wasm_bindgen] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConnectionState { - Disconnected = 0, - Connecting = 1, - Connected = 2, - Failed = 3, -} - -#[wasm_bindgen] -pub struct WasmClient { - transport: Rc>>, - attempt_transport: Rc>>, - connection_generation: Rc>, - state: Rc>, - pending_state_callbacks: Rc>>, - state_callback: Closure, - pub(crate) on_message: js_sys::Function, - pub(crate) on_error: js_sys::Function, - subscriptions: Rc>>, - next_subscription_id: Rc>, - pending_requests: Rc>>, - expired_requests: Rc>>, - ping_timer: Rc>>, - pending_pings: Rc>>, - ping_ms: Rc>>, - pending_pipe_creations: client_pipe::PendingPipeCreations, - expired_pipe_creations: Rc>>, - pending_pipes: client_pipe::PendingPipes, - connection_client_id: Rc>, - on_pipe_request: Rc>>, - receive_decode_limits: Rc>>, -} - -#[wasm_bindgen] -#[allow(deprecated)] -impl WasmClient { - #[wasm_bindgen(constructor)] - pub fn new( - on_state_change: Option, - on_message: Option, - on_error: Option, - ) -> Self { - let noop = || js_sys::Function::new_no_args(""); - let on_state_change = on_state_change.unwrap_or_else(noop); - let pending_state_callbacks = Rc::new(RefCell::new(VecDeque::new())); - let callback_queue = pending_state_callbacks.clone(); - let callback = on_state_change.clone(); - let state_callback = Closure::wrap(Box::new(move || { - let state = callback_queue.borrow_mut().pop_front(); - if let Some(state) = state { - let _ = callback.call1(&JsValue::NULL, &JsValue::from(state as u8)); - } - }) as Box); - Self { - transport: Rc::new(RefCell::new(None)), - attempt_transport: Rc::new(RefCell::new(None)), - connection_generation: Rc::new(Cell::new(0)), - state: Rc::new(Cell::new(ConnectionState::Disconnected)), - pending_state_callbacks, - state_callback, - on_message: on_message.unwrap_or_else(noop), - on_error: on_error.unwrap_or_else(noop), - subscriptions: Rc::new(RefCell::new(HashMap::new())), - next_subscription_id: Rc::new(Cell::new(1)), - pending_requests: Rc::new(RefCell::new(HashMap::new())), - expired_requests: Rc::new(RefCell::new(HashMap::new())), - ping_timer: Rc::new(RefCell::new(None)), - pending_pings: Rc::new(RefCell::new(HashMap::new())), - ping_ms: Rc::new(Cell::new(None)), - pending_pipe_creations: Rc::new(RefCell::new(HashMap::new())), - expired_pipe_creations: Rc::new(RefCell::new(HashMap::new())), - pending_pipes: Rc::new(RefCell::new(HashMap::new())), - connection_client_id: Rc::new(Cell::new(0)), - on_pipe_request: Rc::new(RefCell::new(None)), - receive_decode_limits: Rc::new(RefCell::new(None)), - } - } - pub fn is_supported() -> bool { - js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("WebTransport")).unwrap_or(false) - } - - #[wasm_bindgen(getter)] - pub fn state(&self) -> u8 { - self.state.get() as u8 - } - - #[wasm_bindgen(getter)] - pub fn ping_ms(&self) -> Option { - self.ping_ms.get() - } - - #[wasm_bindgen(getter)] - pub fn client_id(&self) -> u64 { - self.connection_client_id.get() - } - - /// Apply one decoder policy to frames received by this raw WASM client. - /// The high-level SDK calls this before authentication so handshake, - /// transport, and protected opening share the same policy input. - #[wasm_bindgen] - pub fn set_receive_limits(&self, limits: JsValue) -> Result<(), JsValue> { - let parsed = if limits.is_null() || limits.is_undefined() { - None - } else { - Some(decode_limits_from_js(&limits)?) - }; - *self.receive_decode_limits.borrow_mut() = parsed; - Ok(()) - } - - pub(super) fn receive_decode_limits(&self) -> Option { - *self.receive_decode_limits.borrow() - } - - #[wasm_bindgen] - #[deprecated( - note = "use the SDK connection methods; this raw method remains for compatibility" - )] - pub async fn send(&self, frame: Vec) -> Result<(), JsValue> { - if self.state.get() != ConnectionState::Connected { - return Err(js_error("not connected")); - } - let transport = self.transport.borrow().clone(); - match transport { - Some(t) => t.send_frame(&frame).await, - None => Err(js_error("not connected")), - } - } - - #[wasm_bindgen] - pub async fn request( - &self, - frame: Vec, - response_type: Option, - timeout_ms: Option, - ) -> Result { - if self.state.get() != ConnectionState::Connected { - return Err(js_error("not connected")); - } - let generation = self.connection_generation.get(); - let Some(transport) = self.transport.borrow().clone() else { - return Err(js_error("not connected")); - }; - let request = CommunicationValue::try_from_bytes_with_type_map_and_limits( - &frame, - &transport.type_map(), - transport.decode_limits(), - ) - .map_err(|e| js_error(format!("parse request: {}", e)))?; - let request_id = request - .id() - .ok_or_else(|| js_error("request frame must contain an id"))?; - if request_id == 0 { - return Err(js_error("request frame must have a non-zero id")); - } - if client_pipe::is_expired_request(&self.expired_requests, request_id) { - return Err(js_error(format!( - "request id {request_id} recently timed out; use a new request id" - ))); - } - - let (sender, receiver) = oneshot::channel(); - let token = Rc::new(()); - { - let mut pending = self.pending_requests.borrow_mut(); - if pending.contains_key(&request_id) { - return Err(js_error(format!( - "request id {request_id} is already pending" - ))); - } - pending.insert( - request_id, - PendingRequest { - generation, - token: token.clone(), - response_type, - sender, - }, - ); - } - - let timeout_ms = timeout_ms.unwrap_or(DEFAULT_REQUEST_TIMEOUT_MS); - let response = async { - transport.send_frame(&frame).await?; - match receiver.await { - Ok(result) => result, - Err(_) => Err(js_error("request cancelled")), - } - } - .fuse(); - let timeout = wait_for_timeout(timeout_ms).fuse(); - pin_mut!(response, timeout); - select! { - result = response => { - if result.is_err() { - client_pipe::remove_pending_request(&self.pending_requests, request_id, &token); - } - result - }, - result = timeout => { - client_pipe::expire_pending_request( - &self.pending_requests, - &self.expired_requests, - request_id, - &token, - ); - result?; - Err(js_error(format!( - "request {request_id} timed out after {timeout_ms}ms" - ))) - }, - } - } - - #[wasm_bindgen] - pub fn subscribe(&self, message_type: String, callback: js_sys::Function) -> u32 { - let id = self.next_subscription_id.get(); - self.next_subscription_id.set(id.wrapping_add(1).max(1)); - self.subscriptions - .borrow_mut() - .insert(id, (message_type, callback)); - id - } - - #[wasm_bindgen] - pub fn unsubscribe(&self, id: u32) -> bool { - self.subscriptions.borrow_mut().remove(&id).is_some() - } -} diff --git a/wasm/src/client/pipes.rs b/wasm/src/client/pipes.rs deleted file mode 100644 index 2948e49..0000000 --- a/wasm/src/client/pipes.rs +++ /dev/null @@ -1,76 +0,0 @@ -use wasm_bindgen::prelude::*; - -use crate::client::{ConnectionState, WasmClient}; -use crate::client_pipe; -use crate::error::js_error; -use crate::pipe::PipeReader; - -#[wasm_bindgen] -impl WasmClient { - pub fn set_on_pipe_request(&self, callback: Option) { - *self.on_pipe_request.borrow_mut() = callback; - } - - #[wasm_bindgen] - pub async fn create_pipe( - &self, - description: &str, - ) -> Result { - if self.state.get() != ConnectionState::Connected { - return Err(js_error("not connected")); - } - let transport = self - .transport - .borrow() - .clone() - .ok_or_else(|| js_error("not connected"))?; - - let pipe_id = client_pipe::random_pipe_id()?; - client_pipe::wasm_create_pipe( - &transport, - description, - pipe_id, - &self.pending_pipe_creations, - &self.expired_pipe_creations, - self.connection_generation.get(), - &self.connection_generation, - ) - .await - } - - #[wasm_bindgen] - pub async fn accept_pipe(&self, pipe_id: u32) -> Result { - if self.state.get() != ConnectionState::Connected { - return Err(js_error("not connected")); - } - let transport = self - .transport - .borrow() - .clone() - .ok_or_else(|| js_error("not connected"))?; - - let generation = self.connection_generation.get(); - client_pipe::wasm_accept_pipe( - &transport, - pipe_id, - &self.pending_pipes, - generation, - &self.connection_generation, - ) - .await - } - - #[wasm_bindgen] - pub async fn deny_pipe(&self, pipe_id: u32) -> Result<(), JsValue> { - if self.state.get() != ConnectionState::Connected { - return Err(js_error("not connected")); - } - let transport = self - .transport - .borrow() - .clone() - .ok_or_else(|| js_error("not connected"))?; - - client_pipe::wasm_deny_pipe(&transport, pipe_id).await - } -} diff --git a/wasm/src/client/receive.rs b/wasm/src/client/receive.rs deleted file mode 100644 index 51a48bf..0000000 --- a/wasm/src/client/receive.rs +++ /dev/null @@ -1,335 +0,0 @@ -use wasm_bindgen::prelude::*; - -use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue}; - -use crate::client::{ConnectionState, WasmClient}; -use crate::client_pipe; -use crate::error::js_error; -use crate::pipe::PipeReader; -use crate::transport::WasmTransport; - -use super::MAX_SAFE_JS_INTEGER; -use super::dispatch::{ - PendingPing, PingTimer, frame_id, frame_property, frame_type, reject_pending_requests, - route_incoming_frame, set_shared_state, stop_ping_timer, -}; - -#[wasm_bindgen] -impl WasmClient { - pub fn start_protocol_pings(&self, interval_ms: u32, client_id: u64) -> Result<(), JsValue> { - self.stop_protocol_pings(); - if self.state.get() != ConnectionState::Connected { - return Err(js_error("not connected")); - } - let Some(transport) = self.transport.borrow().clone() else { - return Err(js_error("not connected")); - }; - let generation = self.connection_generation.get(); - let current_generation = self.connection_generation.clone(); - let interval_ms = i32::try_from(interval_ms.max(1_000)) - .map_err(|_| js_error("ping interval is too large"))?; - let on_error = self.on_error.clone(); - let pending_pings = self.pending_pings.clone(); - let closure = Closure::wrap(Box::new(move || { - if current_generation.get() != generation { - return; - } - let transport = transport.clone(); - let on_error = on_error.clone(); - let pending_pings = pending_pings.clone(); - let current_generation = current_generation.clone(); - wasm_bindgen_futures::spawn_local(async move { - if current_generation.get() != generation { - return; - } - let sent_at = js_sys::Date::now(); - pending_pings.borrow_mut().retain(|_, pending| { - pending.generation == generation - && sent_at - pending.sent_at < interval_ms as f64 * 3.0 - }); - let timestamp = if sent_at.is_finite() - && sent_at >= 0.0 - && sent_at <= MAX_SAFE_JS_INTEGER - && sent_at.fract() == 0.0 - { - sent_at as u64 - } else { - let _ = on_error.call1(&JsValue::NULL, &js_error("invalid clock value")); - return; - }; - let type_map = transport.type_map(); - let frame = - CommunicationValue::new_with_type_map(CommunicationType::Ping, &type_map) - .add_typed_default( - DataType::Description, - DataValue::Str("protocol ping".into()), - ) - .add_typed_default( - DataType::Timestamp, - DataValue::UnsignedNumber(timestamp as u128), - ) - .with_sender(client_id); - let Some(ping_id) = frame.id() else { - let _ = on_error.call1(&JsValue::NULL, &js_error("ping frame has no id")); - return; - }; - let frame = frame - .to_bytes() - .map_err(|e| js_error(format!("encode ping failed: {}", e))); - match frame { - Ok(frame) => { - if current_generation.get() != generation { - return; - } - pending_pings.borrow_mut().insert( - ping_id, - PendingPing { - generation, - sent_at, - }, - ); - if let Err(error) = transport.send_frame(&frame).await { - if pending_pings - .borrow() - .get(&ping_id) - .is_some_and(|ping| ping.generation == generation) - { - pending_pings.borrow_mut().remove(&ping_id); - } - let _ = on_error.call1(&JsValue::NULL, &error); - } - } - Err(error) => { - let _ = on_error.call1(&JsValue::NULL, &error); - } - } - }); - }) as Box); - - let set_interval = - js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("setInterval"))? - .dyn_into::()?; - let id = set_interval - .call2( - &JsValue::NULL, - closure.as_ref().unchecked_ref(), - &JsValue::from_f64(interval_ms as f64), - )? - .as_f64() - .filter(|value| { - value.is_finite() - && value.fract() == 0.0 - && (i32::MIN as f64..=i32::MAX as f64).contains(value) - }) - .and_then(|value| i32::try_from(value as i64).ok()) - .ok_or_else(|| js_error("setInterval did not return a valid id"))?; - *self.ping_timer.borrow_mut() = Some(PingTimer { id, closure }); - Ok(()) - } - - #[wasm_bindgen] - pub fn stop_protocol_pings(&self) { - self.pending_pings.borrow_mut().clear(); - self.ping_ms.set(None); - let Some(timer) = self.ping_timer.borrow_mut().take() else { - return; - }; - if let Ok(clear_interval) = - js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("clearInterval")) - .and_then(|value| value.dyn_into::()) - { - let _ = clear_interval.call1(&JsValue::NULL, &JsValue::from_f64(timer.id as f64)); - } - drop(timer.closure); - } - - pub(super) fn start_receive_loop( - &self, - transport: WasmTransport, - generation: u32, - client_id: u64, - ) -> bool { - if self.connection_generation.get() != generation { - transport.close(); - return false; - } - let loop_transport = transport.clone(); - self.attempt_transport.borrow_mut().take(); - *self.transport.borrow_mut() = Some(transport); - self.set_state(ConnectionState::Connected); - - let connection_generation = self.connection_generation.clone(); - let error_generation = connection_generation.clone(); - let state = self.state.clone(); - let pending_state_callbacks = self.pending_state_callbacks.clone(); - let state_callback = self.state_callback.as_ref().clone(); - let on_msg = self.on_message.clone(); - let on_err = self.on_error.clone(); - let subscriptions = self.subscriptions.clone(); - let pending_requests = self.pending_requests.clone(); - let loop_pending_requests = pending_requests.clone(); - let expired_requests = self.expired_requests.clone(); - let loop_expired_requests = expired_requests.clone(); - let ping_timer = self.ping_timer.clone(); - let pending_pings = self.pending_pings.clone(); - let loop_pending_pings = pending_pings.clone(); - let ping_ms = self.ping_ms.clone(); - let loop_ping_ms = ping_ms.clone(); - let pending_pipe_creations = self.pending_pipe_creations.clone(); - let expired_pipe_creations = self.expired_pipe_creations.clone(); - let pending_pipes = self.pending_pipes.clone(); - let loop_pending_pipes = pending_pipes.clone(); - let expected_pending_pipes = pending_pipes.clone(); - let on_pipe_request = self.on_pipe_request.clone(); - let loop_pipe_creations = pending_pipe_creations.clone(); - let loop_expired_pipe_creations = expired_pipe_creations.clone(); - let loop_generation = generation; - let frame_generation = connection_generation.clone(); - let transport_for_cleanup = self.transport.clone(); - let connection_client_id = self.connection_client_id.clone(); - wasm_bindgen_futures::spawn_local(async move { - loop_transport - .receive_loop_with_pipes( - move |frame: JsValue| { - if frame_generation.get() != loop_generation { - return; - } - let message_type = frame_type(&frame); - if let Some(ref msg_type) = message_type { - if msg_type == "PipeRequest" { - let Some(pipe_id) = frame_id(&frame).filter(|id| *id != 0) else { - return; - }; - let description = frame_property(&frame, "data") - .and_then(|data| { - let desc = js_sys::Reflect::get( - &data, - &JsValue::from_str("Description"), - ) - .ok()?; - desc.as_string() - }) - .unwrap_or_default(); - - let cb = on_pipe_request.borrow(); - if let Some(ref callback) = *cb { - let obj = js_sys::Object::new(); - let _ = js_sys::Reflect::set( - &obj, - &"pipeId".into(), - &JsValue::from_f64(pipe_id as f64), - ); - let _ = js_sys::Reflect::set( - &obj, - &"description".into(), - &JsValue::from_str(&description), - ); - let _ = callback.call1(&JsValue::NULL, &obj.into()); - } - return; - } - - if msg_type == "PipeResponse" { - let Some(pipe_id) = frame_id(&frame).filter(|id| *id != 0) else { - return; - }; - let accepted = frame_property(&frame, "data") - .and_then(|data| { - let acc = js_sys::Reflect::get( - &data, - &JsValue::from_str("Accepted"), - ) - .ok()?; - acc.as_bool() - }) - .unwrap_or(false); - - let pending = { - let mut pending = loop_pipe_creations.borrow_mut(); - if pending - .get(&pipe_id) - .is_some_and(|entry| entry.generation == loop_generation) - { - pending.remove(&pipe_id) - } else { - None - } - }; - if let Some(entry) = pending { - let _ = entry.sender.send(Ok(accepted)); - } else { - let _ = client_pipe::consume_expired_pipe_creation( - &loop_expired_pipe_creations, - pipe_id, - ); - } - return; - } - } - - route_incoming_frame( - &frame, - loop_generation, - &on_msg, - &subscriptions, - &loop_pending_requests, - &loop_expired_requests, - &loop_pending_pings, - &loop_ping_ms, - ); - }, - move |error| { - if error_generation.get() == generation { - let _ = on_err.call1(&JsValue::NULL, &error); - } - }, - move |pipe_reader: PipeReader| { - let pipe_id = pipe_reader.pipe_id(); - let mut pending = loop_pending_pipes.borrow_mut(); - if pending - .get(&pipe_id) - .is_some_and(|entry| entry.generation == loop_generation) - && let Some(entry) = pending.remove(&pipe_id) - { - let _ = entry.sender.send(Ok(pipe_reader)); - } - }, - move |pipe_id| { - expected_pending_pipes - .borrow() - .get(&pipe_id) - .is_some_and(|entry| entry.generation == loop_generation) - }, - ) - .await; - if connection_generation.get() != generation { - return; - } - if let Some(current_transport) = transport_for_cleanup.borrow_mut().take() { - current_transport.close(); - } - set_shared_state( - &state, - &pending_state_callbacks, - &state_callback, - ConnectionState::Disconnected, - ); - stop_ping_timer(&ping_timer); - pending_pings.borrow_mut().clear(); - ping_ms.set(None); - reject_pending_requests(&pending_requests, "disconnected"); - expired_requests.borrow_mut().clear(); - client_pipe::reject_pending_pipe_creations(&pending_pipe_creations, "disconnected"); - expired_pipe_creations.borrow_mut().clear(); - client_pipe::reject_pending_pipes(&pending_pipes, "disconnected"); - connection_client_id.set(0); - }); - self.connection_client_id.set(client_id); - true - } - - pub(super) fn reject_pending_requests(&self, message: &str) { - reject_pending_requests(&self.pending_requests, message); - self.expired_requests.borrow_mut().clear(); - } -} diff --git a/wasm/src/client_pipe.rs b/wasm/src/client_pipe.rs deleted file mode 100644 index 3c08863..0000000 --- a/wasm/src/client_pipe.rs +++ /dev/null @@ -1,542 +0,0 @@ -use std::cell::RefCell; -use std::collections::HashMap; -use std::rc::Rc; - -use futures_channel::oneshot; -use futures_util::{FutureExt, pin_mut, select}; -use tracing::debug; -use wasm_bindgen::prelude::*; -use wasm_bindgen_futures::JsFuture; - -use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue}; - -use crate::error::js_error; -use crate::pipe::PipeReader; -use crate::transport::WasmTransport; - -pub(crate) struct PendingRequest { - pub(crate) generation: u32, - pub(crate) token: Rc<()>, - pub(crate) response_type: Option, - pub(crate) sender: oneshot::Sender>, -} - -pub(crate) struct PendingPipeCreation { - pub(crate) generation: u32, - pub(crate) token: Rc<()>, - pub(crate) sender: oneshot::Sender>, -} -pub(crate) type PendingPipeCreations = Rc>>; -type PipeResponseReceiver = oneshot::Receiver>; -type PipeResponseCell = Rc>>; - -const DEFAULT_PIPE_CREATION_TIMEOUT_MS: u32 = 30_000; -const EXPIRED_PIPE_CREATION_TOMBSTONE_TTL_MS: f64 = 60_000.0; -const MAX_EXPIRED_PIPE_CREATION_TOMBSTONES: usize = 1024; - -pub(crate) struct PendingPipe { - pub(crate) generation: u32, - pub(crate) sender: oneshot::Sender>, -} - -pub(crate) type PendingPipes = Rc>>; - -pub(crate) fn remove_pending_request( - pending_requests: &Rc>>, - request_id: u32, - token: &Rc<()>, -) { - let mut pending = pending_requests.borrow_mut(); - if pending - .get(&request_id) - .is_some_and(|entry| Rc::ptr_eq(&entry.token, token)) - { - pending.remove(&request_id); - } -} - -const EXPIRED_REQUEST_TOMBSTONE_TTL_MS: f64 = 60_000.0; -const MAX_EXPIRED_REQUEST_TOMBSTONES: usize = 1024; - -pub(crate) fn expire_pending_request( - pending_requests: &Rc>>, - expired_requests: &Rc>>, - request_id: u32, - token: &Rc<()>, -) { - let mut pending = pending_requests.borrow_mut(); - if pending - .get(&request_id) - .is_some_and(|entry| Rc::ptr_eq(&entry.token, token)) - { - pending.remove(&request_id); - drop(pending); - let now = js_sys::Date::now(); - let mut expired = expired_requests.borrow_mut(); - expired.retain(|_, expires_at| *expires_at > now); - if expired.len() >= MAX_EXPIRED_REQUEST_TOMBSTONES - && let Some(oldest) = expired - .iter() - .min_by(|(_, left), (_, right)| left.total_cmp(right)) - .map(|(id, _)| *id) - { - expired.remove(&oldest); - } - expired.insert(request_id, now + EXPIRED_REQUEST_TOMBSTONE_TTL_MS); - } -} - -pub(crate) fn consume_expired_request( - expired_requests: &Rc>>, - request_id: u32, -) -> bool { - let now = js_sys::Date::now(); - let mut expired = expired_requests.borrow_mut(); - expired.retain(|_, expires_at| *expires_at > now); - expired.remove(&request_id).is_some() -} - -pub(crate) fn is_expired_request( - expired_requests: &Rc>>, - request_id: u32, -) -> bool { - let now = js_sys::Date::now(); - let mut expired = expired_requests.borrow_mut(); - expired.retain(|_, expires_at| *expires_at > now); - expired.contains_key(&request_id) -} - -#[wasm_bindgen(typescript_custom_section)] -const PIPE_HANDLE_TS: &str = r#" -export interface WasmPipeHandle { - wait(): Promise; - readonly pipeId: number; - readonly description: string; -} -"#; - -#[wasm_bindgen] -pub struct WasmPipeHandle { - pipe_id: u32, - description: String, - transport: WasmTransport, - response_rx: PipeResponseCell, - pending: PendingPipeCreations, - expired: Rc>>, - generation: u32, - token: Rc<()>, -} - -#[wasm_bindgen] -impl WasmPipeHandle { - pub async fn wait(&self) -> Result { - let rx = self - .response_rx - .borrow_mut() - .take() - .ok_or_else(|| js_error("handle already consumed"))?; - - let response = rx.fuse(); - let timeout = wait_for_timeout(DEFAULT_PIPE_CREATION_TIMEOUT_MS).fuse(); - pin_mut!(response, timeout); - let accepted = select! { - result = response => match result { - Ok(result) => result, - Err(_) => { - expire_pending_pipe_creation( - &self.pending, - &self.expired, - self.pipe_id, - self.generation, - &self.token, - ); - return Err(js_error("pipe handle channel closed")); - } - }, - result = timeout => { - result?; - expire_pending_pipe_creation( - &self.pending, - &self.expired, - self.pipe_id, - self.generation, - &self.token, - ); - return Err(js_error(format!( - "pipe creation timed out after {DEFAULT_PIPE_CREATION_TIMEOUT_MS}ms" - ))); - }, - }; - - match accepted { - Ok(true) => { - let writer = self - .transport - .open_pipe(self.pipe_id, &self.description) - .await?; - Ok(JsValue::from(writer)) - } - Ok(false) => Ok(JsValue::NULL), - Err(e) => Err(e), - } - } - - #[wasm_bindgen(getter)] - pub fn pipe_id(&self) -> u32 { - self.pipe_id - } - - #[wasm_bindgen(getter)] - pub fn description(&self) -> String { - self.description.clone() - } -} - -impl Drop for WasmPipeHandle { - fn drop(&mut self) { - expire_pending_pipe_creation( - &self.pending, - &self.expired, - self.pipe_id, - self.generation, - &self.token, - ); - } -} - -pub(crate) fn random_pipe_id() -> Result { - let mut bytes = [0u8; 4]; - getrandom_v04::fill(&mut bytes).map_err(|_| js_error("rng failed"))?; - Ok(u32::from_be_bytes(bytes)) -} - -pub(crate) fn reject_pending_pipe_creations(pending: &PendingPipeCreations, message: &str) { - let pending = std::mem::take(&mut *pending.borrow_mut()); - for (_, entry) in pending { - let _ = entry.sender.send(Err(js_error(message))); - } -} - -async fn wait_for_timeout(timeout_ms: u32) -> Result<(), JsValue> { - let promise = js_sys::Promise::new(&mut |resolve, reject| { - let result = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("setTimeout")) - .and_then(|value| value.dyn_into::()) - .and_then(|set_timeout| { - set_timeout.call2( - &JsValue::NULL, - &resolve, - &JsValue::from_f64(timeout_ms as f64), - ) - }); - if let Err(error) = result { - let _ = reject.call1(&JsValue::NULL, &error); - } - }); - JsFuture::from(promise).await?; - Ok(()) -} - -fn expire_pending_pipe_creation( - pending: &PendingPipeCreations, - expired: &Rc>>, - pipe_id: u32, - generation: u32, - token: &Rc<()>, -) { - let removed = { - let mut pending = pending.borrow_mut(); - if pending - .get(&pipe_id) - .is_some_and(|entry| entry.generation == generation && Rc::ptr_eq(&entry.token, token)) - { - pending.remove(&pipe_id); - true - } else { - false - } - }; - if !removed { - return; - } - let now = js_sys::Date::now(); - let mut expired = expired.borrow_mut(); - expired.retain(|_, expires_at| *expires_at > now); - if expired.len() >= MAX_EXPIRED_PIPE_CREATION_TOMBSTONES - && let Some(oldest) = expired - .iter() - .min_by(|(_, left), (_, right)| left.total_cmp(right)) - .map(|(id, _)| *id) - { - expired.remove(&oldest); - } - expired.insert(pipe_id, now + EXPIRED_PIPE_CREATION_TOMBSTONE_TTL_MS); -} - -struct PendingPipeCreationGuard { - pending: PendingPipeCreations, - expired: Rc>>, - pipe_id: u32, - generation: u32, - token: Rc<()>, - armed: bool, -} - -impl PendingPipeCreationGuard { - fn new( - pending: PendingPipeCreations, - expired: Rc>>, - pipe_id: u32, - generation: u32, - token: Rc<()>, - ) -> Self { - Self { - pending, - expired, - pipe_id, - generation, - token, - armed: true, - } - } - - fn disarm(&mut self) { - self.armed = false; - } -} - -impl Drop for PendingPipeCreationGuard { - fn drop(&mut self) { - if self.armed { - expire_pending_pipe_creation( - &self.pending, - &self.expired, - self.pipe_id, - self.generation, - &self.token, - ); - } - } -} - -struct PendingPipeGuard { - pending: PendingPipes, - pipe_id: u32, - generation: u32, -} - -impl PendingPipeGuard { - fn new(pending: PendingPipes, pipe_id: u32, generation: u32) -> Self { - Self { - pending, - pipe_id, - generation, - } - } -} - -impl Drop for PendingPipeGuard { - fn drop(&mut self) { - remove_pending_pipe(&self.pending, self.pipe_id, self.generation); - } -} - -pub(crate) fn consume_expired_pipe_creation( - expired: &Rc>>, - pipe_id: u32, -) -> bool { - let now = js_sys::Date::now(); - let mut expired = expired.borrow_mut(); - expired.retain(|_, expires_at| *expires_at > now); - expired.remove(&pipe_id).is_some() -} - -fn is_expired_pipe_creation(expired: &Rc>>, pipe_id: u32) -> bool { - let now = js_sys::Date::now(); - let mut expired = expired.borrow_mut(); - expired.retain(|_, expires_at| *expires_at > now); - expired.contains_key(&pipe_id) -} - -pub(crate) fn reject_pending_pipes(pending: &PendingPipes, message: &str) { - let pending = std::mem::take(&mut *pending.borrow_mut()); - for (_, entry) in pending { - let _ = entry.sender.send(Err(js_error(message))); - } -} - -pub(crate) async fn wasm_create_pipe( - transport: &WasmTransport, - description: &str, - pipe_id: u32, - pending_pipe_creations: &PendingPipeCreations, - expired_pipe_creations: &Rc>>, - generation: u32, - current_generation: &Rc>, -) -> Result { - let (tx, rx) = oneshot::channel(); - let token = Rc::new(()); - let mut pipe_id = pipe_id; - for _ in 0..128 { - let occupied = pipe_id == 0 - || pending_pipe_creations.borrow().contains_key(&pipe_id) - || is_expired_pipe_creation(expired_pipe_creations, pipe_id); - if !occupied { - break; - } - pipe_id = random_pipe_id()?; - } - if pipe_id == 0 - || pending_pipe_creations.borrow().contains_key(&pipe_id) - || is_expired_pipe_creation(expired_pipe_creations, pipe_id) - { - return Err(js_error("could not allocate a unique pipe id")); - } - let type_map = transport.type_map(); - let request = CommunicationValue::new_with_type_map(CommunicationType::PipeRequest, &type_map) - .with_id(pipe_id) - .add_typed_default( - DataType::Description, - DataValue::Str(description.to_string()), - ); - let request_bytes = request - .to_bytes() - .map_err(|e| js_error(format!("encode failed: {}", e)))?; - pending_pipe_creations.borrow_mut().insert( - pipe_id, - PendingPipeCreation { - generation, - token: token.clone(), - sender: tx, - }, - ); - let mut creation_guard = PendingPipeCreationGuard::new( - pending_pipe_creations.clone(), - expired_pipe_creations.clone(), - pipe_id, - generation, - token.clone(), - ); - debug!( - target = "mtp.wasm", - pipe_id, - description, - frame_len = request_bytes.len(), - "sending pipe request" - ); - if let Err(error) = transport.send_frame(&request_bytes).await { - return Err(error); - } - if current_generation.get() != generation { - return Err(js_error("connection attempt superseded")); - } - - creation_guard.disarm(); - Ok(WasmPipeHandle { - pipe_id, - description: description.to_string(), - transport: transport.clone(), - response_rx: Rc::new(RefCell::new(Some(rx))), - pending: pending_pipe_creations.clone(), - expired: expired_pipe_creations.clone(), - generation, - token, - }) -} - -pub(crate) async fn wasm_accept_pipe( - transport: &WasmTransport, - pipe_id: u32, - pending_pipes: &PendingPipes, - generation: u32, - current_generation: &Rc>, -) -> Result { - if pipe_id == 0 { - return Err(js_error("pipe id must be non-zero")); - } - if current_generation.get() != generation { - return Err(js_error("connection attempt superseded")); - } - - let type_map = transport.type_map(); - let resp = CommunicationValue::new_with_type_map(CommunicationType::PipeResponse, &type_map) - .with_id(pipe_id) - .add_typed_default(DataType::Accepted, DataValue::BoolTrue); - let resp_bytes = resp - .to_bytes() - .map_err(|e| js_error(format!("encode failed: {}", e)))?; - - let (tx, rx) = oneshot::channel(); - { - let mut pending = pending_pipes.borrow_mut(); - if pending.contains_key(&pipe_id) { - return Err(js_error(format!("pipe {pipe_id} is already pending"))); - } - pending.insert( - pipe_id, - PendingPipe { - generation, - sender: tx, - }, - ); - } - let _acceptance_guard = PendingPipeGuard::new(pending_pipes.clone(), pipe_id, generation); - - debug!( - target = "mtp.wasm", - pipe_id, - accepted = true, - frame_len = resp_bytes.len(), - "sending pipe response" - ); - if let Err(error) = transport.send_frame(&resp_bytes).await { - return Err(error); - } - if current_generation.get() != generation { - return Err(js_error("connection attempt superseded")); - } - - let response = rx.fuse(); - let timeout = wait_for_timeout(DEFAULT_PIPE_CREATION_TIMEOUT_MS).fuse(); - pin_mut!(response, timeout); - let result = select! { - result = response => match result { - Ok(result) => result, - Err(_) => { - remove_pending_pipe(pending_pipes, pipe_id, generation); - return Err(js_error("pipe closed before stream arrived")); - } - }, - result = timeout => { - result?; - remove_pending_pipe(pending_pipes, pipe_id, generation); - return Err(js_error(format!( - "pipe acceptance timed out after {DEFAULT_PIPE_CREATION_TIMEOUT_MS}ms" - ))); - }, - }; - result -} - -fn remove_pending_pipe(pending_pipes: &PendingPipes, pipe_id: u32, generation: u32) { - let mut pending = pending_pipes.borrow_mut(); - if pending - .get(&pipe_id) - .is_some_and(|entry| entry.generation == generation) - { - pending.remove(&pipe_id); - } -} - -pub(crate) async fn wasm_deny_pipe(transport: &WasmTransport, pipe_id: u32) -> Result<(), JsValue> { - if pipe_id == 0 { - return Err(js_error("pipe id must be non-zero")); - } - let type_map = transport.type_map(); - let resp = CommunicationValue::new_with_type_map(CommunicationType::PipeResponse, &type_map) - .with_id(pipe_id) - .add_typed_default(DataType::Accepted, DataValue::BoolFalse); - let resp_bytes = resp - .to_bytes() - .map_err(|e| js_error(format!("encode failed: {}", e)))?; - transport.send_frame(&resp_bytes).await -} diff --git a/wasm/src/config.rs b/wasm/src/config.rs index efeffd3..381d76e 100644 --- a/wasm/src/config.rs +++ b/wasm/src/config.rs @@ -1,23 +1,13 @@ use wasm_bindgen::prelude::*; -#[derive(Clone)] #[wasm_bindgen] pub struct ConnectionConfig { pub(crate) url: String, pub(crate) server_certificate_hashes: Option>, pub(crate) client_id: u64, pub(crate) max_message_size: u32, - pub(crate) require_pq: bool, - pub(crate) description: Option, } -/// Newer API name for the browser connection configuration. -/// -/// `ConnectionConfig` remains the concrete wasm-bindgen class for backwards -/// compatibility with the existing raw JavaScript bindings. The alias keeps -/// Rust consumers aligned with the native/WASM naming used by the public API. -pub type WasmClientConfig = ConnectionConfig; - #[wasm_bindgen] impl ConnectionConfig { #[wasm_bindgen(constructor)] @@ -26,9 +16,7 @@ impl ConnectionConfig { url, server_certificate_hashes: None, client_id: 0, - max_message_size: 16 * 1024 * 1024, - require_pq: true, - description: None, + max_message_size: 1_000_000_000, } } @@ -61,24 +49,4 @@ impl ConnectionConfig { pub fn max_message_size(&self) -> u32 { self.max_message_size } - - #[wasm_bindgen(setter)] - pub fn set_require_pq(&mut self, require_pq: bool) { - self.require_pq = require_pq; - } - - #[wasm_bindgen(getter)] - pub fn require_pq(&self) -> bool { - self.require_pq - } - - #[wasm_bindgen(setter)] - pub fn set_description(&mut self, description: String) { - self.description = Some(description); - } - - #[wasm_bindgen(getter)] - pub fn description(&self) -> Option { - self.description.clone() - } } diff --git a/wasm/src/crypto.rs b/wasm/src/crypto.rs index baa2f32..48aa4dc 100644 --- a/wasm/src/crypto.rs +++ b/wasm/src/crypto.rs @@ -1,75 +1,12 @@ use wasm_bindgen::prelude::*; -use zeroize::Zeroizing; -use mtp_codec::{ - DataValue, DecodeLimits, EncodeLimits, MtpProtectionPurpose, PROTOCOL_VERSION, - ProtectionPolicy, ProtectionPurpose, SealedRelayBuilder, SignaturePolicy, TypeMap, -}; use mtp_crypto::{ - AeadDecrypt, AeadEncrypt, DualSigner, Ed25519Signer, HybridKem, KemPrivateKey, KemPublicKey, + AeadDecrypt, AeadEncrypt, ChaCha20Poly1305, Ed25519Signer, KemPrivateKey, KemPublicKey, Keyring, PublicKeyBundle, SignaturePqPrivateKey, SignaturePqPublicKey, SignaturePrivateKey, - SignaturePublicKey, SignatureScheme, XChaCha20Poly1305, sha256, sha256_double, + SignaturePublicKey, SignatureScheme, sha256, sha256_double, }; -use crate::error::{from_protection_error, js_error}; -use crate::relay::{decode_error, decode_frame, relay_error, structured_error}; - -fn decode_data_value(value: &[u8]) -> Result { - DataValue::try_from_bytes_with_limits(value, DecodeLimits::default()).map_err(|error| { - let value = decode_error(error, "DataValue decoding failed"); - let _ = js_sys::Reflect::set( - &value, - &JsValue::from_str("code"), - &JsValue::from_str("invalid-data-value"), - ); - value - }) -} - -fn decode_public_key_bundle( - bytes: &[u8], - index: Option, -) -> Result { - PublicKeyBundle::from_bytes(bytes).map_err(|e| { - let prefix = index - .map(|index| format!("recipient {index}: ")) - .unwrap_or_default(); - js_error(format!("{prefix}public bundle initialization failed: {e}")) - }) -} - -pub(crate) fn public_key_bundles_from_js(value: &JsValue) -> Result, JsValue> { - if js_sys::Uint8Array::instanceof(value) { - return Ok(vec![decode_public_key_bundle( - &js_sys::Uint8Array::new(value).to_vec(), - None, - )?]); - } - - if !js_sys::Array::is_array(value) { - return Err(js_error( - "recipient public key bundles must be a Uint8Array or an array of Uint8Arrays", - )); - } - - let array = js_sys::Array::from(value); - if array.length() == 0 { - return Err(js_error( - "at least one recipient public key bundle is required", - )); - } - - array - .iter() - .enumerate() - .map(|(index, value)| { - if !js_sys::Uint8Array::instanceof(&value) { - return Err(js_error(format!("recipient {index} must be a Uint8Array"))); - } - decode_public_key_bundle(&js_sys::Uint8Array::new(&value).to_vec(), Some(index)) - }) - .collect() -} +use crate::error::js_error; // =========================================================================== // Keyring @@ -82,26 +19,17 @@ pub struct WasmKeyring { #[wasm_bindgen] impl WasmKeyring { - /// Serialise the keyring to bytes and report malformed caller-owned - /// material as a JavaScript exception. + /// Serialise the keyring to bytes. #[wasm_bindgen] - pub fn to_bytes(&self) -> Result, JsValue> { - self.try_to_bytes() - } - - #[wasm_bindgen] - pub fn try_to_bytes(&self) -> Result, JsValue> { - self.inner - .try_to_bytes() - .map(|bytes| bytes.to_vec()) - .map_err(|error| js_error(format!("Keyring serialization failed: {error}"))) + pub fn to_bytes(&self) -> Vec { + self.inner.to_bytes() } /// Deserialise a keyring from bytes. #[wasm_bindgen] pub fn from_bytes(bytes: &[u8]) -> Result { let inner = Keyring::from_bytes(bytes) - .map_err(|e| js_error(format!("Keyring::from_bytes: {}", e)))?; + .map_err(|e| js_error(&format!("Keyring::from_bytes: {}", e)))?; Ok(Self { inner }) } @@ -112,40 +40,12 @@ impl WasmKeyring { inner: self.inner.public_key_bundle(), } } - - /// Validate that all full-suite public/private components correspond. - /// Role-specific browser keyrings may intentionally fail this check. - #[wasm_bindgen] - pub fn validate_full(&self) -> Result<(), JsValue> { - self.inner - .validate_full() - .map_err(|e| js_error(format!("Keyring::validate_full: {e}"))) - } - - /// Validate the KEM public/private pair without requiring PQ signing - /// material. This is the invariant needed by envelope recipients and - /// sealed-relay clients that explicitly choose Ed25519 signatures. - #[wasm_bindgen] - pub fn validate_encryption(&self) -> Result<(), JsValue> { - self.inner - .validate_encryption() - .map_err(|e| js_error(format!("Keyring::validate_encryption: {e}"))) - } } /// Generate a full keyring with KEM, ML-DSA, and Ed25519 keys. #[wasm_bindgen] -pub fn keyring_generate() -> Result, JsValue> { - keyring_generate_checked() -} - -/// Generate a full keyring and report serialization failures to JavaScript. -#[wasm_bindgen] -pub fn keyring_generate_checked() -> Result, JsValue> { - Keyring::generate() - .try_to_bytes() - .map(|bytes| bytes.to_vec()) - .map_err(|error| js_error(format!("generated keyring serialization failed: {error}"))) +pub fn keyring_generate() -> Vec { + Keyring::generate().to_bytes() } /// Build a [`Keyring`] containing only an Ed25519 keypair (no KEM, no ML-DSA). @@ -168,10 +68,7 @@ pub fn keyring_from_ed25519(secret_key: &[u8], public_key: &[u8]) -> Result Result, JsValue> { - self.try_to_bytes() - } - - #[wasm_bindgen] - pub fn try_to_bytes(&self) -> Result, JsValue> { - self.inner - .try_as_bytes() - .map_err(|error| js_error(format!("public key bundle serialization failed: {error}"))) + pub fn to_bytes(&self) -> Vec { + self.inner.as_bytes() } #[wasm_bindgen] pub fn from_bytes(bytes: &[u8]) -> Result { let inner = PublicKeyBundle::from_bytes(bytes) - .map_err(|e| js_error(format!("PublicKeyBundle::from_bytes: {}", e)))?; + .map_err(|e| js_error(&format!("PublicKeyBundle::from_bytes: {}", e)))?; Ok(Self { inner }) } - - /// Deserialise an explicitly partial bundle for development-only key - /// material. Protocol encryption and signature verification use the - /// strict `from_bytes` parser above. - #[wasm_bindgen] - pub fn from_bytes_unvalidated(bytes: &[u8]) -> Result { - let inner = PublicKeyBundle::from_bytes_unvalidated(bytes) - .map_err(|e| js_error(format!("PublicKeyBundle::from_bytes_unvalidated: {}", e)))?; - Ok(Self { inner }) - } -} - -// =========================================================================== -// Hybrid KEM (X25519 + ML-KEM-768) -// =========================================================================== - -/// KEM encapsulation result returned to JavaScript. -/// -/// `shared_secret` is the symmetric key both parties will derive; `ciphertext` -/// is the KEM ciphertext that must be sent to the recipient so they can -/// decapsulate and recover the same shared secret. -#[wasm_bindgen] -pub struct WasmEncapsulated { - inner_shared_secret: Zeroizing>, - inner_ciphertext: Vec, -} - -/// A short-lived ephemeral hybrid-KEM keypair for the forward-secure pipe -/// handshake. The secret is zeroized when the object is freed. -#[wasm_bindgen] -pub struct WasmKemKeypair { - secret: Zeroizing>, - public: Vec, -} - -#[wasm_bindgen] -impl WasmKemKeypair { - #[wasm_bindgen(getter)] - pub fn public_key(&self) -> Vec { - self.public.clone() - } - - #[wasm_bindgen(getter)] - pub fn secret_key(&self) -> Vec { - self.secret.to_vec() - } -} - -#[wasm_bindgen] -pub fn wasm_kem_generate_keypair() -> WasmKemKeypair { - let (secret, public) = HybridKem::generate_keypair(); - WasmKemKeypair { - secret: Zeroizing::new(secret.as_bytes().to_vec()), - public: public.as_bytes().to_vec(), - } -} - -#[wasm_bindgen] -impl WasmEncapsulated { - /// Symmetric secret derived during encapsulation. - #[wasm_bindgen(getter)] - pub fn shared_secret(&self) -> Vec { - self.inner_shared_secret.to_vec() - } - - /// KEM ciphertext to transmit to the recipient. - #[wasm_bindgen(getter)] - pub fn ciphertext(&self) -> Vec { - self.inner_ciphertext.clone() - } -} - -/// Encapsulate a fresh shared secret for `recipient_public_key`. -/// -/// Returns a [`WasmEncapsulated`] containing the shared secret and the KEM -/// ciphertext that the recipient needs to recover it via -/// [`wasm_kem_decapsulate`]. -#[wasm_bindgen] -pub fn wasm_kem_encapsulate(recipient_public_key: &[u8]) -> Result { - let pk = KemPublicKey::new(recipient_public_key.to_vec()); - let enc = HybridKem::encapsulate(&pk) - .map_err(|e| js_error(format!("kem_encapsulate failed: {}", e)))?; - Ok(WasmEncapsulated { - inner_shared_secret: enc.shared_secret, - inner_ciphertext: enc.ciphertext, - }) -} - -/// Decapsulate a KEM `ciphertext` with the recipient's `private_key`. -/// -/// Returns the same shared secret the initiator obtained from -/// [`wasm_kem_encapsulate`]. -#[wasm_bindgen] -pub fn wasm_kem_decapsulate( - recipient_private_key: &[u8], - ciphertext: &[u8], -) -> Result, JsValue> { - let sk = KemPrivateKey::new(recipient_private_key.to_vec()); - HybridKem::decapsulate(&sk, ciphertext) - .map(|secret| secret.to_vec()) - .map_err(|e| js_error(format!("kem_decapsulate failed: {}", e))) } // =========================================================================== @@ -327,7 +116,7 @@ pub fn wasm_kem_decapsulate( #[wasm_bindgen] pub struct WasmChaCha20Poly1305 { - inner: XChaCha20Poly1305, + inner: ChaCha20Poly1305, } #[wasm_bindgen] @@ -341,7 +130,7 @@ impl WasmChaCha20Poly1305 { let mut k = [0u8; 32]; k.copy_from_slice(&key); Ok(Self { - inner: XChaCha20Poly1305::new(k), + inner: ChaCha20Poly1305::new(k), }) } @@ -351,7 +140,7 @@ impl WasmChaCha20Poly1305 { pub fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result, JsValue> { self.inner .encrypt(plaintext, aad) - .map_err(|e| js_error(format!("encrypt failed: {}", e))) + .map_err(|e| js_error(&format!("encrypt failed: {}", e))) } /// Decrypt `nonce || ciphertext` with `aad`. @@ -359,7 +148,7 @@ impl WasmChaCha20Poly1305 { pub fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result, JsValue> { self.inner .decrypt(ciphertext, aad) - .map_err(|e| js_error(format!("decrypt failed: {}", e))) + .map_err(|e| js_error(&format!("decrypt failed: {}", e))) } } @@ -379,7 +168,7 @@ impl WasmEd25519Signer { pub fn new(secret_key: Vec) -> Result { let sk = SignaturePrivateKey::new(secret_key); let inner = - Ed25519Signer::new(&sk).map_err(|e| js_error(format!("Ed25519Signer::new: {}", e)))?; + Ed25519Signer::new(&sk).map_err(|e| js_error(&format!("Ed25519Signer::new: {}", e)))?; Ok(Self { inner }) } @@ -388,7 +177,7 @@ impl WasmEd25519Signer { pub fn sign(&self, message: &[u8]) -> Result, JsValue> { self.inner .sign(message) - .map_err(|e| js_error(format!("sign failed: {}", e))) + .map_err(|e| js_error(&format!("sign failed: {}", e))) } /// Verify `signature` against `message`. @@ -396,7 +185,7 @@ impl WasmEd25519Signer { pub fn verify(&self, message: &[u8], signature: &[u8]) -> Result<(), JsValue> { self.inner .verify(message, signature) - .map_err(|e| js_error(format!("verify failed: {}", e))) + .map_err(|e| js_error(&format!("verify failed: {}", e))) } } @@ -441,7 +230,7 @@ pub fn ed25519_verify( ) -> Result<(), JsValue> { let pk = SignaturePublicKey::new(public_key); mtp_crypto::verify_ed25519(&pk, message, signature) - .map_err(|e| js_error(format!("verify_ed25519 failed: {}", e))) + .map_err(|e| js_error(&format!("verify_ed25519 failed: {}", e))) } // =========================================================================== @@ -464,14 +253,6 @@ pub fn wasm_sha256_double(data: &[u8]) -> Vec { // KDF // =========================================================================== -/// Length, in bytes, of symmetric keys produced by the MTP key-derivation -/// bindings. SDKs should query this instead of duplicating the crypto -/// primitive's output size. -#[wasm_bindgen] -pub fn mtp_symmetric_key_length() -> u32 { - 32 -} - /// HKDF-expand: derive `len` bytes from `ikm` with `salt` and `info`. #[wasm_bindgen] pub fn wasm_hkdf_expand( @@ -481,7 +262,7 @@ pub fn wasm_hkdf_expand( len: usize, ) -> Result, JsValue> { mtp_crypto::hkdf_expand(ikm, salt, info, len) - .map_err(|e| js_error(format!("hkdf_expand failed: {}", e))) + .map_err(|e| js_error(&format!("hkdf_expand failed: {}", e))) } /// Derive a 32-byte encryption key from `ikm` with `salt` and `context`. @@ -493,485 +274,7 @@ pub fn wasm_derive_encryption_key( ) -> Result, JsValue> { mtp_crypto::derive_encryption_key(ikm, salt, context) .map(|key| key.to_vec()) - .map_err(|e| js_error(format!("derive_encryption_key failed: {}", e))) -} - -/// Derive a 32-byte key from a passphrase using explicit Argon2id parameters. -/// The salt and parameters are part of the caller's protected-data format. -#[wasm_bindgen] -pub fn wasm_argon2id( - passphrase: &[u8], - salt: &[u8], - memory_kib: u32, - iterations: u32, - lanes: u32, -) -> Result, JsValue> { - mtp_crypto::derive_password_key(passphrase, salt, memory_kib, iterations, lanes) - .map(|key| key.to_vec()) - .map_err(|e| js_error(format!("argon2id password derivation failed: {e}"))) -} - -/// Signature suites accepted by high-level protected-value APIs. -pub const PROTECTION_SIGNATURE_SUITE_ED25519: u8 = 0x01; -pub const PROTECTION_SIGNATURE_SUITE_DUAL: u8 = 0x03; - -pub(crate) fn protection_policy_from_suite(suite: u8) -> Result { - let signature = match suite { - 0 => SignaturePolicy::AnySupported, - PROTECTION_SIGNATURE_SUITE_ED25519 => SignaturePolicy::Ed25519, - PROTECTION_SIGNATURE_SUITE_DUAL => SignaturePolicy::Dual, - _ => { - return Err(js_error(format!( - "unknown protection signature suite: {suite}" - ))); - } - }; - Ok(ProtectionPolicy { signature }) -} - -pub(crate) enum RelaySigner { - Ed25519(Ed25519Signer), - Dual(DualSigner), -} - -impl SignatureScheme for RelaySigner { - fn algorithm(&self) -> u8 { - match self { - Self::Ed25519(signer) => signer.algorithm(), - Self::Dual(signer) => signer.algorithm(), - } - } - - fn sign(&self, message: &[u8]) -> Result, mtp_crypto::CryptoError> { - match self { - Self::Ed25519(signer) => signer.sign(message), - Self::Dual(signer) => signer.sign(message), - } - } - - fn verify(&self, message: &[u8], signature: &[u8]) -> Result<(), mtp_crypto::CryptoError> { - match self { - Self::Ed25519(signer) => signer.verify(message, signature), - Self::Dual(signer) => signer.verify(message, signature), - } - } -} - -pub(crate) fn relay_signer_from_keyring( - keyring: &Keyring, - suite: u8, -) -> Result { - match suite { - PROTECTION_SIGNATURE_SUITE_ED25519 => { - keyring - .validate_ed25519_signing() - .map_err(|e| js_error(format!("signing key validation failed: {e}")))?; - Ed25519Signer::new(&keyring.sig_cl_secret_key) - .map(RelaySigner::Ed25519) - .map_err(|e| js_error(format!("signer initialization failed: {e}"))) - } - PROTECTION_SIGNATURE_SUITE_DUAL => { - keyring - .validate_dual_signing() - .map_err(|e| js_error(format!("dual signing key validation failed: {e}")))?; - DualSigner::new( - &keyring.sig_cl_secret_key, - &keyring.sig_pq_secret_key, - &keyring.sig_pq_public_key, - ) - .map(RelaySigner::Dual) - .map_err(|e| js_error(format!("dual signer initialization failed: {e}"))) - } - _ => Err(js_error(format!( - "unknown protection signature suite: {suite}" - ))), - } -} - -/// Sign a serialized `DataValue` using the selected suite from a serialized -/// keyring. -#[wasm_bindgen] -pub fn sign_data_value_with_keyring( - value: &[u8], - signer_id: u64, - purpose: u8, - keyring: &[u8], - signature_suite: u8, -) -> Result, JsValue> { - let value = decode_data_value(value)?; - let keyring = Keyring::from_bytes(keyring) - .map_err(|e| js_error(format!("keyring initialization failed: {e}")))?; - let signer = relay_signer_from_keyring(&keyring, signature_suite)?; - value - .sign(signer_id, ProtectionPurpose::from(purpose), &signer) - .map_err(from_protection_error)? - .to_bytes() - .map_err(|e| js_error(format!("sign failed: {e}"))) -} - -/// Verify a serialized `Signed` wrapper while enforcing the receiver's -/// required signature suite. `0` retains the legacy any-supported behavior; -/// new protocol callers should pass one of the exported suite constants. -#[wasm_bindgen] -pub fn verify_data_value_with_policy( - value: &[u8], - public_key_bundle: &[u8], - expected_signer_id: u64, - expected_purpose: u8, - signature_suite: u8, -) -> Result<(), JsValue> { - let value = decode_data_value(value)?; - let bundle = decode_public_key_bundle(public_key_bundle, None)?; - let result = if signature_suite == 0 { - value.verify_with_policy( - expected_signer_id, - &bundle, - ProtectionPurpose::from(expected_purpose), - ProtectionPolicy::any_supported(), - ) - } else { - value.verify_with_policy( - expected_signer_id, - &bundle, - ProtectionPurpose::from(expected_purpose), - protection_policy_from_suite(signature_suite)?, - ) - }; - result.map_err(from_protection_error) -} - -/// Encrypt a serialized `DataValue` for one recipient using the canonical -/// multi-recipient envelope. -#[wasm_bindgen] -pub fn encrypt_data_value( - value: &[u8], - recipient_public_key_bundle: &[u8], - purpose: u8, -) -> Result, JsValue> { - let value = decode_data_value(value)?; - let recipient = decode_public_key_bundle(recipient_public_key_bundle, None)?; - let encrypted = value - .encrypt_for(&[recipient], ProtectionPurpose::from(purpose)) - .map_err(from_protection_error)?; - encrypted - .to_bytes() - .map_err(|e| js_error(format!("encryption failed: {e}"))) -} - -/// Encrypt a serialized `DataValue` for one or more recipients. -/// -/// `recipient_public_key_bundles` may be a single `Uint8Array` for the common -/// case or an array of serialized public-key bundles. The array form uses the -/// same canonical envelope as native multi-recipient encryption. -#[wasm_bindgen] -pub fn encrypt_data_value_for_recipients( - value: &[u8], - recipient_public_key_bundles: JsValue, - purpose: u8, -) -> Result, JsValue> { - let value = decode_data_value(value)?; - let recipients = public_key_bundles_from_js(&recipient_public_key_bundles)?; - value - .encrypt_for(&recipients, ProtectionPurpose::from(purpose)) - .map_err(from_protection_error)? - .to_bytes() - .map_err(|e| js_error(format!("encryption failed: {e}"))) -} - -/// Decrypt a serialized `Encrypted` wrapper with a serialized keyring. -/// The expected purpose is supplied by the protocol caller, not taken from -/// the untrusted encrypted wrapper. -#[wasm_bindgen] -pub fn decrypt_data_value( - value: &[u8], - keyring: &[u8], - expected_purpose: u8, -) -> Result, JsValue> { - let value = decode_data_value(value)?; - let keyring = Keyring::from_bytes(keyring) - .map_err(|e| js_error(format!("keyring initialization failed: {e}")))?; - let opened = value - .decrypt(&keyring, ProtectionPurpose::from(expected_purpose)) - .map_err(from_protection_error)?; - opened - .to_bytes() - .map_err(|e| js_error(format!("decryption failed: {e}"))) -} - -/// Decrypt using a caller-supplied local key history. Recipient key -/// identifiers remain absent from the serialized envelope. -#[wasm_bindgen] -pub fn decrypt_data_value_with_keyrings( - value: &[u8], - keyrings: JsValue, - expected_purpose: u8, -) -> Result, JsValue> { - let value = decode_data_value(value)?; - let keyrings = keyrings_from_js(&keyrings)?; - let references: Vec<&Keyring> = keyrings.iter().collect(); - value - .decrypt_with_keyrings_and_limits( - &references, - ProtectionPurpose::from(expected_purpose), - DecodeLimits::default(), - ) - .map_err(from_protection_error)? - .to_bytes() - .map_err(|e| js_error(format!("decryption failed: {e}"))) -} - -pub(crate) fn keyrings_from_js(value: &JsValue) -> Result, JsValue> { - let keyring_bytes: Vec> = if js_sys::Uint8Array::instanceof(value) { - vec![js_sys::Uint8Array::new(value).to_vec()] - } else if js_sys::Array::is_array(value) { - let array = js_sys::Array::from(value); - array - .iter() - .enumerate() - .map(|(index, value)| { - if !js_sys::Uint8Array::instanceof(&value) { - return Err(js_error(format!("keyring {index} must be a Uint8Array"))); - } - Ok(js_sys::Uint8Array::new(&value).to_vec()) - }) - .collect::>()? - } else { - return Err(js_error( - "keyrings must be a Uint8Array or an array of Uint8Arrays", - )); - }; - if keyring_bytes.is_empty() { - return Err(js_error("at least one keyring is required")); - } - keyring_bytes - .iter() - .map(|bytes| { - Keyring::from_bytes(bytes) - .map_err(|e| js_error(format!("keyring initialization failed: {e}"))) - }) - .collect() -} - -/// Protection purposes used by the generic browser relay envelope. -/// -/// The outer encryption purpose is intentionally generic: the actual -/// application operation is inside the encrypted metadata container. -pub const RELAY_METADATA_ENCRYPTION_PURPOSE: u8 = - MtpProtectionPurpose::RelayMetadataEncryption.value(); -pub const RELAY_CONTENT_SIGNATURE_PURPOSE: u8 = MtpProtectionPurpose::RelayContentSignature.value(); -pub const RELAY_CONTENT_ENCRYPTION_PURPOSE: u8 = - MtpProtectionPurpose::RelayContentEncryption.value(); -pub const RELAY_METADATA_SIGNATURE_PURPOSE: u8 = - MtpProtectionPurpose::RelayMetadataSignature.value(); - -/// Return the canonical MTP relay metadata-encryption purpose. -#[wasm_bindgen] -pub fn mtp_relay_metadata_encryption_purpose() -> u8 { - MtpProtectionPurpose::RelayMetadataEncryption.value() -} - -/// Return the canonical MTP relay content-signature purpose. -#[wasm_bindgen] -pub fn mtp_relay_content_signature_purpose() -> u8 { - MtpProtectionPurpose::RelayContentSignature.value() -} - -/// Return the canonical MTP relay content-encryption purpose. -#[wasm_bindgen] -pub fn mtp_relay_content_encryption_purpose() -> u8 { - MtpProtectionPurpose::RelayContentEncryption.value() -} - -/// Return the canonical MTP relay metadata-signature purpose. -#[wasm_bindgen] -pub fn mtp_relay_metadata_signature_purpose() -> u8 { - MtpProtectionPurpose::RelayMetadataSignature.value() -} - -/// Return the canonical MTP pipe-session signature purpose. -#[wasm_bindgen] -pub fn mtp_pipe_session_signature_purpose() -> u8 { - MtpProtectionPurpose::PipeSessionSignature.value() -} - -/// Return the canonical MTP pipe-session encryption purpose. -#[wasm_bindgen] -pub fn mtp_pipe_session_encryption_purpose() -> u8 { - MtpProtectionPurpose::PipeSessionEncryption.value() -} - -#[wasm_bindgen] -pub fn mtp_protection_signature_suite_ed25519() -> u8 { - PROTECTION_SIGNATURE_SUITE_ED25519 -} - -#[wasm_bindgen] -pub fn mtp_protection_signature_suite_dual() -> u8 { - PROTECTION_SIGNATURE_SUITE_DUAL -} - -/// Explicit compatibility policy value accepting any signature suite -/// supported by this WASM build. New callers should prefer a fixed suite. -#[wasm_bindgen] -pub fn mtp_protection_signature_suite_any_supported() -> u8 { - 0 -} - -/// Forward a sealed relay frame to another clear next hop without opening or -/// re-encoding its authenticated encrypted payload. -#[wasm_bindgen] -pub fn forward_encrypted_relay_frame( - frame: &[u8], - next_hop_receiver_id: u64, -) -> Result, JsValue> { - let frame = decode_frame(frame)?; - mtp_codec::forward_relay_frame(&frame, next_hop_receiver_id) - .map_err(relay_error)? - .to_bytes() - .map_err(|e| structured_error("invalid-frame", format!("relay frame encoding failed: {e}"))) -} - -/// Convert browser values and build a sealed relay frame through the native -/// codec builder. The builder owns the protected relay layout so native and -/// browser callers cannot silently diverge. -#[allow(clippy::too_many_arguments)] -fn build_encrypted_relay_frame_impl( - message_type: &str, - data: JsValue, - signer_id: u64, - final_recipient_id: u64, - next_hop_id: u64, - message_id: &str, - created_at: u64, - encoded_metadata: Option>, - signer: &dyn SignatureScheme, - metadata_recipient_public_key_bundles: JsValue, - content_recipient_public_key_bundles: JsValue, - limits: JsValue, -) -> Result, JsValue> { - let tm = TypeMap::new(PROTOCOL_VERSION); - let encode_limits = if limits.is_null() || limits.is_undefined() { - EncodeLimits::default() - } else { - crate::client::encode_limits_from_js(&limits)? - }; - let relay_options = - crate::relay::relay_open_options(ProtectionPolicy::any_supported(), &limits)?; - let application_content = - crate::frame::js_to_data_value_with_limits(&data, &tm, encode_limits)?; - let application_metadata = encoded_metadata - .as_deref() - .map(|bytes| { - DataValue::try_from_bytes_with_limits( - bytes, - DecodeLimits::for_transport_message_size(encode_limits.max_output_size as u64), - ) - .map_err(|error| crate::relay::decode_error(error, "metadata decoding failed")) - }) - .transpose()?; - let content_recipients = public_key_bundles_from_js(&content_recipient_public_key_bundles)?; - let metadata_recipients = public_key_bundles_from_js(&metadata_recipient_public_key_bundles)?; - - let builder = SealedRelayBuilder::new( - message_type, - application_content, - signer_id, - final_recipient_id, - next_hop_id, - signer, - ) - .message_id(message_id) - .created_at(created_at) - .metadata_recipients(metadata_recipients) - .content_recipients(content_recipients) - .encode_limits(encode_limits) - .protected_limits(relay_options.protected_limits) - .type_map(&tm); - let builder = match application_metadata { - Some(metadata) => builder.metadata(metadata), - None => builder, - }; - - builder - .build() - .map_err(relay_error)? - .to_bytes_with_limits(encode_limits) - .map_err(|e| js_error(format!("relay frame encoding failed: {e}"))) -} - -/// Build a relay frame using an explicit Ed25519 or dual-signature policy. -/// `created_at` is Unix epoch milliseconds. -#[wasm_bindgen] -#[allow(clippy::too_many_arguments)] -pub fn build_encrypted_relay_frame_with_keyring( - message_type: &str, - data: JsValue, - signer_id: u64, - final_recipient_id: u64, - next_hop_id: u64, - message_id: &str, - created_at: u64, - encoded_metadata: Option>, - keyring_bytes: &[u8], - signature_suite: u8, - metadata_recipient_public_key_bundles: JsValue, - content_recipient_public_key_bundles: JsValue, -) -> Result, JsValue> { - let keyring = Keyring::from_bytes(keyring_bytes) - .map_err(|e| js_error(format!("keyring initialization failed: {e}")))?; - let signer = relay_signer_from_keyring(&keyring, signature_suite)?; - build_encrypted_relay_frame_impl( - message_type, - data, - signer_id, - final_recipient_id, - next_hop_id, - message_id, - created_at, - encoded_metadata, - &signer, - metadata_recipient_public_key_bundles, - content_recipient_public_key_bundles, - JsValue::UNDEFINED, - ) -} - -/// Build a sealed relay frame with explicit encoder and semantic field -/// limits. The same limits are applied by the native relay builder. -#[wasm_bindgen] -#[allow(clippy::too_many_arguments)] -pub fn build_encrypted_relay_frame_with_keyring_with_limits( - message_type: &str, - data: JsValue, - signer_id: u64, - final_recipient_id: u64, - next_hop_id: u64, - message_id: &str, - created_at: u64, - encoded_metadata: Option>, - keyring_bytes: &[u8], - signature_suite: u8, - metadata_recipient_public_key_bundles: JsValue, - content_recipient_public_key_bundles: JsValue, - limits: JsValue, -) -> Result, JsValue> { - let keyring = Keyring::from_bytes(keyring_bytes) - .map_err(|e| js_error(format!("keyring initialization failed: {e}")))?; - let signer = relay_signer_from_keyring(&keyring, signature_suite)?; - build_encrypted_relay_frame_impl( - message_type, - data, - signer_id, - final_recipient_id, - next_hop_id, - message_id, - created_at, - encoded_metadata, - &signer, - metadata_recipient_public_key_bundles, - content_recipient_public_key_bundles, - limits, - ) + .map_err(|e| js_error(&format!("derive_encryption_key failed: {}", e))) } #[cfg(test)] @@ -1018,38 +321,11 @@ mod tests { }, }; - let bytes = bundle.try_to_bytes().expect("bundle serialization"); - let restored = WasmPublicKeyBundle::from_bytes_unvalidated(&bytes) - .expect("from_bytes_unvalidated failed"); + let bytes = bundle.to_bytes(); + let restored = WasmPublicKeyBundle::from_bytes(&bytes).expect("from_bytes failed"); assert_eq!(restored.sig_cl_public_key(), pk); } - // ------------------------------------------------------------------ - // KEM encapsulate / decapsulate - // ------------------------------------------------------------------ - - #[wasm_bindgen_test] - fn kem_encapsulate_decapsulate_roundtrip() { - let (sk, pk) = HybridKem::generate_keypair(); - let enc = wasm_kem_encapsulate(pk.as_bytes()).expect("encapsulate failed"); - let ss = - wasm_kem_decapsulate(sk.as_bytes(), &enc.ciphertext()).expect("decapsulate failed"); - assert_eq!(enc.shared_secret(), ss); - } - - #[wasm_bindgen_test] - fn kem_encapsulate_invalid_public_key_fails() { - let bad = vec![0u8; 16]; - assert!(wasm_kem_encapsulate(&bad).is_err()); - } - - #[wasm_bindgen_test] - fn kem_decapsulate_invalid_ciphertext_fails() { - let (sk, _pk) = HybridKem::generate_keypair(); - let bad = vec![0u8; 32]; - assert!(wasm_kem_decapsulate(sk.as_bytes(), &bad).is_err()); - } - // ------------------------------------------------------------------ // ChaCha20-Poly1305 // ------------------------------------------------------------------ @@ -1214,80 +490,4 @@ mod tests { wasm_derive_encryption_key(b"pass2", b"salt", b"context").expect("derive failed"); assert_ne!(key, key2); } - - // ------------------------------------------------------------------ - // DataValue protection - // ------------------------------------------------------------------ - - #[wasm_bindgen_test] - fn signed_data_value_can_be_verified_through_wasm() { - let keyring = Keyring::generate(); - let value = DataValue::Str("signed through wasm".into()) - .to_bytes() - .expect("value encoding failed"); - let keyring_bytes = keyring.try_to_bytes().expect("keyring serialization"); - let signed = sign_data_value_with_keyring( - &value, - 0xfeed_beef, - 7, - &keyring_bytes, - PROTECTION_SIGNATURE_SUITE_ED25519, - ) - .expect("sign_data_value_with_keyring failed"); - let bundle = keyring.public_key_bundle(); - - verify_data_value_with_policy( - &signed, - &bundle.try_as_bytes().expect("bundle serialization"), - 0xfeed_beef, - 7, - PROTECTION_SIGNATURE_SUITE_ED25519, - ) - .expect("verify_data_value_with_policy failed"); - let wrong_bundle = Keyring::generate().public_key_bundle(); - assert!( - verify_data_value_with_policy( - &signed, - &wrong_bundle.try_as_bytes().expect("bundle serialization"), - 0xfeed_beef, - 7, - PROTECTION_SIGNATURE_SUITE_ED25519, - ) - .is_err() - ); - } - - #[wasm_bindgen_test] - fn encrypted_data_value_can_be_opened_through_wasm() { - let keyring = Keyring::generate(); - let recipient = keyring.public_key_bundle(); - let value = DataValue::Array(vec![DataValue::BoolTrue, DataValue::UnsignedNumber(42)]) - .to_bytes() - .expect("value encoding failed"); - let recipient_bytes = recipient.try_as_bytes().expect("recipient serialization"); - let encrypted = - encrypt_data_value(&value, &recipient_bytes, 9).expect("encrypt_data_value failed"); - let keyring_bytes = keyring.try_to_bytes().expect("keyring serialization"); - let decrypted = - decrypt_data_value(&encrypted, &keyring_bytes, 9).expect("decrypt_data_value failed"); - - assert_eq!(decrypted, value); - - let second_keyring = Keyring::generate(); - let second_recipient = second_keyring.public_key_bundle(); - let recipients = js_sys::Array::new(); - let second_recipient_bytes = second_recipient - .try_as_bytes() - .expect("second recipient serialization"); - recipients.push(&js_sys::Uint8Array::from(&recipient_bytes[..])); - recipients.push(&js_sys::Uint8Array::from(&second_recipient_bytes[..])); - let multi = encrypt_data_value_for_recipients(&value, recipients.into(), 9) - .expect("multi-recipient encryption failed"); - let second_keyring_bytes = second_keyring - .try_to_bytes() - .expect("second keyring serialization"); - let opened_by_second = decrypt_data_value(&multi, &second_keyring_bytes, 9) - .expect("second recipient could not decrypt"); - assert_eq!(opened_by_second, value); - } } diff --git a/wasm/src/error.rs b/wasm/src/error.rs index 147cbd7..c3ed596 100644 --- a/wasm/src/error.rs +++ b/wasm/src/error.rs @@ -16,10 +16,6 @@ pub fn from_crypto_error(e: mtp_crypto::CryptoError) -> JsValue { js_error(e.to_string()) } -pub fn from_protection_error(e: mtp_codec::ProtectionError) -> JsValue { - js_error(e.to_string()) -} - #[cfg(test)] #[cfg(target_arch = "wasm32")] mod tests { @@ -41,30 +37,26 @@ mod tests { #[wasm_bindgen_test] fn from_codec_error_invalid_encoding() { let err = from_codec_error(mtp_common::CodecError::InvalidEncoding); - let msg = err.as_string().unwrap_or_default(); - assert!(msg.contains("Invalid encoding")); + assert!(err.as_string().unwrap().contains("Invalid encoding")); } #[wasm_bindgen_test] fn from_codec_error_unknown_version() { let err = from_codec_error(mtp_common::CodecError::UnknownVersion); - let msg = err.as_string().unwrap_or_default(); - assert!(msg.contains("Unknown version")); + assert!(err.as_string().unwrap().contains("Unknown version")); } #[wasm_bindgen_test] fn from_communication_error_renders() { use mtp_common::CommunicationError; let err = from_communication_error(CommunicationError::ConnectionLost); - let msg = err.as_string().unwrap_or_default(); - assert!(msg.contains("Connection terminated")); + assert!(err.as_string().unwrap().contains("Connection terminated")); } #[wasm_bindgen_test] fn from_crypto_error_renders() { use mtp_crypto::CryptoError; let err = from_crypto_error(CryptoError::InvalidKeyLength); - let msg = err.as_string().unwrap_or_default(); - assert!(msg.contains("invalid key length")); + assert!(err.as_string().unwrap().contains("invalid key length")); } } diff --git a/wasm/src/frame.rs b/wasm/src/frame.rs index 347ffd7..691b2cc 100644 --- a/wasm/src/frame.rs +++ b/wasm/src/frame.rs @@ -1,50 +1,21 @@ use wasm_bindgen::{JsCast, prelude::*}; use mtp_codec::{ - CommunicationType, CommunicationValue, DataType, DataValue, DecodeLimits, EncodeLimits, - PROTOCOL_VERSION, + CommunicationType, CommunicationValue, DataType, DataValue, communication_type_name, + data_type_name, }; use mtp_type_map::TypeMap; use crate::error::js_error; -use crate::relay::decode_error; #[wasm_bindgen(typescript_custom_section)] const PARSED_FRAME_TS: &'static str = r#" -export interface ParsedEncryptedValue { - kind: "encrypted"; - encryptionType: number; - purpose: number; - recipientCount: number; - encoded: Uint8Array; -} - -export interface ParsedSignedValue { - kind: "signed"; - signatureType: number; - purpose: number; - signerId: bigint; - value: ParsedDataValue; -} - -export type ParsedDataValue = - | boolean - | number - | bigint - | string - | Uint8Array - | ParsedDataValue[] - | { [key: string]: ParsedDataValue } - | ParsedEncryptedValue - | ParsedSignedValue - | null; - export interface ParsedFrame { id?: number; type: string; sender?: bigint; receiver?: bigint; - data: ParsedDataValue; + data: Record; raw: Uint8Array; } "#; @@ -54,136 +25,53 @@ fn set_prop(obj: &js_sys::Object, key: &str, value: &JsValue) -> Result<(), JsVa } fn integer_value(value: &str) -> JsValue { - if let Ok(number) = value.parse::() - && number.fract() == 0.0 - && number.abs() <= 9_007_199_254_740_991.0 - { - return JsValue::from_f64(number); + if let Ok(number) = value.parse::() { + if number.fract() == 0.0 && number.abs() <= 9_007_199_254_740_991.0 { + return JsValue::from_f64(number); + } } - JsValue::bigint_from_str(value) + JsValue::from_str(value) } -pub(crate) fn data_value_to_js(value: &DataValue, tm: &TypeMap) -> Result { +fn data_value_to_js(value: &DataValue) -> Result { match value { DataValue::BoolTrue => Ok(JsValue::TRUE), DataValue::BoolFalse => Ok(JsValue::FALSE), DataValue::Bool(v) => Ok(JsValue::from_bool(*v)), DataValue::SignedNumber(n) => Ok(integer_value(&n.to_string())), DataValue::UnsignedNumber(n) => Ok(integer_value(&n.to_string())), - DataValue::Float(value) => Ok(JsValue::from_f64(*value)), + DataValue::Float(exp, mant) => { + Ok(JsValue::from_f64((*mant as f64) * 10f64.powi(*exp as i32))) + } DataValue::Str(s) => Ok(JsValue::from_str(s)), DataValue::Bytes(bytes) => Ok(js_sys::Uint8Array::from(&bytes[..]).into()), DataValue::Array(values) => { let arr = js_sys::Array::new(); for value in values { - arr.push(&data_value_to_js(value, tm)?); + arr.push(&data_value_to_js(value)?); } Ok(arr.into()) } DataValue::Container(entries) => { let obj = js_sys::Object::new(); for (key, value) in entries { - let name = tm - .data_type_name(key.0) + let name = data_type_name(key.0) .map(str::to_string) .unwrap_or_else(|| key.0.to_string()); - set_prop(&obj, &name, &data_value_to_js(value, tm)?)?; + set_prop(&obj, &name, &data_value_to_js(value)?)?; } Ok(obj.into()) } - DataValue::Encrypted(encrypted) => { - let obj = js_sys::Object::new(); - set_prop(&obj, "kind", &JsValue::from_str("encrypted"))?; - set_prop( - &obj, - "encryptionType", - &JsValue::from_f64(encrypted.encryption_type.to_byte() as f64), - )?; - set_prop( - &obj, - "purpose", - &JsValue::from_f64(encrypted.purpose as f64), - )?; - set_prop( - &obj, - "recipientCount", - &JsValue::from_f64(encrypted.recipients.len() as f64), - )?; - let encoded = value - .to_bytes() - .map_err(|e| js_error(format!("encode protected value: {e}")))?; - set_prop( - &obj, - "encoded", - &js_sys::Uint8Array::from(&encoded[..]).into(), - )?; - Ok(obj.into()) - } - DataValue::Signed(signed) => { - let obj = js_sys::Object::new(); - set_prop(&obj, "kind", &JsValue::from_str("signed"))?; - set_prop( - &obj, - "signatureType", - &JsValue::from_f64(signed.algorithm as f64), - )?; - set_prop(&obj, "purpose", &JsValue::from_f64(signed.purpose as f64))?; - set_prop( - &obj, - "signerId", - &JsValue::bigint_from_str(&signed.signer_id.to_string()), - )?; - set_prop(&obj, "value", &data_value_to_js(&signed.value, tm)?)?; - Ok(obj.into()) + DataValue::EncryptedContainer(bytes) + | DataValue::SignedContainer(bytes) + | DataValue::SignedEncryptedContainer(bytes) => { + Ok(js_sys::Uint8Array::from(&bytes[..]).into()) } DataValue::Null => Ok(JsValue::NULL), } } -const MAX_SAFE_INT: f64 = 9007199254740991.0; // 2^53 - 1 - -struct JsDataValueEncodeContext { - limits: EncodeLimits, - values: usize, -} - -impl JsDataValueEncodeContext { - fn visit(&mut self, depth: usize) -> Result<(), JsValue> { - if depth > self.limits.max_depth { - return Err(js_error("MTP DataValue nesting-depth limit exceeded")); - } - self.values = self - .values - .checked_add(1) - .ok_or_else(|| js_error("MTP DataValue value-count limit exceeded"))?; - if self.values > self.limits.max_values { - return Err(js_error("MTP DataValue value-count limit exceeded")); - } - Ok(()) - } -} - -#[cfg(test)] -pub(crate) fn js_to_data_value(value: &JsValue, tm: &TypeMap) -> Result { - js_to_data_value_with_limits(value, tm, EncodeLimits::default()) -} - -pub(crate) fn js_to_data_value_with_limits( - value: &JsValue, - tm: &TypeMap, - limits: EncodeLimits, -) -> Result { - let mut context = JsDataValueEncodeContext { limits, values: 0 }; - js_to_data_value_with_context(value, tm, &mut context, 0) -} - -fn js_to_data_value_with_context( - value: &JsValue, - tm: &TypeMap, - context: &mut JsDataValueEncodeContext, - depth: usize, -) -> Result { - context.visit(depth)?; +fn js_to_data_value(value: &JsValue) -> Result { if value.is_null() || value.is_undefined() { return Ok(DataValue::Null); } @@ -198,30 +86,21 @@ fn js_to_data_value_with_context( } if js_sys::Array::is_array(value) { let array = js_sys::Array::from(value); - if array.length() as usize > context.limits.max_values { - return Err(js_error("MTP DataValue value-count limit exceeded")); - } let mut values = Vec::with_capacity(array.length() as usize); for item in array.iter() { - values.push(js_to_data_value_with_context( - &item, - tm, - context, - depth + 1, - )?); + values.push(js_to_data_value(&item)?); } return Ok(DataValue::Array(values)); } if let Some(v) = value.as_f64() { - if v.is_finite() && v.fract() == 0.0 && (-(MAX_SAFE_INT + 1.0)..=MAX_SAFE_INT).contains(&v) - { + if v.fract() == 0.0 { if v >= 0.0 { return Ok(DataValue::UnsignedNumber(v as u128)); - } else { - return Ok(DataValue::SignedNumber(v as i128)); } + return Ok(DataValue::SignedNumber(v as i128)); } - return Ok(DataValue::Float(v)); + let mantissa = (v * 1_000_000.0).round() as u32; + return Ok(DataValue::Float(246, mantissa)); } let type_name = value.js_typeof().as_string().unwrap_or_default(); @@ -232,16 +111,10 @@ fn js_to_data_value_with_context( .as_string() .ok_or_else(|| js_error("failed to stringify bigint"))?; if let Some(unsigned) = as_string.strip_prefix('-') { - let magnitude = unsigned - .parse::() + let n = unsigned + .parse::() .map_err(|_| js_error("bigint out of range"))?; - if magnitude > (1u128 << 127) { - return Err(js_error("bigint out of range")); - } - if magnitude == (1u128 << 127) { - return Ok(DataValue::SignedNumber(i128::MIN)); - } - return Ok(DataValue::SignedNumber(-(magnitude as i128))); + return Ok(DataValue::SignedNumber(-n)); } let n = as_string .parse::() @@ -252,26 +125,17 @@ fn js_to_data_value_with_context( if value.is_object() { let object = js_sys::Object::from(value.clone()); let keys = js_sys::Object::keys(&object); - if keys.length() as usize > context.limits.max_values { - return Err(js_error("MTP DataValue value-count limit exceeded")); - } let mut entries = Vec::with_capacity(keys.length() as usize); for key in keys.iter() { let key = key .as_string() .ok_or_else(|| js_error("object key must be a string"))?; let data_type = DataType::from_name(&key) - .ok_or_else(|| js_error(format!("unknown data type: {key}")))?; + .ok_or_else(|| js_error(&format!("unknown data type: {key}")))?; let value = js_sys::Reflect::get(&object, &JsValue::from_str(&key))?; - let id = data_type.try_to_id(tm).ok_or_else(|| { - js_error(format!( - "data type {key} is not available in protocol version {}", - tm.version - )) - })?; entries.push(( - id, - js_to_data_value_with_context(&value, tm, context, depth + 1)?, + data_type.to_id(&TypeMap::latest()), + js_to_data_value(&value)?, )); } return Ok(DataValue::Container(entries)); @@ -286,13 +150,9 @@ fn option_u32(options: &JsValue, key: &str) -> Result, JsValue> { return Ok(None); } let Some(n) = value.as_f64() else { - return Err(js_error(format!("{key} must be a number"))); + return Err(js_error(&format!("{key} must be a number"))); }; - if !n.is_finite() || n.fract() != 0.0 || !(0.0..=MAX_SAFE_INT).contains(&n) { - return Err(js_error(format!("{key} must be an exact integer"))); - } - let n = u32::try_from(n as u64).map_err(|_| js_error(format!("{key} out of range")))?; - Ok(Some(n)) + Ok(Some(n as u32)) } fn option_u64(options: &JsValue, key: &str) -> Result, JsValue> { @@ -301,11 +161,6 @@ fn option_u64(options: &JsValue, key: &str) -> Result, JsValue> { return Ok(None); } if let Some(n) = value.as_f64() { - if !n.is_finite() || n.fract() != 0.0 || !(0.0..=MAX_SAFE_INT).contains(&n) { - return Err(js_error(format!( - "{key} must be an exact integer number at most 2^53-1 or a bigint" - ))); - } return Ok(Some(n as u64)); } let type_name = value.js_typeof().as_string().unwrap_or_default(); @@ -318,69 +173,48 @@ fn option_u64(options: &JsValue, key: &str) -> Result, JsValue> { return as_string .parse::() .map(Some) - .map_err(|_| js_error(format!("{key} out of range"))); + .map_err(|_| js_error(&format!("{key} out of range"))); } - Err(js_error(format!("{key} must be a number or bigint"))) -} - -fn apply_frame_options( - mut message: CommunicationValue, - options: &JsValue, -) -> Result { - if !options.is_null() && !options.is_undefined() { - if let Some(id) = option_u32(options, "id")? { - message = message.with_id(id); - } - if let Some(sender) = option_u64(options, "sender")? { - message = message.with_sender(sender); - } - if let Some(receiver) = option_u64(options, "receiver")? { - message = message.with_receiver(receiver); - } - } - Ok(message) + Err(js_error(&format!("{key} must be a number or bigint"))) } pub(crate) fn parse_frame_value(frame: &[u8]) -> Result { - parse_frame_value_with_limits(frame, &TypeMap::latest(), DecodeLimits::default()) -} - -pub(crate) fn parse_frame_value_with_limits( - frame: &[u8], - type_map: &TypeMap, - limits: DecodeLimits, -) -> Result { - let comm = CommunicationValue::try_from_bytes_with_type_map_and_limits(frame, type_map, limits) - .map_err(|error| decode_error(error, "parse failed"))?; - let tm = type_map; + let comm = CommunicationValue::from_bytes(frame) + .map_err(|e| js_error(&format!("parse failed: {}", e)))?; let obj = js_sys::Object::new(); + let data = js_sys::Object::new(); - if let Some(id) = comm.id() { - set_prop(&obj, "id", &JsValue::from_f64(id as f64))?; + if comm.get_id() != 0 { + set_prop(&obj, "id", &JsValue::from_f64(comm.get_id() as f64))?; } - let frame_type = tm - .communication_type_name(comm.get_type().0) + let frame_type = communication_type_name(comm.get_type().0) .map(str::to_string) .unwrap_or_else(|| comm.get_type().0.to_string()); set_prop(&obj, "type", &JsValue::from_str(&frame_type))?; - if let Some(sender) = comm.sender() { + if comm.get_sender() != 0 { set_prop( &obj, "sender", - &JsValue::bigint_from_str(&sender.to_string()), + &JsValue::bigint_from_str(&comm.get_sender().to_string()), )?; } - if let Some(receiver) = comm.receiver() { + if comm.get_receiver() != 0 { set_prop( &obj, "receiver", - &JsValue::bigint_from_str(&receiver.to_string()), + &JsValue::bigint_from_str(&comm.get_receiver().to_string()), )?; } - set_prop(&obj, "data", &data_value_to_js(comm.payload(), &tm)?)?; + for (key, value) in comm.data() { + let name = data_type_name(key.0) + .map(str::to_string) + .unwrap_or_else(|| key.0.to_string()); + set_prop(&data, &name, &data_value_to_js(value)?)?; + } + set_prop(&obj, "data", &data.into())?; set_prop(&obj, "raw", &js_sys::Uint8Array::from(frame).into())?; Ok(obj.into()) @@ -410,39 +244,34 @@ pub fn build_ping_frame( } msg.to_bytes() - .map_err(|e| js_error(format!("encode failed: {}", e))) + .map_err(|e| js_error(&format!("encode failed: {}", e))) } /// Parse an auth response frame into a JS object. -#[wasm_bindgen(unchecked_return_type = "AuthResponse")] +#[wasm_bindgen] pub fn parse_auth_response(response: &[u8]) -> Result { - let comm = CommunicationValue::try_from_bytes_with_limits(response, DecodeLimits::default()) - .map_err(|error| decode_error(error, "parse failed"))?; + let comm = CommunicationValue::from_bytes(response) + .map_err(|e| js_error(&format!("parse failed: {}", e)))?; - let connected = matches!( - comm.get_data(DataType::Connected), - Some(DataValue::BoolTrue) - ); + let connected = matches!(comm.get_data(DataType::Connected), DataValue::BoolTrue); let client_nonce = match comm.get_data(DataType::ClientNonce) { - Some(DataValue::UnsignedNumber(n)) => Some(*n), + DataValue::UnsignedNumber(n) => Some(*n), _ => None, }; let assigned_id = match comm.get_data(DataType::Id) { - Some(DataValue::UnsignedNumber(n)) => { - Some(u64::try_from(*n).map_err(|_| js_error("assigned ID is out of range"))?) - } + DataValue::UnsignedNumber(n) => Some(*n as u64), _ => None, }; let timestamp = match comm.get_data(DataType::Timestamp) { - Some(DataValue::UnsignedNumber(n)) => Some(*n), + DataValue::UnsignedNumber(n) => Some(*n), _ => None, }; let signature = match comm.get_data(DataType::Signature) { - Some(DataValue::Bytes(b)) => Some(b.clone()), + DataValue::Bytes(b) => Some(b.clone()), _ => None, }; @@ -453,20 +282,10 @@ pub fn parse_auth_response(response: &[u8]) -> Result { js_sys::Reflect::set(&obj, &"clientNonce".into(), &arr).ok(); } if let Some(id) = assigned_id { - js_sys::Reflect::set( - &obj, - &"assignedId".into(), - &JsValue::bigint_from_str(&id.to_string()), - ) - .ok(); + js_sys::Reflect::set(&obj, &"assignedId".into(), &JsValue::from(id as f64)).ok(); } if let Some(ts) = timestamp { - js_sys::Reflect::set( - &obj, - &"timestamp".into(), - &JsValue::bigint_from_str(&ts.to_string()), - ) - .ok(); + js_sys::Reflect::set(&obj, &"timestamp".into(), &JsValue::from(ts as f64)).ok(); } if let Some(sig) = signature { let arr = js_sys::Uint8Array::from(&sig[..]); @@ -479,8 +298,8 @@ pub fn parse_auth_response(response: &[u8]) -> Result { /// Parse any MTP frame into the human-readable CommunicationValue display form. #[wasm_bindgen] pub fn format_frame(frame: &[u8]) -> Result { - let comm = CommunicationValue::try_from_bytes_with_limits(frame, DecodeLimits::default()) - .map_err(|error| decode_error(error, "parse failed"))?; + let comm = CommunicationValue::from_bytes(frame) + .map_err(|e| js_error(&format!("parse failed: {}", e)))?; Ok(comm.to_string()) } @@ -490,85 +309,28 @@ pub fn parse_frame(frame: &[u8]) -> Result { parse_frame_value(frame) } -/// Parse a frame with the caller's bounded receive policy. The compatibility -/// `parse_frame` entry point retains the default policy for existing callers. -#[wasm_bindgen(unchecked_return_type = "ParsedFrame")] -pub fn parse_frame_with_limits(frame: &[u8], limits: JsValue) -> Result { - let limits = crate::client::decode_limits_from_js(&limits)?; - parse_frame_value_with_limits(frame, &TypeMap::latest(), limits) -} - -/// Parse a standalone serialized `DataValue` into the same structured form -/// used for frame payloads. Protected values remain opaque until the caller -/// explicitly opens and verifies them. -#[wasm_bindgen(unchecked_return_type = "ParsedDataValue")] -pub fn parse_data_value(value: &[u8]) -> Result { - parse_data_value_with_decode_limits(value, DecodeLimits::default()) -} - -fn parse_data_value_with_decode_limits( - value: &[u8], - limits: DecodeLimits, -) -> Result { - let value = DataValue::try_from_bytes_with_limits(value, limits) - .map_err(|error| decode_error(error, "decode data value failed"))?; - let tm = TypeMap::new(PROTOCOL_VERSION); - data_value_to_js(&value, &tm) -} - -/// Parse a standalone serialized `DataValue` with the caller's bounded -/// receive policy. The compatibility `parse_data_value` entry point retains -/// the default policy for existing callers. -#[wasm_bindgen(unchecked_return_type = "ParsedDataValue")] -pub fn parse_data_value_with_limits(value: &[u8], limits: JsValue) -> Result { - let limits = crate::client::decode_limits_from_js(&limits)?; - parse_data_value_with_decode_limits(value, limits) -} - -/// Encode one standalone `DataValue` using the negotiated/current type map. -#[wasm_bindgen] -pub fn encode_data_value(value: JsValue) -> Result, JsValue> { - encode_data_value_with_encode_limits(value, EncodeLimits::default()) -} - -fn encode_data_value_with_encode_limits( - value: JsValue, - limits: EncodeLimits, -) -> Result, JsValue> { - let tm = TypeMap::new(PROTOCOL_VERSION); - js_to_data_value_with_limits(&value, &tm, limits)? - .to_bytes_with_limits(limits) - .map_err(|e| js_error(format!("encode data value failed: {e}"))) -} - -/// Encode one standalone `DataValue` using explicit recursion and output -/// limits. The compatibility entry point above keeps the historical default. -#[wasm_bindgen] -pub fn encode_data_value_with_limits(value: JsValue, limits: JsValue) -> Result, JsValue> { - let limits = crate::client::encode_limits_from_js(&limits)?; - encode_data_value_with_encode_limits(value, limits) -} - /// Build a typed MTP frame using generated communication/data type names. #[wasm_bindgen] pub fn build_frame( message_type: &str, data: JsValue, options: JsValue, -) -> Result, JsValue> { - build_frame_with_encode_limits(message_type, data, options, EncodeLimits::default()) -} - -fn build_frame_with_encode_limits( - message_type: &str, - data: JsValue, - options: JsValue, - limits: EncodeLimits, ) -> Result, JsValue> { let comm_type = CommunicationType::from_name(message_type) - .ok_or_else(|| js_error(format!("unknown communication type: {message_type}")))?; - let tm = TypeMap::new(PROTOCOL_VERSION); - let mut msg = apply_frame_options(CommunicationValue::new(comm_type), &options)?; + .ok_or_else(|| js_error(&format!("unknown communication type: {message_type}")))?; + let mut msg = CommunicationValue::new(comm_type); + + if !options.is_null() && !options.is_undefined() { + if let Some(id) = option_u32(&options, "id")? { + msg = msg.with_id(id); + } + if let Some(sender) = option_u64(&options, "sender")? { + msg = msg.with_sender(sender); + } + if let Some(receiver) = option_u64(&options, "receiver")? { + msg = msg.with_receiver(receiver); + } + } if data.is_object() && !js_sys::Uint8Array::instanceof(&data) && !js_sys::Array::is_array(&data) { @@ -579,17 +341,12 @@ fn build_frame_with_encode_limits( .as_string() .ok_or_else(|| js_error("object key must be a string"))?; let data_type = DataType::from_name(&key) - .ok_or_else(|| js_error(format!("unknown data type: {key}")))?; + .ok_or_else(|| js_error(&format!("unknown data type: {key}")))?; let value = js_sys::Reflect::get(&object, &JsValue::from_str(&key))?; - let id = data_type.try_to_id(&tm).ok_or_else(|| { - js_error(format!( - "data type {key} is not available in protocol version {}", - tm.version - )) - })?; - msg = msg - .add_data(id, js_to_data_value_with_limits(&value, &tm, limits)?) - .map_err(|e| js_error(format!("add data failed: {e}")))?; + msg = msg.add_data( + data_type.to_id(&TypeMap::latest()), + js_to_data_value(&value)?, + ); } } else if !data.is_null() && !data.is_undefined() { return Err(js_error( @@ -597,86 +354,14 @@ fn build_frame_with_encode_limits( )); } - msg.to_bytes_with_limits(limits) - .map_err(|e| js_error(format!("encode failed: {}", e))) -} - -/// Build a typed frame with explicit recursion and complete-frame output -/// limits. High-level SDK sends use this entry point with the transport's -/// admitted message size. -#[wasm_bindgen] -pub fn build_frame_with_limits( - message_type: &str, - data: JsValue, - options: JsValue, - limits: JsValue, -) -> Result, JsValue> { - let limits = crate::client::encode_limits_from_js(&limits)?; - build_frame_with_encode_limits(message_type, data, options, limits) -} - -/// Build a typed MTP frame around a complete serialized `DataValue` payload. -/// -/// Unlike [`build_frame`], this does not interpret the payload as a clear data -/// container. It can therefore carry any value supported by the codec, -/// including signed and encrypted protection wrappers. -#[wasm_bindgen] -pub fn build_frame_with_payload( - message_type: &str, - serialized_payload: &[u8], - options: JsValue, -) -> Result, JsValue> { - build_frame_with_payload_with_encode_limits( - message_type, - serialized_payload, - options, - EncodeLimits::default(), - ) -} - -fn build_frame_with_payload_with_encode_limits( - message_type: &str, - serialized_payload: &[u8], - options: JsValue, - limits: EncodeLimits, -) -> Result, JsValue> { - let comm_type = CommunicationType::from_name(message_type) - .ok_or_else(|| js_error(format!("unknown communication type: {message_type}")))?; - let payload = DataValue::try_from_bytes_with_limits( - serialized_payload, - DecodeLimits::for_transport_message_size(limits.max_output_size as u64), - ) - .map_err(|error| decode_error(error, "invalid serialized DataValue payload"))?; - let message = - apply_frame_options(CommunicationValue::new(comm_type), &options)?.with_payload(payload); - - message - .to_bytes_with_limits(limits) - .map_err(|e| js_error(format!("encode failed: {e}"))) -} - -/// Build a typed frame around a serialized payload with explicit output -/// limits. The payload is also parsed with a policy derived from that limit so -/// an oversized/deep input cannot bypass the bounded builder. -#[wasm_bindgen] -pub fn build_frame_with_payload_with_limits( - message_type: &str, - serialized_payload: &[u8], - options: JsValue, - limits: JsValue, -) -> Result, JsValue> { - let limits = crate::client::encode_limits_from_js(&limits)?; - build_frame_with_payload_with_encode_limits(message_type, serialized_payload, options, limits) + msg.to_bytes() + .map_err(|e| js_error(&format!("encode failed: {}", e))) } #[cfg(test)] #[cfg(target_arch = "wasm32")] mod tests { use super::*; - use mtp_codec::ProtectionPurpose; - use mtp_crypto::{ - Ed25519Signer, HybridKem, PublicKeyBundle, SignaturePqPublicKey, SignaturePublicKey, - }; use wasm_bindgen_test::*; #[wasm_bindgen_test] @@ -685,18 +370,15 @@ mod tests { let cv = CommunicationValue::from_bytes(&bytes).expect("decode failed"); let tm = TypeMap::latest(); - assert_eq!( - cv.get_type(), - CommunicationType::Ping.try_to_id(&tm).unwrap() - ); - assert_eq!(cv.sender(), Some(42)); + assert_eq!(cv.get_type(), CommunicationType::Ping.to_id(&tm)); + assert_eq!(cv.get_sender(), 42); assert_eq!( cv.get_data(DataType::Description), - Some(&DataValue::Str("test-ping".into())) + &DataValue::Str("test-ping".into()) ); assert_eq!( cv.get_data(DataType::Timestamp), - Some(&DataValue::UnsignedNumber(1234567890)) + &DataValue::UnsignedNumber(1234567890) ); } @@ -707,22 +389,19 @@ mod tests { let cv = CommunicationValue::from_bytes(&bytes).expect("decode failed"); let tm = TypeMap::latest(); - assert_eq!( - cv.get_type(), - CommunicationType::Ping.try_to_id(&tm).unwrap() - ); - assert_eq!(cv.sender(), Some(99)); + assert_eq!(cv.get_type(), CommunicationType::Ping.to_id(&tm)); + assert_eq!(cv.get_sender(), 99); assert_eq!( cv.get_data(DataType::Description), - Some(&DataValue::Str("with-data".into())) + &DataValue::Str("with-data".into()) ); assert_eq!( cv.get_data(DataType::Timestamp), - Some(&DataValue::UnsignedNumber(555)) + &DataValue::UnsignedNumber(555) ); assert_eq!( cv.get_data(DataType::Id), - Some(&DataValue::Bytes(payload.to_vec())) + &DataValue::Bytes(payload.to_vec()) ); } @@ -730,296 +409,16 @@ mod tests { fn build_ping_frame_client_id_zero() { let bytes = build_ping_frame(0, "zero-id", 0, &[]).expect("encode failed"); let cv = CommunicationValue::from_bytes(&bytes).expect("decode failed"); - assert_eq!(cv.sender(), Some(0)); - } - - #[wasm_bindgen_test] - fn parse_frame_preserves_a_generic_payload() { - let bytes = CommunicationValue::new(CommunicationType::Pong) - .with_payload(DataValue::Bytes(vec![1, 2, 3])) - .to_bytes() - .expect("encode failed"); - - let parsed = parse_frame_value(&bytes).expect("parse failed"); - let data = js_sys::Reflect::get(&parsed, &JsValue::from_str("data")) - .expect("data should be present"); - assert_eq!(js_sys::Uint8Array::new(&data).to_vec(), vec![1, 2, 3]); - } - - #[wasm_bindgen_test] - fn integer_data_values_round_trip_without_losing_numeric_type() { - let tm = TypeMap::latest(); - let cases = [ - (DataValue::UnsignedNumber(9_007_199_254_740_991), "number"), - (DataValue::UnsignedNumber(9_007_199_254_740_992), "bigint"), - (DataValue::SignedNumber(-9_007_199_254_740_992), "bigint"), - (DataValue::SignedNumber(i128::MIN), "bigint"), - (DataValue::UnsignedNumber(u128::from(u64::MAX)), "bigint"), - ]; - - for (original, expected_type) in cases { - let javascript = data_value_to_js(&original, &tm).expect("decode value"); - assert_eq!( - javascript.js_typeof().as_string().as_deref(), - Some(expected_type) - ); - assert_eq!( - js_to_data_value(&javascript, &tm).expect("encode value"), - original - ); - } - } - - #[wasm_bindgen_test] - fn parse_frame_preserves_signed_value_structure() { - let (signer, _secret_key, _public_key) = Ed25519Signer::generate(); - let signed = DataValue::Str("signed payload".into()) - .sign(0xfeed_beef, ProtectionPurpose::from(7), &signer) - .expect("signing failed"); - let bytes = CommunicationValue::new(CommunicationType::Pong) - .with_payload(signed) - .to_bytes() - .expect("encode failed"); - - let parsed = parse_frame_value(&bytes).expect("parse failed"); - let data = js_sys::Reflect::get(&parsed, &"data".into()).expect("data should be present"); - assert_eq!( - js_sys::Reflect::get(&data, &"kind".into()) - .expect("kind should be present") - .as_string() - .as_deref(), - Some("signed") - ); - assert_eq!( - js_sys::Reflect::get(&data, &"purpose".into()) - .expect("purpose should be present") - .as_f64(), - Some(7.0) - ); - let signer_id = js_sys::Reflect::get(&data, &"signerId".into()) - .expect("signerId should be present") - .unchecked_into::() - .to_string(10) - .expect("signerId should stringify") - .as_string(); - assert_eq!(signer_id.as_deref(), Some("4276993775")); - assert_eq!( - js_sys::Reflect::get(&data, &"value".into()) - .expect("value should be present") - .as_string() - .as_deref(), - Some("signed payload") - ); - } - - #[wasm_bindgen_test] - fn parse_frame_keeps_encrypted_contents_private() { - let (_secret_key, public_key) = HybridKem::generate_keypair(); - let recipient = PublicKeyBundle::new( - public_key, - SignaturePqPublicKey::new(Vec::new()), - SignaturePublicKey::new(Vec::new()), - ); - let encrypted = DataValue::Str("secret payload".into()) - .encrypt_for(&[recipient], ProtectionPurpose::from(9)) - .expect("encryption failed"); - let encoded = encrypted.to_bytes().expect("encode protected value failed"); - let bytes = CommunicationValue::new(CommunicationType::Pong) - .with_payload(encrypted) - .to_bytes() - .expect("encode failed"); - - let parsed = parse_frame_value(&bytes).expect("parse failed"); - let data = js_sys::Reflect::get(&parsed, &"data".into()).expect("data should be present"); - assert_eq!( - js_sys::Reflect::get(&data, &"kind".into()) - .expect("kind should be present") - .as_string() - .as_deref(), - Some("encrypted") - ); - assert_eq!( - js_sys::Reflect::get(&data, &"recipientCount".into()) - .expect("recipientCount should be present") - .as_f64(), - Some(1.0) - ); - assert!(!js_sys::Reflect::has(&data, &"value".into()).unwrap_or(false)); - assert_eq!( - js_sys::Reflect::get(&data, &"encoded".into()) - .expect("encoded should be present") - .unchecked_into::() - .to_vec(), - encoded - ); - } - - #[wasm_bindgen_test] - fn parse_frame_preserves_signed_encrypted_composition() { - let (signer, _secret_key, _public_key) = Ed25519Signer::generate(); - let (_kem_secret_key, kem_public_key) = HybridKem::generate_keypair(); - let recipient = PublicKeyBundle::new( - kem_public_key, - SignaturePqPublicKey::new(Vec::new()), - SignaturePublicKey::new(Vec::new()), - ); - let encrypted = DataValue::Container(vec![( - mtp_type_map::DataTypeId(32), - DataValue::Str("secret payload".into()), - )]) - .encrypt_for(&[recipient], ProtectionPurpose::from(9)) - .expect("encryption failed"); - let encrypted_bytes = encrypted.to_bytes().expect("encrypted value should encode"); - let signed = encrypted - .sign(0x0102_0304_0506_0708, ProtectionPurpose::from(7), &signer) - .expect("signing failed"); - let frame = CommunicationValue::new(CommunicationType::Pong) - .with_payload(signed) - .to_bytes() - .expect("frame should encode"); - - let parsed = parse_frame_value(&frame).expect("frame should parse"); - let signed = js_sys::Reflect::get(&parsed, &"data".into()) - .expect("signed payload should be present"); - assert_eq!( - js_sys::Reflect::get(&signed, &"kind".into()) - .expect("signed kind should be present") - .as_string() - .as_deref(), - Some("signed") - ); - let encrypted = js_sys::Reflect::get(&signed, &"value".into()) - .expect("encrypted inner value should be present"); - assert_eq!( - js_sys::Reflect::get(&encrypted, &"kind".into()) - .expect("encrypted kind should be present") - .as_string() - .as_deref(), - Some("encrypted") - ); - assert_eq!( - js_sys::Reflect::get(&encrypted, &"encoded".into()) - .expect("encrypted encoding should be present") - .unchecked_into::() - .to_vec(), - encrypted_bytes - ); - } - - #[wasm_bindgen_test] - fn frame_ids_use_bigints_without_lossy_number_casts() { - let options = js_sys::Object::new(); - js_sys::Reflect::set( - &options, - &"sender".into(), - &JsValue::bigint_from_str("18446744073709551615"), - ) - .expect("sender option should be set"); - let bytes = build_frame("Pong", JsValue::NULL, options.into()).expect("build failed"); - let frame = CommunicationValue::from_bytes(&bytes).expect("decode failed"); - assert_eq!(frame.sender(), Some(u64::MAX)); - - let unsafe_number = js_sys::Object::new(); - js_sys::Reflect::set( - &unsafe_number, - &"sender".into(), - &JsValue::from_f64(MAX_SAFE_INT + 1.0), - ) - .expect("sender option should be set"); - assert!(build_frame("Pong", JsValue::NULL, unsafe_number.into()).is_err()); - } - - #[wasm_bindgen_test] - fn build_frame_with_payload_preserves_clear_and_protected_payloads() { - let (signer, _secret_key, _public_key) = Ed25519Signer::generate(); - let (_kem_secret_key, kem_public_key) = HybridKem::generate_keypair(); - let recipient = PublicKeyBundle::new( - kem_public_key, - SignaturePqPublicKey::new(Vec::new()), - SignaturePublicKey::new(Vec::new()), - ); - let clear = DataValue::Str("generic protected payload".into()); - let signed = clear - .clone() - .sign(0x0102_0304_0506_0708, ProtectionPurpose::from(7), &signer) - .expect("signing failed"); - let encrypted = clear - .clone() - .encrypt_for(&[recipient.clone()], ProtectionPurpose::from(9)) - .expect("encryption failed"); - let signed_encrypted = signed - .clone() - .encrypt_for(&[recipient], ProtectionPurpose::from(9)) - .expect("signed encryption failed"); - - for payload in [clear, signed, encrypted, signed_encrypted] { - let serialized = payload.to_bytes().expect("payload encoding failed"); - let frame = build_frame_with_payload("Pong", &serialized, JsValue::NULL) - .expect("frame encoding failed"); - let decoded = CommunicationValue::from_bytes(&frame).expect("frame decoding failed"); - assert_eq!( - decoded - .payload() - .to_bytes() - .expect("payload re-encoding failed"), - serialized - ); - } - } - - #[wasm_bindgen_test] - fn build_frame_with_payload_applies_frame_options() { - let payload = DataValue::Str("payload".into()) - .to_bytes() - .expect("payload encoding failed"); - let options = js_sys::Object::new(); - js_sys::Reflect::set(&options, &"id".into(), &JsValue::from_f64(17.0)) - .expect("id option should be set"); - js_sys::Reflect::set( - &options, - &"sender".into(), - &JsValue::bigint_from_str("18446744073709551615"), - ) - .expect("sender option should be set"); - js_sys::Reflect::set(&options, &"receiver".into(), &JsValue::from_f64(23.0)) - .expect("receiver option should be set"); - - let frame = build_frame_with_payload("Pong", &payload, options.into()) - .expect("frame encoding failed"); - let decoded = CommunicationValue::from_bytes(&frame).expect("frame decoding failed"); - assert_eq!(decoded.id(), Some(17)); - assert_eq!(decoded.sender(), Some(u64::MAX)); - assert_eq!(decoded.receiver(), Some(23)); - assert_eq!( - decoded - .payload() - .to_bytes() - .expect("payload re-encoding failed"), - payload - ); - } - - #[wasm_bindgen_test] - fn fractional_float_roundtrips_without_corruption() { - let tm = TypeMap::new(PROTOCOL_VERSION); - for expected in [-12.5, 0.125, 1.5e200] { - let encoded = js_to_data_value(&JsValue::from_f64(expected), &tm) - .expect("JS float should encode"); - assert_eq!(encoded, DataValue::Float(expected)); - let decoded = data_value_to_js(&encoded, &tm).expect("float should decode"); - assert_eq!(decoded.as_f64(), Some(expected)); - } + assert_eq!(cv.get_sender(), 0); } #[wasm_bindgen_test] fn parse_auth_response_success() { - const ASSIGNED_ID: u128 = 9_007_199_254_740_993; - const TIMESTAMP: u128 = 9_007_199_254_740_995; let resp = CommunicationValue::new(CommunicationType::IdentificationResponse) .add_typed_default(DataType::Connected, DataValue::BoolTrue) .add_typed_default(DataType::ClientNonce, DataValue::UnsignedNumber(999)) - .add_typed_default(DataType::Id, DataValue::UnsignedNumber(ASSIGNED_ID)) - .add_typed_default(DataType::Timestamp, DataValue::UnsignedNumber(TIMESTAMP)) + .add_typed_default(DataType::Id, DataValue::UnsignedNumber(42)) + .add_typed_default(DataType::Timestamp, DataValue::UnsignedNumber(12345)) .to_bytes() .expect("encode failed"); @@ -1031,20 +430,9 @@ mod tests { assert_eq!(connected, Some(true)); let id = js_sys::Reflect::get(&result, &"assignedId".into()) - .expect("assignedId should be present") - .unchecked_into::() - .to_string(10) - .expect("assignedId should stringify") - .as_string(); - assert_eq!(id.as_deref(), Some("9007199254740993")); - - let timestamp = js_sys::Reflect::get(&result, &"timestamp".into()) - .expect("timestamp should be present") - .unchecked_into::() - .to_string(10) - .expect("timestamp should stringify") - .as_string(); - assert_eq!(timestamp.as_deref(), Some("9007199254740995")); + .ok() + .and_then(|v| v.as_f64()); + assert_eq!(id, Some(42.0)); } #[wasm_bindgen_test] diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index 83e651d..5b49dc6 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -1,21 +1,12 @@ -pub mod auth; pub mod client; -pub mod client_pipe; pub mod config; pub mod crypto; pub mod error; pub mod frame; pub mod logging; -pub mod pipe; -pub mod protected; -pub mod relay; pub mod subscription; pub mod transport; -pub use client::WasmClient; -pub use config::{ConnectionConfig, WasmClientConfig}; -pub use crypto::{decrypt_data_value, encrypt_data_value, encrypt_data_value_for_recipients}; - #[cfg(not(test))] use wasm_bindgen::prelude::*; @@ -23,5 +14,4 @@ use wasm_bindgen::prelude::*; #[wasm_bindgen(start)] pub fn main() { console_error_panic_hook::set_once(); - logging::init_tracing(); } diff --git a/wasm/src/logging.rs b/wasm/src/logging.rs index b2cc490..162b846 100644 --- a/wasm/src/logging.rs +++ b/wasm/src/logging.rs @@ -1,16 +1,5 @@ use wasm_bindgen::prelude::*; -#[cfg(not(test))] -pub(crate) fn init_tracing() { - use tracing::Level; - use wasm_tracing::prelude::WasmLayerConfig; - - let config = WasmLayerConfig::new() - .set_max_level(Level::DEBUG) - .to_owned(); - let _ = wasm_tracing::set_as_global_default_with_config(config); -} - /// Log severity used by the public SDK when translating raw WASM events. #[wasm_bindgen] #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/wasm/src/pipe.rs b/wasm/src/pipe.rs deleted file mode 100644 index 88783ae..0000000 --- a/wasm/src/pipe.rs +++ /dev/null @@ -1,126 +0,0 @@ -use wasm_bindgen::prelude::*; - -use crate::transport::{BrowserRecvStream, BrowserSendStream, log_stream_error_code}; - -#[wasm_bindgen(typescript_custom_section)] -const PIPE_TS: &str = r#" -export interface PipeWriter { - write(data: Uint8Array): Promise; - close(): Promise; - abort(): void; - readonly pipeId: number; -} - -export interface PipeReader { - read(): Promise; - readonly pipeId: number; - readonly description: string; -} -"#; - -#[wasm_bindgen] -pub struct PipeWriter { - stream: BrowserSendStream, - pipe_id: u32, -} - -impl PipeWriter { - pub(crate) fn new(stream: BrowserSendStream, pipe_id: u32) -> Self { - Self { stream, pipe_id } - } -} - -impl Drop for PipeWriter { - fn drop(&mut self) { - self.stream.release(); - } -} - -#[wasm_bindgen] -impl PipeWriter { - pub async fn write(&mut self, data: &[u8]) -> Result<(), JsValue> { - self.stream.write_all(data).await - } - - pub async fn close(mut self) -> Result<(), JsValue> { - let result = self.stream.finish().await; - if let Err(error) = &result { - log_stream_error_code(error, "pipe writer close"); - } - self.stream.release(); - result - } - - pub fn abort(&mut self) -> Result<(), JsValue> { - let result = self.stream.reset(0); - self.stream.release(); - result - } - - pub fn pipe_id(&self) -> u32 { - self.pipe_id - } -} - -#[wasm_bindgen] -pub struct PipeReader { - stream: BrowserRecvStream, - description: String, - pipe_id: u32, - pending: Vec, - finished: bool, -} - -impl PipeReader { - pub(crate) fn new( - stream: BrowserRecvStream, - pipe_id: u32, - description: String, - pending: Vec, - ) -> Self { - Self { - stream, - pipe_id, - description, - pending, - finished: false, - } - } -} - -#[wasm_bindgen] -impl PipeReader { - pub async fn read(&mut self) -> Result { - if !self.pending.is_empty() { - let data = std::mem::take(&mut self.pending); - return Ok(js_sys::Uint8Array::from(&data[..]).into()); - } - - if self.finished { - return Ok(JsValue::NULL); - } - - match self.stream.read_chunk().await? { - Some(value) => Ok(js_sys::Uint8Array::from(&value[..]).into()), - None => { - self.stream.release(); - self.finished = true; - Ok(JsValue::NULL) - } - } - } - - pub fn pipe_id(&self) -> u32 { - self.pipe_id - } - - pub fn description(&self) -> String { - self.description.clone() - } -} - -impl Drop for PipeReader { - fn drop(&mut self) { - self.stream.release(); - } -} diff --git a/wasm/src/protected.rs b/wasm/src/protected.rs deleted file mode 100644 index d0d4da1..0000000 --- a/wasm/src/protected.rs +++ /dev/null @@ -1,725 +0,0 @@ -use wasm_bindgen::prelude::*; - -use mtp_codec::{ - DataValue, DecodeLimits, EncodeLimits, ProtectedError, ProtectedLimits, - ProtectedMessageBuilder, ProtectedOpenOptions, ProtectionError, ProtectionPolicy, - ProtectionPurpose, VerifiedProtectedMessage, -}; - -use crate::crypto::{ - keyrings_from_js, protection_policy_from_suite, public_key_bundles_from_js, - relay_signer_from_keyring, -}; -use crate::relay::{decode_error, decode_frame_with_limits, structured_error}; - -const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; - -fn optional_u64(value: &JsValue, name: &str) -> Result, JsValue> { - if value.is_null() || value.is_undefined() { - return Ok(None); - } - if let Some(number) = value.as_f64() { - if !number.is_finite() - || number.fract() != 0.0 - || !(0.0..=MAX_SAFE_INTEGER).contains(&number) - { - return Err(structured_error( - "invalid-option", - format!("{name} must be an exact non-negative integer"), - )); - } - return Ok(Some(number as u64)); - } - if value.js_typeof().as_string().as_deref() == Some("bigint") { - let bigint = value.clone().unchecked_into::(); - let text = bigint.to_string(10)?.as_string().ok_or_else(|| { - structured_error("invalid-option", format!("failed to stringify {name}")) - })?; - return text - .parse::() - .map(Some) - .map_err(|_| structured_error("invalid-option", format!("{name} is out of range"))); - } - Err(structured_error( - "invalid-option", - format!("{name} must be a number or bigint"), - )) -} - -fn limit_usize(options: &JsValue, key: &str, default: usize) -> Result { - if options.is_null() || options.is_undefined() { - return Ok(default); - } - let value = js_sys::Reflect::get(options, &JsValue::from_str(key))?; - if value.is_null() || value.is_undefined() { - return Ok(default); - } - let Some(number) = value.as_f64() else { - return Err(structured_error( - "invalid-limit", - format!("{key} must be a number"), - )); - }; - if !number.is_finite() || number.fract() != 0.0 || number < 0.0 { - return Err(structured_error( - "invalid-limit", - format!("{key} must be a non-negative integer"), - )); - } - usize::try_from(number as u64) - .map_err(|_| structured_error("invalid-limit", format!("{key} is out of range"))) -} - -fn protected_open_options( - expected_receiver_id: Option, - signature_purpose: u8, - encryption_purpose: u8, - policy: mtp_codec::ProtectionPolicy, - limits: &JsValue, -) -> Result { - let defaults = DecodeLimits::default(); - let encode_defaults = EncodeLimits::default(); - let protected_defaults = ProtectedLimits::default(); - let decode_limits = DecodeLimits { - max_depth: limit_usize(limits, "maxDepth", defaults.max_depth)?, - max_values: limit_usize(limits, "maxValues", defaults.max_values)?, - max_blob_size: limit_usize(limits, "maxBlobSize", defaults.max_blob_size)?, - max_recipients: limit_usize(limits, "maxRecipients", defaults.max_recipients)?, - max_allocated_bytes: limit_usize( - limits, - "maxAllocatedBytes", - defaults.max_allocated_bytes, - )?, - }; - let encode_limits = EncodeLimits { - max_depth: limit_usize(limits, "maxDepth", encode_defaults.max_depth)?, - max_values: limit_usize(limits, "maxValues", encode_defaults.max_values)?, - max_output_size: limit_usize(limits, "maxOutputSize", encode_defaults.max_output_size)?, - }; - let protected_limits = ProtectedLimits { - max_message_id_bytes: limit_usize( - limits, - "maxMessageIdBytes", - protected_defaults.max_message_id_bytes, - )?, - max_metadata_encoded_bytes: limit_usize( - limits, - "maxMetadataEncodedBytes", - protected_defaults.max_metadata_encoded_bytes, - )?, - max_signer_key_history: limit_usize( - limits, - "maxSignerKeyHistory", - protected_defaults.max_signer_key_history, - )?, - max_decryption_key_history: limit_usize( - limits, - "maxDecryptionKeyHistory", - protected_defaults.max_decryption_key_history, - )?, - }; - Ok(ProtectedOpenOptions::new( - expected_receiver_id, - ProtectionPurpose::from(signature_purpose), - ProtectionPurpose::from(encryption_purpose), - policy, - ) - .with_limits(decode_limits, protected_limits) - .with_encode_limits(encode_limits)) -} - -pub(crate) fn protected_error(error: ProtectedError) -> JsValue { - let code = protected_error_code(&error); - let value = structured_error(code, format!("protected opening failed: {error}")); - if let ProtectedError::UnsupportedProtectedVersion(version) = &error { - let _ = js_sys::Reflect::set( - &value, - &JsValue::from_str("protectedVersion"), - &JsValue::bigint_from_str(&version.to_string()), - ); - } - if let ProtectedError::ReservedApplicationType(application_type) = &error { - let _ = js_sys::Reflect::set( - &value, - &JsValue::from_str("applicationType"), - &JsValue::from_str(application_type), - ); - } - value -} - -fn protected_error_code(error: &ProtectedError) -> &'static str { - match error { - ProtectedError::NotApplicationFrame => "not-application-frame", - ProtectedError::MissingReceiver => "missing-receiver", - ProtectedError::PayloadNotEncrypted => "payload-not-encrypted", - ProtectedError::PayloadNotSigned => "payload-not-signed", - ProtectedError::MissingEnvelope => "missing-envelope", - ProtectedError::InvalidLayout(_) => "invalid-layout", - ProtectedError::MissingProtectedVersion => "missing-protected-version", - ProtectedError::UnsupportedProtectedVersion(_) => "unsupported-protected-version", - ProtectedError::MessageTypeMismatch => "message-type-mismatch", - ProtectedError::FinalRecipientMismatch => "final-recipient-mismatch", - ProtectedError::SenderMismatch => "sender-id-mismatch", - ProtectedError::ExpectedReceiverMismatch => "receiver-id-mismatch", - ProtectedError::ReservedApplicationType(_) => "reserved-application-type", - ProtectedError::Replay => "replay", - ProtectedError::ResourceLimit(_) => "resource-limit", - ProtectedError::ReplayGuard(_) => "replay-guard-error", - ProtectedError::Protection(error) => match error { - ProtectionError::NoMatchingRecipient => "no-matching-recipient", - ProtectionError::InvalidSignature => "invalid-signature", - ProtectionError::SignaturePolicyMismatch { .. } => "signature-policy-mismatch", - ProtectionError::PurposeMismatch { .. } => "purpose-mismatch", - ProtectionError::SignerIdMismatch { .. } => "signer-id-mismatch", - ProtectionError::SignerKeyNotFound(_) => "signer-key-not-found", - ProtectionError::ResourceLimit(_) => "resource-limit", - ProtectionError::Crypto(mtp_crypto::CryptoError::InvalidSignature) - | ProtectionError::Crypto(mtp_crypto::CryptoError::VerificationFailed) => { - "invalid-signature" - } - _ => "protection-error", - }, - } -} - -fn serialize_data_value(value: &DataValue) -> Result, JsValue> { - value.to_bytes().map_err(|error| { - structured_error( - "invalid-data-value", - format!("protected value encoding failed: {error}"), - ) - }) -} - -#[wasm_bindgen] -pub struct WasmVerifiedProtectedMessage { - inner: VerifiedProtectedMessage, -} - -#[wasm_bindgen] -impl WasmVerifiedProtectedMessage { - pub fn protected_version(&self) -> u64 { - self.inner.protected_version - } - - pub fn signer_id(&self) -> u64 { - self.inner.signer_id - } - - pub fn final_recipient_id(&self) -> u64 { - self.inner.final_recipient_id - } - - pub fn message_id(&self) -> String { - self.inner.message_id.clone() - } - - pub fn created_at(&self) -> u64 { - self.inner.created_at - } - - pub fn message_type(&self) -> String { - self.inner.message_type.clone() - } - - pub fn content(&self) -> Result, JsValue> { - serialize_data_value(&self.inner.content) - } - - pub fn matched_signer_key_index(&self) -> usize { - self.inner.matched_signer_key_index - } -} - -/// Build a complete encrypted direct protected frame in the native codec. -/// The native builder owns both the protected envelope and the clear outer -/// routing fields, including the optional sender exposure and frame ID. -#[wasm_bindgen] -#[allow(clippy::too_many_arguments)] -pub fn build_protected_frame_with_keyring( - message_type: &str, - encoded_content: &[u8], - signer_id: u64, - final_recipient_id: u64, - message_id: &str, - created_at: u64, - signature_purpose: u8, - encryption_purpose: u8, - keyring_bytes: &[u8], - signature_suite: u8, - frame_id: Option, - expose_sender: bool, - recipient_public_key_bundles: JsValue, -) -> Result, JsValue> { - build_protected_frame_with_keyring_impl( - message_type, - encoded_content, - signer_id, - final_recipient_id, - message_id, - created_at, - signature_purpose, - encryption_purpose, - keyring_bytes, - signature_suite, - frame_id, - expose_sender, - recipient_public_key_bundles, - JsValue::UNDEFINED, - ) -} - -#[allow(clippy::too_many_arguments)] -fn build_protected_frame_with_keyring_impl( - message_type: &str, - encoded_content: &[u8], - signer_id: u64, - final_recipient_id: u64, - message_id: &str, - created_at: u64, - signature_purpose: u8, - encryption_purpose: u8, - keyring_bytes: &[u8], - signature_suite: u8, - frame_id: Option, - expose_sender: bool, - recipient_public_key_bundles: JsValue, - limits: JsValue, -) -> Result, JsValue> { - let encode_limits = if limits.is_null() || limits.is_undefined() { - EncodeLimits::default() - } else { - crate::client::encode_limits_from_js(&limits)? - }; - let open_options = protected_open_options( - None, - signature_purpose, - encryption_purpose, - ProtectionPolicy::any_supported(), - &limits, - )?; - let content = DataValue::try_from_bytes_with_limits( - encoded_content, - DecodeLimits::for_transport_message_size(encode_limits.max_output_size as u64), - ) - .map_err(|error| decode_error(error, "DataValue decoding failed"))?; - let keyring = mtp_crypto::Keyring::from_bytes(keyring_bytes).map_err(|error| { - structured_error( - "invalid-keyring", - format!("keyring initialization failed: {error}"), - ) - })?; - let signer = relay_signer_from_keyring(&keyring, signature_suite)?; - let recipients = public_key_bundles_from_js(&recipient_public_key_bundles)?; - let mut builder = ProtectedMessageBuilder::new( - message_type, - content, - signer_id, - final_recipient_id, - &signer, - ProtectionPurpose::from(signature_purpose), - ProtectionPurpose::from(encryption_purpose), - ) - .message_id(message_id) - .created_at(created_at) - .recipients(recipients) - .encode_limits(encode_limits) - .protected_limits(open_options.protected_limits) - .expose_sender(expose_sender); - if let Some(frame_id) = frame_id { - builder = builder.frame_id(frame_id); - } - let frame = builder.build().map_err(protected_error)?; - frame.to_bytes_with_limits(encode_limits).map_err(|error| { - structured_error( - "invalid-frame", - format!("protected frame encoding failed: {error}"), - ) - }) -} - -/// Build a complete encrypted protected frame with explicit encoder and -/// semantic protected-field limits. -#[wasm_bindgen] -#[allow(clippy::too_many_arguments)] -pub fn build_protected_frame_with_keyring_with_limits( - message_type: &str, - encoded_content: &[u8], - signer_id: u64, - final_recipient_id: u64, - message_id: &str, - created_at: u64, - signature_purpose: u8, - encryption_purpose: u8, - keyring_bytes: &[u8], - signature_suite: u8, - frame_id: Option, - expose_sender: bool, - recipient_public_key_bundles: JsValue, - limits: JsValue, -) -> Result, JsValue> { - build_protected_frame_with_keyring_impl( - message_type, - encoded_content, - signer_id, - final_recipient_id, - message_id, - created_at, - signature_purpose, - encryption_purpose, - keyring_bytes, - signature_suite, - frame_id, - expose_sender, - recipient_public_key_bundles, - limits, - ) -} - -/// Read the claimed, unverified signer ID after decrypting the protected -/// payload. The result may only select trusted keys for the same signer ID. -#[wasm_bindgen] -#[allow(deprecated)] -#[deprecated(note = "use protected_claimed_signer_id_with_limits")] -pub fn protected_claimed_signer_id( - frame: &[u8], - keyrings: JsValue, - encryption_purpose: u8, -) -> Result { - protected_claimed_signer_id_impl(frame, keyrings, encryption_purpose, JsValue::UNDEFINED) -} - -fn protected_claimed_signer_id_impl( - frame: &[u8], - keyrings: JsValue, - encryption_purpose: u8, - limits: JsValue, -) -> Result { - let options = protected_open_options( - None, - 0, - encryption_purpose, - ProtectionPolicy::any_supported(), - &limits, - )?; - let frame = decode_frame_with_limits(frame, options.decode_limits)?; - let keyrings = keyrings_from_js(&keyrings).map_err(|error| { - structured_error( - "invalid-recipient-keyrings", - error.as_string().unwrap_or_default(), - ) - })?; - if keyrings.len() > options.protected_limits.max_decryption_key_history { - return Err(protected_error(ProtectedError::ResourceLimit( - "decryption key history", - ))); - } - let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect(); - mtp_codec::protected_claimed_signer_id_with_options( - &frame, - &references, - ProtectionPurpose::from(encryption_purpose), - options.decode_limits, - options.protected_limits, - ) - .map_err(protected_error) -} - -#[wasm_bindgen] -pub fn protected_claimed_signer_id_with_limits( - frame: &[u8], - keyrings: JsValue, - encryption_purpose: u8, - limits: JsValue, -) -> Result { - let options = protected_open_options( - None, - 0, - encryption_purpose, - ProtectionPolicy::any_supported(), - &limits, - )?; - let frame = decode_frame_with_limits(frame, options.decode_limits)?; - let keyrings = keyrings_from_js(&keyrings).map_err(|error| { - structured_error( - "invalid-recipient-keyrings", - error.as_string().unwrap_or_default(), - ) - })?; - if keyrings.len() > options.protected_limits.max_decryption_key_history { - return Err(protected_error(ProtectedError::ResourceLimit( - "decryption key history", - ))); - } - let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect(); - mtp_codec::protected_claimed_signer_id_with_options( - &frame, - &references, - ProtectionPurpose::from(encryption_purpose), - options.decode_limits, - options.protected_limits, - ) - .map_err(protected_error) -} - -/// Open a protected value without replay protection. This raw entry point is -/// intended for stored/forensic messages; message-processing callers should -/// apply their replay guard in the SDK or use a checked native API. -#[wasm_bindgen] -pub fn open_protected_with_keyrings_without_replay( - frame: &[u8], - keyrings: JsValue, - expected_signer_id: JsValue, - signer_public_key_bundles: JsValue, - expected_receiver_id: JsValue, - signature_purpose: u8, - encryption_purpose: u8, - signature_suite: u8, -) -> Result { - open_protected_with_keyrings_impl( - frame, - keyrings, - expected_signer_id, - signer_public_key_bundles, - expected_receiver_id, - signature_purpose, - encryption_purpose, - signature_suite, - JsValue::UNDEFINED, - ) -} - -fn open_protected_with_keyrings_impl( - frame: &[u8], - keyrings: JsValue, - expected_signer_id: JsValue, - signer_public_key_bundles: JsValue, - expected_receiver_id: JsValue, - signature_purpose: u8, - encryption_purpose: u8, - signature_suite: u8, - limits: JsValue, -) -> Result { - let keyrings = keyrings_from_js(&keyrings).map_err(|error| { - structured_error( - "invalid-recipient-keyrings", - error.as_string().unwrap_or_default(), - ) - })?; - let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect(); - let signer_public_keys = - public_key_bundles_from_js(&signer_public_key_bundles).map_err(|error| { - structured_error("invalid-signer-keys", error.as_string().unwrap_or_default()) - })?; - let expected_signer_id = optional_u64(&expected_signer_id, "expectedSignerId")? - .ok_or_else(|| structured_error("invalid-option", "expectedSignerId is required"))?; - let expected_receiver_id = optional_u64(&expected_receiver_id, "expectedReceiverId")?; - let policy = protection_policy_from_suite(signature_suite).map_err(|error| { - structured_error( - "unsupported-signature-suite", - error.as_string().unwrap_or_default(), - ) - })?; - let options = protected_open_options( - expected_receiver_id, - signature_purpose, - encryption_purpose, - policy, - &limits, - )?; - let frame = decode_frame_with_limits(frame, options.decode_limits)?; - let message = mtp_codec::open_protected_with_keys_without_replay( - &frame, - &references, - expected_signer_id, - &signer_public_keys, - options, - ) - .map_err(protected_error)?; - Ok(WasmVerifiedProtectedMessage { inner: message }) -} - -/// Open a bounded protected value without replay protection. The raw WASM -/// boundary cannot accept a native replay-guard trait, so message-processing -/// callers must use the SDK guard or a native checked API. -#[wasm_bindgen] -pub fn open_protected_with_keyrings_with_limits_without_replay( - frame: &[u8], - keyrings: JsValue, - expected_signer_id: JsValue, - signer_public_key_bundles: JsValue, - expected_receiver_id: JsValue, - signature_purpose: u8, - encryption_purpose: u8, - signature_suite: u8, - limits: JsValue, -) -> Result { - open_protected_with_keyrings_impl( - frame, - keyrings, - expected_signer_id, - signer_public_key_bundles, - expected_receiver_id, - signature_purpose, - encryption_purpose, - signature_suite, - limits, - ) -} - -#[cfg(all(test, target_arch = "wasm32"))] -mod tests { - use super::*; - use crate::crypto::relay_signer_from_keyring; - use mtp_codec::{CommunicationType, CommunicationValue, DataType, TypeMap}; - use wasm_bindgen::JsCast; - use wasm_bindgen_test::*; - - const SIGNATURE_PURPOSE: u8 = 0x40; - const ENCRYPTION_PURPOSE: u8 = 0x41; - - fn structured_error_code(error: JsValue) -> String { - js_sys::Reflect::get(&error, &JsValue::from_str("code")) - .expect("structured error code") - .as_string() - .expect("structured error code string") - } - - fn protected_frame_with_version( - sender: &mtp_crypto::Keyring, - recipient: &mtp_crypto::Keyring, - version: Option, - ) -> Vec { - let type_map = TypeMap::latest(); - let field = |data_type: DataType| data_type.try_to_id(&type_map).expect("field mapping"); - let mut fields = Vec::new(); - if let Some(version) = version { - fields.push(( - field(DataType::ProtectedVersion), - DataValue::UnsignedNumber(version), - )); - } - fields.extend([ - ( - field(DataType::MessageType), - DataValue::Str("ProtectedMessage".into()), - ), - ( - field(DataType::FinalRecipientId), - DataValue::UnsignedNumber(42), - ), - ( - field(DataType::MessageId), - DataValue::Str("wasm-structured-error".into()), - ), - (field(DataType::CreatedAt), DataValue::UnsignedNumber(123)), - (field(DataType::Content), DataValue::Str("hello".into())), - ]); - let signer = relay_signer_from_keyring(sender, 1).expect("Ed25519 signer"); - let signed = DataValue::Container(fields) - .sign(7, ProtectionPurpose::from(SIGNATURE_PURPOSE), &signer) - .expect("sign protected envelope"); - let encrypted = signed - .encrypt_for( - &[recipient.public_key_bundle()], - ProtectionPurpose::from(ENCRYPTION_PURPOSE), - ) - .expect("encrypt protected envelope"); - CommunicationValue::new_with_type_map( - CommunicationType::from_name("ProtectedMessage").expect("application type"), - &type_map, - ) - .with_receiver(42) - .with_payload(encrypted) - .to_bytes() - .expect("encode protected frame") - } - - fn open_for_error( - frame: &[u8], - sender: &mtp_crypto::Keyring, - recipient: &mtp_crypto::Keyring, - ) -> JsValue { - let recipient_bytes = recipient.try_to_bytes().expect("recipient serialization"); - let signer_bundle_bytes = sender - .public_key_bundle() - .try_as_bytes() - .expect("signer bundle serialization"); - match open_protected_with_keyrings_without_replay( - frame, - js_sys::Uint8Array::from(&recipient_bytes[..]).into(), - JsValue::bigint_from_str("7"), - js_sys::Uint8Array::from(&signer_bundle_bytes[..]).into(), - JsValue::bigint_from_str("42"), - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - 1, - ) { - Ok(_) => panic!("protected opening should fail"), - Err(error) => error, - } - } - - #[wasm_bindgen_test] - fn protected_builder_returns_the_complete_frame() { - let sender = mtp_crypto::Keyring::generate(); - let recipient = mtp_crypto::Keyring::generate(); - let sender_bytes = sender.try_to_bytes().expect("sender serialization"); - let recipient_bundle_bytes = recipient - .public_key_bundle() - .try_as_bytes() - .expect("recipient bundle serialization"); - let content = DataValue::Str("complete-frame".into()) - .to_bytes() - .expect("encode content"); - let frame = build_protected_frame_with_keyring( - "ProtectedMessage", - &content, - 7, - 42, - "wasm-complete-frame", - 123, - SIGNATURE_PURPOSE, - ENCRYPTION_PURPOSE, - &sender_bytes, - 1, - Some(19), - true, - js_sys::Uint8Array::from(&recipient_bundle_bytes[..]).into(), - ) - .expect("build complete protected frame"); - let decoded = CommunicationValue::from_bytes(&frame).expect("decode complete frame"); - assert_eq!(decoded.id(), Some(19)); - assert_eq!(decoded.sender(), Some(7)); - assert_eq!(decoded.receiver(), Some(42)); - assert!(decoded.payload().as_encrypted().is_some()); - } - - #[wasm_bindgen_test] - fn protected_opening_maps_missing_and_unsupported_versions() { - let sender = mtp_crypto::Keyring::generate(); - let recipient = mtp_crypto::Keyring::generate(); - let missing = protected_frame_with_version(&sender, &recipient, None); - assert_eq!( - structured_error_code(open_for_error(&missing, &sender, &recipient)), - "missing-protected-version" - ); - - let unsupported = protected_frame_with_version(&sender, &recipient, Some(2)); - let error = open_for_error(&unsupported, &sender, &recipient); - assert_eq!( - structured_error_code(error.clone()), - "unsupported-protected-version" - ); - let version = js_sys::Reflect::get(&error, &JsValue::from_str("protectedVersion")) - .expect("protected version"); - let version = version - .unchecked_into::() - .to_string(10) - .expect("protected version string") - .as_string() - .expect("protected version text"); - assert_eq!(version, "2"); - } -} diff --git a/wasm/src/relay.rs b/wasm/src/relay.rs deleted file mode 100644 index fcf768b..0000000 --- a/wasm/src/relay.rs +++ /dev/null @@ -1,468 +0,0 @@ -use wasm_bindgen::prelude::*; - -use mtp_codec::{ - CommunicationValue, DataValue, DecodeLimits, EncodeLimits, ProtectedLimits, ProtectionError, - ProtectionPolicy, RelayError, RelayOpenOptions, VerifiedRelayContent, VerifiedRelayMetadata, -}; - -use crate::crypto::{keyrings_from_js, protection_policy_from_suite, public_key_bundles_from_js}; - -const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; - -pub(crate) fn structured_error(code: &str, message: impl Into) -> JsValue { - let error = js_sys::Error::new(&message.into()); - let value: JsValue = error.into(); - let _ = js_sys::Reflect::set(&value, &JsValue::from_str("code"), &JsValue::from_str(code)); - value -} - -pub(crate) fn decode_error(error: mtp_codec::DecodeError, context: &str) -> JsValue { - let value = structured_error("invalid-frame", format!("{context}: {error}")); - let _ = js_sys::Reflect::set( - &value, - &JsValue::from_str("decodeCode"), - &JsValue::from_str(decode_error_code(&error)), - ); - value -} - -pub(crate) fn decode_error_code(error: &mtp_codec::DecodeError) -> &'static str { - match error { - mtp_codec::DecodeError::MalformedEncoding => "malformed-encoding", - mtp_codec::DecodeError::DepthLimit => "depth-limit", - mtp_codec::DecodeError::ValueCountLimit => "value-count-limit", - mtp_codec::DecodeError::BlobLimit => "blob-limit", - mtp_codec::DecodeError::AllocationLimit => "allocation-limit", - mtp_codec::DecodeError::RecipientLimit => "recipient-limit", - mtp_codec::DecodeError::DuplicateField => "duplicate-field", - } -} - -fn wrapped_input_error(code: &str, error: JsValue) -> JsValue { - let message = error - .as_string() - .unwrap_or_else(|| "invalid relay operation input".to_owned()); - structured_error(code, message) -} - -pub(crate) fn relay_error(error: mtp_codec::RelayError) -> JsValue { - let code = relay_error_code(&error); - let message = format!("relay opening failed: {error}"); - let value = structured_error(code, message); - if let RelayError::UnsupportedRelayVersion(version) = &error { - let _ = js_sys::Reflect::set( - &value, - &JsValue::from_str("relayVersion"), - &JsValue::bigint_from_str(&version.to_string()), - ); - } - if let RelayError::ReservedApplicationType(application_type) = &error { - let _ = js_sys::Reflect::set( - &value, - &JsValue::from_str("applicationType"), - &JsValue::from_str(application_type), - ); - } - value -} - -fn relay_error_code(error: &RelayError) -> &'static str { - match error { - RelayError::NotRelay => "not-relay", - RelayError::OuterSenderPresent => "outer-sender-present", - RelayError::MissingNextHop => "missing-next-hop", - RelayError::InvalidLayout(_) => "invalid-layout", - RelayError::MissingRelayVersion => "missing-relay-version", - RelayError::UnsupportedRelayVersion(_) => "unsupported-relay-version", - RelayError::NotFinalRecipient => "not-final-recipient", - RelayError::Replay => "replay", - RelayError::ResourceLimit(_) => "resource-limit", - RelayError::ReservedApplicationType(_) => "reserved-application-type", - RelayError::ReplayGuard(_) => "replay-guard-error", - RelayError::Protection(error) => match error { - ProtectionError::NoMatchingRecipient => "no-matching-recipient", - ProtectionError::InvalidSignature => "invalid-signature", - ProtectionError::SignaturePolicyMismatch { .. } => "signature-policy-mismatch", - ProtectionError::PurposeMismatch { .. } => "purpose-mismatch", - ProtectionError::SignerIdMismatch { .. } => "signer-id-mismatch", - ProtectionError::SignerKeyNotFound(_) => "signer-key-not-found", - ProtectionError::ResourceLimit(_) => "resource-limit", - ProtectionError::Crypto(mtp_crypto::CryptoError::InvalidSignature) - | ProtectionError::Crypto(mtp_crypto::CryptoError::VerificationFailed) => { - "invalid-signature" - } - _ => "protection-error", - }, - } -} - -pub(crate) fn decode_frame(frame: &[u8]) -> Result { - decode_frame_with_limits(frame, DecodeLimits::default()) -} - -pub(crate) fn decode_frame_with_limits( - frame: &[u8], - limits: DecodeLimits, -) -> Result { - CommunicationValue::try_from_bytes_with_limits(frame, limits) - .map_err(|error| decode_error(error, "relay frame decoding failed")) -} - -fn optional_u64(value: &JsValue, name: &str) -> Result, JsValue> { - if value.is_null() || value.is_undefined() { - return Ok(None); - } - if let Some(number) = value.as_f64() { - if !number.is_finite() - || number.fract() != 0.0 - || !(0.0..=MAX_SAFE_INTEGER).contains(&number) - { - return Err(structured_error( - "invalid-option", - format!("{name} must be an exact non-negative integer"), - )); - } - return Ok(Some(number as u64)); - } - if value.js_typeof().as_string().as_deref() == Some("bigint") { - let bigint = value.clone().unchecked_into::(); - let text = bigint.to_string(10)?.as_string().ok_or_else(|| { - structured_error("invalid-option", format!("failed to stringify {name}")) - })?; - return text - .parse::() - .map(Some) - .map_err(|_| structured_error("invalid-option", format!("{name} is out of range"))); - } - Err(structured_error( - "invalid-option", - format!("{name} must be a number or bigint"), - )) -} - -fn limit_usize(options: &JsValue, key: &str, default: usize) -> Result { - if options.is_null() || options.is_undefined() { - return Ok(default); - } - let value = js_sys::Reflect::get(options, &JsValue::from_str(key))?; - if value.is_null() || value.is_undefined() { - return Ok(default); - } - let Some(number) = value.as_f64() else { - return Err(structured_error( - "invalid-limit", - format!("{key} must be a number"), - )); - }; - if !number.is_finite() || number.fract() != 0.0 || number < 0.0 { - return Err(structured_error( - "invalid-limit", - format!("{key} must be a non-negative integer"), - )); - } - usize::try_from(number as u64) - .map_err(|_| structured_error("invalid-limit", format!("{key} is out of range"))) -} - -pub(crate) fn relay_open_options( - policy: mtp_codec::ProtectionPolicy, - limits: &JsValue, -) -> Result { - let decode_defaults = DecodeLimits::default(); - let encode_defaults = EncodeLimits::default(); - let protected_defaults = ProtectedLimits::default(); - let options = RelayOpenOptions::new(policy).with_limits( - DecodeLimits { - max_depth: limit_usize(limits, "maxDepth", decode_defaults.max_depth)?, - max_values: limit_usize(limits, "maxValues", decode_defaults.max_values)?, - max_blob_size: limit_usize(limits, "maxBlobSize", decode_defaults.max_blob_size)?, - max_recipients: limit_usize(limits, "maxRecipients", decode_defaults.max_recipients)?, - max_allocated_bytes: limit_usize( - limits, - "maxAllocatedBytes", - decode_defaults.max_allocated_bytes, - )?, - }, - ProtectedLimits { - max_message_id_bytes: limit_usize( - limits, - "maxMessageIdBytes", - protected_defaults.max_message_id_bytes, - )?, - max_metadata_encoded_bytes: limit_usize( - limits, - "maxMetadataEncodedBytes", - protected_defaults.max_metadata_encoded_bytes, - )?, - max_signer_key_history: limit_usize( - limits, - "maxSignerKeyHistory", - protected_defaults.max_signer_key_history, - )?, - max_decryption_key_history: limit_usize( - limits, - "maxDecryptionKeyHistory", - protected_defaults.max_decryption_key_history, - )?, - }, - ); - Ok(options.with_encode_limits(EncodeLimits { - max_depth: limit_usize(limits, "maxDepth", encode_defaults.max_depth)?, - max_values: limit_usize(limits, "maxValues", encode_defaults.max_values)?, - max_output_size: limit_usize(limits, "maxOutputSize", encode_defaults.max_output_size)?, - })) -} - -fn serialize_data_value(value: &DataValue) -> Result, JsValue> { - value.to_bytes().map_err(|error| { - structured_error( - "invalid-data-value", - format!("relay value encoding failed: {error}"), - ) - }) -} - -#[wasm_bindgen] -pub struct WasmVerifiedRelayMetadata { - inner: VerifiedRelayMetadata, -} - -#[wasm_bindgen] -impl WasmVerifiedRelayMetadata { - pub fn relay_version(&self) -> u64 { - self.inner.relay_version() - } - - pub fn signer_id(&self) -> u64 { - self.inner.signer_id() - } - - pub fn final_recipient_id(&self) -> u64 { - self.inner.final_recipient_id() - } - - pub fn message_id(&self) -> String { - self.inner.message_id().to_owned() - } - - pub fn created_at(&self) -> u64 { - self.inner.created_at() - } - - pub fn metadata(&self) -> Result { - match self.inner.metadata() { - Some(value) => { - let bytes = serialize_data_value(value)?; - Ok(js_sys::Uint8Array::from(&bytes[..]).into()) - } - None => Ok(JsValue::NULL), - } - } - - pub fn encrypted_content(&self) -> Result, JsValue> { - serialize_data_value(self.inner.encrypted_content()) - } - - pub fn matched_signer_key_index(&self) -> usize { - self.inner.matched_signer_key_index() - } -} - -#[wasm_bindgen] -pub struct WasmVerifiedRelayContent { - inner: VerifiedRelayContent, -} - -#[wasm_bindgen] -impl WasmVerifiedRelayContent { - pub fn signer_id(&self) -> u64 { - self.inner.signer_id - } - - pub fn final_recipient_id(&self) -> u64 { - self.inner.final_recipient_id - } - - pub fn message_type(&self) -> String { - self.inner.message_type.clone() - } - - pub fn content(&self) -> Result, JsValue> { - serialize_data_value(&self.inner.content) - } -} - -/// Read the claimed, unverified signer ID from a relay without duplicating the -/// versioned relay metadata parser in the JavaScript SDK. The caller must bind -/// this value as the expected signer during the subsequent verification call. -#[wasm_bindgen] -#[allow(deprecated)] -#[deprecated(note = "use relay_metadata_claimed_signer_id_with_limits")] -pub fn relay_metadata_claimed_signer_id(frame: &[u8], keyrings: JsValue) -> Result { - relay_metadata_claimed_signer_id_impl(frame, keyrings, JsValue::UNDEFINED) -} - -fn relay_metadata_claimed_signer_id_impl( - frame: &[u8], - keyrings: JsValue, - limits: JsValue, -) -> Result { - let options = relay_open_options(ProtectionPolicy::any_supported(), &limits)?; - let frame = decode_frame_with_limits(frame, options.decode_limits)?; - let keyrings = keyrings_from_js(&keyrings) - .map_err(|error| wrapped_input_error("invalid-recipient-keyrings", error))?; - if keyrings.len() > options.protected_limits.max_decryption_key_history { - return Err(relay_error(RelayError::ResourceLimit( - "decryption key history", - ))); - } - let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect(); - mtp_codec::relay_metadata_claimed_signer_id_with_options( - &frame, - &references, - options.decode_limits, - options.protected_limits, - ) - .map_err(relay_error) -} - -#[wasm_bindgen] -pub fn relay_metadata_claimed_signer_id_with_limits( - frame: &[u8], - keyrings: JsValue, - limits: JsValue, -) -> Result { - relay_metadata_claimed_signer_id_impl(frame, keyrings, limits) -} - -/// Open relay metadata without replay protection. This raw entry point is for -/// stored/forwarded messages; message-processing paths should add a guard in -/// the SDK or use the checked native API. -#[wasm_bindgen] -pub fn open_relay_metadata_with_keyrings_without_replay( - frame: &[u8], - keyrings: JsValue, - expected_signer_id: JsValue, - signer_public_key_bundles: JsValue, - signature_suite: u8, -) -> Result { - let frame = decode_frame(frame)?; - let keyrings = keyrings_from_js(&keyrings) - .map_err(|error| wrapped_input_error("invalid-recipient-keyrings", error))?; - let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect(); - let signer_public_keys = public_key_bundles_from_js(&signer_public_key_bundles) - .map_err(|error| wrapped_input_error("invalid-signer-keys", error))?; - let expected_signer_id = optional_u64(&expected_signer_id, "expectedSignerId")? - .ok_or_else(|| structured_error("invalid-option", "expectedSignerId is required"))?; - let policy = protection_policy_from_suite(signature_suite) - .map_err(|error| wrapped_input_error("unsupported-signature-suite", error))?; - let metadata = mtp_codec::open_relay_metadata_with_limits_without_replay( - &frame, - &references, - Some(expected_signer_id), - move |_| Some(signer_public_keys), - RelayOpenOptions::new(policy), - ) - .map_err(relay_error)?; - Ok(WasmVerifiedRelayMetadata { inner: metadata }) -} - -/// Open bounded relay metadata without replay protection. Use the SDK's -/// message-processing guard or a native checked API for live traffic. -#[wasm_bindgen] -pub fn open_relay_metadata_with_keyrings_with_limits_without_replay( - frame: &[u8], - keyrings: JsValue, - expected_signer_id: JsValue, - signer_public_key_bundles: JsValue, - signature_suite: u8, - limits: JsValue, -) -> Result { - let keyrings = keyrings_from_js(&keyrings) - .map_err(|error| wrapped_input_error("invalid-recipient-keyrings", error))?; - let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect(); - let signer_public_keys = public_key_bundles_from_js(&signer_public_key_bundles) - .map_err(|error| wrapped_input_error("invalid-signer-keys", error))?; - let expected_signer_id = optional_u64(&expected_signer_id, "expectedSignerId")? - .ok_or_else(|| structured_error("invalid-option", "expectedSignerId is required"))?; - let policy = protection_policy_from_suite(signature_suite) - .map_err(|error| wrapped_input_error("unsupported-signature-suite", error))?; - let options = relay_open_options(policy, &limits)?; - let frame = decode_frame_with_limits(frame, options.decode_limits)?; - let metadata = mtp_codec::open_relay_metadata_with_limits_without_replay( - &frame, - &references, - Some(expected_signer_id), - move |_| Some(signer_public_keys), - options, - ) - .map_err(relay_error)?; - Ok(WasmVerifiedRelayMetadata { inner: metadata }) -} - -/// Open relay content without making a second replay decision. Replay is -/// consumed when live message processing accepts the authenticated metadata. -#[wasm_bindgen] -pub fn open_relay_content_with_keyrings_without_replay( - metadata: &WasmVerifiedRelayMetadata, - keyrings: JsValue, - signer_public_key_bundles: JsValue, - expected_final_recipient_id: JsValue, - signature_suite: u8, -) -> Result { - let keyrings = keyrings_from_js(&keyrings) - .map_err(|error| wrapped_input_error("invalid-recipient-keyrings", error))?; - let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect(); - let signer_public_keys = public_key_bundles_from_js(&signer_public_key_bundles) - .map_err(|error| wrapped_input_error("invalid-signer-keys", error))?; - let expected_final_recipient_id = - optional_u64(&expected_final_recipient_id, "expectedFinalRecipientId")?; - let policy = protection_policy_from_suite(signature_suite) - .map_err(|error| wrapped_input_error("unsupported-signature-suite", error))?; - let content = mtp_codec::open_relay_content_with_limits_without_replay( - &metadata.inner, - &references, - &signer_public_keys, - expected_final_recipient_id, - RelayOpenOptions { - policy, - decode_limits: metadata.inner.decode_limits(), - encode_limits: metadata.inner.encode_limits(), - protected_limits: metadata.inner.protected_limits(), - }, - ) - .map_err(relay_error)?; - Ok(WasmVerifiedRelayContent { inner: content }) -} - -/// Open bounded relay content without replay protection. Replay is consumed -/// when metadata is accepted by the live SDK/native processing boundary. -#[wasm_bindgen] -pub fn open_relay_content_with_keyrings_with_limits_without_replay( - metadata: &WasmVerifiedRelayMetadata, - keyrings: JsValue, - signer_public_key_bundles: JsValue, - expected_final_recipient_id: JsValue, - signature_suite: u8, - limits: JsValue, -) -> Result { - let keyrings = keyrings_from_js(&keyrings) - .map_err(|error| wrapped_input_error("invalid-recipient-keyrings", error))?; - let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect(); - let signer_public_keys = public_key_bundles_from_js(&signer_public_key_bundles) - .map_err(|error| wrapped_input_error("invalid-signer-keys", error))?; - let expected_final_recipient_id = - optional_u64(&expected_final_recipient_id, "expectedFinalRecipientId")?; - let policy = protection_policy_from_suite(signature_suite) - .map_err(|error| wrapped_input_error("unsupported-signature-suite", error))?; - let options = relay_open_options(policy, &limits)?; - let content = mtp_codec::open_relay_content_with_limits_without_replay( - &metadata.inner, - &references, - &signer_public_keys, - expected_final_recipient_id, - options, - ) - .map_err(relay_error)?; - Ok(WasmVerifiedRelayContent { inner: content }) -} diff --git a/wasm/src/transport.rs b/wasm/src/transport.rs index b7ffebe..df59c30 100644 --- a/wasm/src/transport.rs +++ b/wasm/src/transport.rs @@ -1,56 +1,15 @@ -use std::cell::{Cell, RefCell}; +use std::cell::RefCell; use std::rc::Rc; -use futures_util::lock::Mutex as AsyncMutex; use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; use crate::error::js_error; -use crate::frame::parse_frame_value_with_limits; -use mtp_codec::{DecodeLimits, EncodeLimits, TypeMap}; -use mtp_common::{FirstFrameDisposition, classify_first_frame}; +use crate::frame::parse_frame_value; const CLOSE_FRAME_LEN: u32 = u32::MAX; -/// Logs the `streamErrorCode` from a stream-level WebTransportError (STOP_SENDING / RESET_STREAM). Session errors are skipped. -pub(crate) fn log_stream_error_code(error: &JsValue, context: &str) { - let source = js_sys::Reflect::get(error, &JsValue::from_str("source")) - .ok() - .and_then(|v| v.as_string()); - - if source.as_deref() != Some("stream") { - return; - } - - let stream_error_code = js_sys::Reflect::get(error, &JsValue::from_str("streamErrorCode")) - .ok() - .and_then(|v| v.as_f64()); - let message = error - .as_string() - .or_else(|| { - js_sys::Reflect::get(error, &JsValue::from_str("message")) - .ok() - .and_then(|v| v.as_string()) - }) - .unwrap_or_else(|| format!("{:?}", error)); - - let formatted = match stream_error_code { - Some(code) => format!( - "[WasmTransport] {context}: STOP_SENDING/RESET_STREAM streamErrorCode={code} \ - ({message})" - ), - None => format!("[WasmTransport] {context}: stream error ({message})"), - }; - - if let Ok(console) = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("console")) - && let Ok(warn) = js_sys::Reflect::get(&console, &JsValue::from_str("warn")) - .and_then(|f| f.dyn_into::()) - { - let _ = warn.call1(&console, &JsValue::from_str(&formatted)); - } -} - /// Given a `SendStream` (old API with `.writable` or new API where stream IS a WritableStream), /// return the object to call `.getWriter()` on. fn resolve_stream_writable(send_stream: &JsValue) -> Result { @@ -71,251 +30,6 @@ fn resolve_stream_readable(recv_stream: &JsValue) -> Result { } } -#[derive(Clone)] -struct BrowserConnection { - inner: JsValue, - incoming_reader: Rc>>, -} - -pub(crate) struct BrowserSendStream { - writer: JsValue, -} - -pub(crate) struct BrowserRecvStream { - reader: JsValue, -} - -impl BrowserConnection { - async fn connect(url: &str, cert_hashes: Option>) -> Result { - let constructor = - js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("WebTransport"))? - .dyn_into::() - .map_err(|_| js_error("WebTransport not available"))?; - let args = js_sys::Array::new(); - args.push(&JsValue::from_str(url)); - - if let Some(hashes) = cert_hashes { - let webtransport_hashes = js_sys::Array::new(); - for hash in hashes { - let (algorithm, value) = hash.split_once(':').unwrap_or(("sha-256", hash.as_str())); - if let Ok(value) = hex::decode(value) { - let entry = js_sys::Object::new(); - js_sys::Reflect::set( - &entry, - &JsValue::from_str("algorithm"), - &JsValue::from_str(algorithm), - )?; - js_sys::Reflect::set( - &entry, - &JsValue::from_str("value"), - &js_sys::Uint8Array::from(&value[..]), - )?; - webtransport_hashes.push(&entry); - } - } - if webtransport_hashes.length() > 0 { - let options = js_sys::Object::new(); - js_sys::Reflect::set( - &options, - &JsValue::from_str("serverCertificateHashes"), - &webtransport_hashes, - )?; - args.push(&options); - } - } - - let inner = js_sys::Reflect::construct(&constructor, &args)?; - let ready = js_sys::Reflect::get(&inner, &JsValue::from_str("ready"))? - .dyn_into::() - .map_err(|_| js_error("WebTransport.ready is not a Promise"))?; - JsFuture::from(ready) - .await - .map_err(|error| js_error(format!("WebTransport ready failed: {error:?}")))?; - Ok(Self { - inner, - incoming_reader: Rc::new(RefCell::new(None)), - }) - } - - async fn open_uni(&self) -> Result { - let create_stream = js_sys::Reflect::get( - &self.inner, - &JsValue::from_str("createUnidirectionalStream"), - )? - .dyn_into::() - .map_err(|_| js_error("createUnidirectionalStream not a function"))?; - let stream_promise = create_stream - .call0(&self.inner)? - .dyn_into::() - .map_err(|_| js_error("createUnidirectionalStream did not return a Promise"))?; - let stream = JsFuture::from(stream_promise).await?; - let writable = resolve_stream_writable(&stream)?; - let writer = js_sys::Reflect::get(&writable, &JsValue::from_str("getWriter")) - .map_err(|_| js_error("missing getWriter"))? - .dyn_into::() - .map_err(|_| js_error("getWriter not a function"))? - .call0(&writable) - .map_err(|_| js_error("getWriter call failed"))?; - Ok(BrowserSendStream { writer }) - } - - async fn accept_uni(&self) -> Result, JsValue> { - let streams_reader = if let Some(reader) = self.incoming_reader.borrow().clone() { - reader - } else { - let incoming = js_sys::Reflect::get( - &self.inner, - &JsValue::from_str("incomingUnidirectionalStreams"), - )?; - let reader = js_sys::Reflect::get(&incoming, &JsValue::from_str("getReader")) - .map_err(|_| js_error("missing getReader"))? - .dyn_into::() - .map_err(|_| js_error("getReader not a function"))? - .call0(&incoming) - .map_err(|_| js_error("getReader call failed"))?; - *self.incoming_reader.borrow_mut() = Some(reader.clone()); - reader - }; - - let read = js_sys::Reflect::get(&streams_reader, &JsValue::from_str("read")) - .map_err(|_| js_error("missing read"))? - .dyn_into::() - .map_err(|_| js_error("read not a function"))?; - let promise = read - .call0(&streams_reader) - .map_err(|_| js_error("read call failed"))? - .unchecked_into::(); - let result = JsFuture::from(promise).await.map_err(|error| { - log_stream_error_code(&error, "accept_uni"); - js_error(format!("accept stream failed: {error:?}")) - })?; - if js_sys::Reflect::get(&result, &JsValue::from_str("done")) - .ok() - .and_then(|value| value.as_bool()) - .unwrap_or(false) - { - return Ok(None); - } - - let stream = js_sys::Reflect::get(&result, &JsValue::from_str("value")) - .map_err(|_| js_error("missing value"))?; - let readable = resolve_stream_readable(&stream)?; - let reader = js_sys::Reflect::get(&readable, &JsValue::from_str("getReader")) - .map_err(|_| js_error("missing stream getReader"))? - .dyn_into::() - .map_err(|_| js_error("stream getReader not a function"))? - .call0(&readable) - .map_err(|_| js_error("stream getReader call failed"))?; - Ok(Some(BrowserRecvStream { reader })) - } - - fn close(&self) { - if let Some(reader) = self.incoming_reader.borrow_mut().take() { - release_reader_lock(&reader); - } - if let Ok(close) = js_sys::Reflect::get(&self.inner, &JsValue::from_str("close")) - .and_then(|value| value.dyn_into::()) - { - let _ = close.call1(&self.inner, &js_sys::Object::new()); - } - } -} - -impl BrowserSendStream { - pub(crate) async fn write_all(&mut self, bytes: &[u8]) -> Result<(), JsValue> { - let write = js_sys::Reflect::get(&self.writer, &JsValue::from_str("write")) - .map_err(|_| js_error("missing write"))? - .dyn_into::() - .map_err(|_| js_error("write not a function"))?; - let promise = write - .call1(&self.writer, &js_sys::Uint8Array::from(bytes)) - .map_err(|error| js_error(format!("write failed: {error:?}")))? - .unchecked_into::(); - JsFuture::from(promise).await.map(|_| ()) - } - - pub(crate) async fn finish(&mut self) -> Result<(), JsValue> { - let close = js_sys::Reflect::get(&self.writer, &JsValue::from_str("close")) - .map_err(|_| js_error("missing close"))? - .dyn_into::() - .map_err(|_| js_error("close not a function"))?; - let promise = close - .call0(&self.writer) - .map_err(|error| js_error(format!("close failed: {error:?}")))? - .unchecked_into::(); - JsFuture::from(promise).await.map(|_| ()) - } - - pub(crate) fn reset(&mut self, code: u32) -> Result<(), JsValue> { - let abort = js_sys::Reflect::get(&self.writer, &JsValue::from_str("abort")) - .map_err(|_| js_error("missing abort"))? - .dyn_into::() - .map_err(|_| js_error("abort not a function"))?; - let _ = abort.call1(&self.writer, &JsValue::from_f64(code as f64))?; - Ok(()) - } - - pub(crate) fn release(&self) { - release_writer_lock(&self.writer); - } -} - -impl BrowserRecvStream { - pub(crate) async fn read_chunk(&mut self) -> Result>, JsValue> { - let read = js_sys::Reflect::get(&self.reader, &JsValue::from_str("read")) - .map_err(|_| js_error("missing read"))? - .dyn_into::() - .map_err(|_| js_error("read not a function"))?; - let promise = read - .call0(&self.reader) - .map_err(|_| js_error("read call failed"))? - .unchecked_into::(); - let result = JsFuture::from(promise).await?; - if js_sys::Reflect::get(&result, &JsValue::from_str("done")) - .ok() - .and_then(|value| value.as_bool()) - .unwrap_or(true) - { - return Ok(None); - } - let value = js_sys::Reflect::get(&result, &JsValue::from_str("value")) - .map_err(|_| js_error("missing value"))?; - Ok(Some(js_sys::Uint8Array::new(&value).to_vec())) - } - - #[allow(dead_code)] - pub(crate) fn stop(self, code: u32) -> Result<(), JsValue> { - let cancel = js_sys::Reflect::get(&self.reader, &JsValue::from_str("cancel")) - .map_err(|_| js_error("missing cancel"))? - .dyn_into::() - .map_err(|_| js_error("cancel not a function"))?; - let _ = cancel.call1(&self.reader, &JsValue::from_f64(code as f64))?; - Ok(()) - } - - pub(crate) fn release(&self) { - release_reader_lock(&self.reader); - } -} - -/// Releases a writer's lock so an abandoned writer isn't treated as an abort (which sends STOP_SENDING). -pub(crate) fn release_writer_lock(writer: &JsValue) { - if let Ok(release) = js_sys::Reflect::get(writer, &JsValue::from_str("releaseLock")) - .and_then(|f| f.dyn_into::()) - { - let _ = release.call0(writer); - } -} - -/// Releases a reader's lock so an abandoned reader isn't treated as a cancel (which sends STOP_SENDING). -pub(crate) fn release_reader_lock(reader: &JsValue) { - if let Ok(release) = js_sys::Reflect::get(reader, &JsValue::from_str("releaseLock")) - .and_then(|f| f.dyn_into::()) - { - let _ = release.call0(reader); - } -} - /// Outcome of reading the next framed message from the incoming stream(s). enum FrameOutcome { /// A complete application frame. @@ -340,18 +54,14 @@ enum FrameOutcome { */ #[derive(Clone)] pub struct WasmTransport { - connection: BrowserConnection, + inner: JsValue, max_message_size: u32, - /// Current incoming unidirectional stream, shared across handshake and receive loops. - stream_reader: Rc>>, + /// Reader over `incoming_unidirectional_streams()` (a singleton stream of streams). + streams_reader: Rc>>, + /// Reader over the host's current uni-directional stream, if one is open. + stream_reader: Rc>>, /// Bytes already read from the current stream but not yet consumed as a frame. buffer: Rc>>, - /// Set to `true` when `open_next_stream` succeeds; cleared after the first frame is parsed. - new_stream_frame: Rc>, - /// Serializes stream creation and writes across concurrent callers. - send_lock: Rc>, - type_map: Rc>, - decode_limits: Rc>, } impl WasmTransport { @@ -360,141 +70,232 @@ impl WasmTransport { cert_hashes: Option>, max_message_size: u32, ) -> Result { - Self::connect_with_limits(url, cert_hashes, max_message_size, None).await - } + let ctor = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("WebTransport"))? + .dyn_into::() + .map_err(|_| js_error("WebTransport not available"))?; + let args = js_sys::Array::new(); + args.push(&JsValue::from_str(url)); - pub async fn connect_with_limits( - url: &str, - cert_hashes: Option>, - max_message_size: u32, - configured_limits: Option, - ) -> Result { - let connection = BrowserConnection::connect(url, cert_hashes).await?; - let transport_limits = DecodeLimits::for_transport_message_size(max_message_size as u64); - let decode_limits = configured_limits - .map(|limits| restrict_decode_limits(limits, transport_limits)) - .unwrap_or(transport_limits); + if let Some(hashes) = cert_hashes { + let wt_hashes = js_sys::Array::new(); + for h in hashes { + if let Some((algo, hex_val)) = h.split_once(':') { + if let Ok(bytes) = hex::decode(hex_val) { + let hash = js_sys::Object::new(); + js_sys::Reflect::set( + &hash, + &JsValue::from_str("algorithm"), + &JsValue::from_str(algo), + )?; + js_sys::Reflect::set( + &hash, + &JsValue::from_str("value"), + &js_sys::Uint8Array::from(&bytes[..]), + )?; + wt_hashes.push(&hash); + } + } + } + if wt_hashes.length() > 0 { + let opts = js_sys::Object::new(); + js_sys::Reflect::set( + &opts, + &JsValue::from_str("serverCertificateHashes"), + &wt_hashes, + )?; + args.push(&opts); + } + }; + + let transport = js_sys::Reflect::construct(&ctor, &args)?; + let ready = js_sys::Reflect::get(&transport, &JsValue::from_str("ready"))? + .dyn_into::() + .map_err(|_| js_error("WebTransport.ready is not a Promise"))?; + JsFuture::from(ready) + .await + .map_err(|e| js_error(&format!("WebTransport ready failed: {:?}", e)))?; Ok(Self { - connection, + inner: transport, max_message_size, + streams_reader: Rc::new(RefCell::new(None)), stream_reader: Rc::new(RefCell::new(None)), buffer: Rc::new(RefCell::new(Vec::new())), - new_stream_frame: Rc::new(Cell::new(false)), - send_lock: Rc::new(AsyncMutex::new(())), - type_map: Rc::new(RefCell::new(TypeMap::latest())), - decode_limits: Rc::new(RefCell::new(decode_limits)), }) } pub fn inner(&self) -> &JsValue { - &self.connection.inner - } - - pub fn set_type_map(&self, type_map: &TypeMap) { - *self.type_map.borrow_mut() = type_map.clone(); - } - - pub fn type_map(&self) -> TypeMap { - self.type_map.borrow().clone() - } - - pub fn decode_limits(&self) -> DecodeLimits { - *self.decode_limits.borrow() - } - - /// Encoder policy corresponding to the transport's admitted complete - /// frame size. SDK builders use this before constructing a frame so an - /// oversized value is rejected before its serialized buffer is created. - pub fn encode_limits(&self) -> EncodeLimits { - EncodeLimits::for_transport_message_size(self.max_message_size as u64) + &self.inner } pub async fn send_frame(&self, frame: &[u8]) -> Result<(), JsValue> { - let _send_guard = self.send_lock.lock().await; if frame.len() as u64 > self.max_message_size as u64 || frame.len() as u64 >= CLOSE_FRAME_LEN as u64 { return Err(js_error("message too large")); } - // Use one WebTransport uni-stream per MTP frame. Chromium reliably - // publishes a browser-created uni-stream to the peer when it is - // closed; leaving a shared stream open can leave the server waiting - // in accept_uni() until the authentication deadline. The bytes are - // already the canonical MTP self-framed value, so no extra stream - // length prefix is added here. - let mut stream = self.connection.open_uni().await?; - if let Err(e) = stream.write_all(frame).await { - log_stream_error_code(&e, "send_frame write"); - stream.release(); - return Err(e); - } + let create_stream = js_sys::Reflect::get( + &self.inner, + &JsValue::from_str("createUnidirectionalStream"), + )? + .dyn_into::() + .map_err(|_| js_error("createUnidirectionalStream not a function"))?; + let stream_promise = create_stream + .call0(&self.inner)? + .dyn_into::() + .map_err(|_| js_error("createUnidirectionalStream did not return a Promise"))?; + let stream = JsFuture::from(stream_promise).await?; - if let Err(e) = stream.finish().await { - // The frame was already written; do not retry it merely because - // FIN failed, as that would duplicate the MTP frame. - log_stream_error_code(&e, "send_frame close"); - } - stream.release(); + let writable_or_stream = resolve_stream_writable(&stream)?; + + let writer_val = js_sys::Reflect::get(&writable_or_stream, &JsValue::from_str("getWriter")) + .map_err(|_| js_error("missing getWriter"))? + .dyn_into::() + .map_err(|_| js_error("getWriter not a function"))? + .call0(&writable_or_stream) + .map_err(|_| js_error("getWriter call failed"))?; + + let len = frame.len() as u32; + let mut wire = Vec::with_capacity(4 + frame.len()); + wire.extend_from_slice(&len.to_be_bytes()); + wire.extend_from_slice(frame); + + let chunk = js_sys::Uint8Array::from(&wire[..]); + + let write_fn = js_sys::Reflect::get(&writer_val, &JsValue::from_str("write")) + .map_err(|_| js_error("missing write"))? + .dyn_into::() + .map_err(|_| js_error("write not a function"))?; + let write_promise = write_fn + .call1(&writer_val, &chunk) + .map_err(|e| js_error(&format!("write failed: {:?}", e)))?; + JsFuture::from(write_promise.unchecked_into::()).await?; + + let close_fn = js_sys::Reflect::get(&writer_val, &JsValue::from_str("close")) + .map_err(|_| js_error("missing close"))? + .dyn_into::() + .map_err(|_| js_error("close not a function"))?; + let close_promise = close_fn + .call0(&writer_val) + .map_err(|e| js_error(&format!("close failed: {:?}", e)))?; + JsFuture::from(close_promise.unchecked_into::()).await?; Ok(()) } + /// Get (creating once) the reader over `incoming_unidirectional_streams()`. + fn ensure_streams_reader(&self) -> Result { + if let Some(reader) = self.streams_reader.borrow().clone() { + return Ok(reader); + } + let incoming = js_sys::Reflect::get( + &self.inner, + &JsValue::from_str("incomingUnidirectionalStreams"), + )?; + let reader = js_sys::Reflect::get(&incoming, &JsValue::from_str("getReader")) + .map_err(|_| js_error("missing getReader"))? + .dyn_into::() + .map_err(|_| js_error("getReader not a function"))? + .call0(&incoming) + .map_err(|_| js_error("getReader call failed"))?; + *self.streams_reader.borrow_mut() = Some(reader.clone()); + Ok(reader) + } + /// Accept the next incoming uni-directional stream and make it current. /// Returns `false` if the incoming-streams readable has ended. async fn open_next_stream(&self) -> Result { - let Some(stream) = self.connection.accept_uni().await? else { - return Ok(false); - }; + let streams_reader = self.ensure_streams_reader()?; - *self.stream_reader.borrow_mut() = Some(stream); - self.new_stream_frame.set(true); + let read_fn = js_sys::Reflect::get(&streams_reader, &JsValue::from_str("read")) + .map_err(|_| js_error("missing read"))? + .dyn_into::() + .map_err(|_| js_error("read not a function"))?; + let result = JsFuture::from( + read_fn + .call0(&streams_reader) + .map_err(|_| js_error("read call failed"))? + .unchecked_into::(), + ) + .await + .map_err(|e| js_error(&format!("accept stream failed: {:?}", e)))?; + + let done = js_sys::Reflect::get(&result, &JsValue::from_str("done")) + .ok() + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if done { + return Ok(false); + } + + let recv_stream = js_sys::Reflect::get(&result, &JsValue::from_str("value")) + .map_err(|_| js_error("missing value"))?; + let readable = resolve_stream_readable(&recv_stream)?; + let reader = js_sys::Reflect::get(&readable, &JsValue::from_str("getReader")) + .map_err(|_| js_error("missing stream getReader"))? + .dyn_into::() + .map_err(|_| js_error("stream getReader not a function"))? + .call0(&readable) + .map_err(|_| js_error("stream getReader call failed"))?; + + *self.stream_reader.borrow_mut() = Some(reader); Ok(true) } /// Read one chunk from the current stream. `Ok(None)` means the stream ended. async fn read_chunk(&self) -> Result>, JsValue> { - let mut stream = match self.stream_reader.borrow_mut().take() { - Some(stream) => stream, + let reader = match self.stream_reader.borrow().clone() { + Some(r) => r, None => return Ok(None), }; - let result = match stream.read_chunk().await { - Ok(result) => result, - Err(e) => { - log_stream_error_code(&e, "read_chunk"); - stream.release(); - return Err(js_error(format!("read failed: {:?}", e))); - } - }; - if result.is_some() { - *self.stream_reader.borrow_mut() = Some(stream); - } else { - stream.release(); + + let read_fn = js_sys::Reflect::get(&reader, &JsValue::from_str("read")) + .map_err(|_| js_error("missing read"))? + .dyn_into::() + .map_err(|_| js_error("read not a function"))?; + let result = JsFuture::from( + read_fn + .call0(&reader) + .map_err(|_| js_error("read call failed"))? + .unchecked_into::(), + ) + .await + .map_err(|e| js_error(&format!("read failed: {:?}", e)))?; + + let done = js_sys::Reflect::get(&result, &JsValue::from_str("done")) + .ok() + .and_then(|v| v.as_bool()) + .unwrap_or(true); + if done { + return Ok(None); } - Ok(result) + + let value = js_sys::Reflect::get(&result, &JsValue::from_str("value")) + .map_err(|_| js_error("missing value"))?; + Ok(Some(js_sys::Uint8Array::new(&value).to_vec())) } /// Try to pull one complete frame out of the buffer without reading more. - fn parse_buffer(&self, max_message_size: u32) -> Result, JsValue> { + fn parse_buffer(&self) -> Result, JsValue> { let buf = self.buffer.borrow(); if buf.len() < 4 { return Ok(None); } - let body_len = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]); - if body_len == CLOSE_FRAME_LEN { + let frame_len = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]); + if frame_len == CLOSE_FRAME_LEN { return Ok(Some(FrameOutcome::Closed)); } - let frame_len = body_len - .checked_add(4) - .ok_or_else(|| js_error("invalid frame length"))?; - if frame_len > max_message_size { + if frame_len > self.max_message_size { return Err(js_error("message too large")); } - let frame_end = frame_len as usize; + let frame_len = frame_len as usize; + let Some(frame_end) = 4usize.checked_add(frame_len) else { + return Err(js_error("invalid frame length")); + }; if frame_end > buf.len() { return Ok(None); } - let frame = buf[..frame_end].to_vec(); + let frame = buf[4..frame_end].to_vec(); drop(buf); self.buffer.borrow_mut().drain(..frame_end); Ok(Some(FrameOutcome::Frame(frame))) @@ -506,9 +307,9 @@ impl WasmTransport { * persistent uni stream) or one-per-stream; both are handled by buffering * across reads and advancing to the next stream when the current one ends. */ - async fn next_frame(&self, max_message_size: u32) -> Result { + async fn next_frame(&self) -> Result { loop { - if let Some(outcome) = self.parse_buffer(max_message_size)? { + if let Some(outcome) = self.parse_buffer()? { return Ok(outcome); } @@ -520,28 +321,15 @@ impl WasmTransport { match self.read_chunk().await? { Some(chunk) => { if !chunk.is_empty() { - let mut buffer = self.buffer.borrow_mut(); - let maximum_buffer = max_message_size as usize + 4; - if buffer.len().saturating_add(chunk.len()) > maximum_buffer { - return Err(js_error("message too large")); - } - buffer - .try_reserve(chunk.len()) - .map_err(|_| js_error("message allocation failed"))?; - buffer.extend_from_slice(&chunk); + self.buffer.borrow_mut().extend_from_slice(&chunk); } } None => { - // Stream finished; release the reader's lock to avoid a spurious cancel. - // `read_chunk` releases the raw stream lock on clean FIN. - // A frame is never allowed to span stream boundaries. The - // native persistent-stream sender packs frames on one - // stream, while the WASM sender uses one stream per frame; - // either mode must reject a truncated frame instead of - // silently dropping its prefix. - if !self.buffer.borrow().is_empty() { - return Err(js_error("stream ended in the middle of a frame")); - } + // Current stream finished; the next frame (if any) is on a + // subsequent stream. Any trailing partial bytes are dropped + // since the host never splits a frame across streams. + *self.stream_reader.borrow_mut() = None; + self.buffer.borrow_mut().clear(); } } } @@ -549,10 +337,7 @@ impl WasmTransport { /// Read exactly one application frame (used during the auth handshake). pub async fn read_one_frame(&self) -> Result, JsValue> { - match self - .next_frame(self.max_message_size.min(64 * 1024)) - .await? - { + match self.next_frame().await? { FrameOutcome::Frame(frame) => Ok(frame), FrameOutcome::Closed => Err(js_error("connection closed before frame")), FrameOutcome::Ended => Err(js_error("stream ended before frame")), @@ -562,24 +347,18 @@ impl WasmTransport { /// Background loop: deliver every incoming frame to `on_message` until the /// connection closes. Shares reader state with `read_one_frame`, so frames /// buffered during the handshake are not lost. - pub async fn receive_loop(&self, mut on_message: F, on_error: js_sys::Function) - where - F: FnMut(JsValue), - { + pub async fn receive_loop(&self, on_message: js_sys::Function, on_error: js_sys::Function) { loop { - match self.next_frame(self.max_message_size).await { - Ok(FrameOutcome::Frame(frame)) => { - let type_map = self.type_map(); - match parse_frame_value_with_limits(&frame, &type_map, self.decode_limits()) { - Ok(parsed) => { - on_message(parsed); - } - Err(e) => { - let message = e.as_string().unwrap_or_else(|| format!("{:?}", e)); - let _ = on_error.call1(&JsValue::NULL, &JsValue::from_str(&message)); - } + match self.next_frame().await { + Ok(FrameOutcome::Frame(frame)) => match parse_frame_value(&frame) { + Ok(parsed) => { + let _ = on_message.call1(&JsValue::NULL, &parsed); } - } + Err(e) => { + let message = e.as_string().unwrap_or_else(|| format!("{:?}", e)); + let _ = on_error.call1(&JsValue::NULL, &JsValue::from_str(&message)); + } + }, Ok(FrameOutcome::Closed) | Ok(FrameOutcome::Ended) => break, Err(e) => { let _ = on_error.call1(&JsValue::NULL, &e); @@ -589,174 +368,11 @@ impl WasmTransport { } } - /// Pipe-aware receive loop. Identical to `receive_loop` but detects - /// `PipeRequest` as the first frame on a new incoming stream and routes - /// the stream to `on_pipe` instead of `on_message`. - pub async fn receive_loop_with_pipes( - &self, - mut on_message: F, - mut on_error: H, - mut on_pipe: G, - mut pipe_is_expected: I, - ) where - F: FnMut(JsValue), - G: FnMut(crate::pipe::PipeReader), - H: FnMut(JsValue), - I: FnMut(u32) -> bool, - { - loop { - match self.next_frame(self.max_message_size).await { - Ok(FrameOutcome::Frame(frame)) => { - let type_map = self.type_map(); - let decode_limits = self.decode_limits(); - let pipe_request_type = - mtp_codec::CommunicationType::PipeRequest.try_to_id(&type_map); - let pipe_response_type = - mtp_codec::CommunicationType::PipeResponse.try_to_id(&type_map); - let is_first = self.new_stream_frame.get(); - let comm = - mtp_codec::CommunicationValue::try_from_bytes_with_type_map_and_limits( - &frame, - &type_map, - decode_limits, - ) - .ok(); - - if is_first { - self.new_stream_frame.set(false); - if let Some(comm) = comm.as_ref() { - let is_pipe_request = Some(comm.get_type()) == pipe_request_type; - let pipe_id = comm.id().filter(|id| *id != 0); - let is_expected = - is_pipe_request && pipe_id.is_some_and(&mut pipe_is_expected); - let disposition = - match classify_first_frame(is_pipe_request, comm.id(), is_expected) - { - Ok(disposition) => disposition, - Err(error) => { - on_error(JsValue::from_str(&error.to_string())); - self.close(); - break; - } - }; - - if let FirstFrameDisposition::Pipe(pipe_id) = disposition { - let description = comm - .get_str(mtp_codec::DataType::Description) - .unwrap_or("") - .to_string(); - - let pending = { - let mut buf = self.buffer.borrow_mut(); - std::mem::take(&mut *buf) - }; - - if let Some(reader) = self.stream_reader.borrow_mut().take() { - let pipe_reader = crate::pipe::PipeReader::new( - reader, - pipe_id, - description, - pending, - ); - on_pipe(pipe_reader); - } - continue; - } - } - } - - if let Some(comm) = comm.as_ref() - && Some(comm.get_type()) == pipe_response_type - && !matches!(comm.id(), Some(id) if id != 0) - { - on_error(JsValue::from_str( - "PipeResponse frame must contain a non-zero id", - )); - self.close(); - break; - } - - if let Some(comm) = comm.as_ref() - && !matches!(comm.id(), Some(id) if id != 0) - && comm - .get_type_name() - .is_some_and(|name| name.ends_with("Response")) - { - on_error(JsValue::from_str( - "response frame must contain a non-zero id", - )); - self.close(); - break; - } - - match parse_frame_value_with_limits(&frame, &type_map, decode_limits) { - Ok(parsed) => { - on_message(parsed); - } - Err(e) => { - let message = e.as_string().unwrap_or_else(|| format!("{:?}", e)); - on_error(JsValue::from_str(&message)); - } - } - } - Ok(FrameOutcome::Closed) | Ok(FrameOutcome::Ended) => break, - Err(e) => { - on_error(e); - break; - } - } - } - } - - /// Open a new outgoing unidirectional stream and write a `PipeRequest` - /// frame as the first frame. Returns a `PipeWriter` whose underlying - /// `WritableStream` remains open for subsequent raw-data writes. - pub async fn open_pipe( - &self, - pipe_id: u32, - description: &str, - ) -> Result { - let _send_guard = self.send_lock.lock().await; - let mut stream = self.connection.open_uni().await?; - - let type_map = self.type_map(); - let request = mtp_codec::CommunicationValue::new_with_type_map( - mtp_codec::CommunicationType::PipeRequest, - &type_map, - ) - .with_id(pipe_id) - .add_typed_default( - mtp_codec::DataType::Description, - mtp_codec::DataValue::Str(description.to_string()), - ); - let frame_bytes = request - .to_bytes() - .map_err(|e| js_error(format!("encode failed: {}", e)))?; - - if let Err(e) = stream.write_all(&frame_bytes).await { - log_stream_error_code(&e, "open_pipe write"); - stream.release(); - return Err(e); - } - - Ok(crate::pipe::PipeWriter::new(stream, pipe_id)) - } - pub fn close(&self) { - // Release reader locks before closing so they aren't treated as cancels. - if let Some(reader) = self.stream_reader.borrow_mut().take() { - reader.release(); + if let Ok(close) = js_sys::Reflect::get(&self.inner, &JsValue::from_str("close")) + .and_then(|value| value.dyn_into::().map_err(Into::into)) + { + let _ = close.call1(&self.inner, &js_sys::Object::new()); } - self.connection.close(); - } -} - -fn restrict_decode_limits(left: DecodeLimits, right: DecodeLimits) -> DecodeLimits { - DecodeLimits { - max_depth: left.max_depth.min(right.max_depth), - max_values: left.max_values.min(right.max_values), - max_blob_size: left.max_blob_size.min(right.max_blob_size), - max_recipients: left.max_recipients.min(right.max_recipients), - max_allocated_bytes: left.max_allocated_bytes.min(right.max_allocated_bytes), } } diff --git a/wasm/types/mtp_wasm.d.ts b/wasm/types/mtp_wasm.d.ts index 45cd84a..4ecec6b 100644 --- a/wasm/types/mtp_wasm.d.ts +++ b/wasm/types/mtp_wasm.d.ts @@ -1,856 +1,175 @@ -/* tslint:disable */ -/* eslint-disable */ +export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; +export type SyncInitInput = BufferSource | WebAssembly.Module; -export interface ParsedEncryptedValue { - kind: "encrypted"; - encryptionType: number; - purpose: number; - recipientCount: number; - encoded: Uint8Array; +export interface InitOutput { + readonly memory: WebAssembly.Memory; } -export interface ParsedSignedValue { - kind: "signed"; - signatureType: number; - purpose: number; - signerId: bigint; - value: ParsedDataValue; +export interface DisposableWasmObject { + free(): void; + [Symbol.dispose](): void; } -export type ParsedDataValue = -| boolean -| number -| bigint -| string -| Uint8Array -| ParsedDataValue[] -| { [key: string]: ParsedDataValue } -| ParsedEncryptedValue -| ParsedSignedValue -| null; +export type StateChangeCallback = (state: ConnectionState) => void; +export type MessageCallback = (frame: ParsedFrame) => void; +export type ErrorCallback = (error: string) => void; + +export interface Ed25519GenerateResult { + signer: WasmEd25519Signer; + secretKey: Uint8Array; + publicKey: Uint8Array; +} + +export interface AuthResponse { + connected: boolean; + clientNonce?: Uint8Array; + assignedId?: number; + timestamp?: number; + signature?: Uint8Array; +} + +export interface ParsedResponse { + _id?: number; + _type: string; + [field: string]: unknown; +} export interface ParsedFrame { - id?: number; - type: string; - sender?: bigint; - receiver?: bigint; - data: ParsedDataValue; - raw: Uint8Array; + id?: number; + type: string; + sender?: bigint; + receiver?: bigint; + data: Record; + raw: Uint8Array; } - - -export interface PipeWriter { - write(data: Uint8Array): Promise; - close(): Promise; - abort(): void; - readonly pipeId: number; -} - -export interface PipeReader { - read(): Promise; - readonly pipeId: number; - readonly description: string; -} - - - -export interface WasmPipeHandle { - wait(): Promise; - readonly pipeId: number; - readonly description: string; -} - - - -export class ConnectionConfig { - free(): void; - [Symbol.dispose](): void; - constructor(url: string); - client_id: bigint; - get description(): string | undefined; - set description(value: string); - max_message_size: number; - require_pq: boolean; - set server_certificate_hashes(value: string[]); - readonly url: string; +export class ConnectionConfig implements DisposableWasmObject { + constructor(url: string); + free(): void; + [Symbol.dispose](): void; + client_id: bigint; + max_message_size: number; + server_certificate_hashes: string[]; + readonly url: string; } export enum ConnectionState { - Disconnected = 0, - Connecting = 1, - Connected = 2, - Failed = 3, + Disconnected = 0, + Connecting = 1, + Connected = 2, + Failed = 3, } -export class PipeReader { - private constructor(); - free(): void; - [Symbol.dispose](): void; - description(): string; - pipe_id(): number; - read(): Promise; -} - -export class PipeWriter { - private constructor(); - free(): void; - [Symbol.dispose](): void; - abort(): void; - close(): Promise; - pipe_id(): number; - write(data: Uint8Array): Promise; -} - -export class WasmChaCha20Poly1305 { - free(): void; - [Symbol.dispose](): void; - /** - * Decrypt `nonce || ciphertext` with `aad`. - */ - decrypt(ciphertext: Uint8Array, aad: Uint8Array): Uint8Array; - /** - * Encrypt `plaintext` with `aad`. - * Returns `nonce || ciphertext`. - */ - encrypt(plaintext: Uint8Array, aad: Uint8Array): Uint8Array; - /** - * Create a new cipher with a 32-byte key. - */ - constructor(key: Uint8Array); -} - -export class WasmClient { - free(): void; - [Symbol.dispose](): void; - accept_pipe(pipe_id: number): Promise; - authConnectOwned(config: ConnectionConfig, host_public_key_bytes: Uint8Array, keyring_bytes: Uint8Array, client_id: bigint): Promise; - authRegisterOwned(config: ConnectionConfig, host_public_key_bytes: Uint8Array, keyring_bytes: Uint8Array): Promise; - auth_connect(config: ConnectionConfig, host_public_key_bytes: Uint8Array, keyring_bytes: Uint8Array, client_id: bigint): Promise; - auth_register(config: ConnectionConfig, host_public_key_bytes: Uint8Array, keyring_bytes: Uint8Array): Promise; - connect(config: ConnectionConfig): Promise; - connectOwned(config: ConnectionConfig): Promise; - create_pipe(description: string): Promise; - deny_pipe(pipe_id: number): Promise; - disconnect(): void; - static is_supported(): boolean; - constructor(on_state_change?: Function | null, on_message?: Function | null, on_error?: Function | null); - request(frame: Uint8Array, response_type?: string | null, timeout_ms?: number | null): Promise; - send(frame: Uint8Array): Promise; - set_on_pipe_request(callback?: Function | null): void; - /** - * Apply one decoder policy to frames received by this raw WASM client. - * The high-level SDK calls this before authentication so handshake, - * transport, and protected opening share the same policy input. - */ - set_receive_limits(limits: any): void; - start_protocol_pings(interval_ms: number, client_id: bigint): void; - stop_protocol_pings(): void; - subscribe(message_type: string, callback: Function): number; - unsubscribe(id: number): boolean; - readonly client_id: bigint; - readonly ping_ms: number | undefined; - readonly state: number; -} - -export class WasmEd25519Signer { - free(): void; - [Symbol.dispose](): void; - /** - * Load a signer from its 32-byte secret key. - */ - constructor(secret_key: Uint8Array); - /** - * Sign `message` and return the signature bytes. - */ - sign(message: Uint8Array): Uint8Array; - /** - * Verify `signature` against `message`. - */ - verify(message: Uint8Array, signature: Uint8Array): void; -} - -/** - * KEM encapsulation result returned to JavaScript. - * - * `shared_secret` is the symmetric key both parties will derive; `ciphertext` - * is the KEM ciphertext that must be sent to the recipient so they can - * decapsulate and recover the same shared secret. - */ -export class WasmEncapsulated { - private constructor(); - free(): void; - [Symbol.dispose](): void; - /** - * KEM ciphertext to transmit to the recipient. - */ - readonly ciphertext: Uint8Array; - /** - * Symmetric secret derived during encapsulation. - */ - readonly shared_secret: Uint8Array; -} - -/** - * A short-lived ephemeral hybrid-KEM keypair for the forward-secure pipe - * handshake. The secret is zeroized when the object is freed. - */ -export class WasmKemKeypair { - private constructor(); - free(): void; - [Symbol.dispose](): void; - readonly public_key: Uint8Array; - readonly secret_key: Uint8Array; -} - -export class WasmKeyring { - private constructor(); - free(): void; - [Symbol.dispose](): void; - /** - * Deserialise a keyring from bytes. - */ - static from_bytes(bytes: Uint8Array): WasmKeyring; - /** - * Return the public half of this keyring as a bundle. - */ - public_key_bundle(): WasmPublicKeyBundle; - /** - * Serialise the keyring to bytes and report malformed caller-owned - * material as a JavaScript exception. - */ - to_bytes(): Uint8Array; - try_to_bytes(): Uint8Array; - /** - * Validate the KEM public/private pair without requiring PQ signing - * material. This is the invariant needed by envelope recipients and - * sealed-relay clients that explicitly choose Ed25519 signatures. - */ - validate_encryption(): void; - /** - * Validate that all full-suite public/private components correspond. - * Role-specific browser keyrings may intentionally fail this check. - */ - validate_full(): void; -} - -/** - * Log severity used by the public SDK when translating raw WASM events. - */ export enum WasmLogHint { - Info = 0, - Warning = 1, - Error = 2, + Info = 0, + Warning = 1, + Error = 2, } -export class WasmPipeHandle { - private constructor(); - free(): void; - [Symbol.dispose](): void; - wait(): Promise; - readonly description: string; - readonly pipe_id: number; +export class WasmChaCha20Poly1305 implements DisposableWasmObject { + constructor(key: Uint8Array); + free(): void; + [Symbol.dispose](): void; + decrypt(ciphertext: Uint8Array, aad: Uint8Array): Uint8Array; + encrypt(plaintext: Uint8Array, aad: Uint8Array): Uint8Array; } -export class WasmPublicKeyBundle { - private constructor(); - free(): void; - [Symbol.dispose](): void; - static from_bytes(bytes: Uint8Array): WasmPublicKeyBundle; - /** - * Deserialise an explicitly partial bundle for development-only key - * material. Protocol encryption and signature verification use the - * strict `from_bytes` parser above. - */ - static from_bytes_unvalidated(bytes: Uint8Array): WasmPublicKeyBundle; - to_bytes(): Uint8Array; - try_to_bytes(): Uint8Array; - readonly kem_public_key: Uint8Array; - readonly sig_cl_public_key: Uint8Array; - readonly sig_pq_public_key: Uint8Array; +export class WasmClient implements DisposableWasmObject { + constructor( + on_state_change: StateChangeCallback, + on_message: MessageCallback, + on_error: ErrorCallback, + ); + free(): void; + [Symbol.dispose](): void; + auth_connect( + config: ConnectionConfig, + host_public_key_bytes: Uint8Array, + keyring_bytes: Uint8Array, + client_id: bigint, + ): Promise; + auth_register( + config: ConnectionConfig, + host_public_key_bytes: Uint8Array, + keyring_bytes: Uint8Array, + ): Promise; + connect(config: ConnectionConfig): Promise; + disconnect(): void; + request(frame: Uint8Array, response_type?: string | null): Promise; + send(frame: Uint8Array): Promise; + start_protocol_pings(interval_ms: number, client_id: bigint): void; + stop_protocol_pings(): void; + subscribe(message_type: string, callback: MessageCallback): number; + unsubscribe(id: number): boolean; + static is_supported(): boolean; + readonly state: ConnectionState; } -/** - * Minimal message router used by higher-level SDK subscription code. - */ -export class WasmSubscriptionRouter { - free(): void; - [Symbol.dispose](): void; - dispatch(message_type: string, message: any): boolean; - constructor(); - subscribe(message_type: string, callback: Function): void; - unsubscribe(message_type: string): boolean; +export class WasmEd25519Signer implements DisposableWasmObject { + constructor(secret_key: Uint8Array); + free(): void; + [Symbol.dispose](): void; + sign(message: Uint8Array): Uint8Array; + verify(message: Uint8Array, signature: Uint8Array): void; } -export class WasmVerifiedProtectedMessage { - private constructor(); - free(): void; - [Symbol.dispose](): void; - content(): Uint8Array; - created_at(): bigint; - final_recipient_id(): bigint; - matched_signer_key_index(): number; - message_id(): string; - message_type(): string; - protected_version(): bigint; - signer_id(): bigint; +export class WasmKeyring implements DisposableWasmObject { + private constructor(); + free(): void; + [Symbol.dispose](): void; + static from_bytes(bytes: Uint8Array): WasmKeyring; + public_key_bundle(): WasmPublicKeyBundle; + to_bytes(): Uint8Array; } -export class WasmVerifiedRelayContent { - private constructor(); - free(): void; - [Symbol.dispose](): void; - content(): Uint8Array; - final_recipient_id(): bigint; - message_type(): string; - signer_id(): bigint; +export class WasmPublicKeyBundle implements DisposableWasmObject { + private constructor(); + free(): void; + [Symbol.dispose](): void; + static from_bytes(bytes: Uint8Array): WasmPublicKeyBundle; + to_bytes(): Uint8Array; + readonly kem_public_key: Uint8Array; + readonly sig_cl_public_key: Uint8Array; + readonly sig_pq_public_key: Uint8Array; } -export class WasmVerifiedRelayMetadata { - private constructor(); - free(): void; - [Symbol.dispose](): void; - created_at(): bigint; - encrypted_content(): Uint8Array; - final_recipient_id(): bigint; - matched_signer_key_index(): number; - message_id(): string; - metadata(): any; - relay_version(): bigint; - signer_id(): bigint; +export class WasmSubscriptionRouter implements DisposableWasmObject { + constructor(); + free(): void; + [Symbol.dispose](): void; + dispatch(message_type: string, message: unknown): boolean; + subscribe(message_type: string, callback: (message: unknown) => void): void; + unsubscribe(message_type: string): boolean; } -/** - * Build a relay frame using an explicit Ed25519 or dual-signature policy. - * `created_at` is Unix epoch milliseconds. - */ -export function build_encrypted_relay_frame_with_keyring(message_type: string, data: any, signer_id: bigint, final_recipient_id: bigint, next_hop_id: bigint, message_id: string, created_at: bigint, encoded_metadata: Uint8Array | null | undefined, keyring_bytes: Uint8Array, signature_suite: number, metadata_recipient_public_key_bundles: any, content_recipient_public_key_bundles: any): Uint8Array; +export function build_ping_frame( + client_id: bigint, + description: string, + timestamp: bigint, + data: Uint8Array, +): Uint8Array; -/** - * Build a sealed relay frame with explicit encoder and semantic field - * limits. The same limits are applied by the native relay builder. - */ -export function build_encrypted_relay_frame_with_keyring_with_limits(message_type: string, data: any, signer_id: bigint, final_recipient_id: bigint, next_hop_id: bigint, message_id: string, created_at: bigint, encoded_metadata: Uint8Array | null | undefined, keyring_bytes: Uint8Array, signature_suite: number, metadata_recipient_public_key_bundles: any, content_recipient_public_key_bundles: any, limits: any): Uint8Array; +export function build_frame(message_type: string, data: Record, options?: { + id?: number; + sender?: bigint | number; + receiver?: bigint | number; +}): Uint8Array; -/** - * Build a typed MTP frame using generated communication/data type names. - */ -export function build_frame(message_type: string, data: any, options: any): Uint8Array; - -/** - * Build a typed frame with explicit recursion and complete-frame output - * limits. High-level SDK sends use this entry point with the transport's - * admitted message size. - */ -export function build_frame_with_limits(message_type: string, data: any, options: any, limits: any): Uint8Array; - -/** - * Build a typed MTP frame around a complete serialized `DataValue` payload. - * - * Unlike [`build_frame`], this does not interpret the payload as a clear data - * container. It can therefore carry any value supported by the codec, - * including signed and encrypted protection wrappers. - */ -export function build_frame_with_payload(message_type: string, serialized_payload: Uint8Array, options: any): Uint8Array; - -/** - * Build a typed frame around a serialized payload with explicit output - * limits. The payload is also parsed with a policy derived from that limit so - * an oversized/deep input cannot bypass the bounded builder. - */ -export function build_frame_with_payload_with_limits(message_type: string, serialized_payload: Uint8Array, options: any, limits: any): Uint8Array; - -/** - * Build a protocol-level Ping frame with description, timestamp, and optional data. - */ -export function build_ping_frame(client_id: bigint, description: string, timestamp: bigint, data: Uint8Array): Uint8Array; - -/** - * Build a complete encrypted direct protected frame in the native codec. - * The native builder owns both the protected envelope and the clear outer - * routing fields, including the optional sender exposure and frame ID. - */ -export function build_protected_frame_with_keyring(message_type: string, encoded_content: Uint8Array, signer_id: bigint, final_recipient_id: bigint, message_id: string, created_at: bigint, signature_purpose: number, encryption_purpose: number, keyring_bytes: Uint8Array, signature_suite: number, frame_id: number | null | undefined, expose_sender: boolean, recipient_public_key_bundles: any): Uint8Array; - -/** - * Build a complete encrypted protected frame with explicit encoder and - * semantic protected-field limits. - */ -export function build_protected_frame_with_keyring_with_limits(message_type: string, encoded_content: Uint8Array, signer_id: bigint, final_recipient_id: bigint, message_id: string, created_at: bigint, signature_purpose: number, encryption_purpose: number, keyring_bytes: Uint8Array, signature_suite: number, frame_id: number | null | undefined, expose_sender: boolean, recipient_public_key_bundles: any, limits: any): Uint8Array; - -/** - * Decrypt a serialized `Encrypted` wrapper with a serialized keyring. - * The expected purpose is supplied by the protocol caller, not taken from - * the untrusted encrypted wrapper. - */ -export function decrypt_data_value(value: Uint8Array, keyring: Uint8Array, expected_purpose: number): Uint8Array; - -/** - * Decrypt using a caller-supplied local key history. Recipient key - * identifiers remain absent from the serialized envelope. - */ -export function decrypt_data_value_with_keyrings(value: Uint8Array, keyrings: any, expected_purpose: number): Uint8Array; - -/** - * Generate a fresh Ed25519 keypair. - * - * Returns `{ signer: WasmEd25519Signer, secretKey: Uint8Array, publicKey: Uint8Array }`. - */ -export function ed25519_generate(): any; - -/** - * Standalone Ed25519 signature verification. - */ +export function ed25519_generate(): Ed25519GenerateResult; export function ed25519_verify(public_key: Uint8Array, message: Uint8Array, signature: Uint8Array): void; - -/** - * Encode one standalone `DataValue` using the negotiated/current type map. - */ -export function encode_data_value(value: any): Uint8Array; - -/** - * Encode one standalone `DataValue` using explicit recursion and output - * limits. The compatibility entry point above keeps the historical default. - */ -export function encode_data_value_with_limits(value: any, limits: any): Uint8Array; - -/** - * Encrypt a serialized `DataValue` for one recipient using the canonical - * multi-recipient envelope. - */ -export function encrypt_data_value(value: Uint8Array, recipient_public_key_bundle: Uint8Array, purpose: number): Uint8Array; - -/** - * Encrypt a serialized `DataValue` for one or more recipients. - * - * `recipient_public_key_bundles` may be a single `Uint8Array` for the common - * case or an array of serialized public-key bundles. The array form uses the - * same canonical envelope as native multi-recipient encryption. - */ -export function encrypt_data_value_for_recipients(value: Uint8Array, recipient_public_key_bundles: any, purpose: number): Uint8Array; - -/** - * Parse any MTP frame into the human-readable CommunicationValue display form. - */ export function format_frame(frame: Uint8Array): string; - -/** - * Forward a sealed relay frame to another clear next hop without opening or - * re-encoding its authenticated encrypted payload. - */ -export function forward_encrypted_relay_frame(frame: Uint8Array, next_hop_receiver_id: bigint): Uint8Array; - -/** - * Build a [`Keyring`] containing only an Ed25519 keypair (no KEM, no ML-DSA). - * - * Takes the Ed25519 secret key and public key, each 32 bytes. - * Returns the serialised keyring bytes, suitable for passing to `WasmClient.auth_register`. - */ export function keyring_from_ed25519(secret_key: Uint8Array, public_key: Uint8Array): Uint8Array; - -/** - * Generate a full keyring with KEM, ML-DSA, and Ed25519 keys. - */ export function keyring_generate(): Uint8Array; - -/** - * Generate a full keyring and report serialization failures to JavaScript. - */ -export function keyring_generate_checked(): Uint8Array; - export function main(): void; - -/** - * Return the canonical MTP pipe-session encryption purpose. - */ -export function mtp_pipe_session_encryption_purpose(): number; - -/** - * Return the canonical MTP pipe-session signature purpose. - */ -export function mtp_pipe_session_signature_purpose(): number; - -/** - * Explicit compatibility policy value accepting any signature suite - * supported by this WASM build. New callers should prefer a fixed suite. - */ -export function mtp_protection_signature_suite_any_supported(): number; - -export function mtp_protection_signature_suite_dual(): number; - -export function mtp_protection_signature_suite_ed25519(): number; - -/** - * Return the canonical MTP relay content-encryption purpose. - */ -export function mtp_relay_content_encryption_purpose(): number; - -/** - * Return the canonical MTP relay content-signature purpose. - */ -export function mtp_relay_content_signature_purpose(): number; - -/** - * Return the canonical MTP relay metadata-encryption purpose. - */ -export function mtp_relay_metadata_encryption_purpose(): number; - -/** - * Return the canonical MTP relay metadata-signature purpose. - */ -export function mtp_relay_metadata_signature_purpose(): number; - -/** - * Length, in bytes, of symmetric keys produced by the MTP key-derivation - * bindings. SDKs should query this instead of duplicating the crypto - * primitive's output size. - */ -export function mtp_symmetric_key_length(): number; - -/** - * Open a bounded protected value without replay protection. The raw WASM - * boundary cannot accept a native replay-guard trait, so message-processing - * callers must use the SDK guard or a native checked API. - */ -export function open_protected_with_keyrings_with_limits_without_replay(frame: Uint8Array, keyrings: any, expected_signer_id: any, signer_public_key_bundles: any, expected_receiver_id: any, signature_purpose: number, encryption_purpose: number, signature_suite: number, limits: any): WasmVerifiedProtectedMessage; - -/** - * Open a protected value without replay protection. This raw entry point is - * intended for stored/forensic messages; message-processing callers should - * apply their replay guard in the SDK or use a checked native API. - */ -export function open_protected_with_keyrings_without_replay(frame: Uint8Array, keyrings: any, expected_signer_id: any, signer_public_key_bundles: any, expected_receiver_id: any, signature_purpose: number, encryption_purpose: number, signature_suite: number): WasmVerifiedProtectedMessage; - -/** - * Open bounded relay content without replay protection. Replay is consumed - * when metadata is accepted by the live SDK/native processing boundary. - */ -export function open_relay_content_with_keyrings_with_limits_without_replay(metadata: WasmVerifiedRelayMetadata, keyrings: any, signer_public_key_bundles: any, expected_final_recipient_id: any, signature_suite: number, limits: any): WasmVerifiedRelayContent; - -/** - * Open relay content without making a second replay decision. Replay is - * consumed when live message processing accepts the authenticated metadata. - */ -export function open_relay_content_with_keyrings_without_replay(metadata: WasmVerifiedRelayMetadata, keyrings: any, signer_public_key_bundles: any, expected_final_recipient_id: any, signature_suite: number): WasmVerifiedRelayContent; - -/** - * Open bounded relay metadata without replay protection. Use the SDK's - * message-processing guard or a native checked API for live traffic. - */ -export function open_relay_metadata_with_keyrings_with_limits_without_replay(frame: Uint8Array, keyrings: any, expected_signer_id: any, signer_public_key_bundles: any, signature_suite: number, limits: any): WasmVerifiedRelayMetadata; - -/** - * Open relay metadata without replay protection. This raw entry point is for - * stored/forwarded messages; message-processing paths should add a guard in - * the SDK or use the checked native API. - */ -export function open_relay_metadata_with_keyrings_without_replay(frame: Uint8Array, keyrings: any, expected_signer_id: any, signer_public_key_bundles: any, signature_suite: number): WasmVerifiedRelayMetadata; - -/** - * Parse an auth response frame into a JS object. - */ export function parse_auth_response(response: Uint8Array): AuthResponse; - -/** - * Parse a standalone serialized `DataValue` into the same structured form - * used for frame payloads. Protected values remain opaque until the caller - * explicitly opens and verifies them. - */ -export function parse_data_value(value: Uint8Array): ParsedDataValue; - -/** - * Parse a standalone serialized `DataValue` with the caller's bounded - * receive policy. The compatibility `parse_data_value` entry point retains - * the default policy for existing callers. - */ -export function parse_data_value_with_limits(value: Uint8Array, limits: any): ParsedDataValue; - -/** - * Parse any MTP frame into structured JavaScript data. - */ export function parse_frame(frame: Uint8Array): ParsedFrame; - -/** - * Parse a frame with the caller's bounded receive policy. The compatibility - * `parse_frame` entry point retains the default policy for existing callers. - */ -export function parse_frame_with_limits(frame: Uint8Array, limits: any): ParsedFrame; - -/** - * Read the claimed, unverified signer ID after decrypting the protected - * payload. The result may only select trusted keys for the same signer ID. - */ -export function protected_claimed_signer_id(frame: Uint8Array, keyrings: any, encryption_purpose: number): bigint; - -export function protected_claimed_signer_id_with_limits(frame: Uint8Array, keyrings: any, encryption_purpose: number, limits: any): bigint; - -/** - * Read the claimed, unverified signer ID from a relay without duplicating the - * versioned relay metadata parser in the JavaScript SDK. The caller must bind - * this value as the expected signer during the subsequent verification call. - */ -export function relay_metadata_claimed_signer_id(frame: Uint8Array, keyrings: any): bigint; - -export function relay_metadata_claimed_signer_id_with_limits(frame: Uint8Array, keyrings: any, limits: any): bigint; - -/** - * Sign a serialized `DataValue` using the selected suite from a serialized - * keyring. - */ -export function sign_data_value_with_keyring(value: Uint8Array, signer_id: bigint, purpose: number, keyring: Uint8Array, signature_suite: number): Uint8Array; - -/** - * Verify a serialized `Signed` wrapper while enforcing the receiver's - * required signature suite. `0` retains the legacy any-supported behavior; - * new protocol callers should pass one of the exported suite constants. - */ -export function verify_data_value_with_policy(value: Uint8Array, public_key_bundle: Uint8Array, expected_signer_id: bigint, expected_purpose: number, signature_suite: number): void; - -/** - * Derive a 32-byte key from a passphrase using explicit Argon2id parameters. - * The salt and parameters are part of the caller's protected-data format. - */ -export function wasm_argon2id(passphrase: Uint8Array, salt: Uint8Array, memory_kib: number, iterations: number, lanes: number): Uint8Array; - -/** - * Derive a 32-byte encryption key from `ikm` with `salt` and `context`. - */ export function wasm_derive_encryption_key(ikm: Uint8Array, salt: Uint8Array, context: Uint8Array): Uint8Array; - -/** - * HKDF-expand: derive `len` bytes from `ikm` with `salt` and `info`. - */ export function wasm_hkdf_expand(ikm: Uint8Array, salt: Uint8Array, info: Uint8Array, len: number): Uint8Array; - -/** - * Decapsulate a KEM `ciphertext` with the recipient's `private_key`. - * - * Returns the same shared secret the initiator obtained from - * [`wasm_kem_encapsulate`]. - */ -export function wasm_kem_decapsulate(recipient_private_key: Uint8Array, ciphertext: Uint8Array): Uint8Array; - -/** - * Encapsulate a fresh shared secret for `recipient_public_key`. - * - * Returns a [`WasmEncapsulated`] containing the shared secret and the KEM - * ciphertext that the recipient needs to recover it via - * [`wasm_kem_decapsulate`]. - */ -export function wasm_kem_encapsulate(recipient_public_key: Uint8Array): WasmEncapsulated; - -export function wasm_kem_generate_keypair(): WasmKemKeypair; - -/** - * SHA-256 digest. - */ export function wasm_sha256(data: Uint8Array): Uint8Array; - -/** - * Double SHA-256 (SHA-256 applied twice). - */ export function wasm_sha256_double(data: Uint8Array): Uint8Array; -export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; - -export interface InitOutput { - readonly memory: WebAssembly.Memory; - readonly __wbg_wasmchacha20poly1305_free: (a: number, b: number) => void; - readonly __wbg_wasmed25519signer_free: (a: number, b: number) => void; - readonly __wbg_wasmencapsulated_free: (a: number, b: number) => void; - readonly __wbg_wasmkemkeypair_free: (a: number, b: number) => void; - readonly __wbg_wasmkeyring_free: (a: number, b: number) => void; - readonly __wbg_wasmpublickeybundle_free: (a: number, b: number) => void; - readonly build_encrypted_relay_frame_with_keyring: (a: number, b: number, c: any, d: bigint, e: bigint, f: bigint, g: number, h: number, i: bigint, j: number, k: number, l: number, m: number, n: number, o: any, p: any) => [number, number, number, number]; - readonly build_encrypted_relay_frame_with_keyring_with_limits: (a: number, b: number, c: any, d: bigint, e: bigint, f: bigint, g: number, h: number, i: bigint, j: number, k: number, l: number, m: number, n: number, o: any, p: any, q: any) => [number, number, number, number]; - readonly decrypt_data_value: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number]; - readonly decrypt_data_value_with_keyrings: (a: number, b: number, c: any, d: number) => [number, number, number, number]; - readonly ed25519_generate: () => [number, number, number]; - readonly ed25519_verify: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number]; - readonly encrypt_data_value: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number]; - readonly encrypt_data_value_for_recipients: (a: number, b: number, c: any, d: number) => [number, number, number, number]; - readonly forward_encrypted_relay_frame: (a: number, b: number, c: bigint) => [number, number, number, number]; - readonly keyring_from_ed25519: (a: number, b: number, c: number, d: number) => [number, number, number, number]; - readonly keyring_generate: () => [number, number, number, number]; - readonly keyring_generate_checked: () => [number, number, number, number]; - readonly mtp_pipe_session_encryption_purpose: () => number; - readonly mtp_pipe_session_signature_purpose: () => number; - readonly mtp_protection_signature_suite_any_supported: () => number; - readonly mtp_protection_signature_suite_dual: () => number; - readonly mtp_protection_signature_suite_ed25519: () => number; - readonly mtp_relay_content_encryption_purpose: () => number; - readonly mtp_relay_content_signature_purpose: () => number; - readonly mtp_relay_metadata_encryption_purpose: () => number; - readonly mtp_relay_metadata_signature_purpose: () => number; - readonly mtp_symmetric_key_length: () => number; - readonly sign_data_value_with_keyring: (a: number, b: number, c: bigint, d: number, e: number, f: number, g: number) => [number, number, number, number]; - readonly verify_data_value_with_policy: (a: number, b: number, c: number, d: number, e: bigint, f: number, g: number) => [number, number]; - readonly wasm_argon2id: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number]; - readonly wasm_derive_encryption_key: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number, number]; - readonly wasm_hkdf_expand: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number]; - readonly wasm_kem_decapsulate: (a: number, b: number, c: number, d: number) => [number, number, number, number]; - readonly wasm_kem_encapsulate: (a: number, b: number) => [number, number, number]; - readonly wasm_kem_generate_keypair: () => number; - readonly wasm_sha256: (a: number, b: number) => [number, number]; - readonly wasm_sha256_double: (a: number, b: number) => [number, number]; - readonly wasmchacha20poly1305_decrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number]; - readonly wasmchacha20poly1305_encrypt: (a: number, b: number, c: number, d: number, e: number) => [number, number, number, number]; - readonly wasmchacha20poly1305_new: (a: number, b: number) => [number, number, number]; - readonly wasmed25519signer_new: (a: number, b: number) => [number, number, number]; - readonly wasmed25519signer_sign: (a: number, b: number, c: number) => [number, number, number, number]; - readonly wasmed25519signer_verify: (a: number, b: number, c: number, d: number, e: number) => [number, number]; - readonly wasmencapsulated_ciphertext: (a: number) => [number, number]; - readonly wasmencapsulated_shared_secret: (a: number) => [number, number]; - readonly wasmkemkeypair_public_key: (a: number) => [number, number]; - readonly wasmkemkeypair_secret_key: (a: number) => [number, number]; - readonly wasmkeyring_from_bytes: (a: number, b: number) => [number, number, number]; - readonly wasmkeyring_public_key_bundle: (a: number) => number; - readonly wasmkeyring_to_bytes: (a: number) => [number, number, number, number]; - readonly wasmkeyring_try_to_bytes: (a: number) => [number, number, number, number]; - readonly wasmkeyring_validate_encryption: (a: number) => [number, number]; - readonly wasmkeyring_validate_full: (a: number) => [number, number]; - readonly wasmpublickeybundle_from_bytes: (a: number, b: number) => [number, number, number]; - readonly wasmpublickeybundle_from_bytes_unvalidated: (a: number, b: number) => [number, number, number]; - readonly wasmpublickeybundle_kem_public_key: (a: number) => [number, number]; - readonly wasmpublickeybundle_sig_cl_public_key: (a: number) => [number, number]; - readonly wasmpublickeybundle_sig_pq_public_key: (a: number) => [number, number]; - readonly wasmpublickeybundle_to_bytes: (a: number) => [number, number, number, number]; - readonly wasmpublickeybundle_try_to_bytes: (a: number) => [number, number, number, number]; - readonly build_frame: (a: number, b: number, c: any, d: any) => [number, number, number, number]; - readonly build_frame_with_limits: (a: number, b: number, c: any, d: any, e: any) => [number, number, number, number]; - readonly build_frame_with_payload: (a: number, b: number, c: number, d: number, e: any) => [number, number, number, number]; - readonly build_frame_with_payload_with_limits: (a: number, b: number, c: number, d: number, e: any, f: any) => [number, number, number, number]; - readonly build_ping_frame: (a: bigint, b: number, c: number, d: bigint, e: number, f: number) => [number, number, number, number]; - readonly encode_data_value: (a: any) => [number, number, number, number]; - readonly encode_data_value_with_limits: (a: any, b: any) => [number, number, number, number]; - readonly format_frame: (a: number, b: number) => [number, number, number, number]; - readonly parse_auth_response: (a: number, b: number) => [number, number, number]; - readonly parse_data_value: (a: number, b: number) => [number, number, number]; - readonly parse_data_value_with_limits: (a: number, b: number, c: any) => [number, number, number]; - readonly parse_frame: (a: number, b: number) => [number, number, number]; - readonly parse_frame_with_limits: (a: number, b: number, c: any) => [number, number, number]; - readonly __wbg_wasmclient_free: (a: number, b: number) => void; - readonly wasmclient_client_id: (a: number) => bigint; - readonly wasmclient_is_supported: () => number; - readonly wasmclient_new: (a: number, b: number, c: number) => number; - readonly wasmclient_ping_ms: (a: number) => [number, number]; - readonly wasmclient_request: (a: number, b: number, c: number, d: number, e: number, f: number) => any; - readonly wasmclient_send: (a: number, b: number, c: number) => any; - readonly wasmclient_set_receive_limits: (a: number, b: any) => [number, number]; - readonly wasmclient_state: (a: number) => number; - readonly wasmclient_subscribe: (a: number, b: number, c: number, d: any) => number; - readonly wasmclient_unsubscribe: (a: number, b: number) => number; - readonly __wbg_wasmverifiedrelaycontent_free: (a: number, b: number) => void; - readonly __wbg_wasmverifiedrelaymetadata_free: (a: number, b: number) => void; - readonly open_relay_content_with_keyrings_with_limits_without_replay: (a: number, b: any, c: any, d: any, e: number, f: any) => [number, number, number]; - readonly open_relay_content_with_keyrings_without_replay: (a: number, b: any, c: any, d: any, e: number) => [number, number, number]; - readonly open_relay_metadata_with_keyrings_with_limits_without_replay: (a: number, b: number, c: any, d: any, e: any, f: number, g: any) => [number, number, number]; - readonly open_relay_metadata_with_keyrings_without_replay: (a: number, b: number, c: any, d: any, e: any, f: number) => [number, number, number]; - readonly relay_metadata_claimed_signer_id: (a: number, b: number, c: any) => [bigint, number, number]; - readonly relay_metadata_claimed_signer_id_with_limits: (a: number, b: number, c: any, d: any) => [bigint, number, number]; - readonly wasmverifiedrelaycontent_content: (a: number) => [number, number, number, number]; - readonly wasmverifiedrelaycontent_final_recipient_id: (a: number) => bigint; - readonly wasmverifiedrelaycontent_message_type: (a: number) => [number, number]; - readonly wasmverifiedrelaycontent_signer_id: (a: number) => bigint; - readonly wasmverifiedrelaymetadata_created_at: (a: number) => bigint; - readonly wasmverifiedrelaymetadata_encrypted_content: (a: number) => [number, number, number, number]; - readonly wasmverifiedrelaymetadata_final_recipient_id: (a: number) => bigint; - readonly wasmverifiedrelaymetadata_matched_signer_key_index: (a: number) => number; - readonly wasmverifiedrelaymetadata_message_id: (a: number) => [number, number]; - readonly wasmverifiedrelaymetadata_metadata: (a: number) => [number, number, number]; - readonly wasmverifiedrelaymetadata_relay_version: (a: number) => bigint; - readonly wasmverifiedrelaymetadata_signer_id: (a: number) => bigint; - readonly __wbg_wasmverifiedprotectedmessage_free: (a: number, b: number) => void; - readonly build_protected_frame_with_keyring: (a: number, b: number, c: number, d: number, e: bigint, f: bigint, g: number, h: number, i: bigint, j: number, k: number, l: number, m: number, n: number, o: number, p: number, q: any) => [number, number, number, number]; - readonly build_protected_frame_with_keyring_with_limits: (a: number, b: number, c: number, d: number, e: bigint, f: bigint, g: number, h: number, i: bigint, j: number, k: number, l: number, m: number, n: number, o: number, p: number, q: any, r: any) => [number, number, number, number]; - readonly open_protected_with_keyrings_with_limits_without_replay: (a: number, b: number, c: any, d: any, e: any, f: any, g: number, h: number, i: number, j: any) => [number, number, number]; - readonly open_protected_with_keyrings_without_replay: (a: number, b: number, c: any, d: any, e: any, f: any, g: number, h: number, i: number) => [number, number, number]; - readonly protected_claimed_signer_id: (a: number, b: number, c: any, d: number) => [bigint, number, number]; - readonly protected_claimed_signer_id_with_limits: (a: number, b: number, c: any, d: number, e: any) => [bigint, number, number]; - readonly wasmverifiedprotectedmessage_content: (a: number) => [number, number, number, number]; - readonly wasmverifiedprotectedmessage_created_at: (a: number) => bigint; - readonly wasmverifiedprotectedmessage_final_recipient_id: (a: number) => bigint; - readonly wasmverifiedprotectedmessage_matched_signer_key_index: (a: number) => number; - readonly wasmverifiedprotectedmessage_message_id: (a: number) => [number, number]; - readonly wasmverifiedprotectedmessage_message_type: (a: number) => [number, number]; - readonly wasmverifiedprotectedmessage_protected_version: (a: number) => bigint; - readonly wasmverifiedprotectedmessage_signer_id: (a: number) => bigint; - readonly __wbg_wasmpipehandle_free: (a: number, b: number) => void; - readonly wasmpipehandle_description: (a: number) => [number, number]; - readonly wasmpipehandle_pipe_id: (a: number) => number; - readonly wasmpipehandle_wait: (a: number) => any; - readonly __wbg_wasmsubscriptionrouter_free: (a: number, b: number) => void; - readonly wasmsubscriptionrouter_dispatch: (a: number, b: number, c: number, d: any) => number; - readonly wasmsubscriptionrouter_new: () => number; - readonly wasmsubscriptionrouter_subscribe: (a: number, b: number, c: number, d: any) => void; - readonly wasmsubscriptionrouter_unsubscribe: (a: number, b: number, c: number) => number; - readonly main: () => void; - readonly wasmclient_authConnectOwned: (a: number, b: number, c: number, d: number, e: number, f: number, g: bigint) => any; - readonly wasmclient_authRegisterOwned: (a: number, b: number, c: number, d: number, e: number, f: number) => any; - readonly wasmclient_auth_connect: (a: number, b: number, c: number, d: number, e: number, f: number, g: bigint) => any; - readonly wasmclient_auth_register: (a: number, b: number, c: number, d: number, e: number, f: number) => any; - readonly wasmclient_connect: (a: number, b: number) => any; - readonly wasmclient_connectOwned: (a: number, b: number) => any; - readonly __wbg_connectionconfig_free: (a: number, b: number) => void; - readonly __wbg_pipereader_free: (a: number, b: number) => void; - readonly __wbg_pipewriter_free: (a: number, b: number) => void; - readonly connectionconfig_client_id: (a: number) => bigint; - readonly connectionconfig_description: (a: number) => [number, number]; - readonly connectionconfig_max_message_size: (a: number) => number; - readonly connectionconfig_new: (a: number, b: number) => number; - readonly connectionconfig_require_pq: (a: number) => number; - readonly connectionconfig_set_client_id: (a: number, b: bigint) => void; - readonly connectionconfig_set_description: (a: number, b: number, c: number) => void; - readonly connectionconfig_set_max_message_size: (a: number, b: number) => void; - readonly connectionconfig_set_require_pq: (a: number, b: number) => void; - readonly connectionconfig_set_server_certificate_hashes: (a: number, b: number, c: number) => void; - readonly connectionconfig_url: (a: number) => [number, number]; - readonly pipereader_description: (a: number) => [number, number]; - readonly pipereader_pipe_id: (a: number) => number; - readonly pipereader_read: (a: number) => any; - readonly pipewriter_abort: (a: number) => [number, number]; - readonly pipewriter_close: (a: number) => any; - readonly pipewriter_pipe_id: (a: number) => number; - readonly pipewriter_write: (a: number, b: number, c: number) => any; - readonly wasmclient_accept_pipe: (a: number, b: number) => any; - readonly wasmclient_create_pipe: (a: number, b: number, c: number) => any; - readonly wasmclient_deny_pipe: (a: number, b: number) => any; - readonly wasmclient_disconnect: (a: number) => void; - readonly wasmclient_set_on_pipe_request: (a: number, b: number) => void; - readonly wasmclient_start_protocol_pings: (a: number, b: number, c: bigint) => [number, number]; - readonly wasmclient_stop_protocol_pings: (a: number) => void; - readonly wasm_bindgen__convert__closures_____invoke__hbfde51476b904d71: (a: number, b: number, c: any) => [number, number]; - readonly wasm_bindgen__convert__closures_____invoke__h7cf76fd16cb52006: (a: number, b: number, c: any, d: any) => void; - readonly wasm_bindgen__convert__closures_____invoke__heaefed2e0f18042f: (a: number, b: number) => void; - readonly __wbindgen_malloc: (a: number, b: number) => number; - readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; - readonly __wbindgen_exn_store: (a: number) => void; - readonly __externref_table_alloc: () => number; - readonly __wbindgen_externrefs: WebAssembly.Table; - readonly __wbindgen_free: (a: number, b: number, c: number) => void; - readonly __wbindgen_destroy_closure: (a: number, b: number) => void; - readonly __externref_table_dealloc: (a: number) => void; - readonly __wbindgen_start: () => void; -} - -export type SyncInitInput = BufferSource | WebAssembly.Module; - -/** - * Instantiates the given `module`, which can either be bytes or - * a precompiled `WebAssembly.Module`. - * - * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. - * - * @returns {InitOutput} - */ export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; -/** - * If `module_or_path` is {RequestInfo} or {URL}, makes a request and - * for everything else, calls `WebAssembly.instantiate` directly. - * - * @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. - * - * @returns {Promise} - */ -export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise; +export default function init( + module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise, +): Promise;