Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6a8b464e0 |
||
|
|
9cea795d1c |
||
|
4493ed32cf |
|||
|
c7c7afe578 |
|||
|
8d94bc5498 |
|||
|
22d13742ae |
|||
|
df75fd2830 |
|||
|
6348d7884f |
|||
|
7ef6ec9e88 |
|||
|
bd5547ae6f |
|||
|
|
c30315af94 |
||
|
e83cd132a2 |
|||
|
|
101b8322a1 |
||
|
|
24167c4aa0 |
||
|
|
420831cd09 |
||
|
687d56f9f1 |
|||
|
4c10b56a6c |
|||
|
|
bd660b2afb |
||
|
|
2b0bdc3257 |
||
|
a5c8d4f0c8 |
|||
|
a6c4e56835 |
|||
|
d11eb04d12 |
|||
|
|
b331b9f6a3 |
||
|
|
3395b91ad1 |
||
|
|
a7e804c603 |
||
| 113a54048f | |||
| bd187106a1 | |||
|
|
188caf56cc | ||
|
|
e939714557 | ||
|
3c09493b00 |
|||
|
7f0231e3f1 |
|||
| b067614a68 | |||
| 0c08678d30 | |||
| 7adc13adb1 | |||
| 7f95b2356f | |||
| cb509142eb | |||
| b176f8d813 | |||
| 4cc18ef1dd | |||
| 103f5f9773 | |||
| 7182272e3e | |||
| 190a5e2f12 | |||
| 9fc6bf9e24 | |||
| 1d010bc7a3 | |||
| 5effb763f0 | |||
| b4cd71411b | |||
| 5a030073a8 | |||
| 2809435304 | |||
| e8b7c433e6 | |||
| f6db53925e | |||
| 602a98a818 | |||
| a856ee1717 | |||
| 07c9837a69 | |||
| 50c35dc710 | |||
| 02df350b55 | |||
| d75eb49b70 | |||
| a81ac4efca | |||
| a46583d1fd | |||
|
d10266198d |
|||
|
8e22962f57 |
|||
|
88e5851ba2 |
|||
|
8c0c39fe92 |
|||
|
a692bed326 |
|||
|
c3fe269dc6 |
|||
|
8da4bc57ba |
|||
|
6f673ba7f2 |
|||
|
b6483b7f6d |
|||
|
590810ce59 |
|||
|
cab2cd7a52 |
|||
|
fa271e62be |
|||
|
bcf8aee371 |
|||
|
465a0f338a |
|||
|
10c862de59 |
|||
|
|
88ae866b91 | ||
|
|
00f0aaeeff | ||
|
|
04760fd88d | ||
|
|
cf3cccd3ca | ||
|
|
3919eb46fd | ||
|
|
1b796d0ce7 | ||
|
|
6e5c985719 | ||
|
|
5f11d476b6 | ||
|
|
6a65e43ca9 | ||
|
|
3d757e00f2 | ||
|
|
203ef1adcc | ||
|
|
089def45d1 | ||
|
|
69be9f7aca | ||
|
|
a6dd73a41f | ||
|
|
c148314742 | ||
|
|
2126a142f4 | ||
| 40e942337f | |||
| 515244ce66 | |||
| e1fcb90e19 | |||
| 13432c1ac2 | |||
| fdc694bdcd | |||
| 3e12257cf3 | |||
| 20cbb45743 | |||
| 2e7c0b4893 | |||
| c7a57a851e | |||
| c1761aae2b | |||
| 92f1190b11 | |||
| 0bb3f607c7 | |||
| a4447ebce8 | |||
| 4d75f23a17 | |||
| 870b8002d2 | |||
| 391f92c9c5 | |||
|
|
b96c072a0f | ||
|
|
0679a241c2 | ||
|
|
75f4139dea | ||
|
|
44ff1d8781 | ||
|
|
0967120e3b | ||
|
|
b78cf3f82b | ||
|
|
7ffc1ab3fe | ||
|
|
bed73a82f1 | ||
|
|
54a3ef4f11 | ||
|
|
099073dc27 | ||
|
|
b85cda505f | ||
|
|
efe5cca9bb | ||
|
|
eff96ed72f | ||
|
|
912aa9491c |
181 changed files with 52377 additions and 9620 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
[env]
|
[env]
|
||||||
MTP_TYPE_MAPS = { value = "example-type-maps.yaml", relative = true }
|
MTP_TYPE_MAPS = { value = "example/type-maps.yaml", relative = true }
|
||||||
|
|
||||||
# web-sys's WebTransport* bindings are behind unstable APIs, gated by this cfg.
|
# web-sys's WebTransport* bindings are behind unstable APIs, gated by this cfg.
|
||||||
# Scoped to the wasm32 target so it applies to the wasm crate however cargo is
|
# Scoped to the wasm32 target so it applies to the wasm crate however cargo is
|
||||||
|
|
|
||||||
1
.envrc
Normal file
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
use flake
|
||||||
|
|
@ -7,18 +7,14 @@ on:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
NIX_CONFIG: experimental-features = nix-command flakes
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
checks:
|
checks:
|
||||||
name: checks
|
name: checks
|
||||||
runs-on: nixos
|
runs-on: nixos
|
||||||
steps:
|
steps:
|
||||||
- name: Install node
|
|
||||||
run: nix profile add nixpkgs#nodejs_24
|
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v7
|
||||||
|
|
||||||
- name: Run checks
|
- name: Run checks
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -33,12 +29,17 @@ jobs:
|
||||||
cargo machete
|
cargo machete
|
||||||
|
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm run dup
|
|
||||||
|
|
||||||
|
RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack test --node wasm
|
||||||
RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack build wasm --target web
|
RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack build wasm --target web
|
||||||
RUSTFLAGS="--cfg web_sys_unstable_apis" pnpm run build
|
RUSTFLAGS="--cfg web_sys_unstable_apis" pnpm run build
|
||||||
pnpm --filter mtp-web-client run build
|
pnpm --filter mtp-web-client run build
|
||||||
|
|
||||||
|
node test/e2ee.mjs
|
||||||
|
pnpm run test:secrets
|
||||||
|
pnpm run test:types
|
||||||
|
pnpm run test:boundary
|
||||||
|
|
||||||
(
|
(
|
||||||
cd example
|
cd example
|
||||||
export MTP_TYPE_MAPS="$PWD/type-maps.yaml"
|
export MTP_TYPE_MAPS="$PWD/type-maps.yaml"
|
||||||
|
|
|
||||||
|
|
@ -14,27 +14,18 @@ on:
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
env:
|
|
||||||
NIX_CONFIG: experimental-features = nix-command flakes
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: nixos
|
runs-on: nixos
|
||||||
steps:
|
steps:
|
||||||
- name: Install node & bun
|
|
||||||
run: nix profile add nixpkgs#nodejs_24 nixpkgs#bun
|
|
||||||
|
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
- name: Install cc linker, sed & jq
|
|
||||||
run: nix profile add nixpkgs#stdenv.cc nixpkgs#gnused nixpkgs#jq
|
|
||||||
|
|
||||||
- name: Build all
|
- name: Build all
|
||||||
run: bun build:all
|
run: bun build:all
|
||||||
|
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -5,3 +5,5 @@ node_modules/
|
||||||
dist/
|
dist/
|
||||||
*.tgz
|
*.tgz
|
||||||
wasm/pkg/
|
wasm/pkg/
|
||||||
|
web_client/
|
||||||
|
.direnv
|
||||||
|
|
|
||||||
1392
Cargo.lock
generated
1392
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
65
Cargo.toml
65
Cargo.toml
|
|
@ -8,6 +8,8 @@ members = [
|
||||||
"host",
|
"host",
|
||||||
"client",
|
"client",
|
||||||
"wasm",
|
"wasm",
|
||||||
|
"files",
|
||||||
|
"mtp-webserver",
|
||||||
]
|
]
|
||||||
# `wasm` is a wasm32-only crate: it relies on web-sys unstable APIs
|
# `wasm` is a wasm32-only crate: it relies on web-sys unstable APIs
|
||||||
# (`--cfg=web_sys_unstable_apis`, set in wasm/.cargo/config.toml) and the
|
# (`--cfg=web_sys_unstable_apis`, set in wasm/.cargo/config.toml) and the
|
||||||
|
|
@ -26,6 +28,8 @@ default-members = [
|
||||||
"transport",
|
"transport",
|
||||||
"host",
|
"host",
|
||||||
"client",
|
"client",
|
||||||
|
"files",
|
||||||
|
"mtp-webserver",
|
||||||
]
|
]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
|
|
@ -41,27 +45,26 @@ resolver = "3"
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
[package]
|
[package]
|
||||||
name = "mtp"
|
name = "mtp"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# --- always-on core ---
|
# --- always-on core ---
|
||||||
mtp-common = { version = "0.1.0", path = "common" }
|
mtp-common = { version = "0.3.0", path = "common" }
|
||||||
mtp-type-map = { version = "0.1.0", path = "type-map" }
|
mtp-type-map = { version = "0.3.0", path = "type-map" }
|
||||||
mtp-codec = { version = "0.1.0", path = "codec" }
|
mtp-codec = { version = "0.3.0", path = "codec" }
|
||||||
mtp-transport = { version = "0.1.0", path = "transport" }
|
|
||||||
|
|
||||||
# --- optional, behind features ---
|
# --- optional, behind features ---
|
||||||
mtp-crypto = { version = "0.1.0", path = "crypto", optional = true, features = [
|
mtp-crypto = { version = "0.3.0", path = "crypto", optional = true, features = [
|
||||||
"serde",
|
"serde",
|
||||||
"mlkem-tls",
|
"mlkem-tls",
|
||||||
] }
|
] }
|
||||||
mtp-host = { version = "0.1.0", path = "host", optional = true }
|
mtp-host = { version = "0.3.0", path = "host", optional = true }
|
||||||
mtp-client = { version = "0.1.0", path = "client", optional = true }
|
mtp-client = { version = "0.3.0", path = "client", optional = true }
|
||||||
|
mtp-files = { version = "0.3.0", path = "files", optional = true }
|
||||||
|
mtp-webserver = { version = "0.3.0", path = "mtp-webserver", optional = true }
|
||||||
|
mtp-transport = { version = "0.3.0", path = "transport", optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
serde = ["mtp-crypto/serde"]
|
serde = ["mtp-crypto/serde"]
|
||||||
|
|
||||||
|
|
@ -72,15 +75,43 @@ crypto = [
|
||||||
"mtp-codec/crypto",
|
"mtp-codec/crypto",
|
||||||
"mtp-host?/crypto",
|
"mtp-host?/crypto",
|
||||||
"mtp-client?/crypto",
|
"mtp-client?/crypto",
|
||||||
|
"mtp-webserver?/crypto",
|
||||||
]
|
]
|
||||||
|
|
||||||
# MTP server host - version negotiation, Registry, incoming QUIC connections.
|
# MTP server host - version negotiation, Registry, incoming QUIC connections.
|
||||||
host = ["dep:mtp-host", "mtp-codec/registry", "mtp-transport/host"]
|
host = ["dep:mtp-host", "mtp-codec/registry", "transport"]
|
||||||
|
|
||||||
# MTP client - outgoing QUIC connections to a host.
|
# MTP client - outgoing QUIC connections to a host.
|
||||||
client = ["dep:mtp-client"]
|
client = ["dep:mtp-client", "transport"]
|
||||||
|
|
||||||
[dev-dependencies]
|
# Direct access to the framed QUIC transport. Host/client features enable it
|
||||||
tokio = { version = "1", features = ["full"] }
|
# automatically; this feature is useful for low-level integrations.
|
||||||
rcgen = "0.14"
|
transport = ["dep:mtp-transport"]
|
||||||
rand = "0.8"
|
|
||||||
|
# Direct access to the pipes. Pipes can be used to send raw binary
|
||||||
|
# without after creation overhead.
|
||||||
|
pipes = ["mtp-common/pipes", "mtp-codec/pipes", "mtp-transport?/pipes", "mtp-host?/pipes", "mtp-client?/pipes", "mtp-webserver?/pipes"]
|
||||||
|
|
||||||
|
# On-disk storage for keyrings (`.mk`) and public key bundles (`.mpkb`).
|
||||||
|
# Pulls in `crypto` so the `Keyring` / `PublicKeyBundle` types are in scope.
|
||||||
|
files = ["dep:mtp-files", "crypto"]
|
||||||
|
|
||||||
|
# Development/migration-only access to the legacy plaintext keyring format.
|
||||||
|
# Production users should use the Argon2id-protected `.mk` APIs instead.
|
||||||
|
raw = ["mtp-files?/raw"]
|
||||||
|
|
||||||
|
# HTTP/3 routing and WebTransport-based MTP hosting on one QUIC endpoint.
|
||||||
|
web-server = ["dep:mtp-webserver", "dep:mtp-host", "mtp-codec/registry", "transport"]
|
||||||
|
|
||||||
|
# Complete native server surface.
|
||||||
|
full-server = ["host", "web-server", "crypto", "pipes"]
|
||||||
|
|
||||||
|
# Self-signed certificate generation helper (requires crypto).
|
||||||
|
tls = ["crypto", "mtp-crypto?/tls"]
|
||||||
|
|
||||||
|
# Insecure TLS certificate verification (development only).
|
||||||
|
# Requires MTP_INSECURE_TLS=1 at runtime.
|
||||||
|
insecure-tls = ["dep:mtp-transport", "mtp-transport?/insecure-tls"]
|
||||||
|
|
||||||
|
[package.metadata.cargo-machete]
|
||||||
|
ignored = ["mtp-transport"]
|
||||||
|
|
|
||||||
187
README.md
187
README.md
|
|
@ -1,167 +1,108 @@
|
||||||
# Methanium Transport Protocol
|
# Methanium Transport Protocol
|
||||||
|
|
||||||
MTP is a modular transport protocol built on QUIC. It provides version-negotiable type maps, a binary codec, cryptographic primitives (classical and post-quantum), and host/client connection management with mutual authentication.
|
MTP is a modular transport protocol built on QUIC. It provides versioned type maps, a binary codec, native and browser clients, host and WebTransport server implementations, and optional cryptographic authentication and end-to-end encryption.
|
||||||
|
|
||||||
See the area-specific docs for [Native Client](./docs/NATIVE-CLIENT.md), [WASM Client](./docs/WASM-CLIENT.md), [Host](./docs/NATIVE-HOST.md), and [Type Maps](./docs/TYPE-MAP.md).
|
Security assumptions, certificate handling, authentication, cryptographic primitives, key storage, and known limitations: [Security](./docs/SECURITY.md).
|
||||||
|
|
||||||
|
Area-specific guides:
|
||||||
|
|
||||||
|
- [Architecture](./docs/ARCHITECTURE.md)
|
||||||
|
- [Connection lifecycle](./docs/CONNECTIONS.md)
|
||||||
|
- [Native client](./docs/NATIVE-CLIENT.md)
|
||||||
|
- [WASM client](./docs/WASM-CLIENT.md)
|
||||||
|
- [Native host](./docs/NATIVE-HOST.md)
|
||||||
|
- [Web server](./docs/NATIVE-HOST-WEB-SERVER.md)
|
||||||
|
- [Connector and version negotiation](./docs/CONNECTOR.md)
|
||||||
|
- [Protocol reference](./docs/PROTOCOL-REFERENCE.md)
|
||||||
|
- [Type maps](./docs/TYPE-MAP.md)
|
||||||
|
- [Error reference](./docs/ERRORS.md)
|
||||||
|
- [Troubleshooting](./docs/TROUBLESHOOTING.md)
|
||||||
|
- [Operations](./docs/OPERATIONS.md)
|
||||||
|
|
||||||
|
`MTPWebServer` owns TCP TLS (HTTP/1.1 and HTTP/2) plus UDP QUIC (HTTP/3 and WebTransport) on one numeric port. It must not bind its UDP address and port as `MTPHost`: their QUIC ALPN protocols remain incompatible (`h3` for the web server, native MTP for the host).
|
||||||
|
|
||||||
## Browser SDK
|
## Browser SDK
|
||||||
|
|
||||||
The JavaScript package is `mtp`:
|
The JavaScript package is `mtp`.
|
||||||
|
|
||||||
```typescript
|
- `mtp` exports the SDK-first `MTPClient` API and codec helpers.
|
||||||
import { MTPClient } from "mtp";
|
- `mtp/raw` exports generated WASM bindings for lower-level integrations.
|
||||||
import { mtp } from "mtp/vite";
|
- `mtp/vite` exports the Vite integration that builds app-specific bindings.
|
||||||
```
|
- `mtp/type-map` exports generated TypeScript type-name unions.
|
||||||
|
|
||||||
Use `mtp` for the SDK-first API, `mtp/raw` for generated WASM bindings, and `mtp/vite` for the Vite integration.
|
Use the [WASM client guide](./docs/WASM-CLIENT.md) for installation, type-map configuration, credentials, certificate pins, requests, subscriptions, pipes, and protocol pings. Use the SDK methods before raw bindings because the raw API does not provide the wrapper's validation, persistence, timeout, logging, or lifecycle handling.
|
||||||
|
|
||||||
```typescript
|
The SDK exposes crypto helpers and frame codec helpers from the main `mtp` export. The security guide describes the available algorithms and their limitations.
|
||||||
// vite.config.ts
|
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import { mtp } from "mtp/vite";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [mtp({ typeMaps: "./type-maps.yaml" })],
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { MTPClient, type MTPCredentials } from "mtp";
|
|
||||||
|
|
||||||
const credentialsStorageKey = "mtpCredentialsForMyApp";
|
|
||||||
|
|
||||||
function loadCredentials(): MTPCredentials {
|
|
||||||
const saved = localStorage.getItem(credentialsStorageKey);
|
|
||||||
if (saved) {
|
|
||||||
return JSON.parse(saved) as MTPCredentials;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
clientId: null,
|
|
||||||
keyring: MTPClient.crypto.generateKeyring(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await MTPClient.init();
|
|
||||||
|
|
||||||
// Example-looking host public key bundle bytes. Replace this with the public
|
|
||||||
// key bundle published by your MTP host before connecting to a real service.
|
|
||||||
const hostPublicKey = Uint8Array.from({ length: 128 }, (_, index) => (
|
|
||||||
[0xb6, 0x4f, 0x7d, 0x9a, 0x3c, 0x21, 0xe8, 0x05][index % 8] ^ index
|
|
||||||
));
|
|
||||||
|
|
||||||
const client = await MTPClient.create({
|
|
||||||
url: "https://localhost:4433",
|
|
||||||
hostPublicKey,
|
|
||||||
credentials: loadCredentials(),
|
|
||||||
storage: window.localStorage, // Same API as localStorage for convenience
|
|
||||||
credentialsStorageKey,
|
|
||||||
pings: true,
|
|
||||||
logger: (event) => console.log("[MTP]: " + event),
|
|
||||||
});
|
|
||||||
|
|
||||||
client.subscribe("SomeType", (message) => console.log(message));
|
|
||||||
|
|
||||||
const clientId = await client.auth();
|
|
||||||
|
|
||||||
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()`.
|
|
||||||
|
|
||||||
Use `codec` to encode and decode MTP frames from the main SDK export:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { codec } from "mtp";
|
|
||||||
|
|
||||||
const frame = codec.encode("SomeType", { value: "hello" });
|
|
||||||
const parsed = codec.decode(frame);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
Add the `mtp` crate with your desired features:
|
Add the umbrella crate with the features required by the application:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mtp = { path = "..", features = ["client", "crypto"] }
|
mtp = { path = "..", features = ["client", "crypto"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
The `mtp` umbrella crate re-exports all sub-crates behind feature flags:
|
Feature summary:
|
||||||
|
|
||||||
| Feature | Pulls in | Enables |
|
| Feature | Pulls in | Enables |
|
||||||
| -------- | --------------------------- | ----------------------------------------- |
|
| --- | --- | --- |
|
||||||
| `crypto` | `mtp::crypto` | AEAD, signatures, KEM, KDF, hashing |
|
| `serde` | Crypto serialization support | Serde implementations for crypto key types |
|
||||||
| `host` | `mtp::host`, `mtp::codec::registry` | QUIC server, version negotiation |
|
| `crypto` | `mtp::crypto` | AEAD, signatures, KEM, KDF, hashing, and connection authentication support |
|
||||||
| `client` | `mtp::client` | QUIC client connections |
|
| `host` | `mtp::host` | Native QUIC host and version negotiation |
|
||||||
|
| `client` | `mtp::client` | Native QUIC client connections |
|
||||||
|
| `transport` | `mtp-transport` dependency | Low-level transport support; enabled automatically by `host` and `client` |
|
||||||
|
| `pipes` | Pipe support in transport, host, client, and web server | Raw and encrypted byte streams |
|
||||||
|
| `files` | `mtp::files` | `.mk` keyrings and `.mpkb` public bundles; also enables `crypto` |
|
||||||
|
| `raw` | Raw file APIs | Legacy plaintext keyring migration APIs |
|
||||||
|
| `web-server` | `mtp::webserver` | HTTPS server with HTTP/1.1, HTTP/2, HTTP/3, and WebTransport MTP sessions |
|
||||||
|
| `full-server` | Native host and web-server surface | `host`, `web-server`, `crypto`, and `pipes` together |
|
||||||
|
| `tls` | `mtp::crypto::tls` | Development self-signed certificate generation |
|
||||||
|
| `insecure-tls` | Lower-level transport | Development-only certificate verification bypass, gated by `MTP_INSECURE_TLS=1` |
|
||||||
|
|
||||||
Core crates (`codec`, `transport`, `common`, `type_map`) are always available.
|
The core modules always available from the facade are `codec`, `common`, and
|
||||||
|
`type_map`. Native `client` and `host` modules re-export the transport policy
|
||||||
```rust
|
types; the low-level transport crate is not exposed as `mtp::transport`. See the [native client](./docs/NATIVE-CLIENT.md) and [native host](./docs/NATIVE-HOST.md)
|
||||||
use mtp::codec::{CommunicationValue, DataValue};
|
guides for configuration and usage. See [Security](./docs/SECURITY.md) for security boundaries.
|
||||||
use mtp::type_map::{CommunicationType, DataType, TypeMap};
|
|
||||||
use mtp::transport::{Sender, Receiver};
|
|
||||||
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
use mtp::crypto::ChaCha20Poly1305;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Sub-crates
|
## Sub-crates
|
||||||
|
|
||||||
All sub-crates are re-exported through the `mtp` facade and can be referenced as `mtp::codec`, `mtp::transport`, `mtp::common`, `mtp::type_map`, `mtp::crypto`, `mtp::host`, `mtp::client`.
|
The `mtp` facade re-exports the following modules:
|
||||||
|
`mtp::codec`, `mtp::common`, `mtp::type_map`, `mtp::crypto`, `mtp::host`,
|
||||||
|
`mtp::client`, `mtp::files`, and `mtp::webserver` when their features are enabled.
|
||||||
|
|
||||||
### Codec
|
### Codec
|
||||||
|
|
||||||
The codec crate handles binary encoding and decoding of MTP packets using Communication Types and Data Types resolved through the type-map registry.
|
The codec encodes and decodes MTP frames using Communication Types and Data Types resolved through a version-specific type map. It supports self-delimiting containers, integers, booleans, floats, strings, arrays, bytes, null values, and composable `Signed<Value>` and `Encrypted<Value>` protection wrappers. Wrap in either order to choose whether signer metadata is public or encrypted. See [Type Map](./docs/TYPE-MAP.md) for mapping configuration and [Connector](./docs/CONNECTOR.md) for negotiated codecs.
|
||||||
|
|
||||||
**Data Value types:**
|
|
||||||
- Container (key-value map of typed entries)
|
|
||||||
- Encrypted Container (requires `crypto`)
|
|
||||||
- Signed Container (requires `crypto`)
|
|
||||||
- SignedEncrypted Container (requires `crypto`)
|
|
||||||
- Signed Integer (i128)
|
|
||||||
- Unsigned Integer (u128)
|
|
||||||
- Boolean
|
|
||||||
- Float (exponent + mantissa)
|
|
||||||
- String
|
|
||||||
- Array
|
|
||||||
- Bytes
|
|
||||||
- Null
|
|
||||||
|
|
||||||
Encoding and decoding use a `TypeMap` to resolve type names to wire IDs. The `CommunicationValue` struct provides the frame format (type, flags, optional id/sender/receiver, data payload, optional signature).
|
|
||||||
|
|
||||||
### Transport
|
### Transport
|
||||||
|
|
||||||
The transport crate wraps QUIC using `wtransport`. It provides `Sender`/`Receiver` for bidirectional message passing over uni-directional QUIC streams. Supports two send modes: persistent stream and single-stream-per-message.
|
The transport crate wraps QUIC with `wtransport`. `Sender` and `Receiver` exchange framed messages over unidirectional streams. The transport supports a persistent stream and a single-stream-per-message mode, with configurable message limits, timeouts, queues, and stream concurrency.
|
||||||
|
|
||||||
|
Certificate verification is controlled by client configuration. Use system roots or explicit certificate and SPKI pinning for production. Development self-signed and insecure modes: [Security](./docs/SECURITY.md).
|
||||||
|
|
||||||
### Host
|
### Host
|
||||||
|
|
||||||
The host crate provides `MTPHost` with built-in version negotiation and optional authenticated login/registration (requires `crypto`). Accepts connections, negotiates protocol version, and returns `MTPConnection` handles.
|
The host crate provides `MTPHost`, registry-backed version negotiation, optional authentication, and `MTPConnection` handles. Authentication policies and the challenge-response protocol: [Native Host](./docs/NATIVE-HOST.md) and [Security](./docs/SECURITY.md).
|
||||||
|
|
||||||
### Client
|
### Client
|
||||||
|
|
||||||
The client crate provides `MTPClient` that connects to an MTP host. Supports `connect` (unauthenticated), `auth_connect` (login), and `auth_register` (registration) when built with `crypto`.
|
The native client provides unauthenticated connections, authenticated login, and registration when the `crypto` feature is enabled. See [Native Client](./docs/NATIVE-CLIENT.md).
|
||||||
|
|
||||||
|
The browser client uses the `mtp` SDK over WebTransport. See [WASM Client](./docs/WASM-CLIENT.md).
|
||||||
|
|
||||||
### Common
|
### Common
|
||||||
|
|
||||||
Common defines shared error types (`CodecError`, `CommunicationError`) used across all crates.
|
Common defines shared errors such as `CodecError` and `CommunicationError`, as well as protocol-level types used by the other crates.
|
||||||
|
|
||||||
### Type Map
|
### Type Map
|
||||||
|
|
||||||
The type-map build script reads a YAML configuration to generate `CommunicationType` and `DataType` enums at compile time. The runtime crate provides `TypeMap`, `Version`, `CommunicationTypeId`, `DataTypeId`, and the multi-version `Registry` (requires `registry` feature).
|
The type-map build script reads YAML and generates `CommunicationType` and `DataType` enums at compile time. The runtime crate provides `TypeMap`, `Version`, ID types, and the multi-version `Registry` when the registry feature is enabled. See [Type Map](./docs/TYPE-MAP.md).
|
||||||
|
|
||||||
### Crypto Stack
|
### Crypto
|
||||||
|
|
||||||
| Crate | Audited? | Notes |
|
`mtp-crypto` provides AEAD encryption, Ed25519 and ML-DSA-65 signatures, X25519 plus ML-KEM-768 hybrid KEM support, HKDF, SHA-256, keyrings, composable protection envelopes, and certificate generation for development. Feature flags and security boundaries: [Security](./docs/SECURITY.md).
|
||||||
| ---------------- | -------- | ------------------------------------------------ |
|
|
||||||
| ml-dsa | No | NIST vectors pass; regression bug fixed Jan 2026 |
|
## Examples
|
||||||
| ed25519-dalek | Yes | Used by Signal, Diem |
|
|
||||||
| chacha20poly1305 | Yes | NCC Group audit, Dec 2019 |
|
The [`example/`](./example/) workspace contains native client, native server, key-generation, WebTransport server, and browser client examples. The example server stores its generated development certificate and host keys locally; use the certificate pin it prints when connecting the example client.
|
||||||
| aes-gcm | Yes | NCC Group audit, Dec 2019 |
|
|
||||||
| hkdf | No | Simple construction; well-reviewed |
|
|
||||||
| sha2 | No | Standard construction; widely reviewed |
|
|
||||||
| zeroize | No | Simple; widely used |
|
|
||||||
| mlkem-tls | No | mlkem-rs backend unaudited |
|
|
||||||
|
|
|
||||||
2
client/Cargo.lock
generated
2
client/Cargo.lock
generated
|
|
@ -4,4 +4,4 @@ version = 4
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "client"
|
name = "client"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,21 @@
|
||||||
[package]
|
[package]
|
||||||
name = "mtp-client"
|
name = "mtp-client"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mtp-common = { version = "0.1.0", path = "../common" }
|
mtp-common = { version = "0.3.0", path = "../common" }
|
||||||
mtp-codec = { version = "0.1.0", path = "../codec" }
|
mtp-codec = { version = "0.3.0", path = "../codec", features = ["registry"] }
|
||||||
mtp-transport = { version = "0.1.0", path = "../transport" }
|
mtp-transport = { version = "0.3.0", path = "../transport" }
|
||||||
mtp-crypto = { version = "0.1.0", path = "../crypto", optional = true }
|
mtp-crypto = { version = "0.3.0", path = "../crypto", optional = true }
|
||||||
rand = "0.8"
|
rand = "0.10.1"
|
||||||
tokio = { version = "1", features = ["time"] }
|
tokio = { version = "1", features = ["rt", "sync", "time"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
mtp-host = { version = "0.3.0", path = "../host" }
|
||||||
|
mtp-transport = { version = "0.3.0", path = "../transport", features = ["host"] }
|
||||||
|
rcgen = "0.14"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
crypto = ["dep:mtp-crypto", "mtp-codec/crypto"]
|
crypto = ["dep:mtp-crypto", "mtp-codec/crypto"]
|
||||||
|
pipes = ["mtp-common/pipes", "mtp-transport/pipes"]
|
||||||
|
|
|
||||||
115
client/src/config.rs
Normal file
115
client/src/config.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
use tokio::time::Duration;
|
||||||
|
|
||||||
|
pub use mtp_transport::Policy;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ClientTlsConfig {
|
||||||
|
SystemRoots,
|
||||||
|
PinnedPem(Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ClientConfig {
|
||||||
|
pub url: String,
|
||||||
|
pub tls: ClientTlsConfig,
|
||||||
|
pub client_id: u64,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub policy: Policy,
|
||||||
|
pub ping_interval: Duration,
|
||||||
|
pub ping_jitter: Option<Duration>,
|
||||||
|
pub max_missed_pings: usize,
|
||||||
|
pub ping_timestamp: bool,
|
||||||
|
pub request_timeout: Duration,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub auth_timeout: Duration,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub require_pq: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientConfig {
|
||||||
|
pub fn new(url: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
url: url.into(),
|
||||||
|
tls: ClientTlsConfig::SystemRoots,
|
||||||
|
client_id: 0,
|
||||||
|
description: None,
|
||||||
|
policy: Policy::default(),
|
||||||
|
ping_interval: Duration::ZERO,
|
||||||
|
ping_jitter: None,
|
||||||
|
max_missed_pings: 3,
|
||||||
|
ping_timestamp: true,
|
||||||
|
request_timeout: Duration::from_secs(30),
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
auth_timeout: Duration::from_secs(30),
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
require_pq: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_tls(mut self, tls: ClientTlsConfig) -> Self {
|
||||||
|
self.tls = tls;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_pinned_pem(self, cert_pem: Vec<u8>) -> Self {
|
||||||
|
self.with_tls(ClientTlsConfig::PinnedPem(cert_pem))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_client_id(mut self, client_id: u64) -> Self {
|
||||||
|
self.client_id = client_id;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_description(mut self, description: impl Into<String>) -> Self {
|
||||||
|
self.description = Some(description.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_policy(mut self, policy: Policy) -> Self {
|
||||||
|
self.policy = policy;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_ping_interval(mut self, interval: Duration) -> Self {
|
||||||
|
self.ping_interval = interval;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_ping_jitter(mut self, jitter: Option<Duration>) -> Self {
|
||||||
|
self.ping_jitter = jitter;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_max_missed_pings(mut self, max_missed_pings: usize) -> Self {
|
||||||
|
self.max_missed_pings = max_missed_pings;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_ping_timestamp(mut self, ping_timestamp: bool) -> Self {
|
||||||
|
self.ping_timestamp = ping_timestamp;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
|
||||||
|
self.request_timeout = timeout;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub fn with_auth_timeout(mut self, timeout: Duration) -> Self {
|
||||||
|
self.auth_timeout = timeout;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub fn with_require_pq(mut self, require_pq: bool) -> Self {
|
||||||
|
self.require_pq = require_pq;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn server_cert(&self) -> Option<Vec<u8>> {
|
||||||
|
match &self.tls {
|
||||||
|
ClientTlsConfig::SystemRoots => None,
|
||||||
|
ClientTlsConfig::PinnedPem(cert) => Some(cert.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
307
client/src/connection.rs
Normal file
307
client/src/connection.rs
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
use mtp_codec::{CommunicationValue, Version, registry::VersionedCodec};
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
use mtp_codec::{DataType, DataValue};
|
||||||
|
use mtp_common::CommunicationError;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::{Mutex, mpsc};
|
||||||
|
use tokio::time::Duration;
|
||||||
|
|
||||||
|
use crate::config::ClientConfig;
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
use crate::error::AuthState;
|
||||||
|
use crate::ping::{PingSession, start_ping_session};
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
use crate::pipe::PipeRequest;
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
use crate::pipe::is_expired_creation;
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
use crate::pipe::{PendingCreation, PendingCreationGuard};
|
||||||
|
use crate::pipe::{PendingRequest, PipeDispatcher, run_dispatcher};
|
||||||
|
|
||||||
|
pub struct MTPConnection {
|
||||||
|
pub version: Version,
|
||||||
|
pub codec: VersionedCodec,
|
||||||
|
pub sender: mtp_transport::Sender,
|
||||||
|
pub receiver: mtp_transport::Receiver,
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// The peer address observed by the underlying QUIC connection.
|
||||||
|
pub remote_addr: Option<SocketAddr>,
|
||||||
|
pub(crate) ping: Option<PingSession>,
|
||||||
|
pub(crate) app_rx: Mutex<mpsc::Receiver<Result<CommunicationValue, CommunicationError>>>,
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
pub(crate) pipe_req_rx: Mutex<mpsc::Receiver<PipeRequest>>,
|
||||||
|
pub(crate) pipe_dispatcher: Arc<PipeDispatcher>,
|
||||||
|
pub(crate) request_timeout: Duration,
|
||||||
|
pub(crate) _dispatcher_task: tokio::task::JoinHandle<()>,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub auth_state: AuthState,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub client_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MTPConnection {
|
||||||
|
pub fn get_ping(&self) -> Option<Duration> {
|
||||||
|
self.ping.as_ref().and_then(PingSession::get_ping)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn request(
|
||||||
|
&self,
|
||||||
|
request: &CommunicationValue,
|
||||||
|
expected_response: Option<mtp_codec::CommunicationType>,
|
||||||
|
) -> Result<CommunicationValue, CommunicationError> {
|
||||||
|
let request_id = request
|
||||||
|
.id()
|
||||||
|
.ok_or_else(|| CommunicationError::Other("request frame must contain an id".into()))?;
|
||||||
|
if request_id == 0 {
|
||||||
|
return Err(CommunicationError::Other(
|
||||||
|
"request frame must have a non-zero id".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if crate::pipe::is_expired_request(&self.pipe_dispatcher, request_id).await {
|
||||||
|
return Err(CommunicationError::Other(format!(
|
||||||
|
"request id {request_id} recently timed out; use a new request id"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||||
|
let token = Arc::new(());
|
||||||
|
{
|
||||||
|
let mut pending = self.pipe_dispatcher.pending_requests.lock().await;
|
||||||
|
if pending.contains_key(&request_id) {
|
||||||
|
return Err(CommunicationError::Other(format!(
|
||||||
|
"request id {request_id} is already pending"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
pending.insert(
|
||||||
|
request_id,
|
||||||
|
PendingRequest {
|
||||||
|
token: token.clone(),
|
||||||
|
sender,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = match tokio::time::timeout(self.request_timeout, async {
|
||||||
|
self.sender.send(request).await?;
|
||||||
|
receiver
|
||||||
|
.await
|
||||||
|
.map_err(|_| CommunicationError::StreamClosed)?
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => {
|
||||||
|
if result.is_err() {
|
||||||
|
crate::pipe::remove_pending_request(&self.pipe_dispatcher, request_id, &token)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
result?
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
crate::pipe::expire_pending_request(&self.pipe_dispatcher, request_id, &token)
|
||||||
|
.await;
|
||||||
|
return Err(CommunicationError::Other(format!(
|
||||||
|
"request {request_id} timed out after {:?}",
|
||||||
|
self.request_timeout
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(expected) = expected_response {
|
||||||
|
let expected_type = expected.try_to_id(self.codec.type_map());
|
||||||
|
if Some(response.get_type()) != expected_type {
|
||||||
|
return Err(CommunicationError::Other(format!(
|
||||||
|
"unexpected response type: expected {:?}, got {:?}; parsed {}",
|
||||||
|
expected_type,
|
||||||
|
response.get_type(),
|
||||||
|
response
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn receive(&self) -> Result<CommunicationValue, CommunicationError> {
|
||||||
|
let mut rx = self.app_rx.lock().await;
|
||||||
|
match rx.recv().await {
|
||||||
|
Some(result) => result,
|
||||||
|
None => Err(CommunicationError::StreamClosed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
impl MTPConnection {
|
||||||
|
pub async fn create_pipe(
|
||||||
|
&self,
|
||||||
|
description: &str,
|
||||||
|
) -> Result<crate::pipe::PipeHandle, mtp_common::PipeError> {
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
let token = Arc::new(());
|
||||||
|
let pipe_id = {
|
||||||
|
let mut pending = self
|
||||||
|
.pipe_dispatcher
|
||||||
|
.pending_creations
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| mtp_common::PipeError::ConnectionClosed)?;
|
||||||
|
let pipe_id = loop {
|
||||||
|
let candidate = rand::random::<u32>();
|
||||||
|
if candidate != 0
|
||||||
|
&& !pending.contains_key(&candidate)
|
||||||
|
&& !is_expired_creation(&self.pipe_dispatcher, candidate)
|
||||||
|
{
|
||||||
|
break candidate;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pending.insert(
|
||||||
|
pipe_id,
|
||||||
|
PendingCreation {
|
||||||
|
token: token.clone(),
|
||||||
|
sender: tx,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
pipe_id
|
||||||
|
};
|
||||||
|
let mut creation_guard =
|
||||||
|
PendingCreationGuard::new(self.pipe_dispatcher.clone(), pipe_id, token.clone());
|
||||||
|
|
||||||
|
let request = CommunicationValue::new_with_type_map(
|
||||||
|
mtp_codec::CommunicationType::PipeRequest,
|
||||||
|
self.codec.type_map(),
|
||||||
|
)
|
||||||
|
.with_id(pipe_id)
|
||||||
|
.add_typed_default(DataType::Description, DataValue::Str(description.into()));
|
||||||
|
|
||||||
|
if let Err(error) = self.sender.send(&request).await {
|
||||||
|
return Err(mtp_common::PipeError::from(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
creation_guard.disarm();
|
||||||
|
Ok(crate::pipe::PipeHandle {
|
||||||
|
pipe_id,
|
||||||
|
description: description.to_string(),
|
||||||
|
sender: self.sender.clone(),
|
||||||
|
response_rx: rx,
|
||||||
|
dispatcher: self.pipe_dispatcher.clone(),
|
||||||
|
token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn receive_pipe(&self) -> Result<PipeRequest, CommunicationError> {
|
||||||
|
let mut rx = self.pipe_req_rx.lock().await;
|
||||||
|
match rx.recv().await {
|
||||||
|
Some(req) => Ok(req),
|
||||||
|
None => Err(CommunicationError::StreamClosed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn connection_from_parts(
|
||||||
|
config: ClientConfig,
|
||||||
|
sender: mtp_transport::Sender,
|
||||||
|
receiver: mtp_transport::Receiver,
|
||||||
|
version: Version,
|
||||||
|
codec: VersionedCodec,
|
||||||
|
#[cfg(feature = "crypto")] auth_state: AuthState,
|
||||||
|
#[cfg(feature = "crypto")] client_id: u64,
|
||||||
|
) -> MTPConnection {
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
let type_map = codec.type_map().clone();
|
||||||
|
receiver.set_type_map(codec.type_map()).await;
|
||||||
|
let remote_addr = sender.handle().remote_addr();
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
let ping_client_id = client_id;
|
||||||
|
#[cfg(not(feature = "crypto"))]
|
||||||
|
let ping_client_id = config.client_id;
|
||||||
|
let ping = start_ping_session(
|
||||||
|
&config,
|
||||||
|
sender.clone(),
|
||||||
|
&receiver,
|
||||||
|
codec.type_map(),
|
||||||
|
ping_client_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
{
|
||||||
|
let receiver_queue_capacity = config.policy.receiver_queue_capacity.max(1);
|
||||||
|
let (app_tx, app_rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(
|
||||||
|
receiver_queue_capacity,
|
||||||
|
);
|
||||||
|
let (pipe_req_tx, pipe_req_rx) = mpsc::channel::<PipeRequest>(receiver_queue_capacity);
|
||||||
|
|
||||||
|
let dispatcher = Arc::new(PipeDispatcher {
|
||||||
|
pending_requests: Mutex::new(std::collections::HashMap::new()),
|
||||||
|
expired_requests: Mutex::new(std::collections::HashMap::new()),
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
type_map: type_map.clone(),
|
||||||
|
pending_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
|
expired_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
|
pending_pipes: Mutex::new(std::collections::HashMap::new()),
|
||||||
|
policy: Arc::new(config.policy),
|
||||||
|
});
|
||||||
|
|
||||||
|
let dispatcher_clone = dispatcher.clone();
|
||||||
|
let sender_clone = sender.clone();
|
||||||
|
let dispatcher_task = tokio::spawn(run_dispatcher(
|
||||||
|
receiver.clone(),
|
||||||
|
sender_clone,
|
||||||
|
app_tx,
|
||||||
|
pipe_req_tx,
|
||||||
|
dispatcher_clone,
|
||||||
|
));
|
||||||
|
|
||||||
|
MTPConnection {
|
||||||
|
version,
|
||||||
|
codec,
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
app_rx: Mutex::new(app_rx),
|
||||||
|
pipe_req_rx: Mutex::new(pipe_req_rx),
|
||||||
|
pipe_dispatcher: dispatcher,
|
||||||
|
request_timeout: config.request_timeout,
|
||||||
|
description: config.description,
|
||||||
|
remote_addr,
|
||||||
|
ping,
|
||||||
|
_dispatcher_task: dispatcher_task,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
auth_state,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
client_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "pipes"))]
|
||||||
|
{
|
||||||
|
let receiver_queue_capacity = config.policy.receiver_queue_capacity.max(1);
|
||||||
|
let (app_tx, app_rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(
|
||||||
|
receiver_queue_capacity,
|
||||||
|
);
|
||||||
|
let dispatcher = Arc::new(PipeDispatcher {
|
||||||
|
pending_requests: Mutex::new(std::collections::HashMap::new()),
|
||||||
|
expired_requests: Mutex::new(std::collections::HashMap::new()),
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
type_map,
|
||||||
|
});
|
||||||
|
let task = tokio::spawn(run_dispatcher(receiver.clone(), app_tx, dispatcher.clone()));
|
||||||
|
|
||||||
|
MTPConnection {
|
||||||
|
version,
|
||||||
|
codec,
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
app_rx: Mutex::new(app_rx),
|
||||||
|
pipe_dispatcher: dispatcher,
|
||||||
|
request_timeout: config.request_timeout,
|
||||||
|
description: config.description,
|
||||||
|
remote_addr,
|
||||||
|
ping,
|
||||||
|
_dispatcher_task: task,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
auth_state,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
client_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
241
client/src/crypto.rs
Normal file
241
client/src/crypto.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, Version};
|
||||||
|
use mtp_common::CommunicationError;
|
||||||
|
|
||||||
|
pub(crate) fn unexpected_response_type_error(
|
||||||
|
context: &str,
|
||||||
|
expected_type: mtp_codec::CommunicationTypeId,
|
||||||
|
response: &CommunicationValue,
|
||||||
|
) -> CommunicationError {
|
||||||
|
CommunicationError::AuthenticationFailed(format!(
|
||||||
|
"unexpected response type during {context}: expected {:?}, got {:?}; parsed {}",
|
||||||
|
expected_type,
|
||||||
|
response.get_type(),
|
||||||
|
response
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn verify_host_challenge(
|
||||||
|
challenge: &CommunicationValue,
|
||||||
|
host_pk: &mtp_crypto::PublicKeyBundle,
|
||||||
|
id: u64,
|
||||||
|
server_challenge: u128,
|
||||||
|
require_pq: bool,
|
||||||
|
) -> Result<(), CommunicationError> {
|
||||||
|
use mtp_crypto::{auth, verify_ed25519};
|
||||||
|
|
||||||
|
let sig = match challenge.get_data(DataType::Signature) {
|
||||||
|
Some(DataValue::Bytes(b)) => b.clone(),
|
||||||
|
_ => {
|
||||||
|
return Err(CommunicationError::AuthenticationFailed(
|
||||||
|
"Missing host challenge signature".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let pq_sig = match challenge.get_data(DataType::PqSignature) {
|
||||||
|
Some(DataValue::Bytes(b)) => b.clone(),
|
||||||
|
_ => vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let host_requires_pq = challenge.get_data(DataType::RequirePq) == Some(&DataValue::BoolTrue);
|
||||||
|
if host_requires_pq && host_pk.sig_pq_public_key.as_bytes().is_empty() {
|
||||||
|
return Err(CommunicationError::AuthenticationFailed(
|
||||||
|
"Host requires post-quantum authentication but its PQ public key is absent".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if require_pq && pq_sig.is_empty() {
|
||||||
|
return Err(CommunicationError::AuthenticationFailed(
|
||||||
|
"Host challenge is missing the required PQ signature".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = auth::challenge_payload(id, server_challenge);
|
||||||
|
if pq_sig.is_empty() {
|
||||||
|
verify_ed25519(&host_pk.sig_cl_public_key, &payload, &sig).map_err(|_| {
|
||||||
|
CommunicationError::AuthenticationFailed("Host challenge signature invalid".into())
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
mtp_crypto::sign_parallel::verify_dual_parallel(
|
||||||
|
host_pk.sig_cl_public_key.clone(),
|
||||||
|
host_pk.sig_pq_public_key.clone(),
|
||||||
|
payload,
|
||||||
|
sig,
|
||||||
|
pq_sig,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
CommunicationError::AuthenticationFailed("Host challenge signature invalid".into())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn verify_host_final(
|
||||||
|
response: &CommunicationValue,
|
||||||
|
host_pk: &mtp_crypto::PublicKeyBundle,
|
||||||
|
id: u64,
|
||||||
|
client_nonce: u128,
|
||||||
|
server_challenge: u128,
|
||||||
|
require_pq: bool,
|
||||||
|
) -> Result<(), CommunicationError> {
|
||||||
|
use mtp_crypto::{auth, verify_ed25519};
|
||||||
|
|
||||||
|
match response.get_data(DataType::ClientNonce) {
|
||||||
|
Some(DataValue::UnsignedNumber(n)) if *n == client_nonce => {}
|
||||||
|
_ => {
|
||||||
|
return Err(CommunicationError::AuthenticationFailed(
|
||||||
|
"Nonce mismatch".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sig = match response.get_data(DataType::Signature) {
|
||||||
|
Some(DataValue::Bytes(b)) => b.clone(),
|
||||||
|
_ => {
|
||||||
|
return Err(CommunicationError::AuthenticationFailed(
|
||||||
|
"Missing signature".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let pq_sig = match response.get_data(DataType::PqSignature) {
|
||||||
|
Some(DataValue::Bytes(b)) => b.clone(),
|
||||||
|
_ => vec![],
|
||||||
|
};
|
||||||
|
if require_pq && pq_sig.is_empty() {
|
||||||
|
return Err(CommunicationError::AuthenticationFailed(
|
||||||
|
"Host confirmation is missing the required PQ signature".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = auth::host_final_payload(id, client_nonce, server_challenge);
|
||||||
|
if pq_sig.is_empty() {
|
||||||
|
verify_ed25519(&host_pk.sig_cl_public_key, &payload, &sig).map_err(|_| {
|
||||||
|
CommunicationError::AuthenticationFailed("Host signature invalid".into())
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
mtp_crypto::sign_parallel::verify_dual_parallel(
|
||||||
|
host_pk.sig_cl_public_key.clone(),
|
||||||
|
host_pk.sig_pq_public_key.clone(),
|
||||||
|
payload,
|
||||||
|
sig,
|
||||||
|
pq_sig,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| CommunicationError::AuthenticationFailed("Host signature invalid".into()))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn check_connected(
|
||||||
|
response: &CommunicationValue,
|
||||||
|
reject_msg: &str,
|
||||||
|
) -> Result<(), CommunicationError> {
|
||||||
|
match response.get_data(DataType::Connected) {
|
||||||
|
Some(DataValue::BoolTrue) => Ok(()),
|
||||||
|
Some(DataValue::BoolFalse) => Err(CommunicationError::AuthenticationFailed(
|
||||||
|
response
|
||||||
|
.get_str(DataType::ErrorMessage)
|
||||||
|
.unwrap_or(reject_msg)
|
||||||
|
.into(),
|
||||||
|
)),
|
||||||
|
_ => Err(CommunicationError::AuthenticationFailed(
|
||||||
|
"Invalid response".into(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn negotiated_version(
|
||||||
|
response: &CommunicationValue,
|
||||||
|
) -> Result<Version, CommunicationError> {
|
||||||
|
match response.get_data(DataType::Version) {
|
||||||
|
Some(DataValue::Str(version)) => Version::parse(version).ok_or_else(|| {
|
||||||
|
CommunicationError::AuthenticationFailed(
|
||||||
|
"Host returned an invalid negotiated protocol version".into(),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
_ => Err(CommunicationError::AuthenticationFailed(
|
||||||
|
"Host omitted the negotiated protocol version".into(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn signed_challenge_response(
|
||||||
|
keys: &mtp_crypto::Keyring,
|
||||||
|
proof_payload: Vec<u8>,
|
||||||
|
client_nonce: u128,
|
||||||
|
type_map: &TypeMap,
|
||||||
|
) -> Result<CommunicationValue, CommunicationError> {
|
||||||
|
use mtp_crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme};
|
||||||
|
|
||||||
|
let signer = Ed25519Signer::new(&keys.sig_cl_secret_key)
|
||||||
|
.map_err(|e| CommunicationError::Other(e.to_string()))?;
|
||||||
|
let mut proof =
|
||||||
|
CommunicationValue::new_with_type_map(CommunicationType::ChallengeResponse, type_map)
|
||||||
|
.add_typed_default(
|
||||||
|
DataType::ClientNonce,
|
||||||
|
DataValue::UnsignedNumber(client_nonce),
|
||||||
|
);
|
||||||
|
|
||||||
|
if keys.sig_pq_secret_key.as_bytes().is_empty() {
|
||||||
|
let signature = signer
|
||||||
|
.sign(&proof_payload)
|
||||||
|
.map_err(|e| CommunicationError::Other(e.to_string()))?;
|
||||||
|
proof = proof.add_typed_default(DataType::Signature, DataValue::Bytes(signature));
|
||||||
|
} else {
|
||||||
|
let pq_signer = MlDsaSigner::new(&keys.sig_pq_secret_key, &keys.sig_pq_public_key)
|
||||||
|
.map_err(|e| CommunicationError::Other(e.to_string()))?;
|
||||||
|
let (signature, pq_signature) =
|
||||||
|
mtp_crypto::sign_parallel::sign_dual_parallel(signer, pq_signer, proof_payload)
|
||||||
|
.await
|
||||||
|
.map_err(|e| CommunicationError::Other(e.to_string()))?;
|
||||||
|
proof = proof
|
||||||
|
.add_typed_default(DataType::Signature, DataValue::Bytes(signature))
|
||||||
|
.add_typed_default(DataType::PqSignature, DataValue::Bytes(pq_signature));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(proof)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn receive_verified_challenge(
|
||||||
|
receiver: &mtp_transport::Receiver,
|
||||||
|
tm: &mtp_codec::TypeMap,
|
||||||
|
host_public_key_bundle: &mtp_crypto::PublicKeyBundle,
|
||||||
|
bound_id: u64,
|
||||||
|
context: &str,
|
||||||
|
require_pq: bool,
|
||||||
|
client_has_pq_key: bool,
|
||||||
|
) -> Result<u128, CommunicationError> {
|
||||||
|
let challenge = receiver.receive().await?;
|
||||||
|
let expected = CommunicationType::Challenge
|
||||||
|
.try_to_id(tm)
|
||||||
|
.ok_or_else(|| CommunicationError::Other("Challenge is absent from the type map".into()))?;
|
||||||
|
if challenge.get_type() != expected {
|
||||||
|
return Err(unexpected_response_type_error(
|
||||||
|
context, expected, &challenge,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let server_challenge = match challenge.get_data(DataType::ServerNonce) {
|
||||||
|
Some(DataValue::UnsignedNumber(n)) => *n,
|
||||||
|
_ => {
|
||||||
|
return Err(CommunicationError::AuthenticationFailed(
|
||||||
|
"Missing server challenge".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if challenge.get_data(DataType::RequirePq) == Some(&DataValue::BoolTrue) && !client_has_pq_key {
|
||||||
|
return Err(CommunicationError::AuthenticationFailed(
|
||||||
|
"Host requires post-quantum authentication but the client PQ key is absent".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_host_challenge(
|
||||||
|
&challenge,
|
||||||
|
host_public_key_bundle,
|
||||||
|
bound_id,
|
||||||
|
server_challenge,
|
||||||
|
require_pq,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(server_challenge)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
179
client/src/ping.rs
Normal file
179
client/src/ping.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
use rand::RngExt;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::{Mutex, mpsc};
|
||||||
|
use tokio::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap};
|
||||||
|
use mtp_transport::{Receiver, Sender};
|
||||||
|
|
||||||
|
pub(crate) struct PingSession {
|
||||||
|
pub(crate) last_ping: Arc<Mutex<Option<Duration>>>,
|
||||||
|
pub(crate) task: tokio::task::JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct PingTracker {
|
||||||
|
pending: Option<(u32, Instant)>,
|
||||||
|
missed_pings: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PingTracker {
|
||||||
|
fn begin_round(&mut self) -> usize {
|
||||||
|
if self.pending.take().is_some() {
|
||||||
|
self.missed_pings += 1;
|
||||||
|
}
|
||||||
|
self.missed_pings
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sent(&mut self, id: u32) {
|
||||||
|
self.pending = Some((id, Instant::now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn received(&mut self, id: u32) -> Option<Duration> {
|
||||||
|
if self
|
||||||
|
.pending
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|(pending, _)| *pending != id)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (_, sent_at) = self.pending.take()?;
|
||||||
|
self.missed_pings = 0;
|
||||||
|
Some(sent_at.elapsed())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PingSession {
|
||||||
|
pub(crate) fn get_ping(&self) -> Option<Duration> {
|
||||||
|
self.last_ping.try_lock().ok().and_then(|ping| *ping)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PingSession {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn start_ping_session(
|
||||||
|
config: &crate::config::ClientConfig,
|
||||||
|
sender: Sender,
|
||||||
|
receiver: &Receiver,
|
||||||
|
type_map: &TypeMap,
|
||||||
|
client_id: u64,
|
||||||
|
) -> Option<PingSession> {
|
||||||
|
if config.ping_interval.is_zero() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (pong_tx, mut pong_rx) = mpsc::channel(1);
|
||||||
|
receiver.observe_pongs_bounded(pong_tx).await;
|
||||||
|
let last_ping = Arc::new(Mutex::new(None));
|
||||||
|
let ping_state = last_ping.clone();
|
||||||
|
let interval = config.ping_interval;
|
||||||
|
let ping_jitter = config.ping_jitter;
|
||||||
|
let max_missed_pings = config.max_missed_pings;
|
||||||
|
let ping_timestamp = config.ping_timestamp;
|
||||||
|
let type_map = type_map.clone();
|
||||||
|
let ping_receiver = receiver.clone();
|
||||||
|
let mut close_rx = receiver.handle().subscribe_close();
|
||||||
|
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let mut ticker = tokio::time::interval(interval);
|
||||||
|
ticker.tick().await;
|
||||||
|
let mut tracker = PingTracker::default();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = close_rx.changed() => {
|
||||||
|
if close_rx.borrow().is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = ticker.tick() => {
|
||||||
|
let missed_pings = tracker.begin_round();
|
||||||
|
ping_receiver.set_expected_pong_id(None).await;
|
||||||
|
if max_missed_pings > 0 && missed_pings >= max_missed_pings {
|
||||||
|
sender.close().await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(jitter) = ping_jitter && !jitter.is_zero() {
|
||||||
|
let max_ms = jitter.as_millis() as u64;
|
||||||
|
let extra = rand::rng().random_range(0..=max_ms);
|
||||||
|
tokio::time::sleep(Duration::from_millis(extra)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ping = CommunicationValue::new_with_type_map(
|
||||||
|
CommunicationType::Ping,
|
||||||
|
&type_map,
|
||||||
|
)
|
||||||
|
.with_sender(client_id);
|
||||||
|
if ping_timestamp {
|
||||||
|
let sent_at = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis();
|
||||||
|
ping = ping.add_typed_default(
|
||||||
|
DataType::Timestamp,
|
||||||
|
DataValue::UnsignedNumber(sent_at),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let Some(id) = ping.id() else {
|
||||||
|
sender.close().await;
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
ping_receiver.set_expected_pong_id(Some(id)).await;
|
||||||
|
if sender.send(&ping).await.is_err() {
|
||||||
|
ping_receiver.set_expected_pong_id(None).await;
|
||||||
|
sender.close().await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tracker.sent(id);
|
||||||
|
}
|
||||||
|
pong = pong_rx.recv() => match pong {
|
||||||
|
Some(pong) => {
|
||||||
|
if let Some(id) = pong.id()
|
||||||
|
&& let Some(ping) = tracker.received(id)
|
||||||
|
{
|
||||||
|
let mut last_ping = ping_state.lock().await;
|
||||||
|
*last_ping = Some(ping);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Some(PingSession { last_ping, task })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::PingTracker;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn successful_pong_resets_consecutive_misses() {
|
||||||
|
let mut tracker = PingTracker::default();
|
||||||
|
tracker.sent(1);
|
||||||
|
assert_eq!(tracker.begin_round(), 1);
|
||||||
|
|
||||||
|
tracker.sent(2);
|
||||||
|
assert!(tracker.received(2).is_some());
|
||||||
|
|
||||||
|
tracker.sent(3);
|
||||||
|
assert_eq!(tracker.begin_round(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_pong_does_not_acknowledge_current_round() {
|
||||||
|
let mut tracker = PingTracker::default();
|
||||||
|
tracker.sent(1);
|
||||||
|
assert_eq!(tracker.begin_round(), 1);
|
||||||
|
tracker.sent(2);
|
||||||
|
|
||||||
|
assert!(tracker.received(1).is_none());
|
||||||
|
assert_eq!(tracker.begin_round(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
534
client/src/pipe.rs
Normal file
534
client/src/pipe.rs
Normal file
|
|
@ -0,0 +1,534 @@
|
||||||
|
use mtp_codec::CommunicationValue;
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
use mtp_codec::TypeMap;
|
||||||
|
use mtp_common::CommunicationError;
|
||||||
|
use mtp_transport::Receiver;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
use std::sync::Mutex as StdMutex;
|
||||||
|
use tokio::sync::{Mutex, mpsc};
|
||||||
|
use tokio::time::{Duration, Instant};
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
use mtp_codec::{CommunicationType, DataType, DataValue};
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
use mtp_common::PipeError;
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
use mtp_transport::{Policy, Sender};
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
pub struct PipeHandle {
|
||||||
|
pub(crate) pipe_id: u32,
|
||||||
|
pub(crate) description: String,
|
||||||
|
pub(crate) sender: Sender,
|
||||||
|
pub(crate) response_rx: tokio::sync::oneshot::Receiver<Result<bool, PipeError>>,
|
||||||
|
pub(crate) dispatcher: Arc<PipeDispatcher>,
|
||||||
|
pub(crate) token: Arc<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
impl PipeHandle {
|
||||||
|
pub fn pipe_id(&self) -> u32 {
|
||||||
|
self.pipe_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn description(&self) -> &str {
|
||||||
|
&self.description
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn wait(mut self) -> Result<Option<mtp_transport::PipeWriter>, PipeError> {
|
||||||
|
let response =
|
||||||
|
tokio::time::timeout(self.dispatcher.policy.read_timeout, &mut self.response_rx).await;
|
||||||
|
match response {
|
||||||
|
Ok(Ok(Ok(true))) => {
|
||||||
|
let writer = self
|
||||||
|
.sender
|
||||||
|
.open_pipe(self.pipe_id, &self.description)
|
||||||
|
.await
|
||||||
|
.map_err(PipeError::from)?;
|
||||||
|
Ok(Some(writer))
|
||||||
|
}
|
||||||
|
Ok(Ok(Ok(false))) => Ok(None),
|
||||||
|
Ok(Ok(Err(error))) => {
|
||||||
|
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
Ok(Err(_)) => {
|
||||||
|
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
|
||||||
|
Err(PipeError::StreamClosed)
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
|
||||||
|
Err(PipeError::HandshakeTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
impl Drop for PipeHandle {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
pub struct PipeRequest {
|
||||||
|
pub(crate) pipe_id: u32,
|
||||||
|
pub(crate) description: String,
|
||||||
|
pub(crate) sender: Sender,
|
||||||
|
pub(crate) receiver: Receiver,
|
||||||
|
pub(crate) dispatcher: Arc<PipeDispatcher>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
struct ExpectedPipeGuard {
|
||||||
|
receiver: Receiver,
|
||||||
|
pipe_id: u32,
|
||||||
|
armed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
impl ExpectedPipeGuard {
|
||||||
|
fn new(receiver: Receiver, pipe_id: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
receiver,
|
||||||
|
pipe_id,
|
||||||
|
armed: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disarm(&mut self) {
|
||||||
|
self.armed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
impl Drop for ExpectedPipeGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.armed {
|
||||||
|
self.receiver.cancel_expected_pipe(self.pipe_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
impl PipeRequest {
|
||||||
|
pub fn id(&self) -> u32 {
|
||||||
|
self.pipe_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn description(&self) -> &str {
|
||||||
|
&self.description
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn accept(self) -> Result<mtp_transport::PipeReader, PipeError> {
|
||||||
|
self.receiver
|
||||||
|
.expect_pipe(self.pipe_id)
|
||||||
|
.map_err(PipeError::from)?;
|
||||||
|
let mut expected_pipe = ExpectedPipeGuard::new(self.receiver.clone(), self.pipe_id);
|
||||||
|
let (pipe_tx, pipe_rx) = tokio::sync::oneshot::channel();
|
||||||
|
{
|
||||||
|
let mut pending = self.dispatcher.pending_pipes.lock().await;
|
||||||
|
pending.insert(self.pipe_id, pipe_tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = CommunicationValue::new_with_type_map(
|
||||||
|
CommunicationType::PipeResponse,
|
||||||
|
&self.dispatcher.type_map,
|
||||||
|
)
|
||||||
|
.with_id(self.pipe_id)
|
||||||
|
.add_typed_default(DataType::Accepted, DataValue::BoolTrue);
|
||||||
|
if let Err(error) = self.sender.send(&resp).await {
|
||||||
|
self.dispatcher
|
||||||
|
.pending_pipes
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.remove(&self.pipe_id);
|
||||||
|
return Err(PipeError::from(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeout = self.dispatcher.policy.read_timeout;
|
||||||
|
match tokio::time::timeout(timeout, pipe_rx).await {
|
||||||
|
Ok(Ok(reader)) => {
|
||||||
|
expected_pipe.disarm();
|
||||||
|
Ok(reader)
|
||||||
|
}
|
||||||
|
Ok(Err(_)) => {
|
||||||
|
self.dispatcher
|
||||||
|
.pending_pipes
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.remove(&self.pipe_id);
|
||||||
|
Err(PipeError::StreamClosed)
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
self.dispatcher
|
||||||
|
.pending_pipes
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.remove(&self.pipe_id);
|
||||||
|
Err(PipeError::HandshakeTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn deny(self) -> Result<(), PipeError> {
|
||||||
|
let resp = CommunicationValue::new_with_type_map(
|
||||||
|
CommunicationType::PipeResponse,
|
||||||
|
&self.dispatcher.type_map,
|
||||||
|
)
|
||||||
|
.with_id(self.pipe_id)
|
||||||
|
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
|
||||||
|
self.sender.send(&resp).await.map_err(PipeError::from)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct PendingRequest {
|
||||||
|
pub(crate) token: Arc<()>,
|
||||||
|
pub(crate) sender: tokio::sync::oneshot::Sender<Result<CommunicationValue, CommunicationError>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
pub(crate) struct PendingCreation {
|
||||||
|
pub(crate) token: Arc<()>,
|
||||||
|
pub(crate) sender: tokio::sync::oneshot::Sender<Result<bool, PipeError>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
pub(crate) struct PendingCreationGuard {
|
||||||
|
dispatcher: Arc<PipeDispatcher>,
|
||||||
|
pipe_id: u32,
|
||||||
|
token: Arc<()>,
|
||||||
|
armed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
impl PendingCreationGuard {
|
||||||
|
pub(crate) fn new(dispatcher: Arc<PipeDispatcher>, pipe_id: u32, token: Arc<()>) -> Self {
|
||||||
|
Self {
|
||||||
|
dispatcher,
|
||||||
|
pipe_id,
|
||||||
|
token,
|
||||||
|
armed: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn disarm(&mut self) {
|
||||||
|
self.armed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
impl Drop for PendingCreationGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.armed {
|
||||||
|
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct PipeDispatcher {
|
||||||
|
pub(crate) pending_requests: Mutex<HashMap<u32, PendingRequest>>,
|
||||||
|
pub(crate) expired_requests: Mutex<HashMap<u32, Instant>>,
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
pub(crate) type_map: TypeMap,
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
pub(crate) pending_creations: StdMutex<HashMap<u32, PendingCreation>>,
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
pub(crate) expired_creations: StdMutex<HashMap<u32, Instant>>,
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
pub(crate) pending_pipes:
|
||||||
|
Mutex<HashMap<u32, tokio::sync::oneshot::Sender<mtp_transport::PipeReader>>>,
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
pub(crate) policy: Arc<Policy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
const EXPIRED_CREATION_TOMBSTONE_TTL: Duration = Duration::from_secs(60);
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
const MAX_EXPIRED_CREATION_TOMBSTONES: usize = 1024;
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
pub(crate) fn expire_pending_creation(dispatcher: &PipeDispatcher, pipe_id: u32, token: &Arc<()>) {
|
||||||
|
let removed = dispatcher
|
||||||
|
.pending_creations
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut pending| {
|
||||||
|
if pending
|
||||||
|
.get(&pipe_id)
|
||||||
|
.is_some_and(|entry| Arc::ptr_eq(&entry.token, token))
|
||||||
|
{
|
||||||
|
pending.remove(&pipe_id);
|
||||||
|
Some(())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if removed.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(mut expired) = dispatcher.expired_creations.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let now = Instant::now();
|
||||||
|
expired.retain(|_, expires_at| *expires_at > now);
|
||||||
|
if expired.len() >= MAX_EXPIRED_CREATION_TOMBSTONES
|
||||||
|
&& let Some(oldest) = expired
|
||||||
|
.iter()
|
||||||
|
.min_by_key(|(_, expires_at)| **expires_at)
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
{
|
||||||
|
expired.remove(&oldest);
|
||||||
|
}
|
||||||
|
expired.insert(pipe_id, now + EXPIRED_CREATION_TOMBSTONE_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
fn consume_expired_creation(dispatcher: &PipeDispatcher, pipe_id: u32) -> bool {
|
||||||
|
let Ok(mut expired) = dispatcher.expired_creations.lock() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let now = Instant::now();
|
||||||
|
expired.retain(|_, expires_at| *expires_at > now);
|
||||||
|
expired.remove(&pipe_id).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
pub(crate) fn is_expired_creation(dispatcher: &PipeDispatcher, pipe_id: u32) -> bool {
|
||||||
|
let Ok(mut expired) = dispatcher.expired_creations.lock() else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let now = Instant::now();
|
||||||
|
expired.retain(|_, expires_at| *expires_at > now);
|
||||||
|
expired.contains_key(&pipe_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
pub(crate) fn fail_pending_creations(dispatcher: &PipeDispatcher, error: &CommunicationError) {
|
||||||
|
let pending = dispatcher
|
||||||
|
.pending_creations
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.map(|mut pending| std::mem::take(&mut *pending));
|
||||||
|
if let Some(pending) = pending {
|
||||||
|
let error = PipeError::from(error.clone());
|
||||||
|
for (_, pending) in pending {
|
||||||
|
let _ = pending.sender.send(Err(error.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(mut expired) = dispatcher.expired_creations.lock() {
|
||||||
|
expired.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
pub(crate) async fn fail_pending_pipes(dispatcher: &PipeDispatcher) {
|
||||||
|
dispatcher.pending_pipes.lock().await.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn route_message(
|
||||||
|
msg: CommunicationValue,
|
||||||
|
app_tx: &mpsc::Sender<Result<CommunicationValue, CommunicationError>>,
|
||||||
|
dispatcher: &PipeDispatcher,
|
||||||
|
) -> bool {
|
||||||
|
if !matches!(msg.id(), Some(id) if id != 0)
|
||||||
|
&& msg
|
||||||
|
.get_type_name()
|
||||||
|
.is_some_and(|name| name.ends_with("Response"))
|
||||||
|
{
|
||||||
|
return app_tx
|
||||||
|
.send(Err(CommunicationError::Other(
|
||||||
|
"response frame must contain a non-zero id".into(),
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.is_ok();
|
||||||
|
}
|
||||||
|
if let Some(id) = msg.id() {
|
||||||
|
let pending = dispatcher.pending_requests.lock().await.remove(&id);
|
||||||
|
if let Some(tx) = pending {
|
||||||
|
let _ = tx.sender.send(Ok(msg));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if consume_expired_request(dispatcher, id).await {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app_tx.send(Ok(msg)).await.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn fail_pending_requests(dispatcher: &PipeDispatcher, error: CommunicationError) {
|
||||||
|
let pending = std::mem::take(&mut *dispatcher.pending_requests.lock().await);
|
||||||
|
for (_, pending) in pending {
|
||||||
|
let _ = pending.sender.send(Err(error.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPIRED_REQUEST_TOMBSTONE_TTL: Duration = Duration::from_secs(60);
|
||||||
|
const MAX_EXPIRED_REQUEST_TOMBSTONES: usize = 1024;
|
||||||
|
|
||||||
|
pub(crate) async fn expire_pending_request(
|
||||||
|
dispatcher: &PipeDispatcher,
|
||||||
|
request_id: u32,
|
||||||
|
token: &Arc<()>,
|
||||||
|
) {
|
||||||
|
let mut pending = dispatcher.pending_requests.lock().await;
|
||||||
|
if pending
|
||||||
|
.get(&request_id)
|
||||||
|
.is_some_and(|entry| Arc::ptr_eq(&entry.token, token))
|
||||||
|
{
|
||||||
|
pending.remove(&request_id);
|
||||||
|
drop(pending);
|
||||||
|
let mut expired = dispatcher.expired_requests.lock().await;
|
||||||
|
let now = Instant::now();
|
||||||
|
expired.retain(|_, expires_at| *expires_at > now);
|
||||||
|
if expired.len() >= MAX_EXPIRED_REQUEST_TOMBSTONES
|
||||||
|
&& let Some(oldest) = expired
|
||||||
|
.iter()
|
||||||
|
.min_by_key(|(_, expires_at)| **expires_at)
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
{
|
||||||
|
expired.remove(&oldest);
|
||||||
|
}
|
||||||
|
expired.insert(request_id, now + EXPIRED_REQUEST_TOMBSTONE_TTL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn is_expired_request(dispatcher: &PipeDispatcher, request_id: u32) -> bool {
|
||||||
|
let mut expired = dispatcher.expired_requests.lock().await;
|
||||||
|
let now = Instant::now();
|
||||||
|
expired.retain(|_, expires_at| *expires_at > now);
|
||||||
|
expired.contains_key(&request_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn remove_pending_request(
|
||||||
|
dispatcher: &PipeDispatcher,
|
||||||
|
request_id: u32,
|
||||||
|
token: &Arc<()>,
|
||||||
|
) {
|
||||||
|
let mut pending = dispatcher.pending_requests.lock().await;
|
||||||
|
if pending
|
||||||
|
.get(&request_id)
|
||||||
|
.is_some_and(|entry| Arc::ptr_eq(&entry.token, token))
|
||||||
|
{
|
||||||
|
pending.remove(&request_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn consume_expired_request(dispatcher: &PipeDispatcher, request_id: u32) -> bool {
|
||||||
|
let mut expired = dispatcher.expired_requests.lock().await;
|
||||||
|
let now = Instant::now();
|
||||||
|
expired.retain(|_, expires_at| *expires_at > now);
|
||||||
|
expired.remove(&request_id).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
pub(crate) async fn run_dispatcher(
|
||||||
|
receiver: Receiver,
|
||||||
|
sender: Sender,
|
||||||
|
app_tx: mpsc::Sender<Result<CommunicationValue, CommunicationError>>,
|
||||||
|
pipe_req_tx: mpsc::Sender<PipeRequest>,
|
||||||
|
dispatcher: Arc<PipeDispatcher>,
|
||||||
|
) {
|
||||||
|
loop {
|
||||||
|
match receiver.receive_event().await {
|
||||||
|
Ok(mtp_transport::TransportEvent::Message(msg)) => {
|
||||||
|
if msg.is_type(CommunicationType::PipeRequest) {
|
||||||
|
let Some(pipe_id) = msg.id().filter(|id| *id != 0) else {
|
||||||
|
let error = CommunicationError::Other(
|
||||||
|
"PipeRequest frame must contain a non-zero id".into(),
|
||||||
|
);
|
||||||
|
if app_tx.send(Err(error)).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let description = msg.get_str(DataType::Description).unwrap_or("").to_string();
|
||||||
|
let req = PipeRequest {
|
||||||
|
pipe_id,
|
||||||
|
description,
|
||||||
|
sender: sender.clone(),
|
||||||
|
receiver: receiver.clone(),
|
||||||
|
dispatcher: dispatcher.clone(),
|
||||||
|
};
|
||||||
|
let _ = pipe_req_tx.send(req).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.is_type(CommunicationType::PipeResponse) {
|
||||||
|
let Some(pipe_id) = msg.id().filter(|id| *id != 0) else {
|
||||||
|
let error = CommunicationError::Other(
|
||||||
|
"PipeResponse frame must contain a non-zero id".into(),
|
||||||
|
);
|
||||||
|
if app_tx.send(Err(error)).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let accepted = msg.get_bool(DataType::Accepted).unwrap_or(false);
|
||||||
|
let pending = dispatcher
|
||||||
|
.pending_creations
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut pending| pending.remove(&pipe_id));
|
||||||
|
if let Some(entry) = pending {
|
||||||
|
let _ = entry.sender.send(Ok(accepted));
|
||||||
|
} else {
|
||||||
|
let _ = consume_expired_creation(&dispatcher, pipe_id);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !route_message(msg, &app_tx, &dispatcher).await {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(mtp_transport::TransportEvent::Pipe(reader)) => {
|
||||||
|
let pipe_id = reader.pipe_id();
|
||||||
|
let mut pending = dispatcher.pending_pipes.lock().await;
|
||||||
|
if let Some(tx) = pending.remove(&pipe_id) {
|
||||||
|
let _ = tx.send(reader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
fail_pending_requests(&dispatcher, e.clone()).await;
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
fail_pending_creations(&dispatcher, &e);
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
fail_pending_pipes(&dispatcher).await;
|
||||||
|
let _ = app_tx.send(Err(e)).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "pipes"))]
|
||||||
|
pub(crate) async fn run_dispatcher(
|
||||||
|
receiver: Receiver,
|
||||||
|
app_tx: mpsc::Sender<Result<CommunicationValue, CommunicationError>>,
|
||||||
|
dispatcher: Arc<PipeDispatcher>,
|
||||||
|
) {
|
||||||
|
loop {
|
||||||
|
match receiver.receive().await {
|
||||||
|
Ok(msg) => {
|
||||||
|
if !route_message(msg, &app_tx, &dispatcher).await {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
fail_pending_requests(&dispatcher, e.clone()).await;
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
fail_pending_creations(&dispatcher, &e);
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
fail_pending_pipes(&dispatcher).await;
|
||||||
|
let _ = app_tx.send(Err(e)).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
client/tests/ping.rs
Normal file
81
client/tests/ping.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
|
||||||
|
use mtp_client::{ClientConfig, MTPClient};
|
||||||
|
use mtp_host::{HostConfig, MTPHost};
|
||||||
|
|
||||||
|
async fn generate_self_signed_cert() -> Result<(Vec<u8>, Vec<u8>), Box<dyn std::error::Error>> {
|
||||||
|
let key_pair = rcgen::KeyPair::generate()?;
|
||||||
|
let params = rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()])?;
|
||||||
|
let cert = params.self_signed(&key_pair)?;
|
||||||
|
let cert_pem = cert.pem();
|
||||||
|
let key_pem = key_pair.serialize_pem();
|
||||||
|
Ok((cert_pem.into_bytes(), key_pem.into_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_host(send_pongs: bool) -> Result<(MTPHost, Vec<u8>), Box<dyn std::error::Error>> {
|
||||||
|
let (cert_pem, key_pem) = generate_self_signed_cert().await?;
|
||||||
|
let host = MTPHost::new(
|
||||||
|
HostConfig::new(
|
||||||
|
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||||
|
0,
|
||||||
|
cert_pem.clone(),
|
||||||
|
key_pem,
|
||||||
|
)
|
||||||
|
.with_pongs(send_pongs),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok((host, cert_pem))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_ping_rtt_and_missed_ping_teardown() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let (mut host, cert_pem) = start_host(true).await?;
|
||||||
|
let url = format!("https://127.0.0.1:{}", host.local_addr().port());
|
||||||
|
|
||||||
|
let client_connect = MTPClient::connect(
|
||||||
|
ClientConfig::new(url)
|
||||||
|
.with_pinned_pem(cert_pem)
|
||||||
|
.with_ping_interval(std::time::Duration::from_millis(25))
|
||||||
|
.with_max_missed_pings(3),
|
||||||
|
);
|
||||||
|
let (client, accepted) = tokio::join!(client_connect, host.accept());
|
||||||
|
let client = client?;
|
||||||
|
let _accepted = accepted?;
|
||||||
|
|
||||||
|
let ping = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||||
|
loop {
|
||||||
|
if let Some(ping) = client.get_ping() {
|
||||||
|
return ping;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
assert!(ping > std::time::Duration::ZERO);
|
||||||
|
|
||||||
|
let (mut silent_host, silent_cert_pem) = start_host(false).await?;
|
||||||
|
let silent_url = format!("https://127.0.0.1:{}", silent_host.local_addr().port());
|
||||||
|
let silent_connect = MTPClient::connect(
|
||||||
|
ClientConfig::new(silent_url)
|
||||||
|
.with_pinned_pem(silent_cert_pem)
|
||||||
|
.with_ping_interval(std::time::Duration::from_millis(25))
|
||||||
|
.with_max_missed_pings(2),
|
||||||
|
);
|
||||||
|
let (silent_client, accepted) = tokio::join!(silent_connect, silent_host.accept());
|
||||||
|
let silent_client = silent_client?;
|
||||||
|
let _accepted = accepted?;
|
||||||
|
|
||||||
|
let closed = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||||
|
loop {
|
||||||
|
if silent_client.sender.is_closed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(closed.is_ok(), "client should close after missed pings");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
1991
codec/Cargo.lock
generated
1991
codec/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +1,18 @@
|
||||||
[package]
|
[package]
|
||||||
name = "mtp-codec"
|
name = "mtp-codec"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mtp-type-map = { version = "0.1.0", path = "../type-map" }
|
mtp-type-map = { version = "0.3.0", path = "../type-map" }
|
||||||
mtp-common = { version = "0.1.0", path = "../common" }
|
mtp-common = { version = "0.3.0", path = "../common" }
|
||||||
mtp-crypto = { version = "0.1.0", path = "../crypto", optional = true }
|
mtp-crypto = { version = "0.3.0", path = "../crypto", optional = true }
|
||||||
base64 = "0.22"
|
base64 = "0.23"
|
||||||
byteorder = "1.5"
|
byteorder = "1.5"
|
||||||
rand = { version = "0.8", features = ["std", "std_rng"] }
|
rand = { version = "0.10.1", features = ["std", "std_rng"] }
|
||||||
|
thiserror = "2.0.18"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
|
||||||
registry = ["mtp-type-map/registry"]
|
registry = ["mtp-type-map/registry"]
|
||||||
crypto = ["dep:mtp-crypto", "mtp-crypto/mlkem-tls"]
|
crypto = ["dep:mtp-crypto", "mtp-crypto/mlkem-tls"]
|
||||||
|
pipes = ["mtp-type-map/pipes"]
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +1,57 @@
|
||||||
pub mod communication_value;
|
pub mod communication_value;
|
||||||
pub mod data_value;
|
pub mod data_value;
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub mod protected;
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub mod relay;
|
||||||
|
|
||||||
pub use communication_value::CommunicationValue;
|
pub use communication_value::CommunicationValue;
|
||||||
pub use data_value::{DataKind, DataValue};
|
#[cfg(feature = "crypto")]
|
||||||
pub use mtp_common::CodecError;
|
pub use data_value::{
|
||||||
|
ApplicationProtectionPurpose, EncryptedValue, MtpProtectionPurpose, ProtectionError,
|
||||||
|
ProtectionPolicy, ProtectionPurpose, ProtectionPurposeError, SignaturePolicy, SignedValue,
|
||||||
|
};
|
||||||
|
pub use data_value::{
|
||||||
|
DEFAULT_TRANSPORT_ALLOCATION_FACTOR, DataKind, DataValue, DecodeError, DecodeLimits,
|
||||||
|
EncodeLimits,
|
||||||
|
};
|
||||||
|
pub use mtp_common::{CodecError, TimeError, unix_time_millis};
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
#[allow(deprecated)]
|
||||||
|
pub use protected::{
|
||||||
|
CURRENT_PROTECTED_VERSION, InMemoryReplayGuard, ProtectedError, ProtectedLimits,
|
||||||
|
ProtectedMessageBuilder, ProtectedOpenOptions, ReplayError, ReplayGuard,
|
||||||
|
VerifiedProtectedMessage, open_protected_checked, open_protected_with_checked,
|
||||||
|
open_protected_with_keys_checked, open_protected_with_keys_without_replay,
|
||||||
|
open_protected_with_without_replay, open_protected_without_replay, protected_claimed_signer_id,
|
||||||
|
protected_claimed_signer_id_with_limits, protected_claimed_signer_id_with_options,
|
||||||
|
};
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
#[allow(deprecated)]
|
||||||
|
pub use relay::{
|
||||||
|
CURRENT_RELAY_VERSION, RelayError, RelayOpenOptions, SealedRelayBuilder, VerifiedRelayContent,
|
||||||
|
VerifiedRelayMetadata, forward_relay_frame, open_relay_content,
|
||||||
|
open_relay_content_with_keyrings, open_relay_content_with_keyrings_and_limits,
|
||||||
|
open_relay_content_with_keys, open_relay_content_with_limits,
|
||||||
|
open_relay_content_with_limits_without_replay, open_relay_metadata_checked,
|
||||||
|
open_relay_metadata_with_checked, open_relay_metadata_with_limits_checked,
|
||||||
|
open_relay_metadata_with_limits_without_replay, open_relay_metadata_with_without_replay,
|
||||||
|
open_relay_metadata_without_replay, relay_metadata_claimed_signer_id,
|
||||||
|
relay_metadata_claimed_signer_id_with_limits, relay_metadata_claimed_signer_id_with_options,
|
||||||
|
};
|
||||||
|
|
||||||
pub use mtp_type_map::{
|
pub use mtp_type_map::{
|
||||||
CommunicationType, CommunicationTypeId, DataType, DataTypeId, PROTOCOL_VERSION, TypeMap,
|
CommunicationType, CommunicationTypeId, DataType, DataTypeId, PROTOCOL_VERSION, TypeMap,
|
||||||
Version, communication_type_name, data_type_name,
|
Version,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) fn rand_u32() -> u32 {
|
pub(crate) fn rand_u32() -> u32 {
|
||||||
rand::random()
|
loop {
|
||||||
|
let value = rand::random();
|
||||||
|
if value != 0 {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "registry")]
|
#[cfg(feature = "registry")]
|
||||||
|
|
|
||||||
1613
codec/src/protected.rs
Normal file
1613
codec/src/protected.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,8 @@
|
||||||
use mtp_type_map::Version;
|
use mtp_common::CodecError;
|
||||||
|
use mtp_type_map::{PROTOCOL_VERSION, TypeMap, Version};
|
||||||
|
|
||||||
|
use crate::CommunicationValue;
|
||||||
|
use crate::EncodeLimits;
|
||||||
|
|
||||||
pub use mtp_type_map::Registry;
|
pub use mtp_type_map::Registry;
|
||||||
|
|
||||||
|
|
@ -9,11 +13,76 @@ pub use mtp_type_map::Registry;
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct VersionedCodec {
|
pub struct VersionedCodec {
|
||||||
registry: Registry,
|
registry: Registry,
|
||||||
|
type_map: TypeMap,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VersionedCodec {
|
impl VersionedCodec {
|
||||||
pub fn new(registry: Registry) -> Self {
|
pub fn new(registry: Registry) -> Self {
|
||||||
Self { registry }
|
let type_map = registry
|
||||||
|
.latest()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| TypeMap::new(PROTOCOL_VERSION));
|
||||||
|
Self { registry, type_map }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a codec bound to a negotiated protocol version.
|
||||||
|
pub fn for_version(registry: Registry, version: Version) -> Option<Self> {
|
||||||
|
let type_map = registry.get(&version)?.clone();
|
||||||
|
Some(Self { registry, type_map })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the type map used by this codec.
|
||||||
|
pub fn type_map(&self) -> &TypeMap {
|
||||||
|
&self.type_map
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the protocol version used by this codec.
|
||||||
|
pub fn version(&self) -> &Version {
|
||||||
|
&self.type_map.version
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode a value using the codec's negotiated framing rules.
|
||||||
|
pub fn encode(&self, value: &CommunicationValue) -> Result<Vec<u8>, CodecError> {
|
||||||
|
self.encode_with_limits(value, EncodeLimits::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode using an explicit output/resource limit after verifying the
|
||||||
|
/// value belongs to this codec's negotiated type map.
|
||||||
|
pub fn encode_with_limits(
|
||||||
|
&self,
|
||||||
|
value: &CommunicationValue,
|
||||||
|
limits: EncodeLimits,
|
||||||
|
) -> Result<Vec<u8>, CodecError> {
|
||||||
|
let value_map = value.type_map().ok_or(CodecError::MissingTypeMap)?;
|
||||||
|
if value_map.version != self.type_map.version {
|
||||||
|
return Err(CodecError::TypeMapMismatch {
|
||||||
|
expected: self.type_map.version.to_string(),
|
||||||
|
actual: value_map.version.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
value.to_bytes_with_limits(limits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly migrate a clear frame to this codec's negotiated type map
|
||||||
|
/// before encoding it.
|
||||||
|
pub fn encode_migrating(&self, value: &CommunicationValue) -> Result<Vec<u8>, CodecError> {
|
||||||
|
self.encode_migrating_with_limits(value, EncodeLimits::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly migrate and encode with bounded traversal/output.
|
||||||
|
pub fn encode_migrating_with_limits(
|
||||||
|
&self,
|
||||||
|
value: &CommunicationValue,
|
||||||
|
limits: EncodeLimits,
|
||||||
|
) -> Result<Vec<u8>, CodecError> {
|
||||||
|
value
|
||||||
|
.migrate_with_limits(&self.type_map, limits)?
|
||||||
|
.to_bytes_with_limits(limits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a frame and retain the negotiated type map for typed access.
|
||||||
|
pub fn decode(&self, bytes: &[u8]) -> Result<CommunicationValue, CodecError> {
|
||||||
|
CommunicationValue::from_bytes_with(bytes, &self.type_map)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn negotiate(&self, client_versions: &[Version]) -> Option<Version> {
|
pub fn negotiate(&self, client_versions: &[Version]) -> Option<Version> {
|
||||||
|
|
@ -24,3 +93,34 @@ impl VersionedCodec {
|
||||||
&self.registry
|
&self.registry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::DataValue;
|
||||||
|
use mtp_type_map::{CommunicationType, Version};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_rejects_a_value_from_another_negotiated_map() {
|
||||||
|
let mut registry = Registry::new();
|
||||||
|
let version_a = Version::new(3, 0);
|
||||||
|
let version_b = Version::new(4, 0);
|
||||||
|
registry.register(TypeMap::new(version_a.clone()));
|
||||||
|
registry.register(TypeMap::new(version_b.clone()));
|
||||||
|
|
||||||
|
let codec = VersionedCodec::for_version(registry, version_b).expect("codec version");
|
||||||
|
let value = CommunicationValue::new_with_type_map(
|
||||||
|
CommunicationType::Ping,
|
||||||
|
&TypeMap::new(version_a.clone()),
|
||||||
|
)
|
||||||
|
.with_payload(DataValue::Null);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
codec.encode(&value),
|
||||||
|
Err(CodecError::TypeMapMismatch {
|
||||||
|
expected: "4.0".into(),
|
||||||
|
actual: "3.0".into(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
1753
codec/src/relay.rs
Normal file
1753
codec/src/relay.rs
Normal file
File diff suppressed because it is too large
Load diff
1471
common/Cargo.lock
generated
1471
common/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,18 +1,20 @@
|
||||||
[package]
|
[package]
|
||||||
name = "mtp-common"
|
name = "mtp-common"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
pipes = []
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
wtransport = { version = "0.7.1", default-features = false, features = [
|
wtransport = { version = "0.7.1", default-features = false, features = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"quinn",
|
"quinn",
|
||||||
"self-signed",
|
"self-signed",
|
||||||
] }
|
] }
|
||||||
rustls = { version = "0.23.41" }
|
|
||||||
quinn = { version = "0.11.11", default-features = false, features = [
|
quinn = { version = "0.11.11", default-features = false, features = [
|
||||||
"rustls-aws-lc-rs",
|
"rustls-aws-lc-rs",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,32 @@
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
/// Errors returned when the system clock cannot be represented as MTP time.
|
||||||
|
#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum TimeError {
|
||||||
|
#[error("system clock is before the Unix epoch")]
|
||||||
|
BeforeUnixEpoch,
|
||||||
|
#[error("Unix epoch milliseconds exceed the u64 range")]
|
||||||
|
OutOfRange,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duration_to_unix_time_millis(duration: Duration) -> Result<u64, TimeError> {
|
||||||
|
u64::try_from(duration.as_millis()).map_err(|_| TimeError::OutOfRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the current Unix time in milliseconds.
|
||||||
|
///
|
||||||
|
/// MTP protocol fields that use `CreatedAt` store this value as an unsigned
|
||||||
|
/// integer. The conversion is centralized here so native writers do not
|
||||||
|
/// accidentally use seconds.
|
||||||
|
pub fn unix_time_millis() -> Result<u64, TimeError> {
|
||||||
|
let duration = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|_| TimeError::BeforeUnixEpoch)?;
|
||||||
|
duration_to_unix_time_millis(duration)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||||
pub enum CodecError {
|
pub enum CodecError {
|
||||||
#[error("Unknown version")]
|
#[error("Unknown version")]
|
||||||
|
|
@ -14,6 +41,10 @@ pub enum CodecError {
|
||||||
InvalidEncoding,
|
InvalidEncoding,
|
||||||
#[error("Too many entries to encode")]
|
#[error("Too many entries to encode")]
|
||||||
TooManyEntries,
|
TooManyEntries,
|
||||||
|
#[error("Missing negotiated type map")]
|
||||||
|
MissingTypeMap,
|
||||||
|
#[error("Type-map mismatch: expected {expected}, actual {actual}")]
|
||||||
|
TypeMapMismatch { expected: String, actual: String },
|
||||||
#[error("Crypto failed: {0}")]
|
#[error("Crypto failed: {0}")]
|
||||||
CryptoFailed(String),
|
CryptoFailed(String),
|
||||||
#[error("Missing required field: {0}")]
|
#[error("Missing required field: {0}")]
|
||||||
|
|
@ -25,6 +56,24 @@ pub enum CodecError {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unix_time_millis_preserves_subsecond_precision() {
|
||||||
|
let duration = Duration::new(1_786_449_600, 123_000_000);
|
||||||
|
assert_eq!(
|
||||||
|
duration_to_unix_time_millis(duration),
|
||||||
|
Ok(1_786_449_600_123)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unix_time_millis_rejects_values_outside_u64() {
|
||||||
|
let duration = Duration::new(u64::MAX, 0);
|
||||||
|
assert_eq!(
|
||||||
|
duration_to_unix_time_millis(duration),
|
||||||
|
Err(TimeError::OutOfRange)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_codec_error_display() {
|
fn test_codec_error_display() {
|
||||||
let e = CodecError::InvalidEncoding;
|
let e = CodecError::InvalidEncoding;
|
||||||
|
|
@ -50,7 +99,6 @@ mod tests {
|
||||||
* wrappers) is available. On WASM only the transport-independent subset is
|
* wrappers) is available. On WASM only the transport-independent subset is
|
||||||
* compiled. */
|
* compiled. */
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
#[derive(Debug, Error, Clone)]
|
#[derive(Debug, Error, Clone)]
|
||||||
pub enum CommunicationError {
|
pub enum CommunicationError {
|
||||||
#[error("Use after Closed")]
|
#[error("Use after Closed")]
|
||||||
|
|
@ -66,6 +114,7 @@ pub enum CommunicationError {
|
||||||
ConnectionLost,
|
ConnectionLost,
|
||||||
|
|
||||||
#[error("QUIC error: {0}")]
|
#[error("QUIC error: {0}")]
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
Quinn(#[from] quinn::ConnectionError),
|
Quinn(#[from] quinn::ConnectionError),
|
||||||
|
|
||||||
#[error("ParseCommunicationValue error")]
|
#[error("ParseCommunicationValue error")]
|
||||||
|
|
@ -84,24 +133,29 @@ pub enum CommunicationError {
|
||||||
ParseError(String),
|
ParseError(String),
|
||||||
|
|
||||||
#[error("Connection error: {0}")]
|
#[error("Connection error: {0}")]
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
ConnectionError(#[from] wtransport::error::ConnectionError),
|
ConnectionError(#[from] wtransport::error::ConnectionError),
|
||||||
|
|
||||||
#[error("Connecting error: {0}")]
|
#[error("Connecting error: {0}")]
|
||||||
ConnectingError(String),
|
ConnectingError(String),
|
||||||
|
|
||||||
#[error("ReadToEnd error: {0}")]
|
#[error("ReadToEnd error: {0}")]
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
ReadToEndError(#[from] quinn::ReadToEndError),
|
ReadToEndError(#[from] quinn::ReadToEndError),
|
||||||
|
|
||||||
#[error("Write error: {0}")]
|
#[error("Write error: {0}")]
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
WriteError(#[from] quinn::WriteError),
|
WriteError(#[from] quinn::WriteError),
|
||||||
|
|
||||||
#[error("Closed error: {0}")]
|
#[error("Closed error: {0}")]
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
ClosedError(#[from] quinn::ClosedStream),
|
ClosedError(#[from] quinn::ClosedStream),
|
||||||
|
|
||||||
#[error("Message too large")]
|
#[error("Message too large")]
|
||||||
MessageTooLarge,
|
MessageTooLarge,
|
||||||
|
|
||||||
#[error("ReadExactError: {0}")]
|
#[error("ReadExactError: {0}")]
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
ReadExactError(#[from] quinn::ReadExactError),
|
ReadExactError(#[from] quinn::ReadExactError),
|
||||||
|
|
||||||
#[error("Stream Closed")]
|
#[error("Stream Closed")]
|
||||||
|
|
@ -110,10 +164,15 @@ pub enum CommunicationError {
|
||||||
#[error("Stream Error")]
|
#[error("Stream Error")]
|
||||||
StreamError,
|
StreamError,
|
||||||
|
|
||||||
|
#[error("Stream failed after delivery may have started")]
|
||||||
|
DeliveryUnknown,
|
||||||
|
|
||||||
#[error("Stream Error: {0}")]
|
#[error("Stream Error: {0}")]
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
StreamWriteError(#[from] wtransport::error::StreamWriteError),
|
StreamWriteError(#[from] wtransport::error::StreamWriteError),
|
||||||
|
|
||||||
#[error("Read Exact Error: {0}")]
|
#[error("Read Exact Error: {0}")]
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
StreamReadExactError(#[from] wtransport::error::StreamReadExactError),
|
StreamReadExactError(#[from] wtransport::error::StreamReadExactError),
|
||||||
|
|
||||||
#[error("Crypto Provider Install Error")]
|
#[error("Crypto Provider Install Error")]
|
||||||
|
|
@ -126,61 +185,40 @@ pub enum CommunicationError {
|
||||||
Other(String),
|
Other(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
/// How the protocol layer should handle the first frame on a receive stream.
|
||||||
#[derive(Debug, Error, Clone)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum CommunicationError {
|
pub enum FirstFrameDisposition {
|
||||||
#[error("Use after Closed")]
|
Message,
|
||||||
UseAfterClosed,
|
Pipe(u32),
|
||||||
|
}
|
||||||
|
|
||||||
#[error("Connection closed by local shutdown")]
|
/// Classify a first frame without tying the decision to a WebTransport backend.
|
||||||
ClosedLocally,
|
///
|
||||||
|
/// `PipeRequest` is used both as a control message and as the header of the raw
|
||||||
|
/// stream opened after that request is accepted. Only the protocol layer knows
|
||||||
|
/// which raw stream IDs are currently expected.
|
||||||
|
pub fn classify_first_frame(
|
||||||
|
is_pipe_request: bool,
|
||||||
|
pipe_id: Option<u32>,
|
||||||
|
pipe_is_expected: bool,
|
||||||
|
) -> Result<FirstFrameDisposition, CommunicationError> {
|
||||||
|
if !is_pipe_request {
|
||||||
|
return Ok(FirstFrameDisposition::Message);
|
||||||
|
}
|
||||||
|
|
||||||
#[error("Connection closed by peer")]
|
let pipe_id = pipe_id.filter(|id| *id != 0).ok_or_else(|| {
|
||||||
ClosedByPeer,
|
CommunicationError::Other("PipeRequest frame must contain a non-zero id".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
#[error("Connection terminated unexpectedly")]
|
if pipe_is_expected {
|
||||||
ConnectionLost,
|
Ok(FirstFrameDisposition::Pipe(pipe_id))
|
||||||
|
} else {
|
||||||
#[error("ParseCommunicationValue error")]
|
Ok(FirstFrameDisposition::Message)
|
||||||
ParseCommunicationValue,
|
}
|
||||||
|
|
||||||
#[error("Encode error")]
|
|
||||||
Encode,
|
|
||||||
|
|
||||||
#[error("Parse Certificate error")]
|
|
||||||
CertificateParseFailed,
|
|
||||||
|
|
||||||
#[error("Loading Certificate error")]
|
|
||||||
CertificateLoadFailed,
|
|
||||||
|
|
||||||
#[error("Parse error: {0}")]
|
|
||||||
ParseError(String),
|
|
||||||
|
|
||||||
#[error("Connecting error: {0}")]
|
|
||||||
ConnectingError(String),
|
|
||||||
|
|
||||||
#[error("Message too large")]
|
|
||||||
MessageTooLarge,
|
|
||||||
|
|
||||||
#[error("Stream Closed")]
|
|
||||||
StreamClosed,
|
|
||||||
|
|
||||||
#[error("Stream Error")]
|
|
||||||
StreamError,
|
|
||||||
|
|
||||||
#[error("Crypto Provider Install Error")]
|
|
||||||
CryptoProviderInstallFailed,
|
|
||||||
|
|
||||||
#[error("Authentication failed: {0}")]
|
|
||||||
AuthenticationFailed(String),
|
|
||||||
|
|
||||||
#[error("Other: {0}")]
|
|
||||||
Other(String),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- manual PartialEq (quinn / wtransport types don't impl PartialEq) ----
|
// ---- manual PartialEq (quinn / wtransport types don't impl PartialEq) ----
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
impl PartialEq for CommunicationError {
|
impl PartialEq for CommunicationError {
|
||||||
fn eq(&self, other: &Self) -> bool {
|
fn eq(&self, other: &Self) -> bool {
|
||||||
match (self, other) {
|
match (self, other) {
|
||||||
|
|
@ -188,22 +226,31 @@ impl PartialEq for CommunicationError {
|
||||||
(Self::ClosedLocally, Self::ClosedLocally) => true,
|
(Self::ClosedLocally, Self::ClosedLocally) => true,
|
||||||
(Self::ClosedByPeer, Self::ClosedByPeer) => true,
|
(Self::ClosedByPeer, Self::ClosedByPeer) => true,
|
||||||
(Self::ConnectionLost, Self::ConnectionLost) => true,
|
(Self::ConnectionLost, Self::ConnectionLost) => true,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
(Self::Quinn(_), Self::Quinn(_)) => true,
|
(Self::Quinn(_), Self::Quinn(_)) => true,
|
||||||
(Self::ParseCommunicationValue, Self::ParseCommunicationValue) => true,
|
(Self::ParseCommunicationValue, Self::ParseCommunicationValue) => true,
|
||||||
(Self::Encode, Self::Encode) => true,
|
(Self::Encode, Self::Encode) => true,
|
||||||
(Self::CertificateParseFailed, Self::CertificateParseFailed) => true,
|
(Self::CertificateParseFailed, Self::CertificateParseFailed) => true,
|
||||||
(Self::CertificateLoadFailed, Self::CertificateLoadFailed) => true,
|
(Self::CertificateLoadFailed, Self::CertificateLoadFailed) => true,
|
||||||
(Self::ParseError(a), Self::ParseError(b)) => a == b,
|
(Self::ParseError(a), Self::ParseError(b)) => a == b,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
(Self::ConnectionError(_), Self::ConnectionError(_)) => true,
|
(Self::ConnectionError(_), Self::ConnectionError(_)) => true,
|
||||||
(Self::ConnectingError(a), Self::ConnectingError(b)) => a == b,
|
(Self::ConnectingError(a), Self::ConnectingError(b)) => a == b,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
(Self::ReadToEndError(_), Self::ReadToEndError(_)) => true,
|
(Self::ReadToEndError(_), Self::ReadToEndError(_)) => true,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
(Self::WriteError(_), Self::WriteError(_)) => true,
|
(Self::WriteError(_), Self::WriteError(_)) => true,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
(Self::ClosedError(_), Self::ClosedError(_)) => true,
|
(Self::ClosedError(_), Self::ClosedError(_)) => true,
|
||||||
(Self::MessageTooLarge, Self::MessageTooLarge) => true,
|
(Self::MessageTooLarge, Self::MessageTooLarge) => true,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
(Self::ReadExactError(_), Self::ReadExactError(_)) => true,
|
(Self::ReadExactError(_), Self::ReadExactError(_)) => true,
|
||||||
(Self::StreamClosed, Self::StreamClosed) => true,
|
(Self::StreamClosed, Self::StreamClosed) => true,
|
||||||
(Self::StreamError, Self::StreamError) => true,
|
(Self::StreamError, Self::StreamError) => true,
|
||||||
|
(Self::DeliveryUnknown, Self::DeliveryUnknown) => true,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
(Self::StreamWriteError(_), Self::StreamWriteError(_)) => true,
|
(Self::StreamWriteError(_), Self::StreamWriteError(_)) => true,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
(Self::StreamReadExactError(_), Self::StreamReadExactError(_)) => true,
|
(Self::StreamReadExactError(_), Self::StreamReadExactError(_)) => true,
|
||||||
(Self::CryptoProviderInstallFailed, Self::CryptoProviderInstallFailed) => true,
|
(Self::CryptoProviderInstallFailed, Self::CryptoProviderInstallFailed) => true,
|
||||||
(Self::AuthenticationFailed(a), Self::AuthenticationFailed(b)) => a == b,
|
(Self::AuthenticationFailed(a), Self::AuthenticationFailed(b)) => a == b,
|
||||||
|
|
@ -213,36 +260,93 @@ impl PartialEq for CommunicationError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
impl Eq for CommunicationError {}
|
||||||
impl PartialEq for CommunicationError {
|
|
||||||
fn eq(&self, other: &Self) -> bool {
|
/* ================================ PipeError ================================ */
|
||||||
match (self, other) {
|
|
||||||
(Self::UseAfterClosed, Self::UseAfterClosed) => true,
|
#[cfg(feature = "pipes")]
|
||||||
(Self::ClosedLocally, Self::ClosedLocally) => true,
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
(Self::ClosedByPeer, Self::ClosedByPeer) => true,
|
pub enum PipeError {
|
||||||
(Self::ConnectionLost, Self::ConnectionLost) => true,
|
Rejected,
|
||||||
(Self::ParseCommunicationValue, Self::ParseCommunicationValue) => true,
|
HandshakeTimeout,
|
||||||
(Self::Encode, Self::Encode) => true,
|
StreamClosed,
|
||||||
(Self::CertificateParseFailed, Self::CertificateParseFailed) => true,
|
IoError(String),
|
||||||
(Self::CertificateLoadFailed, Self::CertificateLoadFailed) => true,
|
ConnectionClosed,
|
||||||
(Self::ParseError(a), Self::ParseError(b)) => a == b,
|
}
|
||||||
(Self::ConnectingError(a), Self::ConnectingError(b)) => a == b,
|
|
||||||
(Self::MessageTooLarge, Self::MessageTooLarge) => true,
|
#[cfg(feature = "pipes")]
|
||||||
(Self::StreamClosed, Self::StreamClosed) => true,
|
impl std::fmt::Display for PipeError {
|
||||||
(Self::StreamError, Self::StreamError) => true,
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
(Self::CryptoProviderInstallFailed, Self::CryptoProviderInstallFailed) => true,
|
match self {
|
||||||
(Self::AuthenticationFailed(a), Self::AuthenticationFailed(b)) => a == b,
|
PipeError::Rejected => write!(f, "pipe request was rejected"),
|
||||||
(Self::Other(a), Self::Other(b)) => a == b,
|
PipeError::HandshakeTimeout => write!(f, "pipe handshake timed out"),
|
||||||
_ => false,
|
PipeError::StreamClosed => write!(f, "pipe stream closed unexpectedly"),
|
||||||
|
PipeError::IoError(s) => write!(f, "pipe I/O error: {s}"),
|
||||||
|
PipeError::ConnectionClosed => write!(f, "connection closed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(feature = "pipes")]
|
||||||
impl Eq for CommunicationError {}
|
impl std::error::Error for PipeError {}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(feature = "pipes")]
|
||||||
impl Eq for CommunicationError {}
|
impl From<CommunicationError> for PipeError {
|
||||||
|
fn from(e: CommunicationError) -> Self {
|
||||||
|
match e {
|
||||||
|
CommunicationError::StreamClosed => PipeError::StreamClosed,
|
||||||
|
CommunicationError::ConnectionError(_) => PipeError::ConnectionClosed,
|
||||||
|
other => PipeError::IoError(other.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== Handshake Outcome Types ===================== */
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum RejectionReason {
|
||||||
|
BadVersion { supported_versions: Vec<String> },
|
||||||
|
AuthenticationFailed { detail: String },
|
||||||
|
RateLimited,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for RejectionReason {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
RejectionReason::BadVersion { supported_versions } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"unsupported protocol version; supported: {}",
|
||||||
|
supported_versions.join(", ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
RejectionReason::AuthenticationFailed { detail } => {
|
||||||
|
write!(f, "authentication failed: {detail}")
|
||||||
|
}
|
||||||
|
RejectionReason::RateLimited => write!(f, "rate limited"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum HandshakeOutcome {
|
||||||
|
Accepted { version: String, assigned_id: u64 },
|
||||||
|
Rejected { reason: RejectionReason },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for HandshakeOutcome {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
HandshakeOutcome::Accepted {
|
||||||
|
version,
|
||||||
|
assigned_id,
|
||||||
|
} => {
|
||||||
|
write!(f, "accepted (version={version}, id={assigned_id})")
|
||||||
|
}
|
||||||
|
HandshakeOutcome::Rejected { reason } => write!(f, "rejected: {reason}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ================================ TESTS ================================ */
|
/* ================================ TESTS ================================ */
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -291,3 +395,132 @@ mod communication_error_tests {
|
||||||
assert!(format!("{}", e).contains("refused"));
|
assert!(format!("{}", e).contains("refused"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================================ PipeError TESTS ================================ */
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
#[cfg(test)]
|
||||||
|
mod pipe_error_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pipe_error_display() {
|
||||||
|
assert_eq!(
|
||||||
|
format!("{}", PipeError::Rejected),
|
||||||
|
"pipe request was rejected"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format!("{}", PipeError::HandshakeTimeout),
|
||||||
|
"pipe handshake timed out"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format!("{}", PipeError::StreamClosed),
|
||||||
|
"pipe stream closed unexpectedly"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format!("{}", PipeError::ConnectionClosed),
|
||||||
|
"connection closed"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format!("{}", PipeError::IoError("boom".into())),
|
||||||
|
"pipe I/O error: boom"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pipe_error_from_stream_closed() {
|
||||||
|
let pe: PipeError = CommunicationError::StreamClosed.into();
|
||||||
|
assert_eq!(pe, PipeError::StreamClosed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pipe_error_from_connection_error() {
|
||||||
|
let pe: PipeError =
|
||||||
|
CommunicationError::ConnectionError(wtransport::error::ConnectionError::TimedOut)
|
||||||
|
.into();
|
||||||
|
assert_eq!(pe, PipeError::ConnectionClosed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pipe_error_from_other() {
|
||||||
|
let pe: PipeError = CommunicationError::StreamError.into();
|
||||||
|
assert_eq!(pe, PipeError::IoError("Stream Error".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== HandshakeOutcome TESTS ==================== */
|
||||||
|
#[cfg(test)]
|
||||||
|
mod handshake_outcome_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_accepted_display() {
|
||||||
|
let outcome = HandshakeOutcome::Accepted {
|
||||||
|
version: "1.0".into(),
|
||||||
|
assigned_id: 42,
|
||||||
|
};
|
||||||
|
assert_eq!(format!("{outcome}"), "accepted (version=1.0, id=42)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rejected_bad_version_display() {
|
||||||
|
let outcome = HandshakeOutcome::Rejected {
|
||||||
|
reason: RejectionReason::BadVersion {
|
||||||
|
supported_versions: vec!["1.0".into(), "2.0".into()],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let msg = format!("{outcome}");
|
||||||
|
assert!(msg.contains("1.0"));
|
||||||
|
assert!(msg.contains("2.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rejected_auth_failed_display() {
|
||||||
|
let outcome = HandshakeOutcome::Rejected {
|
||||||
|
reason: RejectionReason::AuthenticationFailed {
|
||||||
|
detail: "invalid signature".into(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert!(format!("{outcome}").contains("invalid signature"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rejected_rate_limited_display() {
|
||||||
|
let outcome = HandshakeOutcome::Rejected {
|
||||||
|
reason: RejectionReason::RateLimited,
|
||||||
|
};
|
||||||
|
assert_eq!(format!("{outcome}"), "rejected: rate limited");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rejection_reason_display() {
|
||||||
|
assert!(
|
||||||
|
format!(
|
||||||
|
"{}",
|
||||||
|
RejectionReason::BadVersion {
|
||||||
|
supported_versions: vec!["1.0".into()]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.contains("1.0")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
format!(
|
||||||
|
"{}",
|
||||||
|
RejectionReason::AuthenticationFailed {
|
||||||
|
detail: "bad".into()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.contains("bad")
|
||||||
|
);
|
||||||
|
assert_eq!(format!("{}", RejectionReason::RateLimited), "rate limited");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_handshake_outcome_clone_eq() {
|
||||||
|
let a = HandshakeOutcome::Accepted {
|
||||||
|
version: "1.0".into(),
|
||||||
|
assigned_id: 1,
|
||||||
|
};
|
||||||
|
let b = a.clone();
|
||||||
|
assert_eq!(a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
243
create-web-release.mjs
Normal file
243
create-web-release.mjs
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { execFile, spawn } from "node:child_process";
|
||||||
|
import { access, cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".");
|
||||||
|
const packageJsonPath = path.join(repositoryRoot, "package.json");
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
return `Usage: node create-web-release.mjs [options]
|
||||||
|
|
||||||
|
Build and pack the browser package using the version of the root Cargo package.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--skip-build Pack the existing dist/ and wasm/pkg/ artifacts
|
||||||
|
--output-dir <path> Write the archive to this directory (default: repository root)
|
||||||
|
--help Show this help
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArguments(arguments_) {
|
||||||
|
const options = {
|
||||||
|
outputDir: repositoryRoot,
|
||||||
|
skipBuild: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < arguments_.length; index += 1) {
|
||||||
|
const argument = arguments_[index];
|
||||||
|
if (argument === "--help") {
|
||||||
|
options.help = true;
|
||||||
|
} else if (argument === "--skip-build") {
|
||||||
|
options.skipBuild = true;
|
||||||
|
} else if (argument === "--output-dir") {
|
||||||
|
const outputDir = arguments_[index + 1];
|
||||||
|
if (!outputDir || outputDir.startsWith("--")) {
|
||||||
|
throw new Error("--output-dir requires a directory path");
|
||||||
|
}
|
||||||
|
options.outputDir = path.resolve(repositoryRoot, outputDir);
|
||||||
|
index += 1;
|
||||||
|
} else if (argument.startsWith("--output-dir=")) {
|
||||||
|
const outputDir = argument.slice("--output-dir=".length);
|
||||||
|
if (!outputDir) {
|
||||||
|
throw new Error("--output-dir requires a directory path");
|
||||||
|
}
|
||||||
|
options.outputDir = path.resolve(repositoryRoot, outputDir);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown option: ${argument}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(filePath) {
|
||||||
|
const source = await readFile(filePath, "utf8");
|
||||||
|
try {
|
||||||
|
return JSON.parse(source);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Invalid JSON in ${path.relative(repositoryRoot, filePath)}`, {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(command, arguments_, options = {}) {
|
||||||
|
const renderedArguments = arguments_.map((argument) => JSON.stringify(argument)).join(" ");
|
||||||
|
console.log(`\n> ${command}${renderedArguments ? ` ${renderedArguments}` : ""}`);
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, arguments_, {
|
||||||
|
cwd: options.cwd ?? repositoryRoot,
|
||||||
|
env: options.env ?? process.env,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
child.once("error", (error) => {
|
||||||
|
reject(new Error(`Failed to run ${command}: ${error.message}`, { cause: error }));
|
||||||
|
});
|
||||||
|
child.once("exit", (code, signal) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = signal ? `signal ${signal}` : `exit code ${code}`;
|
||||||
|
reject(new Error(`${command} failed with ${reason}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCargoVersion() {
|
||||||
|
let stdout;
|
||||||
|
try {
|
||||||
|
({ stdout } = await execFileAsync(
|
||||||
|
"cargo",
|
||||||
|
[
|
||||||
|
"metadata",
|
||||||
|
"--no-deps",
|
||||||
|
"--format-version",
|
||||||
|
"1",
|
||||||
|
"--manifest-path",
|
||||||
|
path.join(repositoryRoot, "Cargo.toml"),
|
||||||
|
],
|
||||||
|
{ cwd: repositoryRoot, maxBuffer: 1024 * 1024 },
|
||||||
|
));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Unable to read the root Cargo package version: ${error.message}`, {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata;
|
||||||
|
try {
|
||||||
|
metadata = JSON.parse(stdout);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error("cargo metadata returned invalid JSON", { cause: error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootPackage = metadata.packages?.find((packageMetadata) => packageMetadata.name === "mtp");
|
||||||
|
if (!rootPackage || typeof rootPackage.version !== "string") {
|
||||||
|
throw new Error("The root Cargo package named 'mtp' was not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return rootPackage.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageRelativePath(entry) {
|
||||||
|
if (typeof entry !== "string" || entry.length === 0) {
|
||||||
|
throw new Error("package.json files entries must be non-empty strings");
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = entry.replace(/\/$/, "");
|
||||||
|
if (
|
||||||
|
!relativePath ||
|
||||||
|
path.isAbsolute(relativePath) ||
|
||||||
|
relativePath.split(/[\\/]/u).includes("..") ||
|
||||||
|
relativePath.includes("*")
|
||||||
|
) {
|
||||||
|
throw new Error(`Unsupported package file entry: ${entry}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return relativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPackageFiles(stageRoot, packageJson) {
|
||||||
|
if (!Array.isArray(packageJson.files)) {
|
||||||
|
throw new Error("package.json must declare a files array for Web releases");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of packageJson.files) {
|
||||||
|
const relativePath = packageRelativePath(entry);
|
||||||
|
const sourcePath = path.join(repositoryRoot, relativePath);
|
||||||
|
const destinationPath = path.join(stageRoot, relativePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await access(sourcePath);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Release file is missing: ${relativePath}`, { cause: error });
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(path.dirname(destinationPath), { recursive: true });
|
||||||
|
await cp(sourcePath, destinationPath, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRelease({ outputDir, packageJson, version }) {
|
||||||
|
const stageRoot = await mkdtemp(path.join(os.tmpdir(), "mtp-web-release-"));
|
||||||
|
const stagedPackageJson = {
|
||||||
|
...packageJson,
|
||||||
|
version,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFile(
|
||||||
|
path.join(stageRoot, "package.json"),
|
||||||
|
`${JSON.stringify(stagedPackageJson, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
await copyPackageFiles(stageRoot, packageJson);
|
||||||
|
|
||||||
|
const stagedWasmPackagePath = path.join(stageRoot, "wasm", "pkg", "package.json");
|
||||||
|
const stagedWasmPackageJson = await readJson(stagedWasmPackagePath);
|
||||||
|
stagedWasmPackageJson.version = version;
|
||||||
|
await writeFile(
|
||||||
|
stagedWasmPackagePath,
|
||||||
|
`${JSON.stringify(stagedWasmPackageJson, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await mkdir(outputDir, { recursive: true });
|
||||||
|
const archiveName = `${packageJson.name}-${version}.tgz`;
|
||||||
|
const archivePath = path.join(outputDir, archiveName);
|
||||||
|
await rm(archivePath, { force: true });
|
||||||
|
|
||||||
|
await run("npm", ["pack", "--pack-destination", outputDir], { cwd: stageRoot });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await access(archivePath);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`npm pack did not create ${archiveName}`, { cause: error });
|
||||||
|
}
|
||||||
|
|
||||||
|
return archivePath;
|
||||||
|
} finally {
|
||||||
|
await rm(stageRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArguments(process.argv.slice(2));
|
||||||
|
if (options.help) {
|
||||||
|
console.log(usage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageJson = await readJson(packageJsonPath);
|
||||||
|
if (packageJson.name !== "mtp") {
|
||||||
|
throw new Error("package.json must describe the 'mtp' Web package");
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = await readCargoVersion();
|
||||||
|
console.log(`Using Cargo package version ${version}`);
|
||||||
|
|
||||||
|
if (!options.skipBuild) {
|
||||||
|
await run("pnpm", ["run", "clean"]);
|
||||||
|
await run("pnpm", ["run", "build"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const archivePath = await createRelease({
|
||||||
|
outputDir: options.outputDir,
|
||||||
|
packageJson,
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
console.log(`\nCreated ${path.relative(repositoryRoot, archivePath) || archivePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`\n${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
1405
crypto/Cargo.lock
generated
1405
crypto/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,15 +1,15 @@
|
||||||
[package]
|
[package]
|
||||||
name = "mtp-crypto"
|
name = "mtp-crypto"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[package.metadata.cargo-machete]
|
[package.metadata.cargo-machete]
|
||||||
ignored = ["getrandom"]
|
ignored = ["rand_core"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
chacha20poly1305 = { version = "0.10", optional = true }
|
chacha20poly1305 = { version = "0.10", optional = true }
|
||||||
aes-gcm = { version = "0.10", optional = true }
|
aes-gcm = { version = "0.10", optional = true }
|
||||||
ed25519-dalek = { version = "2.2", optional = true, features = [
|
ed25519-dalek = { version = "3.0", optional = true, features = [
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"pem",
|
"pem",
|
||||||
] }
|
] }
|
||||||
|
|
@ -17,17 +17,31 @@ hkdf = { version = "0.13", optional = true }
|
||||||
sha2 = { version = "0.11", optional = true }
|
sha2 = { version = "0.11", optional = true }
|
||||||
zeroize = { version = "1.9", features = ["derive"] }
|
zeroize = { version = "1.9", features = ["derive"] }
|
||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
|
base64 = "0.22"
|
||||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
|
rand = "0.10.2"
|
||||||
getrandom = "0.4.3"
|
getrandom = "0.4.3"
|
||||||
mlkem-tls = { version = "0.2", optional = true }
|
mlkem-tls = { version = "0.2", optional = true }
|
||||||
ml-dsa = { version = "0.1.1", optional = true }
|
ml-dsa = { version = "0.1.1", optional = true }
|
||||||
|
argon2 = { version = "0.5", optional = true }
|
||||||
serde = { version = "1", optional = true, features = ["derive"] }
|
serde = { version = "1", optional = true, features = ["derive"] }
|
||||||
|
rcgen = { version = "0.14", optional = true }
|
||||||
|
time = { version = "0.3", optional = true }
|
||||||
|
tokio = { version = "1", features = ["macros", "rt"], optional = true }
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
rustls = "0.23.41"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["chacha20poly1305", "ed25519-dalek", "hkdf", "sha2", "ml-dsa"]
|
default = ["chacha20poly1305", "ed25519-dalek", "hkdf", "sha2", "ml-dsa", "parallel"]
|
||||||
# Enabling ml-dsa by default ensures dual-signature support in the handshake
|
# Enabling ml-dsa by default ensures dual-signature support in the handshake
|
||||||
# without requiring a separate PQC feature flag in host/client crates.
|
# without requiring a separate PQC feature flag in host/client crates.
|
||||||
full = ["default", "aes-gcm"]
|
full = ["default", "aes-gcm"]
|
||||||
pqc = ["mlkem-tls", "ml-dsa"]
|
pqc = ["mlkem-tls", "ml-dsa"]
|
||||||
serde = ["dep:serde"]
|
serde = ["dep:serde"]
|
||||||
wasm = ["getrandom/wasm_js"]
|
wasm = ["getrandom/wasm_js"]
|
||||||
|
hkdf = ["dep:hkdf", "dep:sha2"]
|
||||||
|
sha2 = ["dep:sha2"]
|
||||||
|
tls = ["dep:rcgen", "dep:time"]
|
||||||
|
parallel = ["dep:tokio"]
|
||||||
|
password-kdf = ["dep:argon2"]
|
||||||
|
|
|
||||||
164
crypto/README.md
164
crypto/README.md
|
|
@ -1,164 +0,0 @@
|
||||||
# mtp-crypto
|
|
||||||
|
|
||||||
Cryptographic primitives for the MTP protocol. Classical and post-quantum.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
| Feature | Primitives |
|
|
||||||
|---------|-----------|
|
|
||||||
| `default` | XChaCha20-Poly1305, Ed25519, ML-DSA-65, HKDF-SHA-256, SHA-256 |
|
|
||||||
| `full` | default + AES-256-GCM |
|
|
||||||
| `pqc` | ML-KEM-768+X25519 hybrid KEM |
|
|
||||||
|
|
||||||
ML-DSA-65 is enabled by default so dual-signature support is always available
|
|
||||||
without a separate PQC feature flag in protocol crates.
|
|
||||||
|
|
||||||
## 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);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Encrypted containers
|
|
||||||
|
|
||||||
Self-describing encrypted blobs with algorithm selection via `EncryptionType`.
|
|
||||||
Each blob begins with a marking byte so recipients can decrypt without
|
|
||||||
out-of-band agreement.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use mtp_crypto::{EncryptionType, Keyring, encrypt_for, decrypt_with};
|
|
||||||
|
|
||||||
let kr = Keyring::generate();
|
|
||||||
let blob = encrypt_for(EncryptionType::MlKemChaCha20Poly1305, &kr.public_key_bundle(), b"data", b"aad")?;
|
|
||||||
let pt = decrypt_with(&blob, &kr, b"aad")?;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Multi-recipient encryption
|
|
||||||
|
|
||||||
Encrypt a payload for multiple recipients using a content-encryption key wrapped
|
|
||||||
per-recipient via Hybrid KEM.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use mtp_crypto::{Keyring, encrypt_multi, decrypt_multi};
|
|
||||||
|
|
||||||
let alice = Keyring::generate();
|
|
||||||
let bob = Keyring::generate();
|
|
||||||
let msg = encrypt_multi(b"secret", b"aad", &[alice.public_key_bundle(), bob.public_key_bundle()])?;
|
|
||||||
let pt = decrypt_multi(&msg, b"aad", &alice)?;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Authentication handshake
|
|
||||||
|
|
||||||
Canonical domain-separated payloads for the challenge-response handshake.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use mtp_crypto::auth::{challenge_payload, login_proof_payload, register_proof_payload, host_final_payload};
|
|
||||||
```
|
|
||||||
|
|
||||||
Each payload type uses a distinct domain tag to prevent replay across protocol steps.
|
|
||||||
|
|
||||||
## KDF
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use mtp_crypto::{hkdf_expand, hkdf_extract, derive_encryption_key};
|
|
||||||
|
|
||||||
let key = derive_encryption_key(b"ikm", b"salt", b"context")?;
|
|
||||||
let prk = hkdf_extract(b"ikm", b"salt");
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hashing
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use mtp_crypto::{sha256, sha256_double, Sha256Hasher};
|
|
||||||
|
|
||||||
let h = sha256(b"data");
|
|
||||||
let h2 = sha256_double(b"data");
|
|
||||||
|
|
||||||
let mut hasher = Sha256Hasher::new();
|
|
||||||
hasher.update(b"da");
|
|
||||||
hasher.update(b"ta");
|
|
||||||
let h3 = hasher.finalize();
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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 |
|
|
||||||
|
|
||||||
`Keyring` holds all six keys (hybrid KEM + PQ sig + classical sig) plus
|
|
||||||
`generate()`, `to_bytes()`, and `from_bytes()` for serialization.
|
|
||||||
`PublicKeyBundle` holds the three public keys for distribution.
|
|
||||||
|
|
||||||
## Feature flags
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[dependencies]
|
|
||||||
mtp-crypto = { path = "../crypto" } # classical + ML-DSA
|
|
||||||
mtp-crypto = { path = "../crypto", features = ["pqc"] } # adds hybrid KEM
|
|
||||||
mtp-crypto = { path = "../crypto", features = ["full", "pqc"] } # adds AES-256-GCM + hybrid KEM
|
|
||||||
mtp-crypto = { path = "../crypto", features = ["serde"] } # serde support
|
|
||||||
mtp-crypto = { path = "../crypto", features = ["wasm"] } # WASM compat
|
|
||||||
```
|
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
use crate::error::CryptoError;
|
use crate::error::CryptoError;
|
||||||
|
|
||||||
#[cfg(any(feature = "chacha20poly1305", feature = "aes-gcm"))]
|
#[cfg(any(feature = "chacha20poly1305", feature = "aes-gcm"))]
|
||||||
use rand_core::OsRng;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
#[cfg(any(feature = "chacha20poly1305", feature = "aes-gcm"))]
|
#[cfg(any(feature = "chacha20poly1305", feature = "aes-gcm"))]
|
||||||
use rand_core::RngCore;
|
use getrandom::fill;
|
||||||
|
|
||||||
|
/// Authentication-tag length shared by the supported AEAD constructions.
|
||||||
|
pub const AUTH_TAG_LEN: usize = 16;
|
||||||
|
|
||||||
|
/// Nonce length stored at the front of an XChaCha20-Poly1305 output.
|
||||||
|
pub const XCHACHA20POLY1305_NONCE_LEN: usize = 24;
|
||||||
|
|
||||||
|
/// Nonce length stored at the front of an AES-256-GCM output.
|
||||||
|
pub const AES256GCM_NONCE_LEN: usize = 12;
|
||||||
|
|
||||||
pub trait AeadEncrypt {
|
pub trait AeadEncrypt {
|
||||||
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError>;
|
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError>;
|
||||||
|
|
@ -27,29 +36,31 @@ fn prepend_nonce(nonce: &[u8], ciphertext: &mut Vec<u8>) -> Vec<u8> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
pub struct ChaCha20Poly1305 {
|
pub struct XChaCha20Poly1305 {
|
||||||
key: [u8; 32],
|
key: Zeroizing<[u8; 32]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
impl ChaCha20Poly1305 {
|
impl XChaCha20Poly1305 {
|
||||||
pub fn new(key: [u8; 32]) -> Self {
|
pub fn new(key: [u8; 32]) -> Self {
|
||||||
Self { key }
|
Self {
|
||||||
|
key: Zeroizing::new(key),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
impl AeadEncrypt for ChaCha20Poly1305 {
|
impl AeadEncrypt for XChaCha20Poly1305 {
|
||||||
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
use chacha20poly1305::XChaCha20Poly1305;
|
use chacha20poly1305::XChaCha20Poly1305;
|
||||||
use chacha20poly1305::XNonce;
|
use chacha20poly1305::XNonce;
|
||||||
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
||||||
|
|
||||||
let key = chacha20poly1305::Key::from_slice(&self.key);
|
let key = chacha20poly1305::Key::from_slice(self.key.as_ref());
|
||||||
let cipher = XChaCha20Poly1305::new(key);
|
let cipher = XChaCha20Poly1305::new(key);
|
||||||
|
|
||||||
let mut nonce = [0u8; 24];
|
let mut nonce = [0u8; XCHACHA20POLY1305_NONCE_LEN];
|
||||||
OsRng.fill_bytes(&mut nonce);
|
fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?;
|
||||||
let nonce_ref = XNonce::from_slice(&nonce);
|
let nonce_ref = XNonce::from_slice(&nonce);
|
||||||
|
|
||||||
let payload = Payload {
|
let payload = Payload {
|
||||||
|
|
@ -66,18 +77,18 @@ impl AeadEncrypt for ChaCha20Poly1305 {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
impl AeadDecrypt for ChaCha20Poly1305 {
|
impl AeadDecrypt for XChaCha20Poly1305 {
|
||||||
fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
use chacha20poly1305::XChaCha20Poly1305;
|
use chacha20poly1305::XChaCha20Poly1305;
|
||||||
use chacha20poly1305::XNonce;
|
use chacha20poly1305::XNonce;
|
||||||
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
||||||
|
|
||||||
if ciphertext.len() < 24 {
|
if ciphertext.len() < XCHACHA20POLY1305_NONCE_LEN + AUTH_TAG_LEN {
|
||||||
return Err(CryptoError::InvalidNonceLength);
|
return Err(CryptoError::InvalidNonceLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (nonce, ct) = ciphertext.split_at(24);
|
let (nonce, ct) = ciphertext.split_at(XCHACHA20POLY1305_NONCE_LEN);
|
||||||
let key = chacha20poly1305::Key::from_slice(&self.key);
|
let key = chacha20poly1305::Key::from_slice(self.key.as_ref());
|
||||||
let cipher = XChaCha20Poly1305::new(key);
|
let cipher = XChaCha20Poly1305::new(key);
|
||||||
let nonce_ref = XNonce::from_slice(nonce);
|
let nonce_ref = XNonce::from_slice(nonce);
|
||||||
|
|
||||||
|
|
@ -90,21 +101,28 @@ impl AeadDecrypt for ChaCha20Poly1305 {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
impl AeadCipher for ChaCha20Poly1305 {
|
impl AeadCipher for XChaCha20Poly1305 {
|
||||||
fn key_size() -> usize {
|
fn key_size() -> usize {
|
||||||
32
|
32
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compatibility alias for the original public name. The implementation is
|
||||||
|
/// XChaCha20-Poly1305, including its 24-byte nonce format.
|
||||||
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
|
pub type ChaCha20Poly1305 = XChaCha20Poly1305;
|
||||||
|
|
||||||
#[cfg(feature = "aes-gcm")]
|
#[cfg(feature = "aes-gcm")]
|
||||||
pub struct Aes256Gcm {
|
pub struct Aes256Gcm {
|
||||||
key: [u8; 32],
|
key: Zeroizing<[u8; 32]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "aes-gcm")]
|
#[cfg(feature = "aes-gcm")]
|
||||||
impl Aes256Gcm {
|
impl Aes256Gcm {
|
||||||
pub fn new(key: [u8; 32]) -> Self {
|
pub fn new(key: [u8; 32]) -> Self {
|
||||||
Self { key }
|
Self {
|
||||||
|
key: Zeroizing::new(key),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,11 +133,11 @@ impl AeadEncrypt for Aes256Gcm {
|
||||||
use aes_gcm::Nonce;
|
use aes_gcm::Nonce;
|
||||||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||||||
|
|
||||||
let key = aes_gcm::Key::<AesGcmInner>::from_slice(&self.key);
|
let key = aes_gcm::Key::<AesGcmInner>::from_slice(self.key.as_ref());
|
||||||
let cipher = AesGcmInner::new(key);
|
let cipher = AesGcmInner::new(key);
|
||||||
|
|
||||||
let mut nonce = [0u8; 12];
|
let mut nonce = [0u8; AES256GCM_NONCE_LEN];
|
||||||
OsRng.fill_bytes(&mut nonce);
|
fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?;
|
||||||
let nonce_ref = Nonce::from_slice(&nonce);
|
let nonce_ref = Nonce::from_slice(&nonce);
|
||||||
|
|
||||||
let payload = Payload {
|
let payload = Payload {
|
||||||
|
|
@ -142,12 +160,12 @@ impl AeadDecrypt for Aes256Gcm {
|
||||||
use aes_gcm::Nonce;
|
use aes_gcm::Nonce;
|
||||||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||||||
|
|
||||||
if ciphertext.len() < 12 {
|
if ciphertext.len() < AES256GCM_NONCE_LEN + AUTH_TAG_LEN {
|
||||||
return Err(CryptoError::InvalidNonceLength);
|
return Err(CryptoError::InvalidNonceLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (nonce, ct) = ciphertext.split_at(12);
|
let (nonce, ct) = ciphertext.split_at(AES256GCM_NONCE_LEN);
|
||||||
let key = aes_gcm::Key::<AesGcmInner>::from_slice(&self.key);
|
let key = aes_gcm::Key::<AesGcmInner>::from_slice(self.key.as_ref());
|
||||||
let cipher = AesGcmInner::new(key);
|
let cipher = AesGcmInner::new(key);
|
||||||
let nonce_ref = Nonce::from_slice(nonce);
|
let nonce_ref = Nonce::from_slice(nonce);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
* Step 4. Host -> Client : IdentificationResponse { connected, id, host_sig } host_sig over host_final_payload
|
* Step 4. Host -> Client : IdentificationResponse { connected, id, host_sig } host_sig over host_final_payload
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/// Domain-separation tags — a distinct leading byte per signed context.
|
/// Domain-separation tags
|
||||||
pub mod domain {
|
pub mod domain {
|
||||||
/// Host's signature over the challenge it issues (step 2).
|
/// Host's signature over the challenge it issues (step 2).
|
||||||
pub const CHALLENGE: u8 = 0x10;
|
pub const CHALLENGE: u8 = 0x10;
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,15 @@
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
use crate::error::CryptoError;
|
use crate::error::CryptoError;
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
#[cfg(feature = "mlkem-tls")]
|
||||||
use crate::kdf::derive_encryption_key;
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
|
||||||
use crate::kem::HybridKem;
|
use crate::kem::HybridKem;
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
|
||||||
use crate::keypair::{Keyring, PublicKeyBundle};
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Algorithm selector for encrypted containers.
|
* Algorithm selector for encrypted values.
|
||||||
*
|
*
|
||||||
* Mirrors `SigAlgorithm` for signatures: a single marking byte identifies the
|
* Mirrors `SigAlgorithm` for signatures: a single marking byte identifies the
|
||||||
* key-encapsulation mechanism and the AEAD used to seal a container. The byte
|
* key-encapsulation mechanism and the AEAD used to seal a container. The byte
|
||||||
* is stored as the first byte of every encrypted blob so the decryptor can pick
|
* is stored as the first byte of every encrypted envelope so the decryptor can pick
|
||||||
* the matching algorithm (and the matching keypair from a `Keyring`) without
|
* the matching algorithm (and the matching keypair from a `Keyring`) without
|
||||||
* any out-of-band agreement.
|
* any out-of-band agreement.
|
||||||
*
|
*
|
||||||
|
|
@ -34,7 +30,7 @@ impl EncryptionType {
|
||||||
pub const ML_KEM_CHACHA20POLY1305: u8 = 0x01;
|
pub const ML_KEM_CHACHA20POLY1305: u8 = 0x01;
|
||||||
pub const ML_KEM_AES256_GCM: u8 = 0x02;
|
pub const ML_KEM_AES256_GCM: u8 = 0x02;
|
||||||
|
|
||||||
/// The marking byte written at the front of an encrypted blob.
|
/// The marking byte written at the front of an encrypted envelope.
|
||||||
pub const fn to_byte(self) -> u8 {
|
pub const fn to_byte(self) -> u8 {
|
||||||
match self {
|
match self {
|
||||||
Self::MlKemChaCha20Poly1305 => Self::ML_KEM_CHACHA20POLY1305,
|
Self::MlKemChaCha20Poly1305 => Self::ML_KEM_CHACHA20POLY1305,
|
||||||
|
|
@ -50,6 +46,50 @@ impl EncryptionType {
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Size of the content-encryption key wrapped for each recipient.
|
||||||
|
pub const CONTENT_ENCRYPTION_KEY_LEN: usize = 32;
|
||||||
|
|
||||||
|
/// The fixed-size ciphertext emitted by the KEM selected by this suite.
|
||||||
|
pub const fn kem_ciphertext_len(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::MlKemChaCha20Poly1305 | Self::MlKemAes256Gcm => {
|
||||||
|
#[cfg(feature = "mlkem-tls")]
|
||||||
|
{
|
||||||
|
HybridKem::ciphertext_len()
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "mlkem-tls"))]
|
||||||
|
{
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes the selected AEAD prepends/appends to an encrypted payload.
|
||||||
|
pub const fn aead_overhead(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::MlKemChaCha20Poly1305 => {
|
||||||
|
crate::aead::XCHACHA20POLY1305_NONCE_LEN + crate::aead::AUTH_TAG_LEN
|
||||||
|
}
|
||||||
|
Self::MlKemAes256Gcm => crate::aead::AES256GCM_NONCE_LEN + crate::aead::AUTH_TAG_LEN,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total output length for an encrypted plaintext of `plaintext_len` bytes.
|
||||||
|
pub const fn encrypted_len(self, plaintext_len: usize) -> usize {
|
||||||
|
plaintext_len.saturating_add(self.aead_overhead())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimum valid AEAD output length for this suite.
|
||||||
|
pub const fn minimum_ciphertext_len(self) -> usize {
|
||||||
|
self.encrypted_len(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The size of a wrapped 32-byte content key for this suite.
|
||||||
|
pub const fn wrapped_key_len(self) -> usize {
|
||||||
|
self.encrypted_len(Self::CONTENT_ENCRYPTION_KEY_LEN)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -59,7 +99,7 @@ impl EncryptionType {
|
||||||
*/
|
*/
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
fn aead_seal(
|
pub fn seal_with_key(
|
||||||
enc_type: EncryptionType,
|
enc_type: EncryptionType,
|
||||||
key: [u8; 32],
|
key: [u8; 32],
|
||||||
plaintext: &[u8],
|
plaintext: &[u8],
|
||||||
|
|
@ -70,7 +110,7 @@ fn aead_seal(
|
||||||
match enc_type {
|
match enc_type {
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
EncryptionType::MlKemChaCha20Poly1305 => {
|
EncryptionType::MlKemChaCha20Poly1305 => {
|
||||||
crate::aead::ChaCha20Poly1305::new(key).encrypt(plaintext, aad)
|
crate::aead::XChaCha20Poly1305::new(key).encrypt(plaintext, aad)
|
||||||
}
|
}
|
||||||
#[cfg(feature = "aes-gcm")]
|
#[cfg(feature = "aes-gcm")]
|
||||||
EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).encrypt(plaintext, aad),
|
EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).encrypt(plaintext, aad),
|
||||||
|
|
@ -86,7 +126,7 @@ fn aead_seal(
|
||||||
*/
|
*/
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
fn aead_open(
|
pub fn open_with_key(
|
||||||
enc_type: EncryptionType,
|
enc_type: EncryptionType,
|
||||||
key: [u8; 32],
|
key: [u8; 32],
|
||||||
ciphertext: &[u8],
|
ciphertext: &[u8],
|
||||||
|
|
@ -97,7 +137,7 @@ fn aead_open(
|
||||||
match enc_type {
|
match enc_type {
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
EncryptionType::MlKemChaCha20Poly1305 => {
|
EncryptionType::MlKemChaCha20Poly1305 => {
|
||||||
crate::aead::ChaCha20Poly1305::new(key).decrypt(ciphertext, aad)
|
crate::aead::XChaCha20Poly1305::new(key).decrypt(ciphertext, aad)
|
||||||
}
|
}
|
||||||
#[cfg(feature = "aes-gcm")]
|
#[cfg(feature = "aes-gcm")]
|
||||||
EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).decrypt(ciphertext, aad),
|
EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).decrypt(ciphertext, aad),
|
||||||
|
|
@ -106,76 +146,51 @@ fn aead_open(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
|
||||||
const ENC_KDF_SALT: &[u8] = b"mtp-container-enc";
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
|
||||||
const ENC_KDF_CONTEXT: &[u8] = b"single-recipient";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Encrypt `plaintext` for a single recipient, selecting the algorithm with
|
|
||||||
* `enc_type` and the recipient's KEM public key from `recipient`.
|
|
||||||
*
|
|
||||||
* The returned, self-describing blob is laid out as:
|
|
||||||
* [1 byte EncryptionType] [2 bytes u16 kem_ct_len] [kem_ciphertext] [aead_payload]
|
|
||||||
* where `aead_payload` is the AEAD output (nonce + ciphertext + tag). The AEAD
|
|
||||||
* key is derived from the KEM shared secret via HKDF, so no separate content key
|
|
||||||
* is transmitted.
|
|
||||||
*
|
|
||||||
* Requires the `mlkem-tls` and `hkdf` features, plus the AEAD feature backing
|
|
||||||
* `enc_type`.
|
|
||||||
*/
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
|
||||||
pub fn encrypt_for(
|
|
||||||
enc_type: EncryptionType,
|
|
||||||
recipient: &PublicKeyBundle,
|
|
||||||
plaintext: &[u8],
|
|
||||||
aad: &[u8],
|
|
||||||
) -> Result<Vec<u8>, CryptoError> {
|
|
||||||
let enc = HybridKem::encapsulate(&recipient.kem_public_key)?;
|
|
||||||
let key = derive_encryption_key(&enc.shared_secret, ENC_KDF_SALT, ENC_KDF_CONTEXT)?;
|
|
||||||
let aead_payload = aead_seal(enc_type, key, plaintext, aad)?;
|
|
||||||
|
|
||||||
let kem_ct = enc.ciphertext;
|
|
||||||
let mut out = Vec::with_capacity(1 + 2 + kem_ct.len() + aead_payload.len());
|
|
||||||
out.push(enc_type.to_byte());
|
|
||||||
out.extend_from_slice(&(kem_ct.len() as u16).to_be_bytes());
|
|
||||||
out.extend_from_slice(&kem_ct);
|
|
||||||
out.extend_from_slice(&aead_payload);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Decrypt a blob produced by [`encrypt_for`] using `keyring`.
|
|
||||||
*
|
|
||||||
* The leading byte selects the `EncryptionType` (and thus which keypair to use
|
|
||||||
* from the keyring); for the current ML-KEM variants that is `kem_secret_key`.
|
|
||||||
* Returns `DecryptionFailed` on any malformed input or authentication failure.
|
|
||||||
*
|
|
||||||
* Requires the `mlkem-tls` and `hkdf` features, plus the AEAD feature backing
|
|
||||||
* the blob's algorithm.
|
|
||||||
*/
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
|
||||||
pub fn decrypt_with(blob: &[u8], keyring: &Keyring, aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
|
||||||
if blob.len() < 3 {
|
|
||||||
return Err(CryptoError::DecryptionFailed);
|
|
||||||
}
|
|
||||||
let enc_type = EncryptionType::from_byte(blob[0]).ok_or(CryptoError::DecryptionFailed)?;
|
|
||||||
let kem_ct_len = u16::from_be_bytes([blob[1], blob[2]]) as usize;
|
|
||||||
let kem_end = 3usize
|
|
||||||
.checked_add(kem_ct_len)
|
|
||||||
.ok_or(CryptoError::DecryptionFailed)?;
|
|
||||||
let kem_ct = blob.get(3..kem_end).ok_or(CryptoError::DecryptionFailed)?;
|
|
||||||
let aead_payload = blob.get(kem_end..).ok_or(CryptoError::DecryptionFailed)?;
|
|
||||||
|
|
||||||
let shared_secret = HybridKem::decapsulate(&keyring.kem_secret_key, kem_ct)?;
|
|
||||||
let key = derive_encryption_key(&shared_secret, ENC_KDF_SALT, ENC_KDF_CONTEXT)?;
|
|
||||||
aead_open(enc_type, key, aead_payload, aad)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(feature = "mlkem-tls")]
|
||||||
|
#[test]
|
||||||
|
fn envelope_parser_uses_suite_dependent_fixed_widths() {
|
||||||
|
use crate::helper::{MultiEncryptedMessage, RecipientEntry};
|
||||||
|
|
||||||
|
let suites = [
|
||||||
|
EncryptionType::MlKemChaCha20Poly1305,
|
||||||
|
EncryptionType::MlKemAes256Gcm,
|
||||||
|
];
|
||||||
|
assert_ne!(suites[0].wrapped_key_len(), suites[1].wrapped_key_len());
|
||||||
|
|
||||||
|
for (index, suite) in suites.into_iter().enumerate() {
|
||||||
|
let marker = u8::try_from(index).unwrap();
|
||||||
|
let message = MultiEncryptedMessage {
|
||||||
|
encryption_type: suite,
|
||||||
|
purpose: 0xA5,
|
||||||
|
recipients: vec![RecipientEntry {
|
||||||
|
kem_ciphertext: vec![0x10 + marker; suite.kem_ciphertext_len()],
|
||||||
|
encrypted_key: vec![0x20 + marker; suite.wrapped_key_len()],
|
||||||
|
}],
|
||||||
|
ciphertext: vec![0x30 + marker; suite.minimum_ciphertext_len() + 3],
|
||||||
|
};
|
||||||
|
let encoded = message.to_bytes().expect("synthetic envelope is valid");
|
||||||
|
let kem_end = 4 + suite.kem_ciphertext_len();
|
||||||
|
let wrapped_end = kem_end + suite.wrapped_key_len();
|
||||||
|
|
||||||
|
assert_eq!(&encoded[..4], &[suite.to_byte(), 0xA5, 0, 1]);
|
||||||
|
assert_eq!(&encoded[4..kem_end], message.recipients[0].kem_ciphertext);
|
||||||
|
assert_eq!(
|
||||||
|
&encoded[kem_end..wrapped_end],
|
||||||
|
message.recipients[0].encrypted_key
|
||||||
|
);
|
||||||
|
assert_eq!(&encoded[wrapped_end..], message.ciphertext);
|
||||||
|
assert_eq!(
|
||||||
|
MultiEncryptedMessage::from_bytes(&encoded)
|
||||||
|
.expect("suite-specific envelope should parse"),
|
||||||
|
message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn encryption_type_byte_roundtrip() {
|
fn encryption_type_byte_roundtrip() {
|
||||||
for t in [
|
for t in [
|
||||||
|
|
@ -188,59 +203,19 @@ mod tests {
|
||||||
assert_eq!(EncryptionType::from_byte(0xFF), None);
|
assert_eq!(EncryptionType::from_byte(0xFF), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
|
|
||||||
#[test]
|
#[test]
|
||||||
fn encrypt_for_roundtrip() {
|
fn suite_lengths_are_derived_from_the_selected_primitives() {
|
||||||
let kr = Keyring::generate();
|
assert_eq!(
|
||||||
let blob = encrypt_for(
|
EncryptionType::MlKemChaCha20Poly1305.wrapped_key_len(),
|
||||||
EncryptionType::MlKemChaCha20Poly1305,
|
EncryptionType::CONTENT_ENCRYPTION_KEY_LEN
|
||||||
&kr.public_key_bundle(),
|
+ crate::aead::XCHACHA20POLY1305_NONCE_LEN
|
||||||
b"secret payload",
|
+ crate::aead::AUTH_TAG_LEN
|
||||||
b"aad",
|
);
|
||||||
)
|
assert_eq!(
|
||||||
.unwrap();
|
EncryptionType::MlKemAes256Gcm.wrapped_key_len(),
|
||||||
assert_eq!(blob[0], EncryptionType::ML_KEM_CHACHA20POLY1305);
|
EncryptionType::CONTENT_ENCRYPTION_KEY_LEN
|
||||||
|
+ crate::aead::AES256GCM_NONCE_LEN
|
||||||
let pt = decrypt_with(&blob, &kr, b"aad").unwrap();
|
+ crate::aead::AUTH_TAG_LEN
|
||||||
assert_eq!(pt, b"secret payload");
|
);
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
|
|
||||||
#[test]
|
|
||||||
fn decrypt_with_wrong_keyring_fails() {
|
|
||||||
let kr = Keyring::generate();
|
|
||||||
let other = Keyring::generate();
|
|
||||||
let blob = encrypt_for(
|
|
||||||
EncryptionType::MlKemChaCha20Poly1305,
|
|
||||||
&kr.public_key_bundle(),
|
|
||||||
b"secret",
|
|
||||||
b"aad",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(decrypt_with(&blob, &other, b"aad").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
|
|
||||||
#[test]
|
|
||||||
fn decrypt_with_wrong_aad_fails() {
|
|
||||||
let kr = Keyring::generate();
|
|
||||||
let blob = encrypt_for(
|
|
||||||
EncryptionType::MlKemChaCha20Poly1305,
|
|
||||||
&kr.public_key_bundle(),
|
|
||||||
b"secret",
|
|
||||||
b"right",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(decrypt_with(&blob, &kr, b"wrong").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
|
|
||||||
#[test]
|
|
||||||
fn decrypt_with_malformed_fails() {
|
|
||||||
let kr = Keyring::generate();
|
|
||||||
assert!(decrypt_with(b"", &kr, b"").is_err());
|
|
||||||
assert!(decrypt_with(&[0x01, 0x00], &kr, b"").is_err());
|
|
||||||
// Unknown algorithm byte.
|
|
||||||
assert!(decrypt_with(&[0x7F, 0x00, 0x00], &kr, b"").is_err());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,18 @@ pub enum CryptoError {
|
||||||
EncryptionFailed,
|
EncryptionFailed,
|
||||||
#[error("decryption failed")]
|
#[error("decryption failed")]
|
||||||
DecryptionFailed,
|
DecryptionFailed,
|
||||||
|
#[error("decryption output exceeds the caller's allocation limit")]
|
||||||
|
AllocationLimit,
|
||||||
|
#[error("malformed encryption envelope")]
|
||||||
|
MalformedEnvelope,
|
||||||
|
#[error("no encryption recipients")]
|
||||||
|
NoRecipients,
|
||||||
|
#[error("no matching encryption recipient")]
|
||||||
|
NoMatchingRecipient,
|
||||||
#[error("invalid key length")]
|
#[error("invalid key length")]
|
||||||
InvalidKeyLength,
|
InvalidKeyLength,
|
||||||
|
#[error("public and private key material do not match")]
|
||||||
|
InvalidKeyMaterial,
|
||||||
#[error("invalid nonce length")]
|
#[error("invalid nonce length")]
|
||||||
InvalidNonceLength,
|
InvalidNonceLength,
|
||||||
#[error("invalid signature")]
|
#[error("invalid signature")]
|
||||||
|
|
@ -28,4 +38,8 @@ pub enum CryptoError {
|
||||||
UnknownAlgorithm,
|
UnknownAlgorithm,
|
||||||
#[error("invalid hex encoding")]
|
#[error("invalid hex encoding")]
|
||||||
InvalidHex,
|
InvalidHex,
|
||||||
|
#[error("invalid base64 encoding")]
|
||||||
|
InvalidBase64,
|
||||||
|
#[error("TLS error: {0}")]
|
||||||
|
Tls(String),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,198 +1,471 @@
|
||||||
|
// Canonical multi-recipient encryption envelopes.
|
||||||
|
|
||||||
|
use crate::enc::EncryptionType;
|
||||||
use crate::error::CryptoError;
|
use crate::error::CryptoError;
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305};
|
use crate::enc::{open_with_key, seal_with_key};
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
use crate::kdf::derive_encryption_key;
|
use crate::kdf::derive_encryption_key;
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
use crate::kem::HybridKem;
|
use crate::kem::HybridKem;
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
use crate::keypair::{Keyring, PublicKeyBundle};
|
use crate::keypair::{Keyring, PublicKeyBundle};
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
use rand_core::RngCore;
|
use rand::Rng;
|
||||||
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
pub const ENCRYPT_DOMAIN: &[u8] = b"MTP-DATA-ENC-1";
|
||||||
|
pub const KEY_WRAP_DOMAIN: &[u8] = b"MTP-DATA-WRAP-1";
|
||||||
|
/// Operational cap for recipient entries accepted in one envelope.
|
||||||
|
///
|
||||||
|
/// The wire count remains a `u16` for format stability, but decapsulation is
|
||||||
|
/// intentionally bounded because each entry can require a KEM operation.
|
||||||
|
pub const MAX_RECIPIENTS: usize = 64;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct RecipientEntry {
|
pub struct RecipientEntry {
|
||||||
pub kem_ciphertext: Vec<u8>,
|
pub kem_ciphertext: Vec<u8>,
|
||||||
pub encrypted_key: Vec<u8>,
|
pub encrypted_key: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/// The envelope body used by `DataValue::Encrypted`.
|
||||||
* A payload encrypted for multiple recipients.
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
*
|
|
||||||
* Any recipient who possesses the corresponding `KemPrivateKey` can decrypt the message.
|
|
||||||
*/
|
|
||||||
pub struct MultiEncryptedMessage {
|
pub struct MultiEncryptedMessage {
|
||||||
|
pub encryption_type: EncryptionType,
|
||||||
|
pub purpose: u8,
|
||||||
pub recipients: Vec<RecipientEntry>,
|
pub recipients: Vec<RecipientEntry>,
|
||||||
pub nonce: [u8; 24],
|
/// The AEAD output, including its nonce as defined by the selected suite.
|
||||||
pub ciphertext: Vec<u8>,
|
pub ciphertext: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MultiEncryptedMessage {
|
/// Borrowed view of a canonical encrypted envelope.
|
||||||
/*
|
///
|
||||||
* Serialize into a compact byte vector.
|
/// The codec uses this view while validating an attacker-controlled envelope
|
||||||
*
|
/// so parsing it does not first create a complete temporary copy of every
|
||||||
* Format:
|
/// recipient entry and the ciphertext.
|
||||||
* - `num_recipients: u16`
|
#[derive(Debug, Clone, Copy)]
|
||||||
* - for each recipient:
|
pub struct MultiEncryptedMessageRef<'a> {
|
||||||
* - `kem_ct_len: u16` | `kem_ciphertext`
|
encryption_type: EncryptionType,
|
||||||
* - `ek_len: u16` | `encrypted_key`
|
purpose: u8,
|
||||||
* - `nonce: 24 bytes`
|
bytes: &'a [u8],
|
||||||
* - `ciphertext` (remaining)
|
entries_start: usize,
|
||||||
*/
|
entry_len: usize,
|
||||||
pub fn to_bytes(&self) -> Vec<u8> {
|
count: usize,
|
||||||
let mut out = Vec::new();
|
ciphertext_start: usize,
|
||||||
out.extend_from_slice(&(self.recipients.len() as u16).to_be_bytes());
|
|
||||||
for r in &self.recipients {
|
|
||||||
out.extend_from_slice(&(r.kem_ciphertext.len() as u16).to_be_bytes());
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deserialize from bytes produced by `to_bytes`.
|
impl<'a> MultiEncryptedMessageRef<'a> {
|
||||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, CryptoError> {
|
pub fn from_bytes(bytes: &'a [u8]) -> Result<Self, CryptoError> {
|
||||||
let mut offset = 0;
|
if bytes.len() < 4 {
|
||||||
let read_u16 = |off: &mut usize| -> Result<u16, CryptoError> {
|
return Err(CryptoError::MalformedEnvelope);
|
||||||
let slice = bytes
|
}
|
||||||
.get(*off..*off + 2)
|
let encryption_type =
|
||||||
.ok_or(CryptoError::DecryptionFailed)?;
|
EncryptionType::from_byte(bytes[0]).ok_or(CryptoError::UnknownAlgorithm)?;
|
||||||
let arr: [u8; 2] = slice
|
let purpose = bytes[1];
|
||||||
.try_into()
|
let count = u16::from_be_bytes([bytes[2], bytes[3]]) as usize;
|
||||||
.map_err(|_| CryptoError::DecryptionFailed)?;
|
if count == 0 || count > MAX_RECIPIENTS {
|
||||||
*off += 2;
|
return Err(CryptoError::MalformedEnvelope);
|
||||||
Ok(u16::from_be_bytes(arr))
|
}
|
||||||
};
|
let entry_len = encryption_type
|
||||||
|
.kem_ciphertext_len()
|
||||||
let num = read_u16(&mut offset)? as usize;
|
.checked_add(encryption_type.wrapped_key_len())
|
||||||
let mut recipients = Vec::with_capacity(num);
|
.ok_or(CryptoError::MalformedEnvelope)?;
|
||||||
for _ in 0..num {
|
let entries_len = count
|
||||||
let klen = read_u16(&mut offset)? as usize;
|
.checked_mul(entry_len)
|
||||||
let kem_ct = bytes
|
.ok_or(CryptoError::MalformedEnvelope)?;
|
||||||
.get(offset..offset + klen)
|
let entries_start = 4usize;
|
||||||
.ok_or(CryptoError::DecryptionFailed)?
|
let ciphertext_start = entries_start
|
||||||
.to_vec();
|
.checked_add(entries_len)
|
||||||
offset += klen;
|
.ok_or(CryptoError::MalformedEnvelope)?;
|
||||||
|
let ciphertext_len = bytes
|
||||||
let elen = read_u16(&mut offset)? as usize;
|
.len()
|
||||||
let enc_key = bytes
|
.checked_sub(ciphertext_start)
|
||||||
.get(offset..offset + elen)
|
.ok_or(CryptoError::MalformedEnvelope)?;
|
||||||
.ok_or(CryptoError::DecryptionFailed)?
|
if ciphertext_len < encryption_type.minimum_ciphertext_len() {
|
||||||
.to_vec();
|
return Err(CryptoError::MalformedEnvelope);
|
||||||
offset += elen;
|
|
||||||
|
|
||||||
recipients.push(RecipientEntry {
|
|
||||||
kem_ciphertext: kem_ct,
|
|
||||||
encrypted_key: enc_key,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let nonce: [u8; 24] = bytes
|
|
||||||
.get(offset..offset + 24)
|
|
||||||
.ok_or(CryptoError::DecryptionFailed)?
|
|
||||||
.try_into()
|
|
||||||
.map_err(|_| CryptoError::DecryptionFailed)?;
|
|
||||||
offset += 24;
|
|
||||||
|
|
||||||
let ciphertext = bytes
|
|
||||||
.get(offset..)
|
|
||||||
.ok_or(CryptoError::DecryptionFailed)?
|
|
||||||
.to_vec();
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
recipients,
|
encryption_type,
|
||||||
nonce,
|
purpose,
|
||||||
ciphertext,
|
bytes,
|
||||||
|
entries_start,
|
||||||
|
entry_len,
|
||||||
|
count,
|
||||||
|
ciphertext_start,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const fn encryption_type(&self) -> EncryptionType {
|
||||||
|
self.encryption_type
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
pub const fn purpose(&self) -> u8 {
|
||||||
* Encrypt `plaintext` for every recipient in `entities`.
|
self.purpose
|
||||||
*
|
}
|
||||||
* Internally generates a fresh content-encryption key, encrypts the payload
|
|
||||||
* with ChaCha20-Poly1305, then KEM-encapsulates and wraps the key for each
|
pub const fn recipient_count(&self) -> usize {
|
||||||
* recipient. The returned `MultiEncryptedMessage` can be decrypted by any
|
self.count
|
||||||
* entity whose keyring contains the corresponding private KEM key.
|
}
|
||||||
*
|
|
||||||
* Requires the `pqc` and `chacha20poly1305` features.
|
pub fn recipient(&self, index: usize) -> Option<(&'a [u8], &'a [u8])> {
|
||||||
*/
|
if index >= self.count {
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
return None;
|
||||||
pub fn encrypt_multi(
|
}
|
||||||
|
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<Vec<u8>, 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<Self, CryptoError> {
|
||||||
|
Ok(MultiEncryptedMessageRef::from_bytes(bytes)?.to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
|
fn wrap_aad(encryption_type: EncryptionType, purpose: u8, kem_ciphertext: &[u8]) -> Vec<u8> {
|
||||||
|
let mut aad = Vec::with_capacity(KEY_WRAP_DOMAIN.len() + 2 + kem_ciphertext.len());
|
||||||
|
aad.extend_from_slice(KEY_WRAP_DOMAIN);
|
||||||
|
aad.push(encryption_type.to_byte());
|
||||||
|
aad.push(purpose);
|
||||||
|
aad.extend_from_slice(kem_ciphertext);
|
||||||
|
aad
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
|
fn payload_aad(message: &MultiEncryptedMessage) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let count =
|
||||||
|
u16::try_from(message.recipients.len()).map_err(|_| CryptoError::MalformedEnvelope)?;
|
||||||
|
let mut aad = Vec::new();
|
||||||
|
aad.extend_from_slice(ENCRYPT_DOMAIN);
|
||||||
|
aad.push(message.encryption_type.to_byte());
|
||||||
|
aad.push(message.purpose);
|
||||||
|
aad.extend_from_slice(&count.to_be_bytes());
|
||||||
|
for recipient in &message.recipients {
|
||||||
|
aad.extend_from_slice(&recipient.kem_ciphertext);
|
||||||
|
aad.extend_from_slice(&recipient.encrypted_key);
|
||||||
|
}
|
||||||
|
Ok(aad)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt a value for one or more recipients using the canonical envelope.
|
||||||
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
|
pub fn encrypt_multi_for(
|
||||||
|
encryption_type: EncryptionType,
|
||||||
|
purpose: u8,
|
||||||
plaintext: &[u8],
|
plaintext: &[u8],
|
||||||
aad: &[u8],
|
|
||||||
entities: &[PublicKeyBundle],
|
entities: &[PublicKeyBundle],
|
||||||
) -> Result<MultiEncryptedMessage, CryptoError> {
|
) -> Result<MultiEncryptedMessage, CryptoError> {
|
||||||
let mut cek = [0u8; 32];
|
if entities.is_empty() {
|
||||||
rand_core::OsRng.fill_bytes(&mut cek);
|
return Err(CryptoError::NoRecipients);
|
||||||
|
}
|
||||||
|
if entities.len() > MAX_RECIPIENTS {
|
||||||
|
return Err(CryptoError::EncryptionFailed);
|
||||||
|
}
|
||||||
|
|
||||||
let cipher = ChaCha20Poly1305::new(cek);
|
let mut cek = Zeroizing::new([0u8; 32]);
|
||||||
let encrypted_payload = cipher.encrypt(plaintext, aad)?;
|
rand::rng().fill_bytes(cek.as_mut());
|
||||||
|
|
||||||
let nonce: [u8; 24] = encrypted_payload[..24]
|
|
||||||
.try_into()
|
|
||||||
.map_err(|_| CryptoError::EncryptionFailed)?;
|
|
||||||
let ciphertext = encrypted_payload[24..].to_vec();
|
|
||||||
|
|
||||||
let mut recipients = Vec::with_capacity(entities.len());
|
let mut recipients = Vec::with_capacity(entities.len());
|
||||||
for entity in entities {
|
for entity in entities {
|
||||||
let enc = HybridKem::encapsulate(&entity.kem_public_key)?;
|
let enc = HybridKem::encapsulate(&entity.kem_public_key)?;
|
||||||
let wrap_key = derive_encryption_key(
|
let wrap_key = Zeroizing::new(derive_encryption_key(
|
||||||
&enc.shared_secret,
|
&enc.shared_secret,
|
||||||
b"mtp-multi-key-wrap",
|
KEY_WRAP_DOMAIN,
|
||||||
b"multi-recipient",
|
&[encryption_type.to_byte(), purpose],
|
||||||
)?;
|
)?);
|
||||||
|
let aad = wrap_aad(encryption_type, purpose, &enc.ciphertext);
|
||||||
let wrap_cipher = ChaCha20Poly1305::new(wrap_key);
|
let encrypted_key = seal_with_key(encryption_type, *wrap_key, cek.as_ref(), &aad)?;
|
||||||
let encrypted_key = wrap_cipher.encrypt(&cek, b"")?;
|
|
||||||
|
|
||||||
recipients.push(RecipientEntry {
|
recipients.push(RecipientEntry {
|
||||||
kem_ciphertext: enc.ciphertext,
|
kem_ciphertext: enc.ciphertext,
|
||||||
encrypted_key,
|
encrypted_key,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(MultiEncryptedMessage {
|
let mut message = MultiEncryptedMessage {
|
||||||
|
encryption_type,
|
||||||
|
purpose,
|
||||||
recipients,
|
recipients,
|
||||||
nonce,
|
ciphertext: Vec::new(),
|
||||||
ciphertext,
|
};
|
||||||
})
|
let aad = payload_aad(&message)?;
|
||||||
|
message.ciphertext = seal_with_key(encryption_type, *cek, plaintext, &aad)?;
|
||||||
|
Ok(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/// Decrypt a canonical envelope for a recipient in `keyring`.
|
||||||
* Decrypt a `MultiEncryptedMessage` using the recipient's `Keyring`.
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
*
|
pub fn decrypt_multi_for(
|
||||||
* Tries each `RecipientEntry` until one succeeds with the given keyring's
|
message: &MultiEncryptedMessage,
|
||||||
* KEM secret key. Returns the original plaintext.
|
purpose: u8,
|
||||||
*/
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
|
||||||
pub fn decrypt_multi(
|
|
||||||
msg: &MultiEncryptedMessage,
|
|
||||||
aad: &[u8],
|
|
||||||
keyring: &Keyring,
|
keyring: &Keyring,
|
||||||
) -> Result<Vec<u8>, CryptoError> {
|
) -> Result<Vec<u8>, CryptoError> {
|
||||||
for entry in &msg.recipients {
|
decrypt_multi_for_parts(
|
||||||
let ss = match HybridKem::decapsulate(&keyring.kem_secret_key, &entry.kem_ciphertext) {
|
message.encryption_type,
|
||||||
Ok(s) => s,
|
message.purpose,
|
||||||
|
&message.recipients,
|
||||||
|
&message.ciphertext,
|
||||||
|
purpose,
|
||||||
|
keyring,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt an envelope represented by borrowed recipient and ciphertext
|
||||||
|
/// slices. This keeps protected-value opening from cloning an already-owned
|
||||||
|
/// envelope solely to call the cryptographic primitive.
|
||||||
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
|
pub fn decrypt_multi_for_parts(
|
||||||
|
encryption_type: EncryptionType,
|
||||||
|
envelope_purpose: u8,
|
||||||
|
recipients: &[RecipientEntry],
|
||||||
|
ciphertext: &[u8],
|
||||||
|
purpose: u8,
|
||||||
|
keyring: &Keyring,
|
||||||
|
) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
if recipients.is_empty()
|
||||||
|
|| recipients.len() > MAX_RECIPIENTS
|
||||||
|
|| envelope_purpose != purpose
|
||||||
|
|| ciphertext.len() < encryption_type.minimum_ciphertext_len()
|
||||||
|
|| recipients.iter().any(|recipient| {
|
||||||
|
recipient.kem_ciphertext.len() != encryption_type.kem_ciphertext_len()
|
||||||
|
|| recipient.encrypted_key.len() != encryption_type.wrapped_key_len()
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(CryptoError::MalformedEnvelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = u16::try_from(recipients.len()).map_err(|_| CryptoError::MalformedEnvelope)?;
|
||||||
|
let mut payload_aad = Vec::new();
|
||||||
|
payload_aad.extend_from_slice(ENCRYPT_DOMAIN);
|
||||||
|
payload_aad.push(encryption_type.to_byte());
|
||||||
|
payload_aad.push(envelope_purpose);
|
||||||
|
payload_aad.extend_from_slice(&count.to_be_bytes());
|
||||||
|
for entry in recipients {
|
||||||
|
payload_aad.extend_from_slice(&entry.kem_ciphertext);
|
||||||
|
payload_aad.extend_from_slice(&entry.encrypted_key);
|
||||||
|
}
|
||||||
|
for entry in recipients {
|
||||||
|
let shared_secret =
|
||||||
|
match HybridKem::decapsulate(&keyring.kem_secret_key, &entry.kem_ciphertext) {
|
||||||
|
Ok(secret) => secret,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
let wrap_key = derive_encryption_key(&ss, b"mtp-multi-key-wrap", b"multi-recipient")?;
|
let wrap_key = Zeroizing::new(derive_encryption_key(
|
||||||
let wrap_cipher = ChaCha20Poly1305::new(wrap_key);
|
&shared_secret,
|
||||||
let cek = match wrap_cipher.decrypt(&entry.encrypted_key, b"") {
|
KEY_WRAP_DOMAIN,
|
||||||
Ok(k) => k,
|
&[encryption_type.to_byte(), purpose],
|
||||||
|
)?);
|
||||||
|
let aad = wrap_aad(encryption_type, purpose, &entry.kem_ciphertext);
|
||||||
|
let cek = match open_with_key(encryption_type, *wrap_key, &entry.encrypted_key, &aad) {
|
||||||
|
Ok(key) => key,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
let cek_arr: [u8; 32] = cek.try_into().map_err(|_| CryptoError::DecryptionFailed)?;
|
let cek: [u8; 32] = cek.try_into().map_err(|_| CryptoError::DecryptionFailed)?;
|
||||||
|
return open_with_key(encryption_type, cek, ciphertext, &payload_aad);
|
||||||
|
}
|
||||||
|
|
||||||
let mut full_ct = Vec::with_capacity(24 + msg.ciphertext.len());
|
Err(CryptoError::NoMatchingRecipient)
|
||||||
full_ct.extend_from_slice(&msg.nonce);
|
}
|
||||||
full_ct.extend_from_slice(&msg.ciphertext);
|
|
||||||
|
|
||||||
let data_cipher = ChaCha20Poly1305::new(cek_arr);
|
/// Decrypt a canonical envelope only when its plaintext can fit inside the
|
||||||
return data_cipher.decrypt(&full_ct, aad);
|
/// caller's allocation budget.
|
||||||
|
///
|
||||||
|
/// The AEAD implementation allocates its output buffer internally. Checking
|
||||||
|
/// the ciphertext upper bound before entering that implementation makes the
|
||||||
|
/// codec's reservation meaningful instead of merely checking the result
|
||||||
|
/// after the allocation has already happened.
|
||||||
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
|
pub fn decrypt_multi_for_parts_with_limit(
|
||||||
|
encryption_type: EncryptionType,
|
||||||
|
envelope_purpose: u8,
|
||||||
|
recipients: &[RecipientEntry],
|
||||||
|
ciphertext: &[u8],
|
||||||
|
purpose: u8,
|
||||||
|
keyring: &Keyring,
|
||||||
|
max_plaintext_len: usize,
|
||||||
|
) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
if ciphertext.len() > max_plaintext_len {
|
||||||
|
return Err(CryptoError::AllocationLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypt_multi_for_parts(
|
||||||
|
encryption_type,
|
||||||
|
envelope_purpose,
|
||||||
|
recipients,
|
||||||
|
ciphertext,
|
||||||
|
purpose,
|
||||||
|
keyring,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recipient_count_is_operationally_bounded() {
|
||||||
|
assert!(matches!(
|
||||||
|
MultiEncryptedMessage::from_bytes(&[EncryptionType::ML_KEM_CHACHA20POLY1305, 0, 0, 65]),
|
||||||
|
Err(CryptoError::MalformedEnvelope)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
|
||||||
|
#[test]
|
||||||
|
fn authenticated_envelope_fields_reject_tampering() -> Result<(), CryptoError> {
|
||||||
|
let recipient_a = Keyring::generate();
|
||||||
|
let recipient_b = Keyring::generate();
|
||||||
|
let message = encrypt_multi_for(
|
||||||
|
EncryptionType::MlKemChaCha20Poly1305,
|
||||||
|
7,
|
||||||
|
b"authenticated payload",
|
||||||
|
&[
|
||||||
|
recipient_a.public_key_bundle(),
|
||||||
|
recipient_b.public_key_bundle(),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
assert_eq!(
|
||||||
|
decrypt_multi_for(&message, message.purpose, &recipient_a)?,
|
||||||
|
b"authenticated payload"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut wrong_purpose = message.clone();
|
||||||
|
wrong_purpose.purpose ^= 1;
|
||||||
|
assert!(
|
||||||
|
decrypt_multi_for(&wrong_purpose, wrong_purpose.purpose, &recipient_a).is_err(),
|
||||||
|
"mutating the encryption purpose must invalidate the envelope"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut wrong_recipient_table = message.clone();
|
||||||
|
wrong_recipient_table.recipients[1].encrypted_key[0] ^= 1;
|
||||||
|
assert!(
|
||||||
|
decrypt_multi_for(&wrong_recipient_table, message.purpose, &recipient_a).is_err(),
|
||||||
|
"mutating another recipient's table entry must invalidate the payload"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut wrong_ciphertext = message;
|
||||||
|
let last = wrong_ciphertext.ciphertext.len() - 1;
|
||||||
|
wrong_ciphertext.ciphertext[last] ^= 1;
|
||||||
|
assert!(
|
||||||
|
decrypt_multi_for(&wrong_ciphertext, wrong_ciphertext.purpose, &recipient_a).is_err(),
|
||||||
|
"mutating the ciphertext must invalidate the envelope"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "mlkem-tls")]
|
||||||
|
#[test]
|
||||||
|
fn rejects_envelopes_without_a_complete_aead_payload() {
|
||||||
|
let encryption_type = EncryptionType::MlKemChaCha20Poly1305;
|
||||||
|
let message = MultiEncryptedMessage {
|
||||||
|
encryption_type,
|
||||||
|
purpose: 1,
|
||||||
|
recipients: vec![RecipientEntry {
|
||||||
|
kem_ciphertext: vec![0; encryption_type.kem_ciphertext_len()],
|
||||||
|
encrypted_key: vec![0; encryption_type.wrapped_key_len()],
|
||||||
|
}],
|
||||||
|
ciphertext: vec![0; encryption_type.minimum_ciphertext_len() - 1],
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
message.to_bytes(),
|
||||||
|
Err(CryptoError::MalformedEnvelope)
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut encoded = vec![encryption_type.to_byte(), 1, 0, 1];
|
||||||
|
encoded.extend_from_slice(&vec![0; encryption_type.kem_ciphertext_len()]);
|
||||||
|
encoded.extend_from_slice(&vec![0; encryption_type.wrapped_key_len()]);
|
||||||
|
encoded.extend_from_slice(&vec![0; encryption_type.minimum_ciphertext_len() - 1]);
|
||||||
|
assert!(matches!(
|
||||||
|
MultiEncryptedMessage::from_bytes(&encoded),
|
||||||
|
Err(CryptoError::MalformedEnvelope)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(())
|
||||||
}
|
}
|
||||||
Err(CryptoError::DecryptionFailed)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,3 +36,29 @@ pub fn derive_encryption_key(
|
||||||
out.copy_from_slice(&key);
|
out.copy_from_slice(&key);
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "password-kdf")]
|
||||||
|
pub fn derive_password_key(
|
||||||
|
passphrase: &[u8],
|
||||||
|
salt: &[u8],
|
||||||
|
memory_kib: u32,
|
||||||
|
iterations: u32,
|
||||||
|
lanes: u32,
|
||||||
|
) -> Result<[u8; 32], CryptoError> {
|
||||||
|
if passphrase.is_empty()
|
||||||
|
|| salt.len() < 16
|
||||||
|
|| !(8 * 1024..=256 * 1024).contains(&memory_kib)
|
||||||
|
|| !(1..=10).contains(&iterations)
|
||||||
|
|| !(1..=8).contains(&lanes)
|
||||||
|
{
|
||||||
|
return Err(CryptoError::KdfError);
|
||||||
|
}
|
||||||
|
let params = argon2::Params::new(memory_kib, iterations, lanes, Some(32))
|
||||||
|
.map_err(|_| CryptoError::KdfError)?;
|
||||||
|
let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
argon
|
||||||
|
.hash_password_into(passphrase, salt, &mut key)
|
||||||
|
.map_err(|_| CryptoError::KdfError)?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
use crate::error::CryptoError;
|
use crate::error::CryptoError;
|
||||||
use crate::keypair::{KemPrivateKey, KemPublicKey};
|
use crate::keypair::{KemPrivateKey, KemPublicKey};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
pub struct Encapsulated {
|
pub struct Encapsulated {
|
||||||
pub ciphertext: Vec<u8>,
|
pub ciphertext: Vec<u8>,
|
||||||
pub shared_secret: Vec<u8>,
|
pub shared_secret: Zeroizing<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "mlkem-tls")]
|
#[cfg(feature = "mlkem-tls")]
|
||||||
|
|
@ -11,6 +12,11 @@ pub struct HybridKem;
|
||||||
|
|
||||||
#[cfg(feature = "mlkem-tls")]
|
#[cfg(feature = "mlkem-tls")]
|
||||||
impl HybridKem {
|
impl HybridKem {
|
||||||
|
/// Fixed wire size of the KEM ciphertext used by MTP envelopes.
|
||||||
|
pub const fn ciphertext_len() -> usize {
|
||||||
|
mlkem_tls::X25519MlKem768::CIPHERTEXT_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
pub fn generate_keypair() -> (KemPrivateKey, KemPublicKey) {
|
pub fn generate_keypair() -> (KemPrivateKey, KemPublicKey) {
|
||||||
let (ek, dk) = mlkem_tls::X25519MlKem768::keygen(&mut rand_core::OsRng);
|
let (ek, dk) = mlkem_tls::X25519MlKem768::keygen(&mut rand_core::OsRng);
|
||||||
(
|
(
|
||||||
|
|
@ -25,19 +31,19 @@ impl HybridKem {
|
||||||
let (ct, ss) = mlkem_tls::X25519MlKem768::encapsulate(&ek, &mut rand_core::OsRng);
|
let (ct, ss) = mlkem_tls::X25519MlKem768::encapsulate(&ek, &mut rand_core::OsRng);
|
||||||
Ok(Encapsulated {
|
Ok(Encapsulated {
|
||||||
ciphertext: ct.as_bytes().to_vec(),
|
ciphertext: ct.as_bytes().to_vec(),
|
||||||
shared_secret: ss.as_bytes().to_vec(),
|
shared_secret: Zeroizing::new(ss.as_bytes().to_vec()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decapsulate(
|
pub fn decapsulate(
|
||||||
recipient_sk: &KemPrivateKey,
|
recipient_sk: &KemPrivateKey,
|
||||||
ciphertext: &[u8],
|
ciphertext: &[u8],
|
||||||
) -> Result<Vec<u8>, CryptoError> {
|
) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
|
||||||
let dk = mlkem_tls::DecapsKey768::try_from(recipient_sk.as_bytes())
|
let dk = mlkem_tls::DecapsKey768::try_from(recipient_sk.as_bytes())
|
||||||
.map_err(|_| CryptoError::KemDecapsulationFailed)?;
|
.map_err(|_| CryptoError::KemDecapsulationFailed)?;
|
||||||
let ct = mlkem_tls::Ciphertext768Hybrid::try_from(ciphertext)
|
let ct = mlkem_tls::Ciphertext768Hybrid::try_from(ciphertext)
|
||||||
.map_err(|_| CryptoError::KemDecapsulationFailed)?;
|
.map_err(|_| CryptoError::KemDecapsulationFailed)?;
|
||||||
let ss = mlkem_tls::X25519MlKem768::decapsulate(&dk, &ct);
|
let ss = mlkem_tls::X25519MlKem768::decapsulate(&dk, &ct);
|
||||||
Ok(ss.as_bytes().to_vec())
|
Ok(Zeroizing::new(ss.as_bytes().to_vec()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use zeroize::{Zeroize, ZeroizeOnDrop};
|
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose;
|
||||||
|
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
|
||||||
|
|
||||||
// --- Private key types ---
|
// --- Private key types ---
|
||||||
|
|
||||||
|
macro_rules! impl_private_key {
|
||||||
|
($name:ident) => {
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||||
#[cfg_attr(feature = "serde", serde(transparent))]
|
#[cfg_attr(feature = "serde", serde(transparent))]
|
||||||
#[derive(Zeroize, ZeroizeOnDrop)]
|
#[derive(Zeroize, ZeroizeOnDrop)]
|
||||||
pub struct EncryptionPrivateKey(Vec<u8>);
|
pub struct $name(Vec<u8>);
|
||||||
|
|
||||||
impl EncryptionPrivateKey {
|
impl $name {
|
||||||
pub fn new(bytes: Vec<u8>) -> Self {
|
pub fn new(bytes: Vec<u8>) -> Self {
|
||||||
Self(bytes)
|
Self(bytes)
|
||||||
}
|
}
|
||||||
|
|
@ -17,161 +22,39 @@ impl EncryptionPrivateKey {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for EncryptionPrivateKey {
|
impl fmt::Debug for $name {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
f.debug_struct("EncryptionPrivateKey")
|
f.debug_struct(stringify!($name))
|
||||||
.field("len", &self.0.len())
|
.field("len", &self.0.len())
|
||||||
.field("data", &"[REDACTED]")
|
.field("data", &"[REDACTED]")
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AsRef<[u8]> for EncryptionPrivateKey {
|
impl AsRef<[u8]> for $name {
|
||||||
fn as_ref(&self) -> &[u8] {
|
fn as_ref(&self) -> &[u8] {
|
||||||
&self.0
|
&self.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Vec<u8>> for EncryptionPrivateKey {
|
impl From<Vec<u8>> for $name {
|
||||||
fn from(bytes: Vec<u8>) -> Self {
|
fn from(bytes: Vec<u8>) -> Self {
|
||||||
Self(bytes)
|
Self(bytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&[u8]> for EncryptionPrivateKey {
|
impl From<&[u8]> for $name {
|
||||||
fn from(bytes: &[u8]) -> Self {
|
fn from(bytes: &[u8]) -> Self {
|
||||||
Self(bytes.to_vec())
|
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<u8>);
|
|
||||||
|
|
||||||
impl SignaturePrivateKey {
|
|
||||||
pub fn new(bytes: Vec<u8>) -> Self {
|
|
||||||
Self(bytes)
|
|
||||||
}
|
|
||||||
pub fn as_bytes(&self) -> &[u8] {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for SignaturePrivateKey {
|
impl_private_key!(EncryptionPrivateKey);
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
impl_private_key!(SignaturePrivateKey);
|
||||||
f.debug_struct("SignaturePrivateKey")
|
impl_private_key!(KemPrivateKey);
|
||||||
.field("len", &self.0.len())
|
impl_private_key!(SignaturePqPrivateKey);
|
||||||
.field("data", &"[REDACTED]")
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<[u8]> for SignaturePrivateKey {
|
|
||||||
fn as_ref(&self) -> &[u8] {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Vec<u8>> for SignaturePrivateKey {
|
|
||||||
fn from(bytes: Vec<u8>) -> 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<u8>);
|
|
||||||
|
|
||||||
impl KemPrivateKey {
|
|
||||||
pub fn new(bytes: Vec<u8>) -> 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<Vec<u8>> for KemPrivateKey {
|
|
||||||
fn from(bytes: Vec<u8>) -> 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<u8>);
|
|
||||||
|
|
||||||
impl SignaturePqPrivateKey {
|
|
||||||
pub fn new(bytes: Vec<u8>) -> 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<Vec<u8>> for SignaturePqPrivateKey {
|
|
||||||
fn from(bytes: Vec<u8>) -> Self {
|
|
||||||
Self(bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&[u8]> for SignaturePqPrivateKey {
|
|
||||||
fn from(bytes: &[u8]) -> Self {
|
|
||||||
Self(bytes.to_vec())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Public key types ---
|
// --- Public key types ---
|
||||||
|
|
||||||
|
|
@ -197,14 +80,24 @@ fn hex_to_bytes(s: &str) -> Result<Vec<u8>, crate::error::CryptoError> {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---
|
fn bytes_to_base64(bytes: &[u8]) -> String {
|
||||||
|
general_purpose::STANDARD.encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base64_to_bytes(s: &str) -> Result<Vec<u8>, crate::error::CryptoError> {
|
||||||
|
general_purpose::STANDARD
|
||||||
|
.decode(s)
|
||||||
|
.map_err(|_| crate::error::CryptoError::InvalidBase64)
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! impl_public_key {
|
||||||
|
($name:ident) => {
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||||
#[cfg_attr(feature = "serde", serde(transparent))]
|
#[cfg_attr(feature = "serde", serde(transparent))]
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct EncryptionPublicKey(Vec<u8>);
|
pub struct $name(Vec<u8>);
|
||||||
|
|
||||||
impl EncryptionPublicKey {
|
impl $name {
|
||||||
pub fn new(bytes: Vec<u8>) -> Self {
|
pub fn new(bytes: Vec<u8>) -> Self {
|
||||||
Self(bytes)
|
Self(bytes)
|
||||||
}
|
}
|
||||||
|
|
@ -219,191 +112,42 @@ impl EncryptionPublicKey {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for EncryptionPublicKey {
|
impl fmt::Debug for $name {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "EncryptionPublicKey({})", self.to_hex())
|
write!(f, "{}({})", stringify!($name), self.to_hex())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AsRef<[u8]> for EncryptionPublicKey {
|
impl AsRef<[u8]> for $name {
|
||||||
fn as_ref(&self) -> &[u8] {
|
fn as_ref(&self) -> &[u8] {
|
||||||
&self.0
|
&self.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Vec<u8>> for EncryptionPublicKey {
|
impl From<Vec<u8>> for $name {
|
||||||
fn from(bytes: Vec<u8>) -> Self {
|
fn from(bytes: Vec<u8>) -> Self {
|
||||||
Self(bytes)
|
Self(bytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&[u8]> for EncryptionPublicKey {
|
impl From<&[u8]> for $name {
|
||||||
fn from(bytes: &[u8]) -> Self {
|
fn from(bytes: &[u8]) -> Self {
|
||||||
Self(bytes.to_vec())
|
Self(bytes.to_vec())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&EncryptionPublicKey> for Vec<u8> {
|
impl From<&$name> for Vec<u8> {
|
||||||
fn from(key: &EncryptionPublicKey) -> Vec<u8> {
|
fn from(key: &$name) -> Vec<u8> {
|
||||||
key.0.clone()
|
key.0.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
// ---
|
|
||||||
|
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
|
||||||
#[cfg_attr(feature = "serde", serde(transparent))]
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct SignaturePublicKey(Vec<u8>);
|
|
||||||
|
|
||||||
impl SignaturePublicKey {
|
|
||||||
pub fn new(bytes: Vec<u8>) -> 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<Self, crate::error::CryptoError> {
|
|
||||||
hex_to_bytes(s).map(Self)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for SignaturePublicKey {
|
impl_public_key!(EncryptionPublicKey);
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
impl_public_key!(SignaturePublicKey);
|
||||||
write!(f, "SignaturePublicKey({})", self.to_hex())
|
impl_public_key!(KemPublicKey);
|
||||||
}
|
impl_public_key!(SignaturePqPublicKey);
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<[u8]> for SignaturePublicKey {
|
|
||||||
fn as_ref(&self) -> &[u8] {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Vec<u8>> for SignaturePublicKey {
|
|
||||||
fn from(bytes: Vec<u8>) -> Self {
|
|
||||||
Self(bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&[u8]> for SignaturePublicKey {
|
|
||||||
fn from(bytes: &[u8]) -> Self {
|
|
||||||
Self(bytes.to_vec())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&SignaturePublicKey> for Vec<u8> {
|
|
||||||
fn from(key: &SignaturePublicKey) -> Vec<u8> {
|
|
||||||
key.0.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---
|
|
||||||
|
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
|
||||||
#[cfg_attr(feature = "serde", serde(transparent))]
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct KemPublicKey(Vec<u8>);
|
|
||||||
|
|
||||||
impl KemPublicKey {
|
|
||||||
pub fn new(bytes: Vec<u8>) -> 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<Self, crate::error::CryptoError> {
|
|
||||||
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<Vec<u8>> for KemPublicKey {
|
|
||||||
fn from(bytes: Vec<u8>) -> Self {
|
|
||||||
Self(bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&[u8]> for KemPublicKey {
|
|
||||||
fn from(bytes: &[u8]) -> Self {
|
|
||||||
Self(bytes.to_vec())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&KemPublicKey> for Vec<u8> {
|
|
||||||
fn from(key: &KemPublicKey) -> Vec<u8> {
|
|
||||||
key.0.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---
|
|
||||||
|
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
|
||||||
#[cfg_attr(feature = "serde", serde(transparent))]
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct SignaturePqPublicKey(Vec<u8>);
|
|
||||||
|
|
||||||
impl SignaturePqPublicKey {
|
|
||||||
pub fn new(bytes: Vec<u8>) -> 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<Self, crate::error::CryptoError> {
|
|
||||||
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<Vec<u8>> for SignaturePqPublicKey {
|
|
||||||
fn from(bytes: Vec<u8>) -> Self {
|
|
||||||
Self(bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&[u8]> for SignaturePqPublicKey {
|
|
||||||
fn from(bytes: &[u8]) -> Self {
|
|
||||||
Self(bytes.to_vec())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&SignaturePqPublicKey> for Vec<u8> {
|
|
||||||
fn from(key: &SignaturePqPublicKey) -> Vec<u8> {
|
|
||||||
key.0.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Keyring ---
|
// --- Keyring ---
|
||||||
|
|
||||||
|
|
@ -456,6 +200,35 @@ 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 {
|
pub fn public_key_bundle(&self) -> PublicKeyBundle {
|
||||||
PublicKeyBundle {
|
PublicKeyBundle {
|
||||||
kem_public_key: self.kem_public_key.clone(),
|
kem_public_key: self.kem_public_key.clone(),
|
||||||
|
|
@ -464,7 +237,91 @@ impl Keyring {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_bytes(&self) -> Vec<u8> {
|
/// 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<Zeroizing<Vec<u8>>, crate::error::CryptoError> {
|
||||||
let fields: &[&[u8]] = &[
|
let fields: &[&[u8]] = &[
|
||||||
self.kem_public_key.as_bytes(),
|
self.kem_public_key.as_bytes(),
|
||||||
self.kem_secret_key.as_bytes(),
|
self.kem_secret_key.as_bytes(),
|
||||||
|
|
@ -473,25 +330,33 @@ impl Keyring {
|
||||||
self.sig_cl_public_key.as_bytes(),
|
self.sig_cl_public_key.as_bytes(),
|
||||||
self.sig_cl_secret_key.as_bytes(),
|
self.sig_cl_secret_key.as_bytes(),
|
||||||
];
|
];
|
||||||
let mut out = Vec::new();
|
let mut out = Zeroizing::new(Vec::new());
|
||||||
for f in fields {
|
for f in fields {
|
||||||
out.extend_from_slice(&(f.len() as u16).to_be_bytes());
|
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);
|
out.extend_from_slice(f);
|
||||||
}
|
}
|
||||||
out
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[deprecated(note = "use try_to_bytes for the primary fallible serializer")]
|
||||||
|
pub fn to_bytes(&self) -> Result<Zeroizing<Vec<u8>>, crate::error::CryptoError> {
|
||||||
|
self.try_to_bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, crate::error::CryptoError> {
|
pub fn from_bytes(bytes: &[u8]) -> Result<Self, crate::error::CryptoError> {
|
||||||
use crate::error::CryptoError;
|
use crate::error::CryptoError;
|
||||||
let mut offset = 0;
|
let mut offset = 0;
|
||||||
let read_key = |offset: &mut usize| -> Result<Vec<u8>, CryptoError> {
|
let read_key = |offset: &mut usize| -> Result<Vec<u8>, CryptoError> {
|
||||||
let len = u16::from_be_bytes(
|
let slice = bytes
|
||||||
bytes
|
|
||||||
.get(*offset..*offset + 2)
|
.get(*offset..*offset + 2)
|
||||||
.ok_or(CryptoError::InvalidKeyLength)?
|
.ok_or(CryptoError::InvalidKeyLength)?;
|
||||||
.try_into()
|
let len = if let Ok(arr) = <[u8; 2]>::try_from(slice) {
|
||||||
.expect("slice is 2 bytes, verified above"),
|
u16::from_be_bytes(arr)
|
||||||
) as usize;
|
} else {
|
||||||
|
return Err(CryptoError::InvalidKeyLength);
|
||||||
|
} as usize;
|
||||||
*offset += 2;
|
*offset += 2;
|
||||||
let key = bytes
|
let key = bytes
|
||||||
.get(*offset..*offset + len)
|
.get(*offset..*offset + len)
|
||||||
|
|
@ -509,6 +374,39 @@ impl Keyring {
|
||||||
sig_cl_public_key: SignaturePublicKey::new(read_key(&mut offset)?),
|
sig_cl_public_key: SignaturePublicKey::new(read_key(&mut offset)?),
|
||||||
sig_cl_secret_key: SignaturePrivateKey::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<String, crate::error::CryptoError> {
|
||||||
|
self.try_to_hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_to_hex(&self) -> Result<String, crate::error::CryptoError> {
|
||||||
|
Ok(bytes_to_hex(&self.try_to_bytes()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_hex(s: &str) -> Result<Self, crate::error::CryptoError> {
|
||||||
|
Self::from_bytes(&hex_to_bytes(s)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[deprecated(note = "use try_to_base64 for the primary fallible serializer")]
|
||||||
|
pub fn to_base64(&self) -> Result<String, crate::error::CryptoError> {
|
||||||
|
self.try_to_base64()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_to_base64(&self) -> Result<String, crate::error::CryptoError> {
|
||||||
|
Ok(bytes_to_base64(&self.try_to_bytes()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_base64(s: &str) -> Result<Self, crate::error::CryptoError> {
|
||||||
|
Self::from_bytes(&base64_to_bytes(s)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -519,12 +417,6 @@ impl TryFrom<&[u8]> for Keyring {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&Keyring> for Vec<u8> {
|
|
||||||
fn from(keyring: &Keyring) -> Vec<u8> {
|
|
||||||
keyring.to_bytes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for Keyring {
|
impl fmt::Debug for Keyring {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
f.debug_struct("Keyring")
|
f.debug_struct("Keyring")
|
||||||
|
|
@ -568,7 +460,6 @@ impl PublicKeyBundle {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa", feature = "mlkem-tls"))]
|
|
||||||
pub fn validate(&self) -> Result<(), crate::error::CryptoError> {
|
pub fn validate(&self) -> Result<(), crate::error::CryptoError> {
|
||||||
use crate::error::CryptoError;
|
use crate::error::CryptoError;
|
||||||
if self.sig_cl_public_key.as_bytes().len() != SIG_CL_PUBLIC_KEY_LEN {
|
if self.sig_cl_public_key.as_bytes().len() != SIG_CL_PUBLIC_KEY_LEN {
|
||||||
|
|
@ -580,25 +471,70 @@ impl PublicKeyBundle {
|
||||||
if self.kem_public_key.as_bytes().len() != KEM_PUBLIC_KEY_LEN {
|
if self.kem_public_key.as_bytes().len() != KEM_PUBLIC_KEY_LEN {
|
||||||
return Err(CryptoError::InvalidKeyLength);
|
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::<ml_dsa::MlDsa65>::try_from(
|
||||||
|
self.sig_pq_public_key.as_bytes(),
|
||||||
|
)
|
||||||
|
.map_err(|_| CryptoError::InvalidKeyMaterial)?;
|
||||||
|
let _ = ml_dsa::VerifyingKey::<ml_dsa::MlDsa65>::decode(&encoded);
|
||||||
|
}
|
||||||
|
#[cfg(feature = "mlkem-tls")]
|
||||||
|
{
|
||||||
|
crate::kem::HybridKem::encapsulate(&self.kem_public_key)
|
||||||
|
.map_err(|_| CryptoError::InvalidKeyMaterial)?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn as_bytes(&self) -> Vec<u8> {
|
pub fn try_as_bytes(&self) -> Result<Vec<u8>, crate::error::CryptoError> {
|
||||||
let kem = self.kem_public_key.as_bytes();
|
let kem = self.kem_public_key.as_bytes();
|
||||||
let pq = self.sig_pq_public_key.as_bytes();
|
let pq = self.sig_pq_public_key.as_bytes();
|
||||||
let cl = self.sig_cl_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);
|
let mut out = Vec::with_capacity(kem.len() + pq.len() + cl.len() + 6);
|
||||||
out.extend_from_slice(&(kem.len() as u16).to_be_bytes());
|
out.extend_from_slice(&kem_len.to_be_bytes());
|
||||||
out.extend_from_slice(kem);
|
out.extend_from_slice(kem);
|
||||||
out.extend_from_slice(&(pq.len() as u16).to_be_bytes());
|
out.extend_from_slice(&pq_len.to_be_bytes());
|
||||||
out.extend_from_slice(pq);
|
out.extend_from_slice(pq);
|
||||||
out.extend_from_slice(&(cl.len() as u16).to_be_bytes());
|
out.extend_from_slice(&cl_len.to_be_bytes());
|
||||||
out.extend_from_slice(cl);
|
out.extend_from_slice(cl);
|
||||||
out
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[deprecated(note = "use try_as_bytes for the primary fallible serializer")]
|
||||||
|
pub fn as_bytes(&self) -> Result<Vec<u8>, crate::error::CryptoError> {
|
||||||
|
self.try_as_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a complete suite-compatible public bundle.
|
||||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, crate::error::CryptoError> {
|
pub fn from_bytes(bytes: &[u8]) -> Result<Self, crate::error::CryptoError> {
|
||||||
|
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<Self, crate::error::CryptoError> {
|
||||||
use crate::error::CryptoError;
|
use crate::error::CryptoError;
|
||||||
let mut offset = 0;
|
let mut offset = 0;
|
||||||
|
|
||||||
|
|
@ -638,6 +574,11 @@ impl PublicKeyBundle {
|
||||||
.ok_or(CryptoError::InvalidKeyLength)?
|
.ok_or(CryptoError::InvalidKeyLength)?
|
||||||
.to_vec(),
|
.to_vec(),
|
||||||
);
|
);
|
||||||
|
offset += cl_len;
|
||||||
|
|
||||||
|
if offset != bytes.len() {
|
||||||
|
return Err(CryptoError::InvalidKeyLength);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
kem_public_key: kem,
|
kem_public_key: kem,
|
||||||
|
|
@ -645,6 +586,28 @@ impl PublicKeyBundle {
|
||||||
sig_cl_public_key: cl,
|
sig_cl_public_key: cl,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a complete, suite-compatible public bundle.
|
||||||
|
pub fn from_bytes_validated(bytes: &[u8]) -> Result<Self, crate::error::CryptoError> {
|
||||||
|
Self::from_bytes(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[deprecated(note = "use try_to_base64 for the primary fallible serializer")]
|
||||||
|
pub fn to_base64(&self) -> Result<String, crate::error::CryptoError> {
|
||||||
|
self.try_to_base64()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_to_base64(&self) -> Result<String, crate::error::CryptoError> {
|
||||||
|
Ok(bytes_to_base64(&self.try_as_bytes()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_base64(s: &str) -> Result<Self, crate::error::CryptoError> {
|
||||||
|
Self::from_bytes(&base64_to_bytes(s)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_base64_unvalidated(s: &str) -> Result<Self, crate::error::CryptoError> {
|
||||||
|
Self::from_bytes_unvalidated(&base64_to_bytes(s)?)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<&[u8]> for PublicKeyBundle {
|
impl TryFrom<&[u8]> for PublicKeyBundle {
|
||||||
|
|
@ -654,12 +617,6 @@ impl TryFrom<&[u8]> for PublicKeyBundle {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&PublicKeyBundle> for Vec<u8> {
|
|
||||||
fn from(bundle: &PublicKeyBundle) -> Vec<u8> {
|
|
||||||
bundle.as_bytes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for PublicKeyBundle {
|
impl fmt::Debug for PublicKeyBundle {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
f.debug_struct("PublicKeyBundle")
|
f.debug_struct("PublicKeyBundle")
|
||||||
|
|
@ -675,14 +632,14 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn public_key_bundle_roundtrip() {
|
fn public_key_bundle_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let kem = KemPublicKey::new(vec![1u8; 32]);
|
let kem = KemPublicKey::new(vec![1u8; 32]);
|
||||||
let pq = SignaturePqPublicKey::new(vec![2u8; 64]);
|
let pq = SignaturePqPublicKey::new(vec![2u8; 64]);
|
||||||
let cl = SignaturePublicKey::new(vec![3u8; 32]);
|
let cl = SignaturePublicKey::new(vec![3u8; 32]);
|
||||||
|
|
||||||
let bundle = PublicKeyBundle::new(kem, pq, cl);
|
let bundle = PublicKeyBundle::new(kem, pq, cl);
|
||||||
let bytes = bundle.as_bytes();
|
let bytes = bundle.try_as_bytes()?;
|
||||||
let recovered = PublicKeyBundle::from_bytes(&bytes).unwrap();
|
let recovered = PublicKeyBundle::from_bytes_unvalidated(&bytes)?;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bundle.kem_public_key.as_bytes(),
|
bundle.kem_public_key.as_bytes(),
|
||||||
|
|
@ -696,22 +653,42 @@ mod tests {
|
||||||
bundle.sig_cl_public_key.as_bytes(),
|
bundle.sig_cl_public_key.as_bytes(),
|
||||||
recovered.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]
|
#[test]
|
||||||
fn public_key_bundle_try_from_roundtrip() {
|
fn public_key_bundle_try_from_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let bundle = PublicKeyBundle::new(
|
let bundle = PublicKeyBundle::new(
|
||||||
KemPublicKey::new(vec![0xABu8; 48]),
|
KemPublicKey::new(vec![0xABu8; 48]),
|
||||||
SignaturePqPublicKey::new(vec![0xCDu8; 96]),
|
SignaturePqPublicKey::new(vec![0xCDu8; 96]),
|
||||||
SignaturePublicKey::new(vec![0xEFu8; 32]),
|
SignaturePublicKey::new(vec![0xEFu8; 32]),
|
||||||
);
|
);
|
||||||
let bytes: Vec<u8> = Vec::from(&bundle);
|
let bytes = bundle.try_as_bytes()?;
|
||||||
let recovered = PublicKeyBundle::try_from(bytes.as_slice()).unwrap();
|
let recovered = PublicKeyBundle::from_bytes_unvalidated(bytes.as_slice())?;
|
||||||
assert_eq!(bundle.as_bytes(), recovered.as_bytes());
|
assert_eq!(bundle.try_as_bytes()?, recovered.try_as_bytes()?);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keyring_roundtrip() {
|
fn keyring_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let keyring = Keyring::new(
|
let keyring = Keyring::new(
|
||||||
KemPublicKey::new(vec![1u8; 32]),
|
KemPublicKey::new(vec![1u8; 32]),
|
||||||
KemPrivateKey::new(vec![2u8; 32]),
|
KemPrivateKey::new(vec![2u8; 32]),
|
||||||
|
|
@ -720,8 +697,8 @@ mod tests {
|
||||||
SignaturePublicKey::new(vec![5u8; 32]),
|
SignaturePublicKey::new(vec![5u8; 32]),
|
||||||
SignaturePrivateKey::new(vec![6u8; 32]),
|
SignaturePrivateKey::new(vec![6u8; 32]),
|
||||||
);
|
);
|
||||||
let bytes = keyring.to_bytes();
|
let bytes = keyring.try_to_bytes()?;
|
||||||
let recovered = Keyring::from_bytes(&bytes).unwrap();
|
let recovered = Keyring::from_bytes(bytes.as_slice())?;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
keyring.kem_public_key.as_bytes(),
|
keyring.kem_public_key.as_bytes(),
|
||||||
recovered.kem_public_key.as_bytes()
|
recovered.kem_public_key.as_bytes()
|
||||||
|
|
@ -734,10 +711,73 @@ mod tests {
|
||||||
keyring.sig_cl_public_key.as_bytes(),
|
keyring.sig_cl_public_key.as_bytes(),
|
||||||
recovered.sig_cl_public_key.as_bytes()
|
recovered.sig_cl_public_key.as_bytes()
|
||||||
);
|
);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keyring_try_from_roundtrip() {
|
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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
let keyring = Keyring::new(
|
let keyring = Keyring::new(
|
||||||
KemPublicKey::new(vec![0u8; 16]),
|
KemPublicKey::new(vec![0u8; 16]),
|
||||||
KemPrivateKey::new(vec![1u8; 16]),
|
KemPrivateKey::new(vec![1u8; 16]),
|
||||||
|
|
@ -746,18 +786,65 @@ mod tests {
|
||||||
SignaturePublicKey::new(vec![4u8; 16]),
|
SignaturePublicKey::new(vec![4u8; 16]),
|
||||||
SignaturePrivateKey::new(vec![5u8; 16]),
|
SignaturePrivateKey::new(vec![5u8; 16]),
|
||||||
);
|
);
|
||||||
let bytes: Vec<u8> = Vec::from(&keyring);
|
let bytes = keyring.try_to_bytes()?;
|
||||||
let recovered = Keyring::try_from(bytes.as_slice()).unwrap();
|
let recovered = Keyring::try_from(bytes.as_slice())?;
|
||||||
assert_eq!(keyring.to_bytes(), recovered.to_bytes());
|
assert_eq!(keyring.try_to_bytes()?, recovered.try_to_bytes()?);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hex_roundtrip() {
|
fn hex_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let key = KemPublicKey::new(vec![0xDE, 0xAD, 0xBE, 0xEF]);
|
let key = KemPublicKey::new(vec![0xDE, 0xAD, 0xBE, 0xEF]);
|
||||||
let hex = key.to_hex();
|
let hex = key.to_hex();
|
||||||
assert_eq!(hex, "deadbeef");
|
assert_eq!(hex, "deadbeef");
|
||||||
let recovered = KemPublicKey::from_hex(&hex).unwrap();
|
let recovered = KemPublicKey::from_hex(&hex)?;
|
||||||
assert_eq!(key.as_bytes(), recovered.as_bytes());
|
assert_eq!(key.as_bytes(), recovered.as_bytes());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keyring_hex_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
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<dyn std::error::Error>> {
|
||||||
|
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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,12 @@ pub mod auth;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod keypair;
|
pub mod keypair;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use std::sync::Once;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
static CRYPTO_INIT: Once = Once::new();
|
||||||
|
|
||||||
#[cfg(feature = "sha2")]
|
#[cfg(feature = "sha2")]
|
||||||
pub mod hash;
|
pub mod hash;
|
||||||
|
|
||||||
|
|
@ -12,6 +18,9 @@ pub mod kdf;
|
||||||
#[cfg(any(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
#[cfg(any(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||||
pub mod sign;
|
pub mod sign;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa", feature = "parallel"))]
|
||||||
|
pub mod sign_parallel;
|
||||||
|
|
||||||
#[cfg(any(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
#[cfg(any(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||||
pub use sign::SigAlgorithm;
|
pub use sign::SigAlgorithm;
|
||||||
|
|
||||||
|
|
@ -22,6 +31,9 @@ pub mod enc;
|
||||||
|
|
||||||
pub mod helper;
|
pub mod helper;
|
||||||
|
|
||||||
|
#[cfg(feature = "tls")]
|
||||||
|
pub mod tls;
|
||||||
|
|
||||||
pub use aead::{AeadCipher, AeadDecrypt, AeadEncrypt};
|
pub use aead::{AeadCipher, AeadDecrypt, AeadEncrypt};
|
||||||
pub use error::CryptoError;
|
pub use error::CryptoError;
|
||||||
pub use keypair::{
|
pub use keypair::{
|
||||||
|
|
@ -31,7 +43,7 @@ pub use keypair::{
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
pub use aead::ChaCha20Poly1305;
|
pub use aead::{ChaCha20Poly1305, XChaCha20Poly1305};
|
||||||
|
|
||||||
#[cfg(feature = "aes-gcm")]
|
#[cfg(feature = "aes-gcm")]
|
||||||
pub use aead::Aes256Gcm;
|
pub use aead::Aes256Gcm;
|
||||||
|
|
@ -43,11 +55,13 @@ pub use sign::{Ed25519Signer, SignatureScheme, verify_ed25519};
|
||||||
pub use sign::{MlDsaSigner, verify_ml_dsa};
|
pub use sign::{MlDsaSigner, verify_ml_dsa};
|
||||||
|
|
||||||
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||||
pub use sign::{DualSignature, sign_dual};
|
pub use sign::{DualSignature, DualSigner, sign_dual};
|
||||||
|
|
||||||
#[cfg(feature = "sha2")]
|
#[cfg(feature = "sha2")]
|
||||||
pub use hash::{Sha256Hasher, sha256, sha256_double};
|
pub use hash::{Sha256Hasher, sha256, sha256_double};
|
||||||
|
|
||||||
|
#[cfg(feature = "password-kdf")]
|
||||||
|
pub use kdf::derive_password_key;
|
||||||
#[cfg(feature = "hkdf")]
|
#[cfg(feature = "hkdf")]
|
||||||
pub use kdf::{derive_encryption_key, hkdf_expand, hkdf_extract};
|
pub use kdf::{derive_encryption_key, hkdf_expand, hkdf_extract};
|
||||||
|
|
||||||
|
|
@ -56,11 +70,25 @@ pub use kem::{Encapsulated, HybridKem};
|
||||||
|
|
||||||
pub use enc::EncryptionType;
|
pub use enc::EncryptionType;
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
/// Install Rustls' AWS-LC provider once for the entire process.
|
||||||
pub use enc::{decrypt_with, encrypt_for};
|
///
|
||||||
|
/// Rustls only accepts one process-wide default provider. Calling this helper
|
||||||
|
/// from every TLS entry point makes that initialization idempotent.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn ensure_crypto_provider() {
|
||||||
|
CRYPTO_INIT.call_once(|| {
|
||||||
|
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
pub use helper::{ENCRYPT_DOMAIN, KEY_WRAP_DOMAIN};
|
||||||
pub use helper::{MultiEncryptedMessage, RecipientEntry, decrypt_multi, encrypt_multi};
|
|
||||||
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
|
pub use helper::{
|
||||||
|
MAX_RECIPIENTS, MultiEncryptedMessage, MultiEncryptedMessageRef, RecipientEntry,
|
||||||
|
decrypt_multi_for, decrypt_multi_for_parts, decrypt_multi_for_parts_with_limit,
|
||||||
|
encrypt_multi_for,
|
||||||
|
};
|
||||||
|
|
||||||
/* ================================ TESTS ================================ */
|
/* ================================ TESTS ================================ */
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -69,13 +97,14 @@ mod tests {
|
||||||
|
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
#[test]
|
#[test]
|
||||||
fn aead_encrypt_decrypt() {
|
fn aead_encrypt_decrypt() -> Result<(), CryptoError> {
|
||||||
use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305};
|
use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305};
|
||||||
let key = [0xAB; 32];
|
let key = [0xAB; 32];
|
||||||
let cipher = ChaCha20Poly1305::new(key);
|
let cipher = ChaCha20Poly1305::new(key);
|
||||||
let ct = cipher.encrypt(b"hello world", b"aad").unwrap();
|
let ct = cipher.encrypt(b"hello world", b"aad")?;
|
||||||
let pt = cipher.decrypt(&ct, b"aad").unwrap();
|
let pt = cipher.decrypt(&ct, b"aad")?;
|
||||||
assert_eq!(pt, b"hello world");
|
assert_eq!(pt, b"hello world");
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
|
|
@ -84,7 +113,9 @@ mod tests {
|
||||||
use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305};
|
use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305};
|
||||||
let cipher_a = ChaCha20Poly1305::new([0xAB; 32]);
|
let cipher_a = ChaCha20Poly1305::new([0xAB; 32]);
|
||||||
let cipher_b = ChaCha20Poly1305::new([0xCD; 32]);
|
let cipher_b = ChaCha20Poly1305::new([0xCD; 32]);
|
||||||
let ct = cipher_a.encrypt(b"hello", b"").unwrap();
|
let ct = cipher_a
|
||||||
|
.encrypt(b"hello", b"")
|
||||||
|
.expect("encryption should succeed");
|
||||||
assert!(cipher_b.decrypt(&ct, b"").is_err());
|
assert!(cipher_b.decrypt(&ct, b"").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,7 +124,9 @@ mod tests {
|
||||||
fn aead_wrong_aad_fails() {
|
fn aead_wrong_aad_fails() {
|
||||||
use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305};
|
use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305};
|
||||||
let cipher = ChaCha20Poly1305::new([0xAB; 32]);
|
let cipher = ChaCha20Poly1305::new([0xAB; 32]);
|
||||||
let ct = cipher.encrypt(b"hello", b"correct-aad").unwrap();
|
let ct = cipher
|
||||||
|
.encrypt(b"hello", b"correct-aad")
|
||||||
|
.expect("encryption should succeed");
|
||||||
assert!(cipher.decrypt(&ct, b"wrong-aad").is_err());
|
assert!(cipher.decrypt(&ct, b"wrong-aad").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,12 +135,16 @@ mod tests {
|
||||||
fn ed25519_sign_verify() {
|
fn ed25519_sign_verify() {
|
||||||
let (signer, sk, pk) = Ed25519Signer::generate();
|
let (signer, sk, pk) = Ed25519Signer::generate();
|
||||||
let msg = b"test message";
|
let msg = b"test message";
|
||||||
let sig = signer.sign(msg).unwrap();
|
let sig = signer.sign(msg).expect("signing should succeed");
|
||||||
signer.verify(msg, &sig).unwrap();
|
signer
|
||||||
verify_ed25519(&pk, msg, &sig).unwrap();
|
.verify(msg, &sig)
|
||||||
|
.expect("verification should succeed");
|
||||||
|
verify_ed25519(&pk, msg, &sig).expect("verification should succeed");
|
||||||
|
|
||||||
let loaded = Ed25519Signer::new(&sk).unwrap();
|
let loaded = Ed25519Signer::new(&sk).expect("signer loading should succeed");
|
||||||
loaded.verify(msg, &sig).unwrap();
|
loaded
|
||||||
|
.verify(msg, &sig)
|
||||||
|
.expect("verification should succeed");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ed25519-dalek")]
|
#[cfg(feature = "ed25519-dalek")]
|
||||||
|
|
@ -115,7 +152,7 @@ mod tests {
|
||||||
fn ed25519_wrong_sig_fails() {
|
fn ed25519_wrong_sig_fails() {
|
||||||
let (signer, _, pk) = Ed25519Signer::generate();
|
let (signer, _, pk) = Ed25519Signer::generate();
|
||||||
let msg = b"test message";
|
let msg = b"test message";
|
||||||
let sig = signer.sign(msg).unwrap();
|
let sig = signer.sign(msg).expect("signing should succeed");
|
||||||
assert!(verify_ed25519(&pk, b"wrong message", &sig).is_err());
|
assert!(verify_ed25519(&pk, b"wrong message", &sig).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,12 +161,16 @@ mod tests {
|
||||||
fn mldsa_sign_verify() {
|
fn mldsa_sign_verify() {
|
||||||
let (signer, sk, pk) = MlDsaSigner::generate();
|
let (signer, sk, pk) = MlDsaSigner::generate();
|
||||||
let msg = b"test message";
|
let msg = b"test message";
|
||||||
let sig = signer.sign(msg).unwrap();
|
let sig = signer.sign(msg).expect("signing should succeed");
|
||||||
signer.verify(msg, &sig).unwrap();
|
signer
|
||||||
verify_ml_dsa(&pk, msg, &sig).unwrap();
|
.verify(msg, &sig)
|
||||||
|
.expect("verification should succeed");
|
||||||
|
verify_ml_dsa(&pk, msg, &sig).expect("verification should succeed");
|
||||||
|
|
||||||
let loaded = MlDsaSigner::new(&sk, &pk).unwrap();
|
let loaded = MlDsaSigner::new(&sk, &pk).expect("signer loading should succeed");
|
||||||
loaded.verify(msg, &sig).unwrap();
|
loaded
|
||||||
|
.verify(msg, &sig)
|
||||||
|
.expect("verification should succeed");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ml-dsa")]
|
#[cfg(feature = "ml-dsa")]
|
||||||
|
|
@ -137,7 +178,7 @@ mod tests {
|
||||||
fn mldsa_wrong_sig_fails() {
|
fn mldsa_wrong_sig_fails() {
|
||||||
let (signer, _, pk) = MlDsaSigner::generate();
|
let (signer, _, pk) = MlDsaSigner::generate();
|
||||||
let msg = b"test message";
|
let msg = b"test message";
|
||||||
let sig = signer.sign(msg).unwrap();
|
let sig = signer.sign(msg).expect("signing should succeed");
|
||||||
assert!(verify_ml_dsa(&pk, b"wrong message", &sig).is_err());
|
assert!(verify_ml_dsa(&pk, b"wrong message", &sig).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,9 +189,10 @@ mod tests {
|
||||||
|
|
||||||
let (ed_signer, _, _) = Ed25519Signer::generate();
|
let (ed_signer, _, _) = Ed25519Signer::generate();
|
||||||
let (ml_signer, _, _) = MlDsaSigner::generate();
|
let (ml_signer, _, _) = MlDsaSigner::generate();
|
||||||
let dual = sign_dual(ed_signer.signing_key(), ml_signer.signing_key(), b"msg").unwrap();
|
let dual = sign_dual(ed_signer.signing_key(), ml_signer.signing_key(), b"msg")
|
||||||
|
.expect("dual signing should succeed");
|
||||||
dual.verify(ed_signer.verifying_key(), ml_signer.verifying_key(), b"msg")
|
dual.verify(ed_signer.verifying_key(), ml_signer.verifying_key(), b"msg")
|
||||||
.unwrap();
|
.expect("dual verification should succeed");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||||
|
|
@ -160,7 +202,8 @@ mod tests {
|
||||||
|
|
||||||
let (ed_signer, _, _) = Ed25519Signer::generate();
|
let (ed_signer, _, _) = Ed25519Signer::generate();
|
||||||
let (ml_signer, _, _) = MlDsaSigner::generate();
|
let (ml_signer, _, _) = MlDsaSigner::generate();
|
||||||
let dual = sign_dual(ed_signer.signing_key(), ml_signer.signing_key(), b"msg").unwrap();
|
let dual = sign_dual(ed_signer.signing_key(), ml_signer.signing_key(), b"msg")
|
||||||
|
.expect("dual signing should succeed");
|
||||||
assert!(
|
assert!(
|
||||||
dual.verify(
|
dual.verify(
|
||||||
ed_signer.verifying_key(),
|
ed_signer.verifying_key(),
|
||||||
|
|
@ -171,21 +214,39 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||||
|
#[test]
|
||||||
|
fn dual_scheme_implements_signature_trait() {
|
||||||
|
use crate::sign::SignatureScheme;
|
||||||
|
|
||||||
|
let (signer, _, _, _, _) = DualSigner::generate();
|
||||||
|
let signature = signer.sign(b"msg").expect("dual signing should succeed");
|
||||||
|
assert_eq!(signer.algorithm(), SigAlgorithm::DUAL);
|
||||||
|
signer
|
||||||
|
.verify(b"msg", &signature)
|
||||||
|
.expect("dual verification should succeed");
|
||||||
|
assert!(signer.verify(b"wrong", &signature).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "hkdf")]
|
#[cfg(feature = "hkdf")]
|
||||||
#[test]
|
#[test]
|
||||||
fn hkdf_expand_produces_key() {
|
fn hkdf_expand_produces_key() {
|
||||||
let key = derive_encryption_key(b"ikm", b"salt", b"context").unwrap();
|
let key = derive_encryption_key(b"ikm", b"salt", b"context")
|
||||||
|
.expect("key derivation should succeed");
|
||||||
assert_eq!(key.len(), 32);
|
assert_eq!(key.len(), 32);
|
||||||
|
|
||||||
let expanded = hkdf_expand(b"ikm", b"salt", b"info", 64).unwrap();
|
let expanded =
|
||||||
|
hkdf_expand(b"ikm", b"salt", b"info", 64).expect("HKDF expansion should succeed");
|
||||||
assert_eq!(expanded.len(), 64);
|
assert_eq!(expanded.len(), 64);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "hkdf")]
|
#[cfg(feature = "hkdf")]
|
||||||
#[test]
|
#[test]
|
||||||
fn hkdf_different_info_different_key() {
|
fn hkdf_different_info_different_key() {
|
||||||
let a = derive_encryption_key(b"ikm", b"salt", b"info-a").unwrap();
|
let a = derive_encryption_key(b"ikm", b"salt", b"info-a")
|
||||||
let b = derive_encryption_key(b"ikm", b"salt", b"info-b").unwrap();
|
.expect("key derivation should succeed");
|
||||||
|
let b = derive_encryption_key(b"ikm", b"salt", b"info-b")
|
||||||
|
.expect("key derivation should succeed");
|
||||||
assert_ne!(a, b);
|
assert_ne!(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,8 +315,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn keyring_serialize_roundtrip() {
|
fn keyring_serialize_roundtrip() {
|
||||||
let kr = Keyring::generate();
|
let kr = Keyring::generate();
|
||||||
let bytes = kr.to_bytes();
|
let bytes = kr
|
||||||
let loaded = Keyring::from_bytes(&bytes).unwrap();
|
.try_to_bytes()
|
||||||
|
.expect("keyring serialization should succeed");
|
||||||
|
let loaded = Keyring::from_bytes(&bytes).expect("keyring roundtrip should succeed");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
kr.kem_public_key.as_bytes(),
|
kr.kem_public_key.as_bytes(),
|
||||||
loaded.kem_public_key.as_bytes()
|
loaded.kem_public_key.as_bytes()
|
||||||
|
|
@ -275,8 +338,10 @@ mod tests {
|
||||||
fn public_key_bundle_serialize_roundtrip() {
|
fn public_key_bundle_serialize_roundtrip() {
|
||||||
let kr = Keyring::generate();
|
let kr = Keyring::generate();
|
||||||
let bundle = kr.public_key_bundle();
|
let bundle = kr.public_key_bundle();
|
||||||
let bytes = bundle.as_bytes();
|
let bytes = bundle
|
||||||
let loaded = PublicKeyBundle::from_bytes(&bytes).unwrap();
|
.try_as_bytes()
|
||||||
|
.expect("bundle serialization should succeed");
|
||||||
|
let loaded = PublicKeyBundle::from_bytes(&bytes).expect("bundle roundtrip should succeed");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bundle.kem_public_key.as_bytes(),
|
bundle.kem_public_key.as_bytes(),
|
||||||
loaded.kem_public_key.as_bytes()
|
loaded.kem_public_key.as_bytes()
|
||||||
|
|
@ -295,22 +360,52 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn hybrid_kem_roundtrip() {
|
fn hybrid_kem_roundtrip() {
|
||||||
let (sk, pk) = HybridKem::generate_keypair();
|
let (sk, pk) = HybridKem::generate_keypair();
|
||||||
let enc = HybridKem::encapsulate(&pk).unwrap();
|
let enc = HybridKem::encapsulate(&pk).expect("encapsulation should succeed");
|
||||||
let ss = HybridKem::decapsulate(&sk, &enc.ciphertext).unwrap();
|
let ss =
|
||||||
|
HybridKem::decapsulate(&sk, &enc.ciphertext).expect("decapsulation should succeed");
|
||||||
assert_eq!(enc.shared_secret, ss);
|
assert_eq!(enc.shared_secret, ss);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
#[cfg(all(
|
||||||
#[test]
|
feature = "mlkem-tls",
|
||||||
fn encrypt_multi_roundtrip() {
|
feature = "hkdf",
|
||||||
use crate::helper::{decrypt_multi, encrypt_multi};
|
feature = "ml-dsa",
|
||||||
|
feature = "ed25519-dalek"
|
||||||
|
))]
|
||||||
|
fn multi_envelope_roundtrip(encryption_type: EncryptionType) {
|
||||||
|
use crate::helper::{decrypt_multi_for, encrypt_multi_for};
|
||||||
use crate::keypair::Keyring;
|
use crate::keypair::Keyring;
|
||||||
|
|
||||||
let kr = Keyring::generate();
|
let kr = Keyring::generate();
|
||||||
let entities = vec![kr.public_key_bundle()];
|
let entities = vec![kr.public_key_bundle()];
|
||||||
let msg = b"secret data";
|
let msg = b"secret data";
|
||||||
let ct = encrypt_multi(msg, b"aad", &entities).unwrap();
|
let ct = encrypt_multi_for(encryption_type, 7, msg, &entities)
|
||||||
let pt = decrypt_multi(&ct, b"aad", &kr).unwrap();
|
.expect("multi encrypt should succeed");
|
||||||
|
let pt = decrypt_multi_for(&ct, 7, &kr).expect("multi decrypt should succeed");
|
||||||
assert_eq!(pt, msg);
|
assert_eq!(pt, msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(
|
||||||
|
feature = "mlkem-tls",
|
||||||
|
feature = "hkdf",
|
||||||
|
feature = "ml-dsa",
|
||||||
|
feature = "ed25519-dalek",
|
||||||
|
feature = "chacha20poly1305"
|
||||||
|
))]
|
||||||
|
#[test]
|
||||||
|
fn chacha20_multi_envelope_roundtrip() {
|
||||||
|
multi_envelope_roundtrip(EncryptionType::MlKemChaCha20Poly1305);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(
|
||||||
|
feature = "mlkem-tls",
|
||||||
|
feature = "hkdf",
|
||||||
|
feature = "ml-dsa",
|
||||||
|
feature = "ed25519-dalek",
|
||||||
|
feature = "aes-gcm"
|
||||||
|
))]
|
||||||
|
#[test]
|
||||||
|
fn aes_gcm_multi_envelope_roundtrip() {
|
||||||
|
multi_envelope_roundtrip(EncryptionType::MlKemAes256Gcm);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,12 @@ impl SigAlgorithm {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ed25519-dalek")]
|
|
||||||
use rand_core::RngCore;
|
|
||||||
|
|
||||||
#[cfg(feature = "ml-dsa")]
|
#[cfg(feature = "ml-dsa")]
|
||||||
use crate::keypair::{SignaturePqPrivateKey, SignaturePqPublicKey};
|
use crate::keypair::{SignaturePqPrivateKey, SignaturePqPublicKey};
|
||||||
|
|
||||||
pub trait SignatureScheme {
|
pub trait SignatureScheme {
|
||||||
|
/// The wire algorithm identifier produced by this signer.
|
||||||
|
fn algorithm(&self) -> u8;
|
||||||
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError>;
|
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError>;
|
||||||
fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError>;
|
fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError>;
|
||||||
}
|
}
|
||||||
|
|
@ -50,9 +49,12 @@ impl Ed25519Signer {
|
||||||
Ok(Self { secret, public })
|
Ok(Self { secret, public })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ed25519-dalek")]
|
||||||
pub fn generate() -> (Self, SignaturePrivateKey, SignaturePublicKey) {
|
pub fn generate() -> (Self, SignaturePrivateKey, SignaturePublicKey) {
|
||||||
|
use rand::RngExt;
|
||||||
|
|
||||||
let mut bytes = [0u8; 32];
|
let mut bytes = [0u8; 32];
|
||||||
rand_core::OsRng.fill_bytes(&mut bytes);
|
rand::rng().fill(&mut bytes);
|
||||||
let secret = ed25519_dalek::SigningKey::from_bytes(&bytes);
|
let secret = ed25519_dalek::SigningKey::from_bytes(&bytes);
|
||||||
let public = secret.verifying_key();
|
let public = secret.verifying_key();
|
||||||
let priv_key = SignaturePrivateKey::new(secret.to_bytes().to_vec());
|
let priv_key = SignaturePrivateKey::new(secret.to_bytes().to_vec());
|
||||||
|
|
@ -76,6 +78,10 @@ impl Ed25519Signer {
|
||||||
|
|
||||||
#[cfg(feature = "ed25519-dalek")]
|
#[cfg(feature = "ed25519-dalek")]
|
||||||
impl SignatureScheme for Ed25519Signer {
|
impl SignatureScheme for Ed25519Signer {
|
||||||
|
fn algorithm(&self) -> u8 {
|
||||||
|
SigAlgorithm::ED25519
|
||||||
|
}
|
||||||
|
|
||||||
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
use ed25519_dalek::Signer;
|
use ed25519_dalek::Signer;
|
||||||
let signature = self.secret.sign(msg).to_bytes().to_vec();
|
let signature = self.secret.sign(msg).to_bytes().to_vec();
|
||||||
|
|
@ -170,6 +176,10 @@ impl MlDsaSigner {
|
||||||
|
|
||||||
#[cfg(feature = "ml-dsa")]
|
#[cfg(feature = "ml-dsa")]
|
||||||
impl SignatureScheme for MlDsaSigner {
|
impl SignatureScheme for MlDsaSigner {
|
||||||
|
fn algorithm(&self) -> u8 {
|
||||||
|
SigAlgorithm::ML_DSA_65
|
||||||
|
}
|
||||||
|
|
||||||
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
use ml_dsa::Signer;
|
use ml_dsa::Signer;
|
||||||
let signature = self
|
let signature = self
|
||||||
|
|
@ -237,6 +247,75 @@ pub fn sign_dual(
|
||||||
Ok(DualSignature { ed25519, mldsa })
|
Ok(DualSignature { ed25519, mldsa })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A signer that produces the canonical concatenated Ed25519 + ML-DSA-65
|
||||||
|
/// signature represented by [`SigAlgorithm::DUAL`].
|
||||||
|
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||||
|
pub struct DualSigner {
|
||||||
|
ed25519: Ed25519Signer,
|
||||||
|
mldsa: MlDsaSigner,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||||
|
impl DualSigner {
|
||||||
|
pub fn new(
|
||||||
|
ed25519_secret: &SignaturePrivateKey,
|
||||||
|
mldsa_secret: &SignaturePqPrivateKey,
|
||||||
|
mldsa_public: &SignaturePqPublicKey,
|
||||||
|
) -> Result<Self, CryptoError> {
|
||||||
|
Ok(Self {
|
||||||
|
ed25519: Ed25519Signer::new(ed25519_secret)?,
|
||||||
|
mldsa: MlDsaSigner::new(mldsa_secret, mldsa_public)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate() -> (
|
||||||
|
Self,
|
||||||
|
SignaturePrivateKey,
|
||||||
|
SignaturePqPrivateKey,
|
||||||
|
SignaturePublicKey,
|
||||||
|
SignaturePqPublicKey,
|
||||||
|
) {
|
||||||
|
let (ed25519, ed25519_secret, ed25519_public) = Ed25519Signer::generate();
|
||||||
|
let (mldsa, mldsa_secret, mldsa_public) = MlDsaSigner::generate();
|
||||||
|
(
|
||||||
|
Self { ed25519, mldsa },
|
||||||
|
ed25519_secret,
|
||||||
|
mldsa_secret,
|
||||||
|
ed25519_public,
|
||||||
|
mldsa_public,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||||
|
impl SignatureScheme for DualSigner {
|
||||||
|
fn algorithm(&self) -> u8 {
|
||||||
|
SigAlgorithm::DUAL
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let dual = sign_dual(self.ed25519.signing_key(), self.mldsa.signing_key(), msg)?;
|
||||||
|
let mut signature = Vec::with_capacity(
|
||||||
|
SigAlgorithm::length(SigAlgorithm::DUAL).expect("known signature algorithm length"),
|
||||||
|
);
|
||||||
|
signature.extend_from_slice(&dual.ed25519);
|
||||||
|
signature.extend_from_slice(&dual.mldsa);
|
||||||
|
Ok(signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError> {
|
||||||
|
let ed_len =
|
||||||
|
SigAlgorithm::length(SigAlgorithm::ED25519).expect("known signature algorithm length");
|
||||||
|
let mldsa_len = SigAlgorithm::length(SigAlgorithm::ML_DSA_65)
|
||||||
|
.expect("known signature algorithm length");
|
||||||
|
if signature.len() != ed_len + mldsa_len {
|
||||||
|
return Err(CryptoError::InvalidSignature);
|
||||||
|
}
|
||||||
|
verify_ed25519(&self.ed25519.public_key(), msg, &signature[..ed_len])?;
|
||||||
|
verify_ml_dsa(&self.mldsa.public_key(), msg, &signature[ed_len..])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl DualSignature {
|
impl DualSignature {
|
||||||
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||||
pub fn verify(
|
pub fn verify(
|
||||||
|
|
|
||||||
73
crypto/src/sign_parallel.rs
Normal file
73
crypto/src/sign_parallel.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
//! Parallel helpers for dual (classical + post-quantum) signatures.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tokio::task;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
CryptoError, SignaturePqPublicKey, SignaturePublicKey, SignatureScheme, verify_ed25519,
|
||||||
|
verify_ml_dsa,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Signs a message with the classical and PQ schemes concurrently on Tokio's blocking pool.
|
||||||
|
pub async fn sign_dual_parallel<S1, S2>(
|
||||||
|
ed_signer: S1,
|
||||||
|
pq_signer: S2,
|
||||||
|
message: Vec<u8>,
|
||||||
|
) -> Result<(Vec<u8>, Vec<u8>), CryptoError>
|
||||||
|
where
|
||||||
|
S1: SignatureScheme + Send + 'static,
|
||||||
|
S2: SignatureScheme + Send + 'static,
|
||||||
|
{
|
||||||
|
let ed_message = message.clone();
|
||||||
|
let ed_handle = task::spawn_blocking(move || ed_signer.sign(&ed_message));
|
||||||
|
let pq_handle = task::spawn_blocking(move || pq_signer.sign(&message));
|
||||||
|
|
||||||
|
let (ed_result, pq_result) = tokio::join!(ed_handle, pq_handle);
|
||||||
|
let ed_signature = ed_result.map_err(|_| CryptoError::SigningFailed)??;
|
||||||
|
let pq_signature = pq_result.map_err(|_| CryptoError::SigningFailed)??;
|
||||||
|
Ok((ed_signature, pq_signature))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signs with an owned classical signer and a shared PQ signer.
|
||||||
|
///
|
||||||
|
/// This avoids reconstructing the ML-DSA signing key when a host signs both
|
||||||
|
/// the challenge and the final response in one authentication handshake.
|
||||||
|
pub async fn sign_dual_parallel_shared_pq<S1, S2>(
|
||||||
|
ed_signer: S1,
|
||||||
|
pq_signer: Arc<S2>,
|
||||||
|
message: Vec<u8>,
|
||||||
|
) -> Result<(Vec<u8>, Vec<u8>), CryptoError>
|
||||||
|
where
|
||||||
|
S1: SignatureScheme + Send + 'static,
|
||||||
|
S2: SignatureScheme + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let ed_message = message.clone();
|
||||||
|
let ed_handle = task::spawn_blocking(move || ed_signer.sign(&ed_message));
|
||||||
|
let pq_handle = task::spawn_blocking(move || pq_signer.sign(&message));
|
||||||
|
|
||||||
|
let (ed_result, pq_result) = tokio::join!(ed_handle, pq_handle);
|
||||||
|
let ed_signature = ed_result.map_err(|_| CryptoError::SigningFailed)??;
|
||||||
|
let pq_signature = pq_result.map_err(|_| CryptoError::SigningFailed)??;
|
||||||
|
Ok((ed_signature, pq_signature))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies the classical and PQ signatures concurrently on Tokio's blocking pool.
|
||||||
|
pub async fn verify_dual_parallel(
|
||||||
|
ed_public_key: SignaturePublicKey,
|
||||||
|
pq_public_key: SignaturePqPublicKey,
|
||||||
|
message: Vec<u8>,
|
||||||
|
ed_signature: Vec<u8>,
|
||||||
|
pq_signature: Vec<u8>,
|
||||||
|
) -> Result<(), CryptoError> {
|
||||||
|
let ed_message = message.clone();
|
||||||
|
let ed_handle =
|
||||||
|
task::spawn_blocking(move || verify_ed25519(&ed_public_key, &ed_message, &ed_signature));
|
||||||
|
let pq_handle =
|
||||||
|
task::spawn_blocking(move || verify_ml_dsa(&pq_public_key, &message, &pq_signature));
|
||||||
|
|
||||||
|
let (ed_result, pq_result) = tokio::join!(ed_handle, pq_handle);
|
||||||
|
ed_result.map_err(|_| CryptoError::VerificationFailed)??;
|
||||||
|
pq_result.map_err(|_| CryptoError::VerificationFailed)??;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
45
crypto/src/tls.rs
Normal file
45
crypto/src/tls.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
use rcgen::{CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, SanType};
|
||||||
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||||
|
use time::{Duration, OffsetDateTime};
|
||||||
|
|
||||||
|
use crate::CryptoError;
|
||||||
|
|
||||||
|
/// Generate a self-signed TLS certificate and private key for development.
|
||||||
|
///
|
||||||
|
/// Returns `(cert_pem, key_pem)` as byte vectors. The certificate is valid for
|
||||||
|
/// the given domain name plus `127.0.0.1` and `::1`, uses ECDSA P-256, and is
|
||||||
|
/// valid for 13 days from the time of generation.
|
||||||
|
///
|
||||||
|
/// Never panics; all errors are returned as [`CryptoError`].
|
||||||
|
pub fn generate_self_signed_cert(domain: &str) -> Result<(Vec<u8>, Vec<u8>), CryptoError> {
|
||||||
|
let key_pair = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
|
||||||
|
.map_err(|e| CryptoError::Tls(format!("key generation failed: {e}")))?;
|
||||||
|
|
||||||
|
let mut params = CertificateParams::new(vec![domain.to_string()])
|
||||||
|
.map_err(|e| CryptoError::Tls(format!("certificate params failed: {e}")))?;
|
||||||
|
|
||||||
|
params.not_before = OffsetDateTime::now_utc() - Duration::minutes(5);
|
||||||
|
params.not_after = OffsetDateTime::now_utc() + Duration::days(13);
|
||||||
|
|
||||||
|
params
|
||||||
|
.subject_alt_names
|
||||||
|
.push(SanType::IpAddress(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
|
||||||
|
params
|
||||||
|
.subject_alt_names
|
||||||
|
.push(SanType::IpAddress(IpAddr::V6(Ipv6Addr::new(
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 1,
|
||||||
|
))));
|
||||||
|
|
||||||
|
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
|
||||||
|
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
|
||||||
|
params.is_ca = IsCa::NoCa;
|
||||||
|
|
||||||
|
let cert = params
|
||||||
|
.self_signed(&key_pair)
|
||||||
|
.map_err(|e| CryptoError::Tls(format!("certificate signing failed: {e}")))?;
|
||||||
|
|
||||||
|
let cert_pem = cert.pem().into_bytes();
|
||||||
|
let key_pem = key_pair.serialize_pem().into_bytes();
|
||||||
|
|
||||||
|
Ok((cert_pem, key_pem))
|
||||||
|
}
|
||||||
16
deny.toml
16
deny.toml
|
|
@ -8,9 +8,23 @@ ignore = []
|
||||||
|
|
||||||
[bans]
|
[bans]
|
||||||
# Flag multiple versions of the same crate so duplicate trees are visible.
|
# Flag multiple versions of the same crate so duplicate trees are visible.
|
||||||
multiple-versions = "warn"
|
multiple-versions = "deny"
|
||||||
wildcards = "deny"
|
wildcards = "deny"
|
||||||
|
|
||||||
|
# These versions are required by incompatible upstream dependency lines:
|
||||||
|
# - pem/rcgen/wtransport still use base64 0.22.
|
||||||
|
# - ring and wasm-bindgen still use getrandom 0.2.
|
||||||
|
# - current displaydoc/serde/thiserror/tokio and wasm-bindgen trees span syn 2
|
||||||
|
# and syn 3.
|
||||||
|
# - ring still uses windows-sys 0.52 while the Tokio/QUIC tree uses 0.61.
|
||||||
|
# Keep the duplicate-version policy strict for every other crate/version.
|
||||||
|
skip = [
|
||||||
|
{ name = "base64", version = "0.22.1" },
|
||||||
|
{ name = "getrandom", version = "0.2.17" },
|
||||||
|
{ name = "syn", version = "2.0.119" },
|
||||||
|
{ name = "windows-sys", version = "0.52.0" },
|
||||||
|
]
|
||||||
|
|
||||||
[licenses]
|
[licenses]
|
||||||
# Allowlist of licenses acceptable for this project's dependencies.
|
# Allowlist of licenses acceptable for this project's dependencies.
|
||||||
allow = [
|
allow = [
|
||||||
|
|
|
||||||
70
docs/ARCHITECTURE.md
Normal file
70
docs/ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# MTP Architecture
|
||||||
|
|
||||||
|
MTP separates wire encoding, QUIC transport, connection policy, protocol negotiation, and application-facing clients.
|
||||||
|
|
||||||
|
```text
|
||||||
|
application
|
||||||
|
┌────────────────┴────────────────┐
|
||||||
|
│ │
|
||||||
|
Native client Browser SDK
|
||||||
|
mtp-client mtp + WASM
|
||||||
|
│ │
|
||||||
|
└──────────────┬──────────────────┘
|
||||||
|
│ MTP frames
|
||||||
|
┌─────────▼─────────┐
|
||||||
|
│ codec + type-map │
|
||||||
|
│ versions, values │
|
||||||
|
└─────────┬─────────┘
|
||||||
|
│
|
||||||
|
┌─────────▼─────────┐
|
||||||
|
│ QUIC transport │
|
||||||
|
│ framing, policy │
|
||||||
|
└─────────┬─────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────┴─────────────────┐
|
||||||
|
│ │
|
||||||
|
MTPHost MTPWebServer
|
||||||
|
native QUIC HTTPS + HTTP/3 + WebTransport
|
||||||
|
│ │
|
||||||
|
└──────────────┬────────────────────┘
|
||||||
|
│
|
||||||
|
optional mtp-crypto
|
||||||
|
authentication and E2EE
|
||||||
|
```
|
||||||
|
|
||||||
|
`mtp-codec` owns `CommunicationValue` and `DataValue` serialization. A version-specific `TypeMap` translates generated type names to wire IDs.
|
||||||
|
`mtp-transport` writes each frame as a four-byte big-endian length followed by the frame bytes and applies message, timeout, queue, and stream limits.
|
||||||
|
|
||||||
|
The top row represents application entry points. Native Rust code calls the client or host crates directly. Browser code calls the TypeScript SDK, which uses generated WASM bindings for the same codec and WebTransport session.
|
||||||
|
Both clients exchange the same MTP frames with a host.
|
||||||
|
|
||||||
|
The middle row is shared protocol machinery. The type map determines numeric IDs, the codec serializes self-delimiting `DataValue` payloads, and transport framing places each serialized frame on a QUIC stream. `CommunicationValue` contains only routing metadata and one generic payload. Protection is a composable value property (`Signed<Value>` or `Encrypted<Value>`), not a transport or communication-frame mode, so the frame and transport layers never infer encryption or signature state from header flags. This is why a type-map or codec change must be compiled into both peers before the new message can be exchanged.
|
||||||
|
|
||||||
|
The bottom row shows the two server entry points. `MTPHost` is a native QUIC endpoint for native MTP clients. `MTPWebServer` owns TCP HTTPS and UDP HTTP/3/WebTransport listeners on the same numeric port, reuses one `HostConfig` and router, and provides the same `accept()`-based MTP session API. Its QUIC listener still uses only the `h3` ALPN, so it cannot share its UDP address with the native MTP ALPN endpoint. Choose `MTPHost` for native clients and `MTPWebServer` for browser-facing HTTP and WebTransport.
|
||||||
|
|
||||||
|
`mtp-crypto` is an optional cross-cutting layer used by authenticated native connections, WebTransport connections, and browser E2EE; TLS remains the transport security layer in both paths.
|
||||||
|
|
||||||
|
MTP exposes protection as independent capabilities rather than prescribing an
|
||||||
|
application topology:
|
||||||
|
|
||||||
|
- A stateless protected `DataValue` composes `Signed<Value>` and
|
||||||
|
`Encrypted<Value>` in the order selected by the application.
|
||||||
|
- A direct protected frame carries a protected value under its application
|
||||||
|
communication type and routes it straight to the frame receiver.
|
||||||
|
- A sealed relay uses the reserved `Relay` communication type, an absent outer
|
||||||
|
sender, and separately protected metadata and content. Applications choose
|
||||||
|
the next hop, final recipient, and both recipient sets.
|
||||||
|
- A stateful encrypted session advances symmetric send and receive chains for
|
||||||
|
an active exchange.
|
||||||
|
- An encrypted pipe protects an ordered byte stream with transcript-bound
|
||||||
|
records and an authenticated final record; forward-secure duplex setup is an
|
||||||
|
explicit option.
|
||||||
|
|
||||||
|
These constructions are peers. Relay is optional and is not the default path
|
||||||
|
for encrypted application messages. Use direct protected frames when no
|
||||||
|
intermediate component needs relay metadata; use sealed relay when routing or
|
||||||
|
store-and-forward topology requires a distinct metadata-access boundary.
|
||||||
|
|
||||||
|
`mtp-host` performs version negotiation and native authentication before returning an `MTPConnection`. `mtp-webserver` routes HTTP/1.1, HTTP/2, and HTTP/3 requests through one route table and surfaces WebTransport sessions through `accept()`. WebTransport MTP sessions support the same optional cryptographic authentication as native hosts when the `crypto` feature is enabled.
|
||||||
|
|
||||||
|
The [native client](NATIVE-CLIENT.md), [WASM client](WASM-CLIENT.md), [native host](NATIVE-HOST.md), and [web server](NATIVE-HOST-WEB-SERVER.md) guides cover the public APIs for each boundary. The web server guide should be read as the host API for browser-facing deployments; it accepts the same `HostConfig` and authentication callbacks as the native host.
|
||||||
34
docs/CONNECTIONS.md
Normal file
34
docs/CONNECTIONS.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# MTP Connections
|
||||||
|
|
||||||
|
Native clients and server-side hosts expose parallel connection handles after the
|
||||||
|
opening handshake. The client creates its handle; the host receives one from
|
||||||
|
`accept()`.
|
||||||
|
|
||||||
|
| Member | Native client | Native host | Web host (`WebMTPConnection`) |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `version` | Compiled client version accepted by the host | Version selected by the registry | Version selected by the registry |
|
||||||
|
| `sender` | Sends `CommunicationValue` frames | Sends `CommunicationValue` frames | Sends `CommunicationValue` frames |
|
||||||
|
| `receiver` | Underlying receiver; use `receive()` for application frames | Underlying receiver; use `receive()` for application frames | Underlying receiver; use `receive()` for application frames |
|
||||||
|
| `description` | Optional label sent during setup | Optional label received from the client | Optional label received from the client |
|
||||||
|
| `client_id` | Confirmed or assigned ID with `crypto` | Authenticated or guest client ID with `crypto` | Authenticated or guest client ID with `crypto` |
|
||||||
|
| `auth_state` | Authentication result with `crypto` | Authentication result with `crypto` | Authentication result with `crypto` |
|
||||||
|
| `path` | — | Native hosts use `/` | WebTransport CONNECT path (e.g. `/mtp`) |
|
||||||
|
| `remote_addr` | Server `SocketAddr` when available | Peer `SocketAddr` | Peer `SocketAddr` |
|
||||||
|
|
||||||
|
`WebMTPConnection`, returned by `MTPWebServer::accept()`, exposes the same
|
||||||
|
server-side members as the native host connection. Its `path` contains the
|
||||||
|
HTTP/3 path used for the WebTransport extended CONNECT request.
|
||||||
|
|
||||||
|
Server-side MTP connections expose `remote_addr`, the peer address observed by
|
||||||
|
QUIC. HTTP route handlers receive the peer address as `HttpRequest::remote_addr`.
|
||||||
|
It is transport metadata and should not be treated as an authenticated identity;
|
||||||
|
behind a proxy, use the proxy's trusted forwarding mechanism separately.
|
||||||
|
The host connection also exposes a version-scoped `codec` and, for an authenticated client, its `client_public_key`. The native client connection also exposes these methods:
|
||||||
|
|
||||||
|
| Method | Behavior |
|
||||||
|
| --- | --- |
|
||||||
|
| `request` | Sends a frame and waits for a response with the same frame ID, subject to `request_timeout`. |
|
||||||
|
| `get_ping` | Returns the latest matched protocol Ping round-trip duration. |
|
||||||
|
| Pipe methods | Create, accept, deny, read, write, and close native pipe streams when the `pipes` feature is enabled. |
|
||||||
|
|
||||||
|
Connection lifecycle and keepalive behavior are defined in [Protocol Reference](PROTOCOL-REFERENCE.md). Pipe dispatch rules are in [Pipes](PIPES.md). Closing or dropping the connection stops its background tasks and closes the underlying QUIC session.
|
||||||
|
|
@ -4,23 +4,28 @@ This file documents the connection and version negotiation logic.
|
||||||
|
|
||||||
## Registry
|
## Registry
|
||||||
|
|
||||||
The `registry` module provides a multi-version `Registry` used by the host for version negotiation. Accessed through the `mtp` facade (requires the `host` feature):
|
The `registry` module provides a multi-version `Registry` used by the host for
|
||||||
|
version negotiation. Accessed through the `mtp` facade (requires the `host`
|
||||||
|
feature). In this repository, `Registry::builtin()` is generated from
|
||||||
|
[`example/type-maps.yaml`](../example/type-maps.yaml), which currently contains
|
||||||
|
protocol version 3.0 only. Downstream projects can register additional versions
|
||||||
|
in their own YAML configuration.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use mtp::codec::registry::Registry;
|
use mtp::codec::{Version, registry::Registry};
|
||||||
|
|
||||||
let registry = Registry::builtin(); // loads all TypeMaps from config
|
let registry = Registry::builtin(); // loads all TypeMaps from the build config
|
||||||
|
|
||||||
// Check if a version is supported
|
// Check if a version is supported
|
||||||
assert!(registry.supports(&Version(1, 0)));
|
assert!(registry.supports(&Version(3, 0)));
|
||||||
|
|
||||||
// Find highest mutual version for a client
|
// Find highest mutual version for a client
|
||||||
let client_versions = &[Version(0, 0), Version(1, 0)];
|
let client_versions = &[Version(2, 0), Version(3, 0)];
|
||||||
let negotiated = registry.negotiate(client_versions);
|
let negotiated = registry.negotiate(client_versions);
|
||||||
assert_eq!(negotiated, Some(Version(1, 0)));
|
assert_eq!(negotiated, Some(Version(3, 0)));
|
||||||
|
|
||||||
// Look up a version's TypeMap
|
// Look up a version's TypeMap
|
||||||
let tm = registry.get(&Version(2, 0)).unwrap();
|
let tm = registry.get(&Version(3, 0)).unwrap();
|
||||||
```
|
```
|
||||||
|
|
||||||
The `Registry::builtin()` constructor uses the `TypeMap::vX_Y()` methods generated from the config.
|
The `Registry::builtin()` constructor uses the `TypeMap::vX_Y()` methods generated from the config.
|
||||||
|
|
@ -54,40 +59,16 @@ let mut host = MTPHost::new(config).await?;
|
||||||
while let Some(conn) = host.accept().await? {
|
while let Some(conn) = host.accept().await? {
|
||||||
// conn.version is the negotiated version
|
// conn.version is the negotiated version
|
||||||
// conn.codec is a VersionedCodec scoped to that version
|
// conn.codec is a VersionedCodec scoped to that version
|
||||||
// conn.sender / conn.receiver for raw CommunicationValue I/O
|
// conn.sender / conn.receive() for application CommunicationValue I/O
|
||||||
|
|
||||||
let msg = conn.receiver.receive().await?;
|
let msg = conn.receive().await?;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The host's `accept()` method:
|
The host reads the reserved opening frame, extracts `DataType::Version`, calls `registry.negotiate`, and returns `AcceptError::UnsupportedVersion` when no registered version matches.
|
||||||
1. Accepts a QUIC connection
|
|
||||||
2. If authentication is required (crypto feature): performs login/register handshake
|
|
||||||
3. Reads the first `CommunicationValue` (always encoded with reserved type IDs)
|
|
||||||
4. Extracts the client's protocol version from `DataType::Version` (reserved data type ID 0)
|
|
||||||
5. Calls `registry.negotiate(&[client_version])`
|
|
||||||
6. Returns an `AcceptError` if the version is unsupported
|
|
||||||
7. Returns `Ok(Some(MTPConnection))` with the negotiated version otherwise
|
|
||||||
|
|
||||||
### Login/Register Handshake
|
Authentication follows the version-bearing hello when the host enables it.
|
||||||
|
The sequence is defined in [Protocol Reference](PROTOCOL-REFERENCE.md).
|
||||||
When `authentication_policy` is `ForceAuthentication` or `AllowAuthentication`,
|
|
||||||
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`.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -111,34 +92,45 @@ let conn = MTPClient::auth_connect(pinned.with_client_id(8765), &keys, &host_pk)
|
||||||
let conn = MTPClient::auth_register(config, &keys, &host_pk).await?;
|
let conn = MTPClient::auth_register(config, &keys, &host_pk).await?;
|
||||||
```
|
```
|
||||||
|
|
||||||
The client's `PROTOCOL_VERSION` constant is set by `protocol_version` in `type-maps.yaml` and baked in at compile time. The client never imports the `registry` crate; it only uses `mtp::type_map` for enum types and `mtp::codec` for encoding.
|
The client's `PROTOCOL_VERSION` constant is set by `protocol_version` in `type-maps.yaml` and baked in at compile time. The client uses one version and does not import the registry.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Version Negotiation Flow
|
## Version Negotiation Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
Client (v2.0) Host (v0.0, v1.0, v2.0)
|
Client (v3.0) Host (v3.0)
|
||||||
| |
|
| |
|
||||||
| QUIC connect |
|
| QUIC connect |
|
||||||
|----------------------->|
|
|----------------------->|
|
||||||
| |
|
| |
|
||||||
| CommValue{ Ident. } |
|
| CommValue{ Ident. } |
|
||||||
| Version -> "2.0" |
|
| Version -> "3.0" |
|
||||||
| Id -> 8765 |
|
| Id -> 8765 |
|
||||||
| (unsigned hello; auth |
|
| (unsigned hello; auth |
|
||||||
| challenge follows) |
|
| challenge follows) |
|
||||||
|----------------------->|
|
|----------------------->|
|
||||||
| | registry.negotiate(&[Version(2,0)])
|
| | registry.negotiate(&[Version(3,0)])
|
||||||
| | -> Some(Version(2,0))
|
| | -> Some(Version(3,0))
|
||||||
| |
|
| |
|
||||||
| Response |
|
| Response | selected v3.0 TypeMap
|
||||||
|<-----------------------| (uses v2.0 TypeMap for encoding)
|
|<-----------------------|
|
||||||
| Status, Nonces, |
|
| Status, version |
|
||||||
| Signature |
|
|
||||||
| |
|
| |
|
||||||
| (subsequent messages |
|
| subsequent messages |
|
||||||
| use v2.0 TypeMap) |
|
| use v3.0 TypeMap |
|
||||||
```
|
```
|
||||||
|
|
||||||
If the client sends an unsupported version (e.g. v3.0 when the host only knows up to v2.0), `negotiate` returns `None` and the connection is closed.
|
If the client sends an unsupported version (for example, v2.0 to the current
|
||||||
|
repository builtin host), `negotiate` returns `None` and the connection is
|
||||||
|
closed.
|
||||||
|
|
||||||
|
## Protocol Ping and Pong
|
||||||
|
|
||||||
|
See [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive).
|
||||||
|
|
||||||
|
## Protocol Version Changes
|
||||||
|
|
||||||
|
Add a protocol version by adding its type-map entry and `protocol_version` to the YAML configuration, then rebuild both peers. The type-map build script generates a version-specific `TypeMap`. Native hosts built with the registry feature keep an enum union across configured versions; a browser client and its generated `mtp/type-map` declarations use only the map selected by that client's `protocol_version`, plus reserved names.
|
||||||
|
|
||||||
|
For a backward-compatible change, keep existing communication and data IDs stable and add new types with the new version. For a breaking change, add a new version and register both versions on the host while clients migrate. A client compiles one protocol version; it can connect only when that version is present in the host registry. Remove an old version only after its clients no longer connect, because the host closes connections whose version is unsupported.
|
||||||
|
|
|
||||||
48
docs/ERRORS.md
Normal file
48
docs/ERRORS.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Error Reference
|
||||||
|
|
||||||
|
MTP reports codec failures separately from connection and transport failures.
|
||||||
|
|
||||||
|
## CodecError
|
||||||
|
|
||||||
|
| Variant | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `UnknownVersion` | A codec was asked to use an unsupported protocol version. |
|
||||||
|
| `UnknownCommunicationType` | A communication type has no mapping in the selected type map. |
|
||||||
|
| `UnknownDataType` | A data type has no mapping in the selected type map. |
|
||||||
|
| `ReservedCommunicationType` | An application attempted to use a reserved communication type ID. |
|
||||||
|
| `InvalidEncoding` | Bytes do not match the MTP value or frame format. |
|
||||||
|
| `TooManyEntries` | A serialized value or frame exceeds its representable size. |
|
||||||
|
| `MissingTypeMap` | A versioned codec was asked to encode a value without a retained negotiated type map. |
|
||||||
|
| `TypeMapMismatch` | A value was created with a different protocol type map from the codec or peer operation. |
|
||||||
|
| `CryptoFailed` | Signing, verification, encryption, or decryption failed while encoding or decoding. |
|
||||||
|
| `MissingField` | A required typed field is absent. |
|
||||||
|
|
||||||
|
An application should select a `TypeMap` for the negotiated version and treat `UnknownCommunicationType` and `UnknownDataType` as a type-map compatibility failure. Do not send the unmapped variant again on that connection.
|
||||||
|
|
||||||
|
Retry guidance: retry `ConnectionLost`, `ConnectingError`, and transient stream errors after applying backoff. Correct the request before retrying `MessageTooLarge`, `InvalidEncoding`, `MissingField`, and type-map errors.
|
||||||
|
Retry `AuthenticationFailed` only after changing credentials or host policy;
|
||||||
|
repeating the same proof does not repair a validation failure.
|
||||||
|
|
||||||
|
## CommunicationError
|
||||||
|
|
||||||
|
| Variant | Typical cause |
|
||||||
|
| --- | --- |
|
||||||
|
| `UseAfterClosed` | A send or receive operation ran after the connection handle was closed. |
|
||||||
|
| `ClosedLocally` | The local endpoint initiated shutdown. |
|
||||||
|
| `ClosedByPeer` | The peer closed the connection. |
|
||||||
|
| `ConnectionLost` | The connection ended without a normal close. |
|
||||||
|
| `ParseCommunicationValue` or `ParseError` | An incoming frame or certificate could not be parsed. |
|
||||||
|
| `Encode` | An outgoing value could not be serialized. |
|
||||||
|
| `MessageTooLarge` | A frame exceeds `Policy::max_message_size` or the handshake limit. |
|
||||||
|
| `StreamClosed` or `StreamError` | A QUIC stream ended or returned an I/O error. |
|
||||||
|
| `ConnectingError` or `ConnectionError` | The endpoint could not establish or maintain QUIC. |
|
||||||
|
| `AuthenticationFailed` | A login, registration, signature, nonce, or host response failed validation. |
|
||||||
|
| `CertificateParseFailed` or `CertificateLoadFailed` | TLS certificate input is malformed or unavailable. |
|
||||||
|
| `CryptoProviderInstallFailed` | The native TLS crypto provider could not be installed. |
|
||||||
|
| `Other` | A component returned an error without a more specific variant. |
|
||||||
|
|
||||||
|
Native builds may expose additional variants wrapping QUIC and WebTransport errors. WASM builds expose the transport-independent subset.
|
||||||
|
|
||||||
|
## Authentication Rejections
|
||||||
|
|
||||||
|
The host reports unsupported or missing protocol versions through `AcceptError`. Authentication failures return `AcceptError::AuthenticationFailed` after the host sends a rejected handshake response; a handshake that exceeds the configured limit returns `AcceptError::AuthenticationTimedOut`. The authentication flow and its signed fields are defined in [Security](SECURITY.md).
|
||||||
|
|
@ -2,77 +2,103 @@
|
||||||
|
|
||||||
The native client is a Rust library (`mtp-client`) for connecting to an MTP host over QUIC. It uses `wtransport` under the hood and provides both unauthenticated and authenticated (crypto handshake) connection modes.
|
The native client is a Rust library (`mtp-client`) for connecting to an MTP host over QUIC. It uses `wtransport` under the hood and provides both unauthenticated and authenticated (crypto handshake) connection modes.
|
||||||
|
|
||||||
## Cargo Dependency
|
## Prerequisites
|
||||||
|
|
||||||
Add the `mtp` umbrella crate with the `client` feature (and optionally `crypto` for authentication):
|
Add the `mtp` umbrella crate with `client`. Add `crypto` for authenticated connections, `pipes` for raw streams, and `tls` for development certificate generation. The `insecure-tls` feature applies only to the lower-level transport API. The feature table is in the [README](../README.md).
|
||||||
|
|
||||||
```toml
|
## Quick Start
|
||||||
[dependencies]
|
|
||||||
mtp = { path = "/path/to/mtp", features = ["client"] }
|
|
||||||
|
|
||||||
# Add crypto for auth_connect / auth_register:
|
```rust
|
||||||
mtp = { path = "/path/to/mtp", features = ["client", "crypto"] }
|
use mtp::client::{ClientConfig, MTPClient};
|
||||||
|
use mtp::codec::{CommunicationType, CommunicationValue};
|
||||||
|
|
||||||
|
let conn = MTPClient::connect(
|
||||||
|
ClientConfig::new("https://host.example.com:4433").with_client_id(42),
|
||||||
|
).await?;
|
||||||
|
let request = CommunicationValue::new(CommunicationType::Ping).with_id(1);
|
||||||
|
conn.sender.send(&request).await?;
|
||||||
|
let response = conn.receive().await?;
|
||||||
|
println!("received {:?}", response.id());
|
||||||
|
conn.sender.close().await;
|
||||||
```
|
```
|
||||||
|
|
||||||
## ClientConfig
|
## Configuration
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use mtp::client::{ClientConfig, ClientTlsConfig};
|
use mtp::client::{ClientConfig, ClientTlsConfig};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
let config = ClientConfig::new("https://host.example.com:4433")
|
let config = ClientConfig::new("https://host.example.com:4433")
|
||||||
.with_tls(ClientTlsConfig::SystemRoots)
|
.with_tls(ClientTlsConfig::SystemRoots)
|
||||||
.with_client_id(0);
|
.with_client_id(0)
|
||||||
|
.with_ping_interval(Duration::from_secs(5))
|
||||||
|
.with_max_missed_pings(3)
|
||||||
|
.with_ping_timestamp(true);
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Default | Description |
|
||||||
|---------------|--------------------|-----------------------------------------------------|
|
|-------------------------|--------------------|------------------|---------------------------------------------|
|
||||||
| `url` | `String` | `https://host:port` address of the MTP host |
|
| `url` | `String` | required | Host URL (`https://host:port`) |
|
||||||
| `tls` | `ClientTlsConfig` | `SystemRoots` or `PinnedPem(pem_bytes)` |
|
| `tls` | `ClientTlsConfig` | `SystemRoots` | `SystemRoots` or `PinnedPem(Vec<u8>)` |
|
||||||
| `client_id` | `u64` | Client identifier (ignored during `auth_register`) |
|
| `client_id` | `u64` | `0` | Client identifier (for login) |
|
||||||
| `description` | `Option<String>` | Optional label sent during handshake (e.g. `"phone"`) |
|
| `description` | `Option<String>` | `None` | Optional label sent to host |
|
||||||
| `auth_timeout` | `Duration` (crypto) | Authentication handshake timeout (default 30s) |
|
| `policy` | `Policy` | default | Transport policy (timeouts, send mode) |
|
||||||
|
| `ping_interval` | `Duration` | `Duration::ZERO` | Interval between protocol Ping frames |
|
||||||
|
| `ping_jitter` | `Option<Duration>` | `None` | Random jitter added to each interval |
|
||||||
|
| `max_missed_pings` | `usize` | `3` | Disconnect after this many unanswered Pings |
|
||||||
|
| `ping_timestamp` | `bool` | `true` | Include a `Timestamp` data entry in Ping |
|
||||||
|
| `request_timeout` | `Duration` | `30s` | Max time for `MTPConnection::request` |
|
||||||
|
| `auth_timeout` (crypto) | `Duration` | `30s` | Max time for auth handshake |
|
||||||
|
| `require_pq` (crypto) | `bool` | `true` | Require ML-DSA-65 during authentication |
|
||||||
|
|
||||||
### TLS Certificate Handling
|
### TLS Certificate Handling
|
||||||
|
|
||||||
When `tls` is `ClientTlsConfig::SystemRoots` (the default), the client loads the **system's
|
`ClientTlsConfig::SystemRoots` is the default. Use `ClientTlsConfig::PinnedPem` or `ClientConfig::with_pinned_pem` for a supplied certificate chain. SPKI pinning and development or insecure transport configuration are available through lower-level transport APIs. See [Security](SECURITY.md) for trust models, certificate generation, rotation, and the insecure-mode gates.
|
||||||
native root certificate store** via `rustls_native_certs`. This works with
|
|
||||||
publicly-trusted CAs out of the box on Linux (using `openssl-probe`), macOS
|
|
||||||
(Keychain), and Windows (Root Store).
|
|
||||||
|
|
||||||
For development or self-signed certificates, provide one or more PEM-encoded
|
## Connecting
|
||||||
certificates:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
let pem = std::fs::read("my-server-cert.pem")?;
|
|
||||||
let config = ClientConfig::new("https://host.example.com:4433").with_pinned_pem(pem);
|
|
||||||
```
|
|
||||||
|
|
||||||
When pinned, **only** the given certificate(s) are trusted for the TLS
|
|
||||||
handshake.
|
|
||||||
|
|
||||||
## Connection Methods
|
|
||||||
|
|
||||||
All methods return a `Result<MTPConnection, CommunicationError>`.
|
All methods return a `Result<MTPConnection, CommunicationError>`.
|
||||||
|
|
||||||
### MTPConnection
|
### MTPConnection
|
||||||
|
|
||||||
|
Shared fields and lifecycle: [MTP Connections](CONNECTIONS.md).
|
||||||
|
|
||||||
|
Keepalive behavior is defined in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive).
|
||||||
|
|
||||||
|
### Requests
|
||||||
|
|
||||||
|
`MTPConnection::request` sends a `CommunicationValue` and waits for a response with the same frame ID. It uses `ClientConfig::request_timeout`; timeout and connection errors reject the request.
|
||||||
|
|
||||||
|
The request must have a non-zero ID. The response is removed from the pending request table and is not returned by a later `conn.receive()` call. A timeout removes the pending request and returns `CommunicationError`; a response with the wrong expected type also returns an error. Frames with other IDs remain available through `conn.receive()`.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub struct MTPConnection {
|
let response = conn
|
||||||
pub version: Version,
|
.request(&request_value, Some(CommunicationType::Pong))
|
||||||
pub sender: Sender,
|
.await?;
|
||||||
pub receiver: Receiver,
|
```
|
||||||
pub description: Option<String>,
|
|
||||||
#[cfg(feature = "crypto")]
|
### Protocol keepalive
|
||||||
pub auth_state: AuthState,
|
|
||||||
#[cfg(feature = "crypto")]
|
Enable it with `ClientConfig` and inspect the latest matched round-trip time with `get_ping()`. See [Protocol Reference](PROTOCOL-REFERENCE.md).
|
||||||
pub client_id: u64,
|
|
||||||
|
```rust
|
||||||
|
use mtp::client::{ClientConfig, MTPClient};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
let config = ClientConfig::new("https://host.example.com:4433")
|
||||||
|
.with_client_id(42)
|
||||||
|
.with_ping_interval(Duration::from_secs(5))
|
||||||
|
.with_max_missed_pings(3)
|
||||||
|
.with_ping_timestamp(true);
|
||||||
|
|
||||||
|
let conn = MTPClient::connect(config).await?;
|
||||||
|
|
||||||
|
if let Some(round_trip) = conn.get_ping() {
|
||||||
|
println!("latest MTP round trip: {round_trip:?}");
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `version` -- the negotiated protocol version
|
Pong dispatch and missed-Ping behavior are defined in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive). Set `ping_interval` to `Duration::ZERO` (the default) to disable protocol pings.
|
||||||
- `sender` / `receiver` -- for message I/O
|
|
||||||
- `description` -- the label sent during handshake (set via `ClientConfig::with_description`)
|
|
||||||
- `client_id` -- the confirmed/assigned client identifier (crypto only)
|
|
||||||
|
|
||||||
### Unauthenticated Connect
|
### Unauthenticated Connect
|
||||||
|
|
||||||
|
|
@ -84,8 +110,7 @@ let config = ClientConfig::new("https://host.example.com:4433").with_client_id(4
|
||||||
let conn = MTPClient::connect(config).await?;
|
let conn = MTPClient::connect(config).await?;
|
||||||
```
|
```
|
||||||
|
|
||||||
Sends an `Identification` frame with the compiled-in protocol version and
|
Sends an `Identification` frame with the compiled-in protocol version and client ID. No cryptographic handshake is performed.
|
||||||
client ID. No cryptographic handshake is performed.
|
|
||||||
|
|
||||||
### Authenticated Login
|
### Authenticated Login
|
||||||
|
|
||||||
|
|
@ -102,21 +127,7 @@ let config = ClientConfig::new("https://host.example.com:4433")
|
||||||
let conn = MTPClient::auth_connect(config, &keys, &host_pk).await?;
|
let conn = MTPClient::auth_connect(config, &keys, &host_pk).await?;
|
||||||
```
|
```
|
||||||
|
|
||||||
Protocol (challenge-response, the host issues the freshness):
|
Authentication uses the signed challenge flow in [Protocol Reference](PROTOCOL-REFERENCE.md#authentication-flow). Cryptographic fields and domain separation are defined in [Security](SECURITY.md).
|
||||||
1. Client sends an unsigned `Identification` hello (version, client ID)
|
|
||||||
2. Host replies with a `Challenge` carrying a fresh random `server_challenge`
|
|
||||||
and the host's signature over it; the client verifies that signature
|
|
||||||
3. Client generates a random `client_nonce` and signs
|
|
||||||
`version || client_id || server_challenge || client_nonce` with Ed25519
|
|
||||||
(and optionally ML-DSA-65)
|
|
||||||
4. Client sends a `ChallengeResponse` frame (nonce + signature(s))
|
|
||||||
5. Host verifies the proof against `server_challenge` and responds with
|
|
||||||
`IdentificationResponse` (echoed nonce + host signature)
|
|
||||||
6. Client verifies the host signature and nonce echo
|
|
||||||
|
|
||||||
Because the client's signature covers the host-issued `server_challenge`, a
|
|
||||||
captured proof cannot be replayed on another connection (each connection gets a
|
|
||||||
different challenge).
|
|
||||||
|
|
||||||
### Registration
|
### Registration
|
||||||
|
|
||||||
|
|
@ -131,11 +142,10 @@ let conn = MTPClient::auth_register(config, &keyring, &host_pk).await?;
|
||||||
|
|
||||||
// Save for next session
|
// Save for next session
|
||||||
let id = conn.client_id;
|
let id = conn.client_id;
|
||||||
let keyring_bytes = keyring.to_bytes();
|
let keyring_bytes = keyring.try_to_bytes()?;
|
||||||
```
|
```
|
||||||
|
|
||||||
When callers already know whether a saved client id exists, the convenience
|
When callers already know whether a saved client ID exists, the convenience helper uses `Some(id)` for login and `None` for registration:
|
||||||
helper chooses login or registration:
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let conn = MTPClient::auth_connect_or_register(
|
let conn = MTPClient::auth_connect_or_register(
|
||||||
|
|
@ -146,17 +156,7 @@ let conn = MTPClient::auth_connect_or_register(
|
||||||
).await?;
|
).await?;
|
||||||
```
|
```
|
||||||
|
|
||||||
Protocol (challenge-response):
|
Registration uses the authentication flow in [Protocol Reference](PROTOCOL-REFERENCE.md#authentication-flow).
|
||||||
1. Client sends an unsigned `Register` hello (version, public key bundle)
|
|
||||||
2. Host replies with a `Challenge` carrying a fresh random `server_challenge`
|
|
||||||
(signed by the host); the client verifies that signature
|
|
||||||
3. Client generates a random `client_nonce` and signs
|
|
||||||
`version || server_challenge || client_nonce || public_key_bytes` with
|
|
||||||
Ed25519 (and optionally ML-DSA-65)
|
|
||||||
4. Client sends a `ChallengeResponse` frame (nonce + signature(s))
|
|
||||||
5. Host verifies the proof against `server_challenge`, assigns a new client ID,
|
|
||||||
and responds with `RegisterResponse` (the ID, echoed nonce, host signature)
|
|
||||||
6. Client verifies the host signature and nonce echo
|
|
||||||
|
|
||||||
## Key Material
|
## Key Material
|
||||||
|
|
||||||
|
|
@ -175,14 +175,13 @@ pub struct Keyring {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- Serialise: `keyring.to_bytes()` -> `Vec<u8>`
|
- Serialise: `keyring.try_to_bytes()` -> `Result<Zeroizing<Vec<u8>>, CryptoError>`
|
||||||
- Deserialise: `Keyring::from_bytes(&bytes)` -> `Result<Keyring, CryptoError>`
|
- Deserialise: `Keyring::from_bytes(&bytes)` -> `Result<Keyring, CryptoError>`
|
||||||
- Get public half: `keyring.public_key_bundle()` -> `PublicKeyBundle`
|
- Get public half: `keyring.public_key_bundle()` -> `PublicKeyBundle`
|
||||||
|
|
||||||
### PublicKeyBundle
|
### PublicKeyBundle
|
||||||
|
|
||||||
The public half of a keyring, used by the host for signature verification and
|
The public half of a keyring, used by the host for signature verification and by the client for host signature verification:
|
||||||
by the client for host signature verification:
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub struct PublicKeyBundle {
|
pub struct PublicKeyBundle {
|
||||||
|
|
@ -192,10 +191,11 @@ pub struct PublicKeyBundle {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Obtain the host's `PublicKeyBundle` out of band (e.g. from files exported by
|
Obtain the host's `PublicKeyBundle` out of band (e.g. from files exported by the host, or from a trusted directory).
|
||||||
the host, or from a trusted directory).
|
|
||||||
|
|
||||||
## Sending and Receiving Messages
|
## Communicate
|
||||||
|
|
||||||
|
### Sending and Receiving Messages
|
||||||
|
|
||||||
### CommunicationValue
|
### CommunicationValue
|
||||||
|
|
||||||
|
|
@ -212,9 +212,7 @@ let msg = CommunicationValue::new(CommunicationType::Ping)
|
||||||
.to_bytes();
|
.to_bytes();
|
||||||
```
|
```
|
||||||
|
|
||||||
When the `registry` feature is enabled (via the `host` feature), you can also
|
When the `registry` feature is enabled (via the `host` feature), you can also use `add_typed` with a `TypeMap` to resolve data type names from your project's type-map configuration.
|
||||||
use `add_typed` with a `TypeMap` to resolve data type names from your project's
|
|
||||||
type-map configuration.
|
|
||||||
|
|
||||||
### Send
|
### Send
|
||||||
|
|
||||||
|
|
@ -222,9 +220,7 @@ type-map configuration.
|
||||||
conn.sender.send(&msg).await?;
|
conn.sender.send(&msg).await?;
|
||||||
```
|
```
|
||||||
|
|
||||||
For request/response flows, `MTPConnection::request` sends one frame and waits
|
For request/response flows, `MTPConnection::request` sends one frame and waits for a response with the same non-zero frame id. An expected response type can be provided for validation:
|
||||||
for a response with the same non-zero frame id. An expected response type can be
|
|
||||||
provided for validation:
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let response = conn
|
let response = conn
|
||||||
|
|
@ -232,92 +228,97 @@ let response = conn
|
||||||
.await?;
|
.await?;
|
||||||
```
|
```
|
||||||
|
|
||||||
Frames with other ids are consumed by this helper. Applications that need
|
Requests are routed by id through the connection's receive dispatcher. Frames with other ids remain available through `conn.receive()`.
|
||||||
subscriptions or broad routing should use one receive task and correlate there.
|
|
||||||
|
|
||||||
Two send modes (configured via `mtp::transport::Policy`):
|
Two send modes (configured via `mtp::client::Policy`):
|
||||||
- `PersistentStream` (default) -- reuses one QUIC uni-directional stream
|
- `PersistentStream` (default): reuses one QUIC unidirectional stream
|
||||||
- `SingleStreamPerMessage` -- opens a new stream per message
|
- `SingleStreamPerMessage`: opens a new stream per message
|
||||||
|
|
||||||
### Receive
|
### Receive
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
match conn.receiver.receive().await {
|
match conn.receive().await {
|
||||||
Ok(msg) => { /* handle CommunicationValue */ }
|
Ok(msg) => { /* handle CommunicationValue */ }
|
||||||
Err(e) => { /* connection closed or error */ }
|
Err(e) => { /* connection closed or error */ }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Inbound frames are queued internally. The `receive()` method returns the next
|
Inbound frames are queued internally. The `receive()` method returns the next available message. Do not read from `conn.receiver` directly because the connection dispatcher owns the shared transport receive loop.
|
||||||
available message.
|
|
||||||
|
|
||||||
### Close
|
### Close
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
conn.sender.close();
|
conn.sender.close().await;
|
||||||
// or
|
// or
|
||||||
conn.receiver.close();
|
conn.receiver.close();
|
||||||
```
|
```
|
||||||
|
|
||||||
Sends a close frame and signals the peer. The `Sender::close()` spawns an async
|
`Sender::close().await` gracefully finishes the active send stream, sends the
|
||||||
task that sends the frame, waits for `force_close_delay` (default 300ms), then
|
MTP close frame, and waits for `force_close_delay` (default 300ms) before
|
||||||
force-closes the QUIC connection if the peer has not already done so.
|
force-closing the QUIC connection if necessary. `Sender::close_immediate()` is
|
||||||
|
the fire-and-forget variant. `Receiver::close()` closes the local receive
|
||||||
|
handle without performing the sender's graceful close sequence.
|
||||||
|
|
||||||
## Crypto Containers
|
### Pipes
|
||||||
|
|
||||||
With the `crypto` feature, `DataValue` supports encrypted, signed, and
|
The complete pipe protocol, native API, browser API, lifecycle, and errors are documented in [Pipes](PIPES.md). Use the connection facade described there when the `pipes` feature is enabled.
|
||||||
signed+encrypted containers. Encryption uses ML-KEM to encapsulate to a
|
|
||||||
recipient's KEM public key (from their `PublicKeyBundle`); only the holder of
|
## Appendix: Composable Data Protection
|
||||||
the matching `Keyring` can decrypt. Signing uses the sender's Ed25519 key.
|
|
||||||
|
With the `crypto` feature, any `DataValue` can be signed or encrypted. The operations return typed errors and compose by operation order. `Encrypted(Signed(Value))` keeps the signer identity inside the encrypted plaintext; `Signed(Encrypted(Value))` leaves it visible. The example uses different keyrings for the signer and recipient to make the ownership explicit.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use mtp::crypto::{EncryptionType, Ed25519Signer, SigAlgorithm};
|
use mtp::codec::{ProtectionPurpose, DataTypeId, DataValue};
|
||||||
|
use mtp::crypto::{Ed25519Signer, Keyring};
|
||||||
|
|
||||||
let enc_type = EncryptionType::MlKemChaCha20Poly1305;
|
let sender_keyring = Keyring::generate();
|
||||||
let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)?;
|
let recipient_keyring = Keyring::generate();
|
||||||
|
let signer = Ed25519Signer::new(&sender_keyring.sig_cl_secret_key)?;
|
||||||
// `recipient` is the PublicKeyBundle of whoever should be able to decrypt
|
let recipient = recipient_keyring.public_key_bundle();
|
||||||
// (e.g. the host's bundle, obtained out of band).
|
let sender_public_keys = sender_keyring.public_key_bundle();
|
||||||
|
let value = DataValue::Container(vec![
|
||||||
// Encrypted container
|
(DataTypeId(32), DataValue::Str("secret".into())),
|
||||||
let mut enc = DataValue::Container(vec![
|
|
||||||
(DataTypeId(1), DataValue::Str("secret".into())),
|
|
||||||
]);
|
]);
|
||||||
enc.encrypt_container(enc_type, &recipient, b"aad");
|
|
||||||
|
|
||||||
// Signed container
|
// The outer encrypted wrapper hides the signer metadata.
|
||||||
let mut sig = DataValue::Container(vec![
|
let private_signer = value.clone().sign(7, ProtectionPurpose::from(1), &signer)?;
|
||||||
(DataTypeId(1), DataValue::Str("signed".into())),
|
let sealed = private_signer.encrypt_for(
|
||||||
]);
|
std::slice::from_ref(&recipient),
|
||||||
sig.sign_container(SigAlgorithm::ED25519, &signer);
|
ProtectionPurpose::from(2),
|
||||||
|
)?;
|
||||||
// Signed + encrypted
|
|
||||||
let mut sec = DataValue::Container(vec![
|
|
||||||
(DataTypeId(1), DataValue::Str("both".into())),
|
|
||||||
]);
|
|
||||||
sec.sign_and_encrypt_container(SigAlgorithm::ED25519, &signer, enc_type, &recipient, b"aad");
|
|
||||||
```
|
```
|
||||||
|
|
||||||
On the receiving side, the recipient decrypts with its own `Keyring` (each blob
|
Reverse the calls when the signer identity should remain visible to the recipient before opening the encrypted value:
|
||||||
is self-describing: its leading byte selects the algorithm and the matching KEM
|
|
||||||
key from the keyring):
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
enc.decrypt_into_container(&keyring, b"aad"); // -> Container
|
let encrypted = value.encrypt_for(
|
||||||
sig.verify_into_container(&verifier); // verifier: impl SignatureScheme
|
std::slice::from_ref(&recipient),
|
||||||
sec.decrypt_signed_encrypted_container(&keyring, b"aad"); // -> SignedContainer, then verify_into_container
|
ProtectionPurpose::from(2),
|
||||||
|
)?;
|
||||||
|
let public_signer = encrypted.sign(7, ProtectionPurpose::from(1), &signer)?;
|
||||||
```
|
```
|
||||||
|
|
||||||
## Policy Configuration
|
Opening and verification are explicit and return the inner value without mutating the wrapper:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let signed = sealed.decrypt(&recipient_keyring, ProtectionPurpose::from(2))?;
|
||||||
|
signed.verify(7, &sender_public_keys, ProtectionPurpose::from(1))?;
|
||||||
|
let plain = signed.into_verified(7, &sender_public_keys, ProtectionPurpose::from(1))?;
|
||||||
|
```
|
||||||
|
|
||||||
|
For `public_signer`, call `verify` and `into_verified` before calling `decrypt`; its outer signature is available before the encrypted value is opened.
|
||||||
|
|
||||||
|
### Policy Configuration
|
||||||
|
|
||||||
The `Policy` struct controls transport behaviour:
|
The `Policy` struct controls transport behaviour:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use mtp::transport::{Policy, SendMode};
|
use mtp::client::{Policy, SendMode};
|
||||||
|
|
||||||
let policy = Policy {
|
let policy = Policy {
|
||||||
send_mode: SendMode::PersistentStream,
|
send_mode: SendMode::PersistentStream,
|
||||||
max_message_size: 1_000_000_000,
|
max_message_size: 16 * 1024 * 1024,
|
||||||
|
handshake_max_message_size: 64 * 1024,
|
||||||
open_stream_timeout: Duration::from_millis(2000),
|
open_stream_timeout: Duration::from_millis(2000),
|
||||||
write_timeout: Duration::from_millis(2000),
|
write_timeout: Duration::from_millis(2000),
|
||||||
read_timeout: Duration::from_millis(30_000),
|
read_timeout: Duration::from_millis(30_000),
|
||||||
|
|
@ -327,41 +328,20 @@ let policy = Policy {
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
To apply a custom policy, call `mtp_transport::connect()` directly instead of
|
Apply a custom policy with `ClientConfig::with_policy`:
|
||||||
using `MTPClient`:
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use mtp_transport::{connect, Policy};
|
let config = config.with_policy(policy);
|
||||||
|
let conn = MTPClient::connect(config).await?;
|
||||||
let server_cert = match &config.tls {
|
|
||||||
ClientTlsConfig::SystemRoots => None,
|
|
||||||
ClientTlsConfig::PinnedPem(pem) => Some(pem.clone()),
|
|
||||||
};
|
|
||||||
let (sender, receiver) = connect(&config.url, server_cert, policy).await?;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then build and send the initial `Identification` frame manually to complete
|
### Version
|
||||||
version negotiation.
|
|
||||||
|
|
||||||
## Version
|
The client's protocol version is baked in at compile time via the `PROTOCOL_VERSION` constant from `mtp::codec`. The version is set by the `protocol_version` field in your `type-maps.yaml`.
|
||||||
|
|
||||||
The client's protocol version is baked in at compile time via the
|
The client never imports the `registry` module; it uses a single compiled-in version and expects the host to negotiate a compatible version.
|
||||||
`PROTOCOL_VERSION` constant from `mtp::codec`. The version is set by the
|
|
||||||
`protocol_version` field in your `type-maps.yaml`.
|
|
||||||
|
|
||||||
The client never imports the `registry` module; it uses a single compiled-in
|
### Error Handling
|
||||||
version and expects the host to negotiate a compatible version.
|
|
||||||
|
|
||||||
## Error Handling
|
`CommunicationError` is summarized in the [Error Reference](ERRORS.md).
|
||||||
|
Native builds can expose additional variants that wrap QUIC and WebTransport errors.
|
||||||
`CommunicationError` covers transport errors:
|
|
||||||
|
|
||||||
| Variant | Meaning |
|
|
||||||
|-------------------------|--------------------------------------------|
|
|
||||||
| `StreamClosed` | Connection was closed by peer or timed out |
|
|
||||||
| `StreamError` | Transport-level I/O error |
|
|
||||||
| `MessageTooLarge` | Frame exceeds `max_message_size` |
|
|
||||||
| `ParseCommunicationValue` | Failed to deserialize incoming frame |
|
|
||||||
| `AuthenticationFailed` | Nonce mismatch or invalid host signature |
|
|
||||||
| `ConnectionError` | QUIC connection failure |
|
|
||||||
| `UseAfterClosed` | Attempted send/receive after close |
|
|
||||||
|
|
|
||||||
184
docs/NATIVE-HOST-WEB-SERVER.md
Normal file
184
docs/NATIVE-HOST-WEB-SERVER.md
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
# MTP Web Server
|
||||||
|
|
||||||
|
`MTPWebServer` is a complete browser-facing HTTPS server. TCP TLS serves HTTP/1.1 and HTTP/2, while UDP QUIC serves HTTP/3 and WebTransport. Both listeners use the same certificate, router, IP address, and numeric port. Ordinary HTTP requests are handled inside the server; WebTransport MTP sessions are returned by `accept()` for application messages.
|
||||||
|
|
||||||
|
`MTPWebServer` and the native `MTPHost` cannot bind the same IP and port. The TCP integration does not add the native MTP QUIC ALPN protocol to `MTPWebServer`.
|
||||||
|
|
||||||
|
The repository's server example serves the compiled web client at `/`, exposes status at `/health`, and accepts WebTransport sessions at the same origin. No second TCP server is required.
|
||||||
|
|
||||||
|
## WebServerConfig
|
||||||
|
|
||||||
|
| Builder | Default | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `route(path, handler)` | None | Register an exact-path HTTP handler. |
|
||||||
|
| `route_method(method, path, handler)` | None | Register a method-specific handler. |
|
||||||
|
| `route_pattern(pattern, handler)` | None | Register a route with `{name}` single-segment parameters. |
|
||||||
|
| `route_pattern_method(method, pattern, handler)` | None | Register a method-specific parameterized route. |
|
||||||
|
| `fallback(handler)` | None | Handle requests that match no route. |
|
||||||
|
| `mtp_path(path)` | `/` | Path for WebTransport extended CONNECT. |
|
||||||
|
| `serve_tcp_https(enabled)` | `true` | Enable the TCP TLS listener for HTTP/1.1 and HTTP/2. |
|
||||||
|
| `max_tcp_connections(count)` | 256 | Maximum concurrent TCP TLS connections. |
|
||||||
|
| `tls_handshake_timeout(duration)` | 10 seconds | Maximum TCP TLS handshake duration. |
|
||||||
|
| `max_request_body(bytes)` | 4 MiB | Maximum request body across all HTTP versions. |
|
||||||
|
| `max_connections(count)` | 256 | Maximum concurrent QUIC/HTTP/3 connections. |
|
||||||
|
| `request_timeout(duration)` | 30 seconds | Handler timeout across all HTTP versions. |
|
||||||
|
| `drain_timeout(duration)` | 5 seconds | Graceful shutdown period across both transports. |
|
||||||
|
| `with_metrics(metrics)` | None | Receive connection, request, and error callbacks. |
|
||||||
|
|
||||||
|
The route and fallback builders return `Result` because duplicate routes and duplicate fallback handlers are rejected.
|
||||||
|
|
||||||
|
Parameterized routes use braces around a name and pass extracted values to the
|
||||||
|
handler as `RouteParams`. Each parameter matches exactly one path segment. Exact
|
||||||
|
routes take precedence over parameterized routes; among parameterized routes,
|
||||||
|
method-specific and more-specific routes take precedence.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use http::{Method, StatusCode};
|
||||||
|
use mtp::webserver::{HttpRequest, HttpResponse, RouteParams, WebServerConfig};
|
||||||
|
|
||||||
|
async fn profile(
|
||||||
|
_request: HttpRequest,
|
||||||
|
response: HttpResponse,
|
||||||
|
params: RouteParams,
|
||||||
|
) -> HttpResponse {
|
||||||
|
let Some(userid) = params.get("userid") else {
|
||||||
|
return response.status(StatusCode::BAD_REQUEST);
|
||||||
|
};
|
||||||
|
|
||||||
|
response
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(format!(r#"{{"userid":"{}"}}"#, userid))
|
||||||
|
}
|
||||||
|
|
||||||
|
let web = WebServerConfig::new()
|
||||||
|
.route_pattern_method(
|
||||||
|
Method::GET,
|
||||||
|
"/api/get/{userid}/profile.json",
|
||||||
|
profile,
|
||||||
|
)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /api/get/user-123/profile.json` invokes `profile` with
|
||||||
|
`params["userid"] == "user-123"`. Percent-encoded parameter values are
|
||||||
|
UTF-8 decoded before being passed to the handler. Malformed encoded values do
|
||||||
|
not match the route. Query strings remain available through
|
||||||
|
`request.uri.query()` and are not part of route matching.
|
||||||
|
|
||||||
|
## HTTP Requests and Responses
|
||||||
|
|
||||||
|
`HttpRequest` contains `method`, `uri`, `headers`, the connecting `remote_addr`, and an optional buffered `body` represented by `bytes::Bytes`. `HttpResponse::status`, `header`, and `body` build a buffered response. `try_header` returns an error for invalid header names or values. `stream` takes a `tokio::sync::mpsc::Receiver<Bytes>` for incremental response chunks. The deprecated `Http3Request` and `Http3Response` aliases remain available for source compatibility.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use bytes::Bytes;
|
||||||
|
use http::{Method, StatusCode};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use mtp::webserver::{HttpRequest, HttpResponse, WebServerConfig};
|
||||||
|
|
||||||
|
async fn health(_request: HttpRequest, response: HttpResponse) -> HttpResponse {
|
||||||
|
response.status(StatusCode::OK).body("ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn whoami(request: HttpRequest, response: HttpResponse) -> HttpResponse {
|
||||||
|
response.body(format!("client: {}", request.remote_addr))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream_numbers(_request: HttpRequest, response: HttpResponse) -> HttpResponse {
|
||||||
|
let (tx, rx) = mpsc::channel::<Bytes>(10);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
for number in 0..10 {
|
||||||
|
if tx.send(Bytes::from(format!("{number}\n"))).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
response
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("content-type", "text/plain")
|
||||||
|
.stream(rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
let web = WebServerConfig::new()
|
||||||
|
.route("/health", health)?
|
||||||
|
.route("/whoami", whoami)?
|
||||||
|
.route_method(Method::GET, "/numbers", stream_numbers)?
|
||||||
|
.fallback(|_request, response| async move {
|
||||||
|
response.status(StatusCode::NOT_FOUND).body("not found")
|
||||||
|
})?
|
||||||
|
.mtp_path("/mtp");
|
||||||
|
```
|
||||||
|
|
||||||
|
## Starting and Accepting MTP Sessions
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use mtp::{host::HostConfig, webserver::MTPWebServer};
|
||||||
|
|
||||||
|
let host_config = HostConfig::new(
|
||||||
|
"0.0.0.0".parse()?,
|
||||||
|
4433,
|
||||||
|
std::fs::read("cert.pem")?,
|
||||||
|
std::fs::read("key.pem")?,
|
||||||
|
);
|
||||||
|
let mut server = MTPWebServer::new(host_config, web).await?;
|
||||||
|
|
||||||
|
while let Some(connection) = server.accept().await? {
|
||||||
|
// connection: WebMTPConnection
|
||||||
|
while let Ok(message) = connection.receive().await {
|
||||||
|
println!("received MTP message {:?}", message.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> `MTPWebServer::new` consumes a `HostConfig` (not an `MTPHost` instance). It creates its own QUIC endpoint and does not share a port with a running `MTPHost`.
|
||||||
|
|
||||||
|
`server.accept()` returns `Option<WebMTPConnection>` for each WebTransport session. Ordinary HTTP routes do not surface through `accept()` because the server dispatches them internally. `WebMTPConnection` retains the negotiated version, codec, `path`, remote address, description, sender, and receiver used by native MTP connections.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
For direct browser access, leave `serve_tcp_https(true)` enabled. The server advertises `h2` and `http/1.1` on TCP TLS and `h3` on UDP QUIC; WebTransport extended CONNECT is available only over HTTP/3. Both transports must present the certificate supplied by the same `HostConfig` and use the same origin port.
|
||||||
|
|
||||||
|
When a reverse proxy or another process owns TCP, use `WebServerConfig::new().serve_tcp_https(false)`. This retains the UDP HTTP/3/WebTransport endpoint and its shared router without claiming the TCP port.
|
||||||
|
|
||||||
|
With port `0` and TCP enabled, construction binds TCP first and binds UDP to the selected TCP port, so `local_addr()` reports the common address. With TCP disabled, Quinn selects the UDP port as before. `shutdown().await` stops both accept loops, gracefully finishes active HTTP requests until `drain_timeout`, closes Quinn, and then aborts remaining work. `close().await` and dropping the server stop both listeners immediately.
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
`MTPWebServer` does not impose its own authentication policy. It respects the `AuthenticationPolicy` set on the supplied `HostConfig`:
|
||||||
|
|
||||||
|
| Policy | Behavior |
|
||||||
|
|--------|----------|
|
||||||
|
| `Unauthenticated` (default) | No authentication handshake is performed. The connection has `AuthState::Unauthenticated` and a random full-width `u64` client ID. `guest_id_generator` is not used by this adapter. |
|
||||||
|
| `AllowAuthentication` | The server accepts the first message. If it is an `Identification` or `Register` message, a full challenge-response handshake is performed. If it is an ordinary opening message, the connection remains unauthenticated. |
|
||||||
|
| `ForceAuthentication` | The server requires a valid `Identification` or `Register` message as the first frame and performs the challenge-response handshake. Any other opening message is rejected. |
|
||||||
|
|
||||||
|
When authentication is required or allowed and the client presents credentials, the server performs the same Ed25519/ML-DSA challenge-response handshake used by native MTP host connections:
|
||||||
|
|
||||||
|
1. The client sends `Identification` (with a client ID) or `Register` (with a public-key bundle).
|
||||||
|
2. The server looks up or accepts the client's public keys, generates a random 128-bit server nonce, and signs a challenge payload with its host keyring.
|
||||||
|
3. The client responds with a proof signed by its own keys.
|
||||||
|
4. The server verifies the proof, assigns the client ID, and sends a final signed response.
|
||||||
|
|
||||||
|
On success, the connection has `AuthState::Authenticated`, the assigned `client_id`, and `client_public_key` populated. On failure, `accept()` returns `AcceptError::AuthenticationFailed` (or `AcceptError::AuthenticationTimedOut` if the handshake exceeds `host_config.auth_timeout`).
|
||||||
|
|
||||||
|
`MTPWebServer::new` returns `CommunicationError` for certificate parsing, certificate loading, and bind failures. It does **not** reject `HostConfig` based on `AuthenticationPolicy`; any policy is accepted at construction time.
|
||||||
|
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
`MTPWebServer::new` returns `CommunicationError` for certificate parsing,
|
||||||
|
certificate loading, and bind failures. Authentication policy is evaluated when
|
||||||
|
WebTransport sessions are accepted, not rejected during construction.
|
||||||
|
`accept()` returns `AcceptError` for a missing or unsupported version, a receive failure, or a send failure during the WebTransport opening handshake. HTTP route failures are reported through `WebServerMetrics::error_occurred` when metrics are configured. See [Errors](ERRORS.md) for shared error variants.
|
||||||
|
|
||||||
|
`WebServerMetrics` has these callbacks:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
fn connection_accepted(&self)
|
||||||
|
fn connection_closed(&self, duration: Duration, reason: &str)
|
||||||
|
fn request_started(&self, path: &str)
|
||||||
|
fn request_completed(&self, path: &str, status: u16, duration: Duration)
|
||||||
|
fn error_occurred(&self, error: &WebServerError)
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors include invalid requests, body-limit failures, handler timeouts, response write failures, TLS failures, and transport failures. Completion callbacks include the final HTTP status for every supported HTTP version. Supply the metrics object with `WebServerConfig::with_metrics`.
|
||||||
|
|
@ -1,45 +1,23 @@
|
||||||
# MTP Native Host
|
# MTP Native Host
|
||||||
|
|
||||||
The native host is a Rust library (`mtp-host`) that runs a QUIC server, accepts
|
The native host is a Rust library (`mtp-host`) that runs a QUIC server, accepts MTP client connections, negotiates protocol versions, and optionally performs a mutual-authentication handshake (login/register) using Ed25519 and ML-DSA-65 signatures.
|
||||||
MTP client connections, negotiates protocol versions, and optionally performs a
|
|
||||||
mutual-authentication handshake (login/register) using Ed25519 and ML-DSA-65
|
> **Note:** `MTPHost` serves native MTP clients over raw QUIC. For browser-facing HTTP/1.1, HTTP/2, HTTP/3, and WebTransport, use [`MTPWebServer`](NATIVE-HOST-WEB-SERVER.md). It accepts the same `HostConfig` but its UDP endpoint uses HTTP/3 rather than the native MTP QUIC ALPN.
|
||||||
signatures.
|
|
||||||
|
|
||||||
## Cargo Dependency
|
## Cargo Dependency
|
||||||
|
|
||||||
```toml
|
Add the `mtp` umbrella crate with `host`. Add `crypto` for authenticated connections and `pipes` for raw streams. The feature table is in the [README](../README.md).
|
||||||
[dependencies]
|
|
||||||
mtp = { path = "/path/to/mtp", features = ["host"] }
|
|
||||||
|
|
||||||
# Add crypto for authenticated connections:
|
|
||||||
mtp = { path = "/path/to/mtp", features = ["host", "crypto"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## HostConfig
|
## HostConfig
|
||||||
|
|
||||||
```rust
|
`HostConfig::new` takes the bind address, port, PEM certificate chain, and PEM private key. Configure authentication and transport behavior with builders:
|
||||||
use mtp::host::HostConfig;
|
|
||||||
use std::net::{IpAddr, Ipv4Addr};
|
|
||||||
|
|
||||||
let config = HostConfig::new(
|
```rust
|
||||||
IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
let config = HostConfig::new(ip, port, certificate, private_key)
|
||||||
4433,
|
.with_pongs(true)
|
||||||
std::fs::read("cert.pem")?,
|
.with_policy(Policy::default())
|
||||||
std::fs::read("key.pem")?,
|
.with_authentication(host_keyring, get_existing_client, complete_register)
|
||||||
)
|
.with_authentication_policy(AuthenticationPolicy::ForceAuthentication);
|
||||||
.with_authentication(
|
|
||||||
/* Keyring */,
|
|
||||||
|client_id: u64| {
|
|
||||||
let db = CLIENT_DB.clone();
|
|
||||||
Box::pin(async move { db.lock().unwrap().get(&client_id).cloned() })
|
|
||||||
},
|
|
||||||
|bundle: PublicKeyBundle| {
|
|
||||||
let mut db = CLIENT_DB.lock().unwrap();
|
|
||||||
let id = next_id();
|
|
||||||
db.insert(id, bundle);
|
|
||||||
Box::pin(async move { id })
|
|
||||||
},
|
|
||||||
);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|
|
@ -48,34 +26,40 @@ let config = HostConfig::new(
|
||||||
| `port` | `u16` | Listen port |
|
| `port` | `u16` | Listen port |
|
||||||
| `tls_fullchain` | `Vec<u8>` | PEM-encoded TLS certificate chain |
|
| `tls_fullchain` | `Vec<u8>` | PEM-encoded TLS certificate chain |
|
||||||
| `tls_key` | `Vec<u8>` | PEM-encoded TLS private key |
|
| `tls_key` | `Vec<u8>` | PEM-encoded TLS private key |
|
||||||
|
| `send_pongs` | `bool` | Sends a Pong for each received Ping (default `true`) |
|
||||||
| `authentication_policy` | `AuthenticationPolicy` (crypto) | `ForceAuthentication`, `AllowAuthentication`, or `Unauthenticated` |
|
| `authentication_policy` | `AuthenticationPolicy` (crypto) | `ForceAuthentication`, `AllowAuthentication`, or `Unauthenticated` |
|
||||||
| `host_keyring` | `Keyring` (crypto) | Host's signing and KEM keys |
|
| `host_keyring` | `Keyring` (crypto) | Host's signing and KEM keys |
|
||||||
| `get_existing_user` | `Fn(u64) -> Pin<Box<dyn Future<Output = Option<PublicKeyBundle>> + Send>> + Send + Sync` (crypto) | Async lookup callback for login |
|
| `get_existing_client` | Async callback returning `Option<PublicKeyBundle>` | Receives `(client_id, description)`. `Some` supplies the stored key bundle. `description = None` is used for guest-ID collision checks. |
|
||||||
| `complete_register` | `Fn(PublicKeyBundle) -> Pin<Box<dyn Future<Output = u64> + Send>> + Send + Sync` (crypto) | Async registration callback, returns new client ID |
|
| `guest_id_generator` | Async callback returning `Option<u64>` | Custom guest ID assignment. The default generates random IDs. |
|
||||||
|
| `complete_register` | Async callback returning `u64` | Stores the public bundle and returns its assigned client ID. |
|
||||||
|
|
||||||
### AuthenticationPolicy
|
### AuthenticationPolicy
|
||||||
|
|
||||||
`ForceAuthentication` requires every client to complete the login/register handshake. `AllowAuthentication` accepts both authenticated and unauthenticated connections — unauthenticated clients get a random ID and `AuthState::Unauthenticated`. `Unauthenticated` rejects any client that tries to authenticate and is the default.
|
`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).
|
||||||
```rust
|
|
||||||
use mtp::host::AuthenticationPolicy;
|
|
||||||
|
|
||||||
// Force authentication (default was `require_authentication: true`):
|
|
||||||
let config = HostConfig::new(ip, port, cert, key)
|
|
||||||
.with_authentication(host_keyring, get_user, register);
|
|
||||||
|
|
||||||
// Allow both authenticated and unauthenticated:
|
|
||||||
let config = HostConfig::new(ip, port, cert, key)
|
|
||||||
.with_allow_authentication(host_keyring, get_user, register);
|
|
||||||
|
|
||||||
// Unauthenticated only (default):
|
|
||||||
let config = HostConfig::new(ip, port, cert, key);
|
|
||||||
```
|
|
||||||
|
|
||||||
### TLS
|
### TLS
|
||||||
|
|
||||||
The host requires a TLS certificate. For development, generate a self-signed
|
`HostConfig::new` always uses the certificate and key supplied by the caller.
|
||||||
certificate using `rcgen`. For production, use a CA-signed certificate.
|
Certificate trust and development settings are in [Security](SECURITY.md).
|
||||||
|
|
||||||
|
### Ping-Pong
|
||||||
|
|
||||||
|
Keepalive behavior is defined in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let config = HostConfig::new(ip, port, cert, key)
|
||||||
|
.with_pongs(true);
|
||||||
|
```
|
||||||
|
|
||||||
|
Disable automatic responses only when the application needs to handle Ping frames itself:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let config = HostConfig::new(ip, port, cert, key)
|
||||||
|
.with_pongs(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
Follow the responder contract in [Protocol Reference](PROTOCOL-REFERENCE.md).
|
||||||
|
|
||||||
## Accepting Connections
|
## Accepting Connections
|
||||||
|
|
||||||
|
|
@ -92,166 +76,44 @@ while let Some(conn) = host.accept().await? {
|
||||||
|
|
||||||
### MTPConnection
|
### MTPConnection
|
||||||
|
|
||||||
Returned by `accept()` after version negotiation (and authentication if
|
`accept()` returns the shared connection shape in [MTP Connections](CONNECTIONS.md)
|
||||||
enabled):
|
after version negotiation and authentication, when enabled. The host-specific `codec` is scoped to the negotiated version, and `client_public_key` is set for authenticated clients.
|
||||||
|
The connection's `remote_addr` is the peer `SocketAddr` observed by QUIC. It is
|
||||||
```rust
|
network metadata, not an authenticated client identity.
|
||||||
pub struct MTPConnection {
|
|
||||||
pub version: Version,
|
|
||||||
pub codec: VersionedCodec,
|
|
||||||
pub sender: Sender,
|
|
||||||
pub receiver: Receiver,
|
|
||||||
pub description: Option<String>,
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
pub auth_state: AuthState,
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
pub client_id: u64,
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
pub client_public_key: Option<PublicKeyBundle>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `version` -- the negotiated protocol version
|
|
||||||
- `codec` -- a `VersionedCodec` scoped to the negotiated version (use for
|
|
||||||
version-aware encode/decode)
|
|
||||||
- `sender` / `receiver` -- for message I/O
|
|
||||||
- `description` -- optional client-provided label (e.g. `"phone"`, `"desktop"`)
|
|
||||||
- `client_id` -- the authenticated client's ID
|
|
||||||
- `client_public_key` -- the client's public key bundle (for signature
|
|
||||||
verification of subsequent messages)
|
|
||||||
|
|
||||||
## Version Negotiation
|
## Version Negotiation
|
||||||
|
|
||||||
When a client connects, `accept()` performs the following sequence:
|
`accept()` uses the version-bearing opening frame and registry flow in [Connector](CONNECTOR.md). The host registry is built from the type maps in [`example/type-maps.yaml`](../example/type-maps.yaml) by `Registry::builtin()` in this repository; downstream builds can provide their own `MTP_TYPE_MAPS` configuration.
|
||||||
|
|
||||||
1. Accept the QUIC connection
|
|
||||||
2. Read the client's first `CommunicationValue` (always encoded with reserved
|
|
||||||
type IDs)
|
|
||||||
3. Extract the protocol version from `DataType::Version` (reserved data type ID 0) as a
|
|
||||||
`DataValue::Str("major.minor")`
|
|
||||||
4. Call `registry.negotiate(&[client_version])` to find the highest mutually
|
|
||||||
supported version
|
|
||||||
5. Return an `AcceptError` (closing the connection) if no compatible version exists
|
|
||||||
6. Return `Ok(Some(MTPConnection))` with the negotiated version
|
|
||||||
|
|
||||||
The `Registry` is built automatically from all type maps defined in your
|
|
||||||
`type-maps.yaml` via `Registry::builtin()`.
|
|
||||||
|
|
||||||
### Registry
|
### Registry
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use mtp::codec::registry::Registry;
|
use mtp::codec::Version;
|
||||||
|
|
||||||
let registry = host.registry();
|
let registry = host.registry();
|
||||||
assert!(registry.supports(&Version(2, 0)));
|
assert!(registry.supports(&Version(3, 0)));
|
||||||
|
|
||||||
let negotiated = registry.negotiate(&[Version(1, 0), Version(2, 0)]);
|
let negotiated = registry.negotiate(&[Version(2, 0), Version(3, 0)]);
|
||||||
// -> Some(Version(2, 0)) if both versions are registered
|
// -> Some(Version(3, 0)) for this repository's builtin map
|
||||||
```
|
```
|
||||||
|
|
||||||
## Authentication Flow
|
## Authentication Flow
|
||||||
|
|
||||||
When `authentication_policy` is `ForceAuthentication`, `accept()` runs a mutually-authenticated
|
The connection lifecycle and authentication sequence are in [Protocol Reference](PROTOCOL-REFERENCE.md). Host callback contracts are documented below.
|
||||||
**challenge-response** handshake before returning the connection. The host issues
|
|
||||||
a fresh, random `server_challenge` that the client must sign, which is what makes
|
|
||||||
the client's proof unreplayable: a captured proof is bound to a one-time challenge
|
|
||||||
the host generates per connection and will never reissue. The challenge lives only
|
|
||||||
on the accepting task's stack; there is no replay database or shared state.
|
|
||||||
|
|
||||||
All signed payloads begin with a one-byte domain-separation tag (see
|
After a successful handshake, `MTPConnection` exposes `AuthState::Authenticated`, the client ID, and the client's public key bundle when one is available.
|
||||||
`mtp::crypto::auth`) so a signature for one step can never be reused as another.
|
|
||||||
|
|
||||||
### Login
|
|
||||||
|
|
||||||
```
|
|
||||||
Client Host
|
|
||||||
| |
|
|
||||||
| QUIC connect |
|
|
||||||
|---------------------------------------->|
|
|
||||||
| |
|
|
||||||
| Identification { Version, Id } | (unsigned hello)
|
|
||||||
|---------------------------------------->|
|
|
||||||
| | lookup get_existing_user(id)
|
|
||||||
| | generate random server_challenge
|
|
||||||
| Challenge { |
|
|
||||||
| ServerNonce(server_challenge), |
|
|
||||||
| Signature, [PqSignature] | host signs the challenge
|
|
||||||
| } |
|
|
||||||
|<----------------------------------------|
|
|
||||||
| ChallengeResponse { |
|
|
||||||
| ClientNonce, Signature, [PqSignature]| client signs the challenge
|
|
||||||
| } |
|
|
||||||
|---------------------------------------->|
|
|
||||||
| | verify proof over server_challenge
|
|
||||||
| IdentificationResponse { |
|
|
||||||
| Connected=true, Id, |
|
|
||||||
| ClientNonce(echoed), |
|
|
||||||
| Signature, [PqSignature] |
|
|
||||||
| } |
|
|
||||||
|<----------------------------------------|
|
|
||||||
```
|
|
||||||
|
|
||||||
Payloads (`||` is concatenation, integers big-endian; `DS_*` are domain tags):
|
|
||||||
|
|
||||||
- Host challenge: `DS_CHALLENGE || id (8) || server_challenge (16)`
|
|
||||||
- Client proof: `DS_LOGIN_PROOF || version_string || id (8) || server_challenge (16) || client_nonce (16)`
|
|
||||||
- Host final: `DS_HOST_FINAL || assigned_id (8) || client_nonce (16) || server_challenge (16)`
|
|
||||||
|
|
||||||
### Register
|
|
||||||
|
|
||||||
```
|
|
||||||
Client Host
|
|
||||||
| |
|
|
||||||
| QUIC connect |
|
|
||||||
|---------------------------------------->|
|
|
||||||
| |
|
|
||||||
| Register { |
|
|
||||||
| Version, | (unsigned hello)
|
|
||||||
| PublicKeys (serialized PublicKeyBundle)
|
|
||||||
| } |
|
|
||||||
|---------------------------------------->|
|
|
||||||
| | generate random server_challenge
|
|
||||||
| Challenge { |
|
|
||||||
| ServerNonce(server_challenge), |
|
|
||||||
| Signature, [PqSignature] | (challenge binds id = 0)
|
|
||||||
| } |
|
|
||||||
|<----------------------------------------|
|
|
||||||
| ChallengeResponse { |
|
|
||||||
| ClientNonce, Signature, [PqSignature]|
|
|
||||||
| } |
|
|
||||||
|---------------------------------------->|
|
|
||||||
| | verify proof over server_challenge
|
|
||||||
| | call complete_register(bundle) -> new_id
|
|
||||||
| RegisterResponse { |
|
|
||||||
| Connected=true, Id(new_id), |
|
|
||||||
| ClientNonce(echoed), |
|
|
||||||
| Signature, [PqSignature] |
|
|
||||||
| } |
|
|
||||||
|<----------------------------------------|
|
|
||||||
```
|
|
||||||
|
|
||||||
The register client proof is:
|
|
||||||
`DS_REGISTER_PROOF || version_string || server_challenge (16) || client_nonce (16) || public_key_bytes`
|
|
||||||
|
|
||||||
After a successful handshake, `accept()` returns an `MTPConnection` with
|
|
||||||
`auth_state = Authenticated`, `client_id` set, and `client_public_key`
|
|
||||||
available for verifying subsequent signed messages from the client.
|
|
||||||
|
|
||||||
### Rejection
|
|
||||||
|
|
||||||
If verification fails or the client is not found (login), the host sends a
|
|
||||||
rejection response with `Connected=false` and closes the send stream, returning
|
|
||||||
`AcceptError::AuthenticationFailed` from `accept()`.
|
|
||||||
|
|
||||||
## Handling Messages
|
## Handling Messages
|
||||||
|
|
||||||
Use `conn.sender` and `conn.receiver` for bidirectional message exchange:
|
Use `conn.sender` and `conn.receive()` for bidirectional message exchange. The
|
||||||
|
connection dispatcher owns the underlying receiver, especially when `pipes` is
|
||||||
|
enabled:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
while let Some(conn) = host.accept().await? {
|
while let Some(conn) = host.accept().await? {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
match conn.receiver.receive().await {
|
match conn.receive().await {
|
||||||
Ok(msg) => {
|
Ok(msg) => {
|
||||||
let response = process_message(&msg, &conn);
|
let response = process_message(&msg, &conn);
|
||||||
conn.sender.send(&response).await.ok();
|
conn.sender.send(&response).await.ok();
|
||||||
|
|
@ -265,8 +127,7 @@ while let Some(conn) = host.accept().await? {
|
||||||
|
|
||||||
### Versioned Codec
|
### Versioned Codec
|
||||||
|
|
||||||
The `conn.codec` is a `VersionedCodec` pre-configured with the negotiated
|
The `conn.codec` is a `VersionedCodec` pre-configured with the negotiated version. Use it to encode/decode with version-specific type maps:
|
||||||
version. Use it to encode/decode with version-specific type maps:
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let tm = conn.codec.registry().get(&conn.version).unwrap();
|
let tm = conn.codec.registry().get(&conn.version).unwrap();
|
||||||
|
|
@ -276,41 +137,67 @@ let desc_id = DataTypeId(tm.data_id_enum(DataType::Description).unwrap());
|
||||||
let value = msg.get_data(desc_id);
|
let value = msg.get_data(desc_id);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Pipes
|
||||||
|
|
||||||
|
The complete pipe protocol and host API are documented in [Pipes](PIPES.md).
|
||||||
|
|
||||||
## Host Callbacks
|
## Host Callbacks
|
||||||
|
|
||||||
### get_existing_user
|
### get_existing_client
|
||||||
|
|
||||||
Called during login to retrieve a client's public key bundle for signature
|
Called during login to retrieve a client's public key bundle for signature verification, and also during guest ID generation to check whether a random candidate collides with a registered client. When used for collision checking the `description` argument is `None`.
|
||||||
verification. Must return `Some(PublicKeyBundle)` if the client ID is known,
|
|
||||||
or `None` to reject.
|
Must return `Some(PublicKeyBundle)` if the client ID is known, or `None` otherwise.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let get_existing_user = |id: u64| {
|
// db: Arc<tokio::sync::Mutex<HashMap<u64, PublicKeyBundle>>>
|
||||||
|
let get_existing_client = |id: u64, _description: Option<String>| {
|
||||||
let db = db.clone();
|
let db = db.clone();
|
||||||
Box::pin(async move { db.lock().unwrap().get(&id).cloned() })
|
Box::pin(async move { db.lock().await.get(&id).cloned() })
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### guest_id_generator
|
||||||
|
|
||||||
|
Optional callback that controls how unauthenticated connections receive their client ID. When `None` (the default), the host generates a random full-width `u64` ID and checks it against `get_existing_client` to avoid collisions.
|
||||||
|
|
||||||
|
Return `Some(id)` to accept the guest with that full-width `u64` ID, or `None` to reject the connection.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
// Sequential guest IDs:
|
||||||
|
let counter = AtomicU64::new(1);
|
||||||
|
let guest_id_generator = Box::new(move || {
|
||||||
|
Box::pin(async move { Some(counter.fetch_add(1, Ordering::SeqCst)) })
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reject all guests (no unauthenticated connections):
|
||||||
|
let guest_id_generator = Box::new(|| Box::pin(async { None }));
|
||||||
|
|
||||||
|
let config = HostConfig::new(ip, port, cert, key)
|
||||||
|
.with_authentication(host_keyring, get_existing_client, complete_register)
|
||||||
|
.with_authentication_policy(AuthenticationPolicy::AllowAuthentication)
|
||||||
|
.with_guest_id_generator(guest_id_generator);
|
||||||
|
```
|
||||||
|
|
||||||
### complete_register
|
### complete_register
|
||||||
|
|
||||||
Called during registration to persist a new client's public key bundle and
|
Called during registration to persist a new client's public key bundle and assign a client ID. The returned `u64` becomes the client's permanent identifier.
|
||||||
assign a client ID. The returned `u64` becomes the client's permanent
|
|
||||||
identifier.
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let complete_register = |bundle: PublicKeyBundle| {
|
// db: Arc<tokio::sync::Mutex<HashMap<u64, PublicKeyBundle>>>
|
||||||
|
let complete_register = |bundle: PublicKeyBundle, _description: Option<String>| {
|
||||||
let db = db.clone();
|
let db = db.clone();
|
||||||
let id = next_id.fetch_add(1, Ordering::SeqCst);
|
let id = next_id.fetch_add(1, Ordering::SeqCst);
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
db.lock().unwrap().insert(id, bundle);
|
db.lock().await.insert(id, bundle);
|
||||||
id
|
id
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Both callbacks are called from within `accept()` and must be `Send + Sync`. They
|
All callbacks are called from within `accept()` and must be `Send + Sync`. They are `async` (returning `Pin<Box<dyn Future<...>>`) and are `.await`ed by the host, so they can perform I/O or other async work as needed. The `complete_register` callback returns no error value. A panic aborts the normal callback flow; validate storage and ID allocation before returning the ID.
|
||||||
are `async` (returning `Pin<Box<dyn Future<...>>`) and are `.await`ed by the
|
|
||||||
host, so they can perform I/O or other async work as needed.
|
|
||||||
|
|
||||||
## Host Key Generation
|
## Host Key Generation
|
||||||
|
|
||||||
|
|
@ -327,7 +214,7 @@ let (kem_sk, kem_pk) = HybridKem::generate_keypair();
|
||||||
let host_keyring = Keyring::new(kem_pk, kem_sk, sig_pq_pk, sig_pq_sk, sig_pk, sig_sk);
|
let host_keyring = Keyring::new(kem_pk, kem_sk, sig_pq_pk, sig_pq_sk, sig_pk, sig_sk);
|
||||||
|
|
||||||
// Save to disk
|
// Save to disk
|
||||||
let bytes = host_keyring.to_bytes();
|
let bytes = host_keyring.try_to_bytes()?;
|
||||||
std::fs::write("host_keys.bin", bytes)?;
|
std::fs::write("host_keys.bin", bytes)?;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -342,21 +229,17 @@ std::fs::write("host_sig_pq_pk.bin", bundle.sig_pq_public_key.as_bytes())?;
|
||||||
|
|
||||||
## Policy
|
## Policy
|
||||||
|
|
||||||
The transport `Policy` is set to defaults internally. To customise (timeouts,
|
Customize transport limits and timeouts through `HostConfig::with_policy`:
|
||||||
send mode, etc.), use `mtp_transport::host()` directly instead of `MTPHost`:
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use mtp_transport::{host, Policy};
|
let config = HostConfig::new(ip, port, cert, key)
|
||||||
|
.with_policy(custom_policy);
|
||||||
let transport = host(ip, port, cert, key, custom_policy).await?;
|
let host = MTPHost::new(config).await?;
|
||||||
// Then build version negotiation on top:
|
|
||||||
// - accept transport.next()
|
|
||||||
// - read first frame
|
|
||||||
// - registry.negotiate()
|
|
||||||
// - return MTPConnection
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Graceful Shutdown
|
## Graceful Shutdown
|
||||||
|
|
||||||
Drop the `MTPHost` to stop accepting new connections. Active connections
|
Drop the `MTPHost` to stop accepting new connections. Active connections continue until their `Sender`/`Receiver` are dropped or the peer disconnects.
|
||||||
continue until their `Sender`/`Receiver` are dropped or the peer disconnects.
|
|
||||||
|
Run one accept loop per `MTPHost` and spawn one task per accepted connection.
|
||||||
|
Stop the accept loop before dropping the host, then close active senders and wait for application tasks to finish. Use [Operations](OPERATIONS.md) for the deployment sequence and monitoring signals.
|
||||||
|
|
|
||||||
42
docs/OPERATIONS.md
Normal file
42
docs/OPERATIONS.md
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Operations
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Expose counters and gauges around the host and transport callbacks:
|
||||||
|
|
||||||
|
| Metric | Interpretation |
|
||||||
|
| --- | --- |
|
||||||
|
| Accepted and rejected connections | Compare admission failures with traffic volume. A rise in rejected connections points to certificate, version, policy, or authentication problems. |
|
||||||
|
| Active connections and active pipe streams | Capacity currently consumed by sessions and raw streams. |
|
||||||
|
| Authentication failures and timeouts | Credential, policy, or reachability failures during the handshake. |
|
||||||
|
| Unsupported protocol versions | Clients that require a version still absent from the registry. |
|
||||||
|
| Message-too-large and decode errors | Peer or schema mismatch, malformed input, or an overly small policy limit. |
|
||||||
|
| Request latency and request timeout count | Application handler time and transport deadline pressure. |
|
||||||
|
| Ping round-trip time and missed pings | Peer reachability and path latency. |
|
||||||
|
| Pipe accept, reject, EOF, and reset counts | Application admission and stream completion behavior. |
|
||||||
|
|
||||||
|
Implement `WebServerMetrics` for HTTP/1.1, HTTP/2, HTTP/3, TLS, and WebTransport callbacks. Record the request path, status, duration, and `WebServerError` category without logging credentials, private keys, or message contents. Export host callback results through the application's metrics system for native deployments.
|
||||||
|
|
||||||
|
## Tuning
|
||||||
|
|
||||||
|
`Policy::default()` uses a 16 MiB message limit, a 64 KiB handshake limit, a 30 second read timeout, a 30 second idle timeout, a receiver queue capacity of 1000, and 128 concurrent stream tasks.
|
||||||
|
|
||||||
|
For low-latency request traffic, use `SendMode::SingleStreamPerMessage`, keep message sizes bounded, use shorter read and idle timeouts, and keep queue and concurrency limits near the amount of work the application can process.
|
||||||
|
|
||||||
|
For high-throughput bulk traffic, use persistent streams, raise `max_message_size` only when messages require it, and size `receiver_queue_capacity` and `max_concurrent_stream_tasks` for available memory and downstream processing capacity. Use pipes for large sequential byte streams instead of increasing message limits.
|
||||||
|
|
||||||
|
Every queued frame consumes memory until the application reads it. Test policy changes with realistic peer counts and payload sizes before deployment.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Certificate Rotation
|
||||||
|
|
||||||
|
Publish the replacement certificate or pin before changing the server. Update clients to trust the replacement while the current certificate remains valid, switch the server, then remove the old trust value after clients migrate. Use system roots when certificate rotation is managed by the issuing authority.
|
||||||
|
|
||||||
|
### Key Backup
|
||||||
|
|
||||||
|
Back up host keyrings and client keyrings as protected secrets. Test restoring a backup before relying on it. Keep private key files owner-only on Unix, protect backup access, and store public key bundles separately from private material.
|
||||||
|
|
||||||
|
### Graceful Shutdown
|
||||||
|
|
||||||
|
Stop accepting new connections, reject new work at the application layer, and allow active requests and pipe writers to finish. For `MTPWebServer`, call `shutdown().await`; its `drain_timeout` controls graceful TCP HTTP completion and the QUIC drain period before remaining connection tasks are terminated.
|
||||||
198
docs/PIPES.md
Normal file
198
docs/PIPES.md
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
# MTP Pipes
|
||||||
|
|
||||||
|
Pipes are unidirectional QUIC/WebTransport streams. The transport primitive is
|
||||||
|
byte-oriented, but raw pipe bytes are not confidential or authenticated by
|
||||||
|
MTP. The creator sends a `PipeRequest` communication value, the peer accepts
|
||||||
|
or rejects it, and an application that carries sensitive data must place the
|
||||||
|
encrypted record layer described below on top of the accepted stream.
|
||||||
|
|
||||||
|
The request's `Description` and `PipeRequest` type remain clear transport
|
||||||
|
metadata. Do not put identities, call details, file names, or other sensitive
|
||||||
|
protocol information in them.
|
||||||
|
|
||||||
|
The creator owns the writer. The accepting peer owns the reader. A writer finishes with a stream FIN or aborts with a stream reset. A reader returns EOF after FIN and reports a connection or stream error when the peer closes unexpectedly.
|
||||||
|
|
||||||
|
## Opening a Pipe
|
||||||
|
|
||||||
|
The creator calls `create_pipe` or the corresponding SDK `createPipe` method with a description. MTP assigns a pipe ID and sends a `PipeRequest` frame. The creator receives a handle, not an active writer, because the peer must decide whether to accept the request.
|
||||||
|
|
||||||
|
The request description is application metadata. It does not grant access to the stream, authenticate the creator, or negotiate an application protocol.
|
||||||
|
Use the authenticated MTP connection and the host's admission policy when a pipe carries sensitive data.
|
||||||
|
|
||||||
|
The browser SDK's `createEncryptedPipe` and `acceptEncryptedPipe` convenience
|
||||||
|
methods derive the local identity, actual pipe ID, random session ID, and
|
||||||
|
default application purpose from MTP state. Use the lower-level session
|
||||||
|
functions only when integrating a custom pipe transport. The low-level API
|
||||||
|
checks that a supplied pipe ID matches the actual pipe; it does not infer a
|
||||||
|
caller-provided sender or recipient identity.
|
||||||
|
|
||||||
|
The convenience methods intentionally require registered client credentials
|
||||||
|
because their endpoint identity is the transport client's registered MTP
|
||||||
|
identity. An application that needs a cryptographic identity independent from
|
||||||
|
transport registration must use the lower-level session functions and provide
|
||||||
|
the endpoint IDs and key material explicitly.
|
||||||
|
|
||||||
|
## Endpoint Encryption
|
||||||
|
|
||||||
|
`initiate_pipe_session`/`accept_pipe_session` in the native transport, or
|
||||||
|
`initiateMTPPipeSession`/`acceptMTPPipeSession` in the browser SDK, perform the
|
||||||
|
pipe-establishment step. The initiator sends an
|
||||||
|
`Encrypted(Signed(Array<...>))` offer containing a fresh 32-byte initial chain key,
|
||||||
|
session ID, pipe ID, direction, purpose, and both endpoint IDs. The recipient
|
||||||
|
decrypts it with its keyring, resolves the expected sender bundle, verifies
|
||||||
|
the signature, and checks every expected field before returning the record
|
||||||
|
reader. The offer is bounded and separately framed from application records.
|
||||||
|
|
||||||
|
The helpers then return `EncryptedPipeWriter`/`EncryptedPipeReader` (or their
|
||||||
|
browser equivalents) without changing the raw QUIC/WebTransport adapter. The
|
||||||
|
context contains the unique pipe/session identity, endpoint identities,
|
||||||
|
direction, and application protocol purpose. Do not derive the initial chain
|
||||||
|
key from the clear description or pipe ID alone.
|
||||||
|
|
||||||
|
The receiver's signature verification policy is explicit and independent from
|
||||||
|
its decryption keyring. Configure `signaturePolicy` on the browser accept
|
||||||
|
helper, or use the client's `defaultSignatureVerificationPolicy`. The
|
||||||
|
initiator and responder signing `signatureSuite` remain separate from this
|
||||||
|
receive policy. Both sides default to Ed25519; choose `signatureSuite: "dual"`
|
||||||
|
and a matching `signaturePolicy: "dual"` explicitly when hybrid signatures
|
||||||
|
are required.
|
||||||
|
|
||||||
|
Each record is encoded as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[4-byte big-endian ciphertext length]
|
||||||
|
[1-byte record type: DATA=0, FINAL=1]
|
||||||
|
[XChaCha20-Poly1305 nonce || ciphertext || tag]
|
||||||
|
```
|
||||||
|
|
||||||
|
The AEAD associated data is `MTP-PIPE-E2EE-1 || purpose || direction ||
|
||||||
|
transcript-hash || sequence || record length || record type`. The transcript
|
||||||
|
hash binds the session ID, pipe ID, sender, recipient, purpose, and direction.
|
||||||
|
The sequence starts at zero and advances only after successful authentication.
|
||||||
|
A missing, duplicated, reordered, or modified record causes authentication to
|
||||||
|
fail. Each record derives a one-use message key and the next chain key with
|
||||||
|
HKDF using the authenticated context and sequence number; the bootstrap key is
|
||||||
|
never used directly as an AEAD key. The record layer caps one encoded record at
|
||||||
|
16 MiB.
|
||||||
|
|
||||||
|
`FINAL` is an authenticated empty record. A reader returns clean EOF only
|
||||||
|
after validating it; transport EOF before `FINAL` is truncation.
|
||||||
|
Authentication, framing, sequence, and I/O failures permanently poison the
|
||||||
|
encrypted reader or writer and erase its current chain key. This is a one-way
|
||||||
|
chain, not a Diffie-Hellman ratchet, so the ordinary offer does not provide
|
||||||
|
forward secrecy.
|
||||||
|
|
||||||
|
The wrapper exposes `writeRecord`/`readRecord`. Callers that already have an
|
||||||
|
independently authenticated session may still construct it directly with a
|
||||||
|
key and context; otherwise use the establishment helpers.
|
||||||
|
|
||||||
|
For more than two members, native `initiate_group_pipe_session` and the browser
|
||||||
|
`initiateMTPPipeSession` recipient-array form encrypt one fresh session key to
|
||||||
|
each current member. Membership changes are rekeys: create a new session ID
|
||||||
|
and offer with the new recipient set, and stop using the old record chain. A
|
||||||
|
removed member must never receive a later session key; an added member must
|
||||||
|
not receive historical records.
|
||||||
|
|
||||||
|
When a live call needs forward secrecy, use the duplex handshake
|
||||||
|
`initiate_forward_secure_pipe_session`/`accept_forward_secure_pipe_session` or
|
||||||
|
the browser `initiateMTPForwardSecurePipeSession`/
|
||||||
|
`acceptMTPForwardSecurePipeSession`. The responder contributes a fresh
|
||||||
|
ephemeral hybrid-KEM key, while long-term signing keys authenticate the
|
||||||
|
exchange. These helpers require a bidirectional stream and bind the handshake
|
||||||
|
transcript into the record context.
|
||||||
|
|
||||||
|
## Accepting or Rejecting a Pipe
|
||||||
|
|
||||||
|
The receiving side reads pipe requests through `receive_pipe`, the host dispatcher, or the browser pipe callback. It calls `accept` to obtain a reader or `deny` to reject the request. A rejected request completes the creator's handle with `Rejected` and no raw byte stream becomes available.
|
||||||
|
|
||||||
|
With native pipes enabled, do not read the underlying `receiver` directly.
|
||||||
|
Normal messages and pipe requests share the transport and must pass through the connection facade so a dispatcher does not deliver one event to the wrong consumer.
|
||||||
|
|
||||||
|
## Closing a Pipe
|
||||||
|
|
||||||
|
The creator closes a successful encrypted pipe with `EncryptedPipeWriter::finish`
|
||||||
|
or the browser writer's `close`; this authenticates `FINAL` and then sends a
|
||||||
|
QUIC FIN. Use `abort` when the peer should discard the stream immediately; this
|
||||||
|
resets the stream and the reader receives an error instead of a clean EOF.
|
||||||
|
Dropping the connection closes all active pipes. Raw pipe FIN is not an
|
||||||
|
authenticated application completion signal.
|
||||||
|
|
||||||
|
The accepting side closes its reader by consuming it or dropping it. A reader does not send an application-level acknowledgement for EOF. If the application needs completion metadata, send an ordinary MTP message before finishing the pipe.
|
||||||
|
|
||||||
|
## Pipe Errors
|
||||||
|
|
||||||
|
| Error | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `Rejected` | The peer denied the request. |
|
||||||
|
| `HandshakeTimeout` | The peer did not complete the pipe handshake in time. |
|
||||||
|
| `StreamClosed` | The pipe stream ended unexpectedly. |
|
||||||
|
| `IoError` | The underlying byte stream returned an I/O error. |
|
||||||
|
| `ConnectionClosed` | The MTP connection closed while the pipe was active. |
|
||||||
|
|
||||||
|
Native applications use the pipe APIs on `MTPConnection`; browser applications use the SDK methods in [WASM Client](WASM-CLIENT.md#pipes). With native pipes enabled, normal messages and pipe requests must be read through the connection facade so the dispatcher can route each event to the correct queue.
|
||||||
|
|
||||||
|
## Native File Upload and Processing
|
||||||
|
|
||||||
|
The creator streams a file in encrypted records. The accepting side processes
|
||||||
|
each decrypted chunk without buffering the complete file. The `session_key`
|
||||||
|
below is obtained from the authenticated pipe-establishment protocol:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Client
|
||||||
|
use mtp_transport::{PipeSessionParameters, initiate_pipe_session};
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
|
let handle = conn.create_pipe("file-upload").await?;
|
||||||
|
let pipe_id = handle.pipe_id();
|
||||||
|
if let Some(writer) = handle.wait().await? {
|
||||||
|
let params = PipeSessionParameters::new(
|
||||||
|
format!("file-upload/{pipe_id}"), pipe_id, own_client_id, host_client_id, 0x40, 0,
|
||||||
|
)?;
|
||||||
|
let mut writer = initiate_pipe_session(
|
||||||
|
writer.into_inner(), params, &own_keyring, &host_public_bundle,
|
||||||
|
).await?;
|
||||||
|
let mut file = tokio::fs::File::open("input.bin").await?;
|
||||||
|
let mut buffer = [0u8; 64 * 1024];
|
||||||
|
loop {
|
||||||
|
let count = file.read(&mut buffer).await?;
|
||||||
|
if count == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
writer.write_record(&buffer[..count]).await?;
|
||||||
|
}
|
||||||
|
writer.finish().await?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Host
|
||||||
|
use mtp_transport::{PipeSessionParameters, accept_pipe_session};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
// The streaming digest below requires `sha2` as a direct application dependency.
|
||||||
|
|
||||||
|
while let Ok(request) = conn.receive_pipe().await {
|
||||||
|
if request.description() != "file-upload" {
|
||||||
|
request.deny().await?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pipe_id = request.id();
|
||||||
|
let reader = request.accept().await?;
|
||||||
|
let params = PipeSessionParameters::new(
|
||||||
|
format!("file-upload/{pipe_id}"), pipe_id, client_id, own_client_id, 0x40, 0,
|
||||||
|
)?;
|
||||||
|
let mut reader = accept_pipe_session(
|
||||||
|
reader.into_inner(), ¶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.
|
||||||
120
docs/PROTOCOL-REFERENCE.md
Normal file
120
docs/PROTOCOL-REFERENCE.md
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
# Protocol Reference
|
||||||
|
|
||||||
|
This document owns the connection lifecycle, protocol keepalive, and application authentication flow. API guides link here for configuration.
|
||||||
|
|
||||||
|
## Connection Lifecycle
|
||||||
|
|
||||||
|
```text
|
||||||
|
bind -> accept QUIC -> negotiate version -> authenticate if enabled
|
||||||
|
-> dispatch application frames -> close or drain
|
||||||
|
```
|
||||||
|
|
||||||
|
The opening version frame is processed before application messages. The host selects a registered type map. Authentication then completes according to the host policy. A connection is returned to the application only after these stages complete.
|
||||||
|
|
||||||
|
## Protocol Keepalive
|
||||||
|
|
||||||
|
The client sends an MTP `Ping` communication value with a frame ID. The host returns a `Pong` with the same ID when automatic responses are enabled. The client records the matched round-trip duration and closes after its configured missed-Ping limit. These frames are handled by the keepalive dispatcher and do not reach ordinary message handlers.
|
||||||
|
|
||||||
|
If automatic responses are disabled, the application must read Ping frames and send compatible Pong frames. Keepalive configuration is documented in the [native client](NATIVE-CLIENT.md) and [native host](NATIVE-HOST.md) guides.
|
||||||
|
|
||||||
|
## Relay metadata version
|
||||||
|
|
||||||
|
Protected relay metadata declares the reserved `RelayVersion` field as an unsigned integer. Builders currently emit version `1` automatically. Receivers select the metadata schema from this field before interpreting any version-specific fields. Missing versions are unsupported legacy relays, and unknown versions are rejected.
|
||||||
|
|
||||||
|
Relay format versions are independent of application type-map versions. A type-map version selects application-defined communication and data types. It does not select the protected relay metadata schema.
|
||||||
|
|
||||||
|
## Relay `CreatedAt`
|
||||||
|
|
||||||
|
The reserved `CreatedAt` field in relay metadata is an unsigned integer containing milliseconds elapsed since `1970-01-01T00:00:00Z`. It is not an ISO timestamp and it is not measured in seconds.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
2026-08-11T12:00:00.000Z
|
||||||
|
↓
|
||||||
|
Unix epoch milliseconds
|
||||||
|
↓
|
||||||
|
CreatedAt = 1786449600000
|
||||||
|
```
|
||||||
|
|
||||||
|
Native relay builders and browser relay senders use this unit. Verified browser metadata exposes `createdAt` as a `bigint`; native verified metadata exposes `u64`.
|
||||||
|
|
||||||
|
## Direct protected envelope
|
||||||
|
|
||||||
|
The high-level direct protected API signs an MTP-owned envelope before it is
|
||||||
|
encrypted for the recipient. Its reserved fields are `ProtectedVersion`,
|
||||||
|
`MessageType`, `FinalRecipientId`, `MessageId`, `CreatedAt`, and `Content`.
|
||||||
|
Receivers verify the envelope before dispatching application content and require
|
||||||
|
the signed message type and final recipient to match the outer communication
|
||||||
|
type and receiver. If the outer sender is present, it must match the signed
|
||||||
|
signer ID. `MessageId` and `CreatedAt` are authenticated; callers can pass a
|
||||||
|
replay guard to reject a previously accepted `(signerId, MessageId)` pair.
|
||||||
|
Native and browser replay guards both receive `CreatedAt` as authenticated
|
||||||
|
metadata, but the timestamp is not part of the replay key.
|
||||||
|
Verified SDK results expose the authenticated `protectedVersion` and
|
||||||
|
`finalRecipientId` alongside the application content.
|
||||||
|
|
||||||
|
Native applications use the same schema through `ProtectedMessageBuilder` and
|
||||||
|
the replay-explicit `open_protected_checked` or `open_protected_without_replay`
|
||||||
|
APIs; language bindings delegate envelope construction and opening to this
|
||||||
|
codec boundary.
|
||||||
|
|
||||||
|
Message processing uses the replay-required native APIs
|
||||||
|
`open_protected_checked` and `open_relay_metadata_checked` (or the equivalent
|
||||||
|
browser client path). Stored-message or forensic tooling must opt into the
|
||||||
|
explicit `*_without_replay` APIs. Native in-memory guards are bounded and
|
||||||
|
configurable; durable guards must perform an atomic insert-if-absent on
|
||||||
|
`(signer ID, MessageId)`.
|
||||||
|
|
||||||
|
Protected identifiers have semantic limits separate from the generic codec
|
||||||
|
blob limit. The default maximum `MessageId` is 256 UTF-8 bytes and relay
|
||||||
|
metadata is limited to 1 MiB of encoded metadata. Deployments can provide
|
||||||
|
stricter limits through the receive policy. Limits are checked after
|
||||||
|
authentication and before retained values enter replay or application state.
|
||||||
|
|
||||||
|
Transport-derived resource policies use a conservative decoder allocation
|
||||||
|
factor of `4 * max_message_size`, in addition to the frame-size output limit.
|
||||||
|
This factor accounts for owned wrapper, recipient, ciphertext, and decoded
|
||||||
|
value copies; it is an implementation admission policy rather than a wire
|
||||||
|
field.
|
||||||
|
|
||||||
|
## Authentication Flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
Client Host
|
||||||
|
| |
|
||||||
|
| Identification or Register, unsigned |
|
||||||
|
|------------------------------------------>|
|
||||||
|
| | generate challenge
|
||||||
|
| Challenge plus host signature |
|
||||||
|
|<------------------------------------------|
|
||||||
|
| ChallengeResponse plus client signature |
|
||||||
|
|------------------------------------------>|
|
||||||
|
| | verify proof and assign identity
|
||||||
|
| IdentificationResponse plus host signature|
|
||||||
|
|<------------------------------------------|
|
||||||
|
```
|
||||||
|
|
||||||
|
Login proof binds the protocol version, client ID, host challenge, and client nonce. Registration proof binds the protocol version, public key bundle, host challenge, and client nonce. The host challenge is generated per connection.
|
||||||
|
|
||||||
|
Authentication attempts pass through a deployment-configurable limiter before
|
||||||
|
client lookup, key validation, challenge signing, or registration callbacks.
|
||||||
|
The default host configuration uses a bounded in-memory window. Hosts may key
|
||||||
|
limits by connection, peer identity, claimed client ID, or registration flow.
|
||||||
|
When identity concealment is enabled, an unknown client ID follows a dummy
|
||||||
|
challenge/proof path and receives the same generic authentication failure as a
|
||||||
|
known client with an invalid proof; disabling concealment restores the legacy
|
||||||
|
identity-specific response for deployments where IDs are public.
|
||||||
|
|
||||||
|
`ForceAuthentication` requires login or registration. `AllowAuthentication` accepts authenticated and unauthenticated clients. `Unauthenticated` rejects authentication attempts. The connection states are `Pending`, `Authenticated`, `Unauthenticated`, and `Failed`.
|
||||||
|
|
||||||
|
## Version Negotiation
|
||||||
|
|
||||||
|
The client sends one compiled-in protocol version. The host compares it with the versions in its registry and returns the selected version in the opening response. Subsequent frames use that version's type map. An unsupported version closes the connection with `AcceptError::UnsupportedVersion`.
|
||||||
|
|
||||||
|
The current self-delimiting `DataValue` codec and three-bit communication header
|
||||||
|
are used by the repository's protocol 3.0 map. The checked-in builtin registry
|
||||||
|
contains only 3.0, so its native clients and hosts do not provide legacy map
|
||||||
|
fallbacks. Type-map versions are configuration-driven; a custom registry may
|
||||||
|
register another version number, but its map must use the current codec format
|
||||||
|
and is not a fallback for a different legacy wire format.
|
||||||
324
docs/SECURITY.md
Normal file
324
docs/SECURITY.md
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
# Security
|
||||||
|
|
||||||
|
This document describes the security controls implemented by MTP, the crypto APIs exposed by `mtp-crypto`, and the limits that operators and application developers must account for.
|
||||||
|
|
||||||
|
## Reporting Vulnerabilities
|
||||||
|
|
||||||
|
Report suspected vulnerabilities privately to the project maintainers. Include the affected crate, feature flags, protocol path, reproducible input, and the commit or release being tested. Do not include private keys or credentials in the report.
|
||||||
|
|
||||||
|
## Security Boundaries
|
||||||
|
|
||||||
|
MTP runs over QUIC and relies on TLS for transport confidentiality and peer authentication. The native transport uses `wtransport`; the browser client uses WebTransport. MTP authentication adds application-level signatures and does not replace TLS certificate verification.
|
||||||
|
|
||||||
|
MTP does not provide anonymity. Client identifiers and connection metadata are visible to the host. It also cannot protect data after a client or host endpoint has been compromised.
|
||||||
|
|
||||||
|
## TLS Certificate Verification
|
||||||
|
|
||||||
|
The native client uses the system root store by default. It also supports a pinned PEM certificate or an SPKI SHA-256 pin. Browser clients use the browser root store unless `serverCertificateHashes` is configured for WebTransport.
|
||||||
|
|
||||||
|
| Configuration | Trusts | Intended use |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| System roots | Certificates trusted by the operating system or browser | Publicly trusted production certificates |
|
||||||
|
| Pinned PEM | The supplied PEM certificate chain | Private CA deployments and controlled environments |
|
||||||
|
| SPKI hash | The public key represented by the supplied certificate | A fixed server key, with planned rotation |
|
||||||
|
| Insecure verification | Any certificate | Local development only |
|
||||||
|
|
||||||
|
For rotation, publish the replacement certificate or key before changing the server, update clients to trust both values where the client API permits it, then remove the old value after all clients have migrated. A pin is a key constraint, not a substitute for a certificate rotation plan.
|
||||||
|
|
||||||
|
### Development Certificates
|
||||||
|
|
||||||
|
The `tls` feature exposes `mtp_crypto::tls::generate_self_signed_cert`. It creates an ECDSA P-256 server certificate for the requested domain, `127.0.0.1`, and `::1`; the certificate is valid for 13 days. The lower-level `mtp_transport::HostConfig::self_signed` provides a transport-level self-signed setup without the crypto certificate helper.
|
||||||
|
|
||||||
|
Self-signed certificates are for development. Production deployments should use a certificate trusted by the client or an explicitly pinned certificate.
|
||||||
|
|
||||||
|
### Insecure Verification
|
||||||
|
|
||||||
|
Native insecure verification has two gates:
|
||||||
|
|
||||||
|
1. Compile with the `insecure-tls` feature.
|
||||||
|
2. Set `MTP_INSECURE_TLS=1` at runtime.
|
||||||
|
|
||||||
|
Without the runtime variable, the connection fails rather than silently disabling verification. Do not use this mode on an untrusted network.
|
||||||
|
|
||||||
|
## Authentication Policies
|
||||||
|
|
||||||
|
Hosts choose one of three policies:
|
||||||
|
|
||||||
|
- `ForceAuthentication` requires login or registration.
|
||||||
|
- `AllowAuthentication` accepts authenticated and unauthenticated clients.
|
||||||
|
- `Unauthenticated` rejects authentication attempts and is the default.
|
||||||
|
|
||||||
|
An unauthenticated connection receives `AuthState::Unauthenticated`. Use `ForceAuthentication` when every client must have a registered identity.
|
||||||
|
|
||||||
|
The native host exposes four authentication states:
|
||||||
|
|
||||||
|
| State | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `Unauthenticated` | The connection completed without application authentication. |
|
||||||
|
| `Pending` | The authentication handshake is in progress. |
|
||||||
|
| `Authenticated` | The host verified the client proof and assigned or confirmed its identity. |
|
||||||
|
| `Failed` | Authentication started but validation failed or the handshake timed out. |
|
||||||
|
|
||||||
|
Authorize requests only after `Authenticated`. A failed handshake is reported through `AcceptError::AuthenticationFailed` or `AcceptError::AuthenticationTimedOut` on the host.
|
||||||
|
|
||||||
|
### Hybrid Signatures
|
||||||
|
|
||||||
|
Authenticated handshakes support Ed25519 and ML-DSA-65 dual signatures. The host and clients default to `require_pq = true`, so both signatures are required. Calling `with_require_pq(false)` permits Ed25519-only authentication and should be treated as an explicit compatibility decision.
|
||||||
|
|
||||||
|
The `ml-dsa` dependency is enabled by default in `mtp-crypto`. The project has not recorded an independent audit for `ml-dsa`; see [Cryptographic review status](#cryptographic-review-status).
|
||||||
|
|
||||||
|
### Challenge-Response Flow
|
||||||
|
|
||||||
|
The complete sequence is in [Protocol Reference](PROTOCOL-REFERENCE.md#authentication-flow). This section defines the signed fields and domain-separation tags used by that sequence.
|
||||||
|
|
||||||
|
### Domain Separation
|
||||||
|
|
||||||
|
Every signed handshake payload begins with a distinct byte:
|
||||||
|
|
||||||
|
| Tag | Payload |
|
||||||
|
| --- | --- |
|
||||||
|
| `0x10` | Host challenge |
|
||||||
|
| `0x11` | Client login proof |
|
||||||
|
| `0x12` | Client registration proof |
|
||||||
|
| `0x13` | Host final confirmation |
|
||||||
|
|
||||||
|
The tags prevent a valid signature for one handshake step from being accepted as a signature for another step.
|
||||||
|
|
||||||
|
## Cryptographic Primitives
|
||||||
|
|
||||||
|
`mtp-crypto` exposes the following building blocks:
|
||||||
|
|
||||||
|
| Area | Implementation | Availability |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| AEAD | XChaCha20-Poly1305 | Default |
|
||||||
|
| AEAD | AES-256-GCM | `full` feature |
|
||||||
|
| Classical signatures | Ed25519 | Default |
|
||||||
|
| Post-quantum signatures | ML-DSA-65 | Default |
|
||||||
|
| KDF and hashing | HKDF-SHA-256, SHA-256 | Default |
|
||||||
|
| Password KDF for `.mk` files | Argon2id | `files` feature |
|
||||||
|
| Hybrid KEM | X25519 plus ML-KEM-768 | `pqc` feature |
|
||||||
|
|
||||||
|
AEAD output stores the nonce before the authenticated ciphertext. `DataValue::Encrypted` uses one canonical multi-recipient envelope and derives a content key through authenticated KEM key wrapping. `DataValue::Signed` authenticates a domain-separated purpose, signer ID, and exact serialized inner value. MTP does not accept caller-supplied AAD as a replacement for this context.
|
||||||
|
|
||||||
|
| Protection | Authenticated fields |
|
||||||
|
| --- | --- |
|
||||||
|
| `Signed<Value>` | `MTP-DATA-SIGN-1`, signature algorithm, purpose, signer ID, and the exact serialized inner value. |
|
||||||
|
| `Encrypted<Value>` | `MTP-DATA-ENC-1`, encryption suite, purpose, recipient count, recipient table, and the ciphertext. Each wrapped content key also authenticates `MTP-DATA-WRAP-1`, suite, purpose, and its KEM ciphertext. |
|
||||||
|
|
||||||
|
The communication header is routing metadata, not automatically part of either
|
||||||
|
generic value wrapper's authenticated data. The high-level direct protected API
|
||||||
|
adds an MTP-owned signed envelope that binds its application type, final
|
||||||
|
recipient, message ID, creation time, and content to the outer route. Callers
|
||||||
|
using the generic protection primitives must bind any routing or message
|
||||||
|
metadata they require in their own signed value.
|
||||||
|
|
||||||
|
Protection composition is significant: `Encrypted(Signed(Value))` hides signer metadata until decryption and is the construction used for sealed-sender payloads; `Signed(Encrypted(Value))` exposes the signer metadata while protecting the contents. A sealed-sender frame simply omits the outer communication sender, routes with its receiver field, and carries an `Encrypted(Signed(Value))` payload. There is no sealed-sender frame flag or wire type.
|
||||||
|
|
||||||
|
### Protected Frame Visibility
|
||||||
|
|
||||||
|
Before opening an `Encrypted(Signed(Value))` payload, a component with access to the MTP frame can read the frame length, communication type, presence flags, transport correlation ID, and next-hop receiver. Relayable application messages use the generic reserved `Relay` communication type; operation-specific names are inside the ciphertext. The outer encrypted value also reveals its encryption suite, generic relay protection purpose, recipient count, unlabeled KEM ciphertext and wrapped-key entries, and ciphertext length. Recipient entries contain no recipient IDs, although recipient count and the cryptographic entry material remain visible.
|
||||||
|
|
||||||
|
The signer algorithm, signature purpose, signer ID, signature, and application-defined inner value are encrypted. They become available only after a recipient opens the encrypted value. The recipient must still verify the inner signature before trusting its signer ID or contents.
|
||||||
|
|
||||||
|
Sealed sender is therefore a construction rule, not an anonymity guarantee or a separate protocol type. The frame sender is absent, the next-hop receiver remains visible for routing, and MTP does not inspect application containers to infer identities or protection flags.
|
||||||
|
|
||||||
|
Connection authentication and protected identity are separate. For a sealed
|
||||||
|
relay sent over an authenticated connection, the host knows the connection's
|
||||||
|
registered MTP identity even though the outer relay sender is absent. The
|
||||||
|
protected signer remains hidden until a metadata recipient decrypts and
|
||||||
|
verifies the relay metadata.
|
||||||
|
|
||||||
|
For a sealed relay sent over an unauthenticated connection, the host receives
|
||||||
|
no registered MTP identity from connection authentication. The outer relay
|
||||||
|
sender is still absent, and the protected signer is still hidden until metadata
|
||||||
|
decryption and verification. The network connection nevertheless has observable
|
||||||
|
metadata such as peer addressing, timing, sizes, and the visible frame fields
|
||||||
|
described above. Neither case provides network anonymity.
|
||||||
|
|
||||||
|
### Relay access model and replay protection
|
||||||
|
|
||||||
|
Relay messages separate metadata recipients from content recipients. A relay
|
||||||
|
service can receive the metadata key, verify the authenticated signer and
|
||||||
|
message identifiers, index the opaque encrypted-content value, and forward the
|
||||||
|
frame without receiving a content key. Only a content recipient can open the
|
||||||
|
content. The final recipient and application message type remain inside the
|
||||||
|
protected metadata/content structure; the outer frame exposes only the chosen
|
||||||
|
next hop.
|
||||||
|
|
||||||
|
The receiver must consume the authenticated `(signer ID, MessageId)` pair with
|
||||||
|
a replay guard. `CreatedAt` is authenticated metadata that the guard receives
|
||||||
|
for retention or observability, but it is not part of the replay identity and
|
||||||
|
must not be used as the replay defense. The native codec exposes `ReplayGuard`
|
||||||
|
and the browser SDK exposes the matching `MTPReplayGuard` contract. Both
|
||||||
|
high-level APIs use bounded process-local guards by default for direct and
|
||||||
|
relay subscriptions. Those defaults are duplicate suppression only while an
|
||||||
|
entry remains in the fixed cache: eviction, reloads, or multiple receiver
|
||||||
|
processes can permit a previously accepted message again. Low-level relay
|
||||||
|
metadata opening remains replay-optional for callers reopening stored frames.
|
||||||
|
Use a durable guard when replay state must survive cache eviction, reloads, or
|
||||||
|
process boundaries. A guard should atomically record a new ID before
|
||||||
|
dispatching application content. Transport frame IDs must not be used for
|
||||||
|
this purpose.
|
||||||
|
|
||||||
|
Native message-processing boundaries require a replay guard through the
|
||||||
|
checked opening APIs. Reopening stored or forensic frames without a guard is
|
||||||
|
available only through an explicitly named `without_replay` API. The reference
|
||||||
|
in-memory guard is bounded and FIFO-evicts old entries, so it is a duplicate
|
||||||
|
suppression cache rather than durable replay protection. A durable deployment
|
||||||
|
must use an atomic insert-if-absent operation keyed by `(signer ID, MessageId)`;
|
||||||
|
a separate read followed by insert is race-prone.
|
||||||
|
|
||||||
|
`VerifiedRelayMetadata` is an authenticated capability rather than a caller
|
||||||
|
constructed data transfer object. Rust fields are private and the browser
|
||||||
|
implementation keeps authenticated state behind a branded class. Content
|
||||||
|
opening consumes that authenticated state, so changing a message ID or
|
||||||
|
recipient in a normal object cannot make unrelated encrypted content inherit
|
||||||
|
those fields. Browser callers can call `dispose()` or `free()` on the metadata
|
||||||
|
capability for deterministic native-handle release; finalization remains a
|
||||||
|
fallback.
|
||||||
|
|
||||||
|
### Signature policy
|
||||||
|
|
||||||
|
Verification takes a receiver-side `SignaturePolicy`/`ProtectionPolicy`.
|
||||||
|
`AnySupported` is useful for compatibility at the low-level codec boundary,
|
||||||
|
but protocol receivers should select `Ed25519` or `Dual`. The browser SDK uses
|
||||||
|
an explicit `ed25519` default and permits an operation or client override. Its
|
||||||
|
`MTPSecurityProfile` resolves protected-message sender/receiver suites,
|
||||||
|
encrypted-pipe suites, and the authentication PQ requirement together;
|
||||||
|
`any-supported` remains an explicit compatibility value. It never derives
|
||||||
|
receive policy from the recipient keyring. Signature policy must be applied
|
||||||
|
independently to relay metadata, relay content, and pipe session establishment.
|
||||||
|
|
||||||
|
### Key history and rotation
|
||||||
|
|
||||||
|
Recipient KEM key history is tried locally without adding a stable recipient
|
||||||
|
key identifier to the visible encrypted-recipient table. Signing-key resolvers
|
||||||
|
receive a claimed, unverified signer ID only as a trusted-key lookup key; the
|
||||||
|
relay helpers authenticate that ID when they verify against the returned
|
||||||
|
history. Deployments should retain old
|
||||||
|
verification keys for at least as long as stored signed messages remain
|
||||||
|
accepted, and should make key-history lookup an authorization decision rather
|
||||||
|
than accepting any key supplied with a message.
|
||||||
|
|
||||||
|
[mtp-crypto API](../crypto/), [native client](NATIVE-CLIENT.md), and [native host](NATIVE-HOST.md).
|
||||||
|
|
||||||
|
The crate's feature groups are:
|
||||||
|
|
||||||
|
| Feature | Adds |
|
||||||
|
| --- | --- |
|
||||||
|
| Default | XChaCha20-Poly1305, Ed25519, ML-DSA-65, HKDF, and SHA-256 |
|
||||||
|
| `full` | AES-256-GCM in addition to the default features |
|
||||||
|
| `pqc` | Hybrid X25519 and ML-KEM-768 support |
|
||||||
|
| `serde` | Serialization support for key types |
|
||||||
|
| `wasm` | `getrandom` support for WebAssembly |
|
||||||
|
| `tls` | Development certificate generation |
|
||||||
|
| `password-kdf` | Argon2id password derivation for protected keyring files |
|
||||||
|
|
||||||
|
The main types are `Keyring`, `PublicKeyBundle`, `EncryptionType`, `HybridKem`, `XChaCha20Poly1305` (with the legacy `ChaCha20Poly1305` alias), `Aes256Gcm`, `Ed25519Signer`, and `MlDsaSigner`. Hashing and KDF helpers include `sha256`, `sha256_double`, `hkdf_extract`, `hkdf_expand`, and `derive_encryption_key`. Handshake payload builders are in `mtp_crypto::auth`.
|
||||||
|
|
||||||
|
## Cryptographic Review Status
|
||||||
|
|
||||||
|
The project records the following status for its cryptographic dependencies:
|
||||||
|
|
||||||
|
| Crate | Audited? | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ed25519-dalek` | Yes | Used by Signal and Diem |
|
||||||
|
| `chacha20poly1305` | Yes | NCC Group audit, December 2019 |
|
||||||
|
| `aes-gcm` | Yes | NCC Group audit, December 2019 |
|
||||||
|
| `ml-dsa` | No | NIST vectors pass in project tests |
|
||||||
|
| `mlkem-tls` | No | Uses an unaudited `mlkem-rs` backend |
|
||||||
|
| `hkdf` | No | Standard construction |
|
||||||
|
| `sha2` | No | Standard construction |
|
||||||
|
| `zeroize` | No | Used for secret-key containers |
|
||||||
|
|
||||||
|
The audit entries describe the dependency projects. MTP's crypto tests cover round trips, wrong-key failures, wrong-AAD failures, and signature failures;
|
||||||
|
they do not replace a review of protocol composition or deployment.
|
||||||
|
|
||||||
|
## Browser End-to-End Encryption
|
||||||
|
|
||||||
|
The browser SDK's optional E2EE session uses XChaCha20-Poly1305 with message keys derived from a one-way HKDF chain. Each send and receive operation advances its chain and authenticates the message header as AAD. Initial messages can carry a hybrid KEM ciphertext for session setup.
|
||||||
|
|
||||||
|
This is a single-chain ratchet. It has no Diffie-Hellman ratchet step and does not provide post-compromise security. Out-of-order messages can create skipped keys; the SDK accepts a receive gap of at most 100 messages and retains at most 100 skipped keys. Consumed or evicted keys are zeroed in the SDK state where the implementation owns the buffer.
|
||||||
|
|
||||||
|
The session root key comes from the authenticated handshake's KEM shared secret. The initiator and responder derive separate send and receive chains.
|
||||||
|
Each message consumes one chain key, derives one message key with HKDF, and increments its counter. `sessionStorage` stores browser session state for the current origin. `encryptedSecretProvider` is an independent caller-managed encrypted-secret facility; it is not automatically used by `MTPSessionStorage` or `MTPSessionManager`. Applications that need encrypted session persistence must coordinate those stores explicitly. The provider must protect its wrapping secret outside the SDK; the SDK does not recover a lost secret or skipped message keys.
|
||||||
|
|
||||||
|
Relay envelopes, browser session E2EE, and encrypted pipes are separate
|
||||||
|
protocols:
|
||||||
|
|
||||||
|
| Model | State | Intended use |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `RelayEnvelope` | Stateless `Encrypted(Signed(Value))`, multi-recipient | Store-and-forward messages and routing |
|
||||||
|
| `SessionE2EE` | Stateful symmetric ratchet in `sessionStorage` | Active browser exchanges |
|
||||||
|
| `EncryptedPipeSession` | Authenticated setup plus ordered record chain | Protected streams |
|
||||||
|
|
||||||
|
Encrypted pipes bind the pipe/session transcript, direction, purpose, sequence,
|
||||||
|
record length, and record type to each record. `FINAL` is authenticated and
|
||||||
|
unexpected EOF is reported as truncation. The ordinary signed/KEM offer is not
|
||||||
|
forward-secure; the native and browser duplex helpers use an ephemeral
|
||||||
|
authenticated KEM exchange before deriving the record chain. Group membership
|
||||||
|
changes require a new session key and recipient set.
|
||||||
|
|
||||||
|
## Key Storage
|
||||||
|
|
||||||
|
`Keyring` contains three public and three private key values. Its private key fields use `ZeroizeOnDrop`, and serialized keyring output is held in a zeroizing buffer while it is constructed. Public key bundles contain only the three public values.
|
||||||
|
|
||||||
|
Role-specific protocol boundaries should validate only the material they need:
|
||||||
|
`validate_encryption()` checks that a KEM public/private pair corresponds, while
|
||||||
|
`validate_full()` additionally requires a complete hybrid signing identity.
|
||||||
|
This keeps partial browser keyrings usable without allowing an envelope sender
|
||||||
|
to proceed with an invalid local decryption key.
|
||||||
|
|
||||||
|
Applications remain responsible for storage at rest. The `files` feature writes passphrase-protected keyrings to `.mk` files and public bundles to `.mpkb` files. Protected `.mk` files store the Argon2id identifier, parameters, salt, and AEAD ciphertext; they do not derive their key with HKDF. On Unix, keyring files are created with owner-only `0600` permissions.
|
||||||
|
Restrict those files to the owning account and protect backups. Browser applications should treat the configured credential storage as sensitive application data.
|
||||||
|
|
||||||
|
Key-material parsing is explicit in the SDK: use the hex, Base64, or byte
|
||||||
|
helpers for encoded key material. Arbitrary strings are no longer treated as
|
||||||
|
passphrases by the compatibility `secretKeyFromString` helper. Applications
|
||||||
|
migrating data written by the old implicit-HKDF behavior can use the explicitly
|
||||||
|
named, deprecated `legacySecretKeyFromStringV1` helper only for that migration;
|
||||||
|
new data must not use it. Passwords must use the explicit Argon2id passphrase
|
||||||
|
API with a stored per-record salt and versioned parameters. The SDK's
|
||||||
|
`deriveKeyFromPassphrase` uses a worker when browser workers are available;
|
||||||
|
the explicitly named `deriveKeyFromPassphraseSync` form is for workers and
|
||||||
|
command-line migrations. HKDF helpers are for high-entropy key material and
|
||||||
|
are not password-hardening functions.
|
||||||
|
|
||||||
|
## Resource Limits and Operational Controls
|
||||||
|
|
||||||
|
`Policy::default()` sets a 16 MiB application message limit and a 64 KiB handshake message limit. It also sets a 30 second read timeout, a 30 second maximum idle timeout, a receiver queue capacity of 1000, and a maximum of 128 concurrent stream tasks. Tune these values for the deployment and peer trust level.
|
||||||
|
|
||||||
|
The recursive codec applies additional defaults while parsing untrusted values:
|
||||||
|
maximum nesting depth 64, 65,536 value nodes, 16 MiB per blob or envelope,
|
||||||
|
64 encrypted recipients, and a 64 MiB cumulative decoder allocation budget.
|
||||||
|
Decrypted values are parsed with the same limits. Transport derives the blob,
|
||||||
|
allocation, and encoder output budgets from its admitted frame size rather than
|
||||||
|
serializing an unrestricted recursive value first. The default transport
|
||||||
|
allocation budget is four times the admitted frame size to cover conservative
|
||||||
|
owned-copy and crypto-buffer accounting; deployments may choose another
|
||||||
|
factor with `DecodeLimits::for_transport_message_size_with_allocation_factor`.
|
||||||
|
|
||||||
|
The host applies an authentication-attempt limiter before storage lookups,
|
||||||
|
public-key validation, challenge signing, and registration callbacks. The
|
||||||
|
default limiter is a bounded in-memory sliding window; configure a durable or
|
||||||
|
distributed limiter when limits must coordinate across host instances. Unknown
|
||||||
|
client IDs are sent through a fixed dummy challenge/proof path by default, so
|
||||||
|
they receive a generic authentication failure instead of an enumeration hint.
|
||||||
|
Deployments that intentionally publish client IDs can disable this concealment.
|
||||||
|
|
||||||
|
Keepalive Pong observation is bounded and accepts only the currently pending
|
||||||
|
ping ID. Unsolicited Pongs are dropped before they can consume application
|
||||||
|
receiver capacity.
|
||||||
|
|
||||||
|
## Security Limitations
|
||||||
|
|
||||||
|
- The first version-negotiation frame is sent before authentication and is not signed.
|
||||||
|
- `AllowAuthentication` intentionally permits unauthenticated clients; it is not an authenticated-only mode.
|
||||||
|
- Browser-side Rust panics cannot be recovered by JavaScript. The WASM client contains panic paths from internal `expect` calls.
|
||||||
|
- The browser E2EE ratchet does not provide post-compromise security.
|
||||||
|
- The ordinary encrypted-pipe offer does not provide forward secrecy; use the
|
||||||
|
duplex handshake when recorded-call confidentiality after long-term KEM
|
||||||
|
compromise is required.
|
||||||
|
- Replay state is process-local by default for high-level subscriptions. Use a
|
||||||
|
durable replay guard when protection must survive reloads or coordinate
|
||||||
|
multiple receiver processes.
|
||||||
108
docs/TROUBLESHOOTING.md
Normal file
108
docs/TROUBLESHOOTING.md
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
Use the failure stage to narrow the cause. MTP connections pass through TLS, the opening version frame, optional authentication, and application framing in that order.
|
||||||
|
|
||||||
|
Each entry identifies the symptom, diagnosis, fix, and prevention. Security labels identify workarounds that change certificate verification or keepalive.
|
||||||
|
|
||||||
|
## Diagnosis Flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
Connection fails?
|
||||||
|
-> TLS or WebTransport error? Check certificate, origin, and endpoint.
|
||||||
|
-> UnsupportedVersion? Check compiled client version and host registry.
|
||||||
|
-> AuthenticationFailed? Check policy, key lookup, and key bundles.
|
||||||
|
-> CodecError? Check generated type maps and negotiated version.
|
||||||
|
-> MessageTooLarge? Compare peer payload with Policy limits.
|
||||||
|
-> Ping or pipe failure? Check the protocol reference or Pipes guide.
|
||||||
|
```
|
||||||
|
|
||||||
|
## TLS Connection Failures
|
||||||
|
|
||||||
|
**Security impact:** Safe when the certificate or pin is corrected. Insecure TLS is restricted to Development Mode.
|
||||||
|
|
||||||
|
Check the certificate before investigating MTP frames.
|
||||||
|
|
||||||
|
- With native clients, confirm the certificate chains to the system roots or pass the expected PEM certificate with `ClientConfig::with_pinned_pem`.
|
||||||
|
- With browser clients, confirm WebTransport is supported and that `serverCertificateHashes` contains the expected certificate hash when using a pinned certificate.
|
||||||
|
- For local self-signed certificates, pin the generated PEM certificate. The lower-level insecure mode requires both the `insecure-tls` feature and `MTP_INSECURE_TLS=1`.
|
||||||
|
- Confirm the hostname or IP address is present in the certificate's subject alternative names. A valid certificate with the wrong name still fails TLS.
|
||||||
|
|
||||||
|
Use [Security](SECURITY.md) for certificate trust and rotation rules.
|
||||||
|
|
||||||
|
## Version Negotiation Failures
|
||||||
|
|
||||||
|
An `AcceptError::UnsupportedVersion` means the client's compiled `PROTOCOL_VERSION` is absent from the host registry. Check that both peers were built from compatible `type-maps.yaml` files and that the host includes the required generated version.
|
||||||
|
|
||||||
|
An `AcceptError::MissingVersion` means the opening frame did not contain a valid `DataType::Version` string in `major.minor` form. Do not send an application frame before the opening version frame completes.
|
||||||
|
|
||||||
|
Use [Connector](CONNECTOR.md) for registry and migration rules.
|
||||||
|
|
||||||
|
## Authentication Failures
|
||||||
|
|
||||||
|
Check the selected `AuthenticationPolicy` first.
|
||||||
|
|
||||||
|
- `Unauthenticated` rejects login and registration by configuration.
|
||||||
|
- `AllowAuthentication` accepts both authenticated and guest connections.
|
||||||
|
- `ForceAuthentication` requires a registered login or a successful registration callback.
|
||||||
|
|
||||||
|
For login, verify the client ID lookup returns the expected public key bundle, the client keyring matches that bundle, and the client has the host public key used to verify the host signature. For registration, verify that `complete_register` persists the submitted public bundle and returns a valid client ID.
|
||||||
|
|
||||||
|
When `require_pq` is true, both Ed25519 and ML-DSA-65 keys and signatures must be available. Set `with_require_pq(false)` only for an explicit Ed25519-only compatibility deployment. Authentication sequence: [Protocol Reference](PROTOCOL-REFERENCE.md#authentication-flow).
|
||||||
|
|
||||||
|
## CodecError Failures
|
||||||
|
|
||||||
|
**Symptom:** `UnknownCommunicationType` or `UnknownDataType`.
|
||||||
|
|
||||||
|
**Diagnosis:** The peers use different generated type maps or the selected version does not define the value.
|
||||||
|
|
||||||
|
**Fix:** Build both peers from the same type-map configuration and send only types defined by the negotiated version.
|
||||||
|
|
||||||
|
**Prevention:** Treat generated type maps as versioned build artifacts.
|
||||||
|
|
||||||
|
`CodecError::UnknownVersion` means the codec was created for a version absent from its registry. `UnknownCommunicationType` and `UnknownDataType` mean the selected `TypeMap` has no mapping for the value being encoded. `MissingTypeMap` means a versioned value lost its retained negotiated map; `TypeMapMismatch` means it was combined with a value or codec for another version. Select the negotiated type map and do not send an unmapped variant.
|
||||||
|
|
||||||
|
`ReservedCommunicationType` means application code attempted to use a reserved wire ID. Use generated communication types instead of assigning protocol IDs manually. `MissingField` means a required typed field was not present.
|
||||||
|
|
||||||
|
`InvalidEncoding` indicates truncated, malformed, duplicate-field, reserved-kind, or structurally invalid bytes. `TooManyEntries` indicates that an array, container, or frame exceeds the codec's representable count or length. Protection operations return typed errors for malformed envelopes, authentication failures, invalid signatures, and missing recipients. The complete variant table is in [Errors](ERRORS.md).
|
||||||
|
|
||||||
|
## Frames and Message Limits
|
||||||
|
|
||||||
|
`MessageTooLarge` means the serialized frame exceeds the configured policy. Native transport defaults are a 16 MiB application message limit and a 64 KiB handshake limit. The browser SDK defaults `maxMessageSize` to 16 MiB.
|
||||||
|
|
||||||
|
`ParseCommunicationValue`, `ParseError`, or `CodecError::InvalidEncoding` means the received bytes do not match the length-prefixed frame and value format. Check that the sender writes the four-byte big-endian frame length exactly once and that the receiver reads exactly that many bytes. Frame layout: [Type Map](TYPE-MAP.md).
|
||||||
|
|
||||||
|
## Requests and Subscriptions
|
||||||
|
|
||||||
|
If `request()` times out, confirm that the peer sends a response with the same non-zero frame ID. If `responseType` is set, confirm that the response uses the expected communication type after the ID matches.
|
||||||
|
|
||||||
|
If a subscription callback does not run, confirm that the generated type map contains the message type and that the client is connected before the sender emits the frame. Enable the SDK logger to inspect state changes and errors.
|
||||||
|
|
||||||
|
## Protocol Pings
|
||||||
|
|
||||||
|
If `get_ping()` remains `None` or the connection closes after missed pings, check the keepalive configuration and responder mode in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive).
|
||||||
|
|
||||||
|
## Pipes
|
||||||
|
|
||||||
|
If a pipe handle resolves to `null` or `PipeError::Rejected`, the peer denied the request. If `receive_pipe()` never produces a request, use the connection facade instead of reading the underlying receiver directly. If a reader sees an error instead of EOF, the writer likely aborted the stream or the connection closed. Pipe lifecycle: [Pipes](PIPES.md).
|
||||||
|
|
||||||
|
## Browser Diagnostics
|
||||||
|
|
||||||
|
Use `MTPClient.isSupported()` before creating a browser client. Inspect browser console errors, WebTransport session state, certificate pins, and the SDK logger callback. Raw WASM bindings expose lower-level callbacks, but the SDK adds timeout and lifecycle handling.
|
||||||
|
|
||||||
|
| Browser or WebTransport signal | Meaning and next check |
|
||||||
|
| --- | --- |
|
||||||
|
| `WebTransportError.source = "stream"`, `streamErrorCode = 0` | The peer closed a one-frame stream normally in the supported browser behavior. Check session state before treating it as a failure. |
|
||||||
|
| `WebTransportError.source = "stream"`, non-zero `streamErrorCode` | A stream-level failure. Check whether the peer sent `STOP_SENDING` or reset the stream, then inspect the active pipe or frame. |
|
||||||
|
| `WebTransportError.source = "session"` | The WebTransport session failed. Check TLS, the endpoint, the `webtransport` CONNECT path, and server logs. |
|
||||||
|
| `WebTransportError` without `streamErrorCode` | The error is session-level or browser-specific. Inspect `error.message`, `error.source`, and the browser network panel. |
|
||||||
|
| Close code `0`, reason `mtp-webserver shutdown` | `MTPWebServer` performed an intentional shutdown. Reconnect after the process restarts. |
|
||||||
|
|
||||||
|
MTP logs stream-level `STOP_SENDING` and `RESET_STREAM` events with their `streamErrorCode`. QUIC transport error numbers are implementation-specific; use the browser's source, stream code, message, and server logs together.
|
||||||
|
|
||||||
|
## Development Mode
|
||||||
|
|
||||||
|
Use `mtp_crypto::tls::generate_self_signed_cert` for local certificates. Native insecure verification requires the `insecure-tls` feature and `MTP_INSECURE_TLS=1`; it disables certificate verification and is not a production fix.
|
||||||
|
|
||||||
|
## Getting More Help
|
||||||
|
|
||||||
|
Capture the negotiated version, connection state, error variant, endpoint, and relevant server log entries. Enable the SDK logger or Rust tracing, then remove credentials, private keys, and message contents before sharing a report.
|
||||||
186
docs/TYPE-MAP.md
186
docs/TYPE-MAP.md
|
|
@ -1,6 +1,106 @@
|
||||||
# Type Map
|
# Type Map
|
||||||
|
|
||||||
This file documents the Type Map & Registry configuration used by the MTP protocol.
|
This file documents the type-map and registry configuration used by MTP. The
|
||||||
|
repository workspace uses [`example/type-maps.yaml`](../example/type-maps.yaml)
|
||||||
|
through [`.cargo/config.toml`](../.cargo/config.toml); that map currently
|
||||||
|
selects protocol version 3.0. The root [`example-type-maps.yaml`](../example-type-maps.yaml)
|
||||||
|
is a separate illustrative multi-version configuration used by the manual WASM
|
||||||
|
build script. Downstream applications should provide their own map.
|
||||||
|
|
||||||
|
The protocol version selects the generated codec/type-map build, while the
|
||||||
|
type-map entries define the available application types and their IDs.
|
||||||
|
|
||||||
|
## Binary Frame Format
|
||||||
|
|
||||||
|
Every transport frame is a four-byte big-endian length followed by one `CommunicationValue`. The length counts all bytes after the length field.
|
||||||
|
|
||||||
|
This is the only transport frame length prefix. Transports write the
|
||||||
|
`CommunicationValue` bytes directly and do not add another length before this
|
||||||
|
field. The close-frame sentinel occupies the same four-byte position.
|
||||||
|
|
||||||
|
```text
|
||||||
|
[4 bytes total length]
|
||||||
|
[2 bytes communication type]
|
||||||
|
[1 byte flags]
|
||||||
|
bit 0 = has ID
|
||||||
|
bit 1 = has sender ID
|
||||||
|
bit 2 = has receiver ID
|
||||||
|
bits 3-7 must be zero
|
||||||
|
[4 bytes ID] if bit 0
|
||||||
|
[8 bytes sender ID] if bit 1
|
||||||
|
[8 bytes receiver ID] if bit 2
|
||||||
|
[DataValue payload]
|
||||||
|
```
|
||||||
|
|
||||||
|
The only defined flag values are `0x01` for ID, `0x02` for sender, and `0x04` for receiver. Unknown flag bits are rejected. IDs are full-width unsigned big-endian values: the correlation ID is `u32`, while sender and receiver IDs are `u64`. Encryption and signing are properties of the `DataValue` payload, never of the frame header.
|
||||||
|
|
||||||
|
`Relay` is the reserved opaque application communication type. Relay frames
|
||||||
|
omit the outer sender, expose only the next-hop receiver and transport
|
||||||
|
correlation data, and carry the actual operation and application metadata in
|
||||||
|
their protected payload.
|
||||||
|
|
||||||
|
## DataValue Wire Format
|
||||||
|
|
||||||
|
Every `DataValue` begins with a one-byte kind marker. MTP assigns `0x01` and `0x02` to boolean true and false, `0x03` to signed `i128`, `0x04` to unsigned `u128`, `0x05` to `f64`, `0x06` to UTF-8 strings, `0x07` to bytes, `0x08` to arrays, `0x09` to containers, `0x0A` to `Encrypted<Value>`, `0x0B` to `Signed<Value>`, and `0xFF` to null. Kind `0x0C` is reserved and rejected. All multibyte numeric values, counts, and lengths are big-endian.
|
||||||
|
|
||||||
|
Strings and bytes have a four-byte byte length. Arrays have a two-byte element count followed by that many self-delimiting values. The protection wrappers have the following canonical layouts.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Container
|
||||||
|
|
||||||
|
09
|
||||||
|
[2 bytes element count]
|
||||||
|
|
||||||
|
repeat for each element:
|
||||||
|
[2 bytes DataTypeId]
|
||||||
|
[DataValue]
|
||||||
|
```
|
||||||
|
|
||||||
|
Container field IDs must be unique. Each nested value is self-delimiting, so container elements have no generic per-element payload length.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Signed
|
||||||
|
|
||||||
|
0B
|
||||||
|
[4 bytes wrapper length]
|
||||||
|
|
||||||
|
[1 byte signature algorithm]
|
||||||
|
[1 byte purpose]
|
||||||
|
[8 bytes signer ID]
|
||||||
|
[signature]
|
||||||
|
[DataValue]
|
||||||
|
```
|
||||||
|
|
||||||
|
The wrapper length counts the bytes after the length field. Signature length is determined by the signature algorithm. The signature covers `MTP-DATA-SIGN-1 || algorithm || purpose || signer ID || serialized inner value`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Encrypted
|
||||||
|
|
||||||
|
0A
|
||||||
|
[4 bytes envelope length]
|
||||||
|
|
||||||
|
[1 byte encryption suite]
|
||||||
|
[1 byte purpose]
|
||||||
|
[2 bytes recipient count]
|
||||||
|
|
||||||
|
[recipient entry]
|
||||||
|
...
|
||||||
|
|
||||||
|
[encrypted DataValue bytes]
|
||||||
|
```
|
||||||
|
|
||||||
|
The envelope length counts the bytes after the length field. A recipient entry is an unlabeled fixed-size KEM ciphertext and wrapped content-encryption key; both lengths are determined by the selected suite. The encrypted bytes are the AEAD output for the complete serialized inner `DataValue`.
|
||||||
|
|
||||||
|
Protection nesting directly represents both signer-visibility choices: `Encrypted(Signed(Container))` keeps signer metadata private, while `Signed(Encrypted(Container))` exposes it. A frame with no outer sender and an `Encrypted(Signed(Container))` payload uses sealed sender. Sealed sender adds no flag or distinct wire type.
|
||||||
|
|
||||||
|
### Container ordering and signatures
|
||||||
|
|
||||||
|
Container entries are ordered sequences in the current format. Insertion order
|
||||||
|
is therefore semantic: two containers with the same field/value pairs in a
|
||||||
|
different order have different serialized bytes and different signatures. The
|
||||||
|
decoder rejects duplicate field IDs. Applications that need map semantics must
|
||||||
|
canonicalize their own input before signing; a future canonical map encoding
|
||||||
|
requires a protocol-format version and cannot be inferred by a receiver.
|
||||||
|
|
||||||
## TypeMap & Compile-Time Type Safety
|
## TypeMap & Compile-Time Type Safety
|
||||||
|
|
||||||
|
|
@ -23,6 +123,12 @@ export default defineConfig({
|
||||||
|
|
||||||
Rust and manual WASM builds can set `MTP_TYPE_MAPS` directly (see [Customizing Type Maps in Downstream Projects](#customizing-type-maps-in-downstream-projects)).
|
Rust and manual WASM builds can set `MTP_TYPE_MAPS` directly (see [Customizing Type Maps in Downstream Projects](#customizing-type-maps-in-downstream-projects)).
|
||||||
|
|
||||||
|
For browser builds, `protocol_version` selects the one application map compiled
|
||||||
|
into that WASM client. The Vite-generated `mtp/type-map` module contains the
|
||||||
|
reserved MTP names and the application names from that selected version only;
|
||||||
|
the selected version must be present in `type_maps`. This keeps its TypeScript
|
||||||
|
unions aligned with the client runtime.
|
||||||
|
|
||||||
### Using Generated Enums
|
### Using Generated Enums
|
||||||
|
|
||||||
After editing the config and rebuilding, `CommunicationType` and `DataType` enums are generated automatically. Use them in code:
|
After editing the config and rebuilding, `CommunicationType` and `DataType` enums are generated automatically. Use them in code:
|
||||||
|
|
@ -30,44 +136,49 @@ After editing the config and rebuilding, `CommunicationType` and `DataType` enum
|
||||||
```rust
|
```rust
|
||||||
use mtp::type_map::{CommunicationType, DataType, TypeMap};
|
use mtp::type_map::{CommunicationType, DataType, TypeMap};
|
||||||
|
|
||||||
let tm = TypeMap::v2_0();
|
let tm = TypeMap::v3_0();
|
||||||
let id = tm.data_id_enum(DataType::SomeType).unwrap();
|
let id = tm.data_id_enum(DataType::ExampleText).unwrap();
|
||||||
```
|
```
|
||||||
|
|
||||||
The enums are a **union across all versions**; every type name from every version is a variant. The version-specific `TypeMap` maps each variant to the correct wire ID for that version. Types not defined in a version return `None`:
|
For native builds with the `registry` feature, the enums are a **union across
|
||||||
|
all versions**; every type name from every version is a variant. The
|
||||||
|
version-specific `TypeMap` maps each variant to the correct wire ID for that
|
||||||
|
version. For a type absent from a selected version, the lookup returns `None`.
|
||||||
|
Browser-generated TypeScript unions intentionally differ: they contain only
|
||||||
|
the selected `protocol_version` plus reserved names, matching the WASM client
|
||||||
|
compiled by the Vite plugin.
|
||||||
|
|
||||||
Encoding/decoding uses a `TypeMap` to resolve type names to wire IDs:
|
Encoding/decoding uses a `TypeMap` to resolve type names to wire IDs:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use mtp::codec::{encode, decode, DataValue};
|
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||||
use mtp::type_map::TypeMap;
|
use mtp::type_map::TypeMap;
|
||||||
|
|
||||||
let tm = TypeMap::v2_0();
|
let tm = TypeMap::v3_0();
|
||||||
let value = DataValue::Str("hello".into());
|
let value = CommunicationValue::new_with_type_map(CommunicationType::Ping, &tm)
|
||||||
|
.add_typed(DataType::Description, &tm, DataValue::Str("hello".into()));
|
||||||
|
|
||||||
let bytes = encode(&value, &tm).unwrap();
|
let bytes = value.to_bytes().unwrap();
|
||||||
let decoded = decode(&bytes, &tm).unwrap();
|
let decoded = CommunicationValue::from_bytes_with(&bytes, &tm).unwrap();
|
||||||
```
|
```
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let tm_v2 = TypeMap::v2_0();
|
let tm_v3 = TypeMap::v3_0();
|
||||||
assert!(tm_v2.data_id_enum(DataType::SomeType).is_some()); // defined in v2.0
|
assert!(tm_v3.data_id_enum(DataType::ExampleText).is_some());
|
||||||
assert!(tm_v2.data_id_enum(DataType::ExampleType).is_none()); // NOT in v2.0
|
|
||||||
|
|
||||||
let tm_v1 = TypeMap::v1_0();
|
|
||||||
assert!(tm_v1.data_id_enum(DataType::ExampleType).is_some()); // defined in v1.0
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When communicating with a peer on another version, encode only variants that map in the negotiated version. If an incoming frame names a type absent from the selected map, reject it as a protocol or type-map compatibility error; do not reinterpret its wire ID using another version's map. The current repository map uses the self-delimiting codec format for protocol version `3.0`; a custom registry may register other version numbers, but those maps are not legacy wire-format fallbacks.
|
||||||
|
|
||||||
### Forward/Backward Compatibility Between Versions
|
### Forward/Backward Compatibility Between Versions
|
||||||
|
|
||||||
Because enums are a union of all types across versions, a variant might exist that has no wire mapping in the *negotiated* version:
|
Because enums are a union of all types across versions, a variant might exist that has no wire mapping in the *negotiated* version:
|
||||||
|
|
||||||
```
|
```
|
||||||
v2.0 client sends DataType::SomeType → host encodes with v2.0 TypeMap → wire ID 32
|
v3.0 client sends DataType::ExampleText → host encodes with v3.0 TypeMap → wire ID 43
|
||||||
v2.0 host receives DataType::ExampleType (from v1.0 client) → not in v2.0 TypeMap → None → Error
|
v3.0 host receives a version absent from the registry → version negotiation error
|
||||||
```
|
```
|
||||||
|
|
||||||
This is by design: the host maps unknown types to `Error`, and the client should only send types that exist in its compiled-in version.
|
Encoding a frame with an unmapped communication or data type returns `CodecError::UnknownCommunicationType` or `CodecError::UnknownDataType`. Select a mapped variant from the compiled-in version before sending it.
|
||||||
|
|
||||||
## Registry
|
## Registry
|
||||||
|
|
||||||
|
|
@ -82,33 +193,40 @@ mtp = { path = "..", features = ["host"] }
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use mtp::codec::registry::{Registry, VersionedCodec};
|
use mtp::codec::registry::{Registry, VersionedCodec};
|
||||||
|
use mtp::codec::{CommunicationType, CommunicationValue, DataValue};
|
||||||
|
use mtp_type_map::Version;
|
||||||
|
|
||||||
let registry = Registry::builtin();
|
let registry = Registry::builtin();
|
||||||
let codec = VersionedCodec::new(registry);
|
let codec = VersionedCodec::for_version(registry, Version(3, 0)).unwrap();
|
||||||
|
let value = CommunicationValue::new_with_type_map(
|
||||||
|
CommunicationType::Ping,
|
||||||
|
codec.type_map(),
|
||||||
|
).with_payload(DataValue::Null);
|
||||||
|
|
||||||
// Encode with a specific version
|
// The value must retain the negotiated map used to construct it.
|
||||||
let bytes = codec.encode(&value, Version(2, 0)).unwrap();
|
let bytes = codec.encode(&value).unwrap();
|
||||||
|
|
||||||
// Decode with a specific version
|
let decoded = codec.decode(&bytes).unwrap();
|
||||||
let decoded = codec.decode(&bytes, Version(2, 0)).unwrap();
|
|
||||||
|
// A clear value can be migrated explicitly when the application has chosen
|
||||||
|
// that behavior. Protected values are not silently remapped.
|
||||||
|
let migrated = codec.encode_migrating(&value).unwrap();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`VersionedCodec::encode` compares the retained map identity (its protocol
|
||||||
|
version) and returns `CodecError::MissingTypeMap` or
|
||||||
|
`CodecError::TypeMapMismatch` on failure. `reply_to` retains the request's
|
||||||
|
map, while `try_merge` rejects frames from different maps before copying any
|
||||||
|
fields. The deprecated `merge` method records the error for compatibility; new
|
||||||
|
code should migrate to `try_merge` and handle the result.
|
||||||
|
|
||||||
## Customizing Type Maps in Downstream Projects
|
## Customizing Type Maps in Downstream Projects
|
||||||
|
|
||||||
External projects must provide their own type map configuration. Browser projects should install `mtp` and configure `mtp/vite`; they do not need to publish, fork, or copy a generated WASM package.
|
External projects must provide their own type map configuration. Browser projects use the Vite plugin from [Defining Type Maps](#defining-type-maps) and do not need to publish, fork, or copy a generated WASM package.
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import { mtp } from "mtp/vite";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [mtp({ typeMaps: "./type-maps.yaml" })],
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
For Rust builds, or when invoking `wasm-pack` manually, set the `MTP_TYPE_MAPS` environment variable. If the variable points to an invalid file, the build fails. If `MTP_TYPE_MAPS` is not set, the build script emits a warning and generates reserved protocol types only; application-specific communication and data types will not be available.
|
For Rust builds, or when invoking `wasm-pack` manually, set the `MTP_TYPE_MAPS` environment variable. If the variable points to an invalid file, the build fails. If `MTP_TYPE_MAPS` is not set, the build script emits a warning and generates reserved protocol types only; application-specific communication and data types will not be available.
|
||||||
|
|
||||||
1. Create a `type-maps.yaml` in your project root
|
1. Create a `type-maps.yaml` in your project root (or anywhere else, adapt the path accordingly)
|
||||||
2. Set the `MTP_TYPE_MAPS` environment variable in `.cargo/config.toml`:
|
2. Set the `MTP_TYPE_MAPS` environment variable in `.cargo/config.toml`:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,17 @@
|
||||||
|
|
||||||
The browser client is exposed through the `mtp` npm package. Most applications should use the SDK-first `MTPClient` API; direct generated WASM bindings remain available from `mtp/raw` for advanced integrations.
|
The browser client is exposed through the `mtp` npm package. Most applications should use the SDK-first `MTPClient` API; direct generated WASM bindings remain available from `mtp/raw` for advanced integrations.
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
|
||||||
|
The SDK requires the browser to expose `WebTransport`. `MTPClient.isSupported()` is the runtime check. A browser without WebTransport cannot connect through this client.
|
||||||
|
|
||||||
|
| Requirement | Check |
|
||||||
|
| --- | --- |
|
||||||
|
| WebTransport API | `MTPClient.isSupported()` |
|
||||||
|
| Certificate trust | Browser validation or `serverCertificateHashes` |
|
||||||
|
| Secure context | Serve the application from HTTPS where required by the browser |
|
||||||
|
| Generated bindings | Run the Vite integration during development and build |
|
||||||
|
|
||||||
## Package Entry Points
|
## Package Entry Points
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -17,20 +28,17 @@ import { mtp } from "mtp/vite";
|
||||||
|
|
||||||
## Vite Type-Map Workflow
|
## Vite Type-Map Workflow
|
||||||
|
|
||||||
Browser apps provide their own type map. The Vite plugin runs `wasm-pack` during dev/build with `MTP_TYPE_MAPS` set, writes generated output under `node_modules/.vite/mtp/` by default, and aliases `mtp/raw` plus `mtp/type-map` to that generated output.
|
Browser apps provide their own type map. The Vite plugin runs `wasm-pack` during dev and build with `MTP_TYPE_MAPS` set, writes generated output under `node_modules/.vite/mtp/` by default, and aliases `mtp/raw` plus `mtp/type-map` to that generated output. Configuration: [Type Map](TYPE-MAP.md).
|
||||||
|
|
||||||
```typescript
|
The browser build uses the map named by `protocol_version` and includes the
|
||||||
// vite.config.ts
|
reserved MTP names. It does not advertise application names from other map
|
||||||
import { defineConfig } from "vite";
|
versions, because the generated WASM client is compiled for that one protocol
|
||||||
import { mtp } from "mtp/vite";
|
version. The selected version must exist in `type_maps`.
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [mtp({ typeMaps: "./type-maps.yaml" })],
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
You do not need to publish, fork, or copy an app-specific generated WASM package.
|
You do not need to publish, fork, or copy an app-specific generated WASM package.
|
||||||
|
|
||||||
|
The [web client example](../example/web-client/src/main.ts) shows the entry point. Its [Vite configuration](../example/web-client/vite.config.ts) shows the generated binding integration.
|
||||||
|
|
||||||
## SDK Quick Start
|
## SDK Quick Start
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -80,6 +88,261 @@ if (!MTPClient.isSupported()) {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## MTPClient Options
|
||||||
|
|
||||||
|
| Option | Default | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `url` | Required | WebTransport endpoint. |
|
||||||
|
| `descriptor` | None | Client label sent during connection setup. |
|
||||||
|
| `hostPublicKey` | None | Host public key bundle for authenticated login or registration. |
|
||||||
|
| `credentials` | None | Existing client ID and serialized keyring. |
|
||||||
|
| `credentialsStorageKey` | `mtp:credentials` | Key used by configured credential storage. |
|
||||||
|
| `storage` | None | Sync or async credential storage adapter. |
|
||||||
|
| `serverCertificateHashes` | Omitted | WebTransport certificate pins. |
|
||||||
|
| `maxMessageSize` | 16 MiB | Inbound and outbound frame limit. Values below frame overhead are rejected by the transport. |
|
||||||
|
| `authTimeoutMs` | No SDK timeout | Login and registration timeout. `undefined` leaves the promise pending until transport or peer failure. |
|
||||||
|
| `requestTimeoutMs` | 30 seconds | Default `request()` timeout. |
|
||||||
|
| `pings` | `false` | Protocol pings, or an object with `intervalMs`. |
|
||||||
|
| `logger` | No-op | Receives SDK state and error events. |
|
||||||
|
| `schemas` | None | Client-wide request and response schema registry. |
|
||||||
|
| `throwProtocolErrors` | `false` | Reject requests whose correlated response is an `Error*` frame. |
|
||||||
|
| `onValidationError` | No-op | Receives subscription validation failures. |
|
||||||
|
| `sessionStorage` | In-memory | E2EE session state storage. |
|
||||||
|
| `encryptedSecretProvider` | In-memory | Independent caller-managed encrypted secret storage. |
|
||||||
|
| `defaultSignatureVerificationPolicy` | `"ed25519"` | Receiver policy for protected signatures. |
|
||||||
|
|
||||||
|
`wasm` selects a custom generated WASM module. `MTPClient.create` validates positive safe-integer values for the numeric limits and timeout options.
|
||||||
|
|
||||||
|
## Differences from Native Client
|
||||||
|
|
||||||
|
The browser SDK uses WebTransport and JavaScript promises. The native client uses Rust futures, direct QUIC configuration, and `MTPConnection` handles. Browser pipes expose promise-based readers and writers; native pipes implement Tokio I/O traits.
|
||||||
|
|
||||||
|
### Native and Browser Credential Persistence
|
||||||
|
|
||||||
|
The `storage` option supplies the credential adapter. The adapter stores the client ID and serialized keyring after registration and returns them for later connections. The SDK does not select `localStorage` or IndexedDB for an application. Treat the serialized keyring as private key material.
|
||||||
|
|
||||||
|
`sessionStorage` and `encryptedSecretProvider` are separate caller-managed
|
||||||
|
stores. The latter exchanges `MTPEncryptedSecretRecord` values through
|
||||||
|
`set`, `get`, and `delete`; the `MTPClient` convenience methods are named
|
||||||
|
`setEncryptedSecret`, `getEncryptedSecret`, and `deleteEncryptedSecret`.
|
||||||
|
`MTPSessionManager` does not automatically route session state through the
|
||||||
|
provider. If session material must be encrypted at rest, the caller must make
|
||||||
|
that coordination explicit in its `MTPSessionStorage` implementation. Secret
|
||||||
|
IDs are opaque to MTP, so a caller can map its own state to the ID while
|
||||||
|
choosing the backing store and protecting its wrapping key.
|
||||||
|
|
||||||
|
### Direct Protected Messages
|
||||||
|
|
||||||
|
Use `sendProtected` when the destination is the frame receiver and no
|
||||||
|
intermediate relay needs a separately encrypted metadata layer. It keeps the
|
||||||
|
application communication type on the outer frame and encrypts an MTP-owned
|
||||||
|
signed envelope for the exact recipient bundles supplied by the caller. The
|
||||||
|
envelope authenticates `ProtectedVersion`, `MessageType`, `FinalRecipientId`,
|
||||||
|
`MessageId`, `CreatedAt`, and `Content`. The opening operation checks the
|
||||||
|
authenticated type and final recipient against the outer frame.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await client.sendProtected("ProtectedMessage", { Content: "hello" }, {
|
||||||
|
receiverId: recipientId,
|
||||||
|
recipients: [recipientPublicKey],
|
||||||
|
signaturePurpose: 0x40,
|
||||||
|
encryptionPurpose: 0x41,
|
||||||
|
exposeSender: false,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The protection purposes are application-defined domain-separation values.
|
||||||
|
`exposeSender` controls only the outer frame sender; the protected value remains
|
||||||
|
signed in either case. If `identity` is omitted, the SDK uses stored registered
|
||||||
|
credentials and rejects the operation when no usable protection identity is
|
||||||
|
available.
|
||||||
|
|
||||||
|
An unauthenticated connection can still send a protected value when the caller
|
||||||
|
provides an explicit `identity` with the signer ID and keyring. The connection's
|
||||||
|
authentication state and the protected signer's identity are independent.
|
||||||
|
|
||||||
|
When `signatureSuite` is omitted, protected send helpers use Ed25519 even when
|
||||||
|
the signing keyring also contains post-quantum keys. This matches the default
|
||||||
|
receiver policy. Use `signatureSuite: "dual"` together with
|
||||||
|
`signaturePolicy: "dual"` when both sides explicitly require hybrid
|
||||||
|
signatures.
|
||||||
|
|
||||||
|
Open a direct protected frame with the recipient keyring and a resolver that
|
||||||
|
receives the claimed, unverified signer ID only as a trusted-key lookup key:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const message = await client.openProtected(frame, {
|
||||||
|
recipient: {
|
||||||
|
id: recipientId,
|
||||||
|
keyring: recipientKeyring,
|
||||||
|
keyringHistory: previousRecipientKeyrings,
|
||||||
|
},
|
||||||
|
expectedReceiverId: recipientId,
|
||||||
|
expectedSignerId: signerId,
|
||||||
|
resolveSignerPublicKeys: (id) => signerDirectory.get(id) ?? [],
|
||||||
|
signaturePolicy: "dual",
|
||||||
|
signaturePurpose: 0x40,
|
||||||
|
encryptionPurpose: 0x41,
|
||||||
|
replayGuard,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(message.type, message.signerId, message.messageId, message.data);
|
||||||
|
```
|
||||||
|
|
||||||
|
`protectedVersion`, `finalRecipientId`, `signerId`, `messageId`, and `createdAt`
|
||||||
|
are taken from the verified protected envelope. `outerSender`, when present,
|
||||||
|
must equal the authenticated signer.
|
||||||
|
Protected application data may be any supported MTP `DataValue`, including
|
||||||
|
scalar, byte, array, and container values. Direct opening uses a bounded
|
||||||
|
process-local duplicate-suppression guard by default. The bounded cache can
|
||||||
|
evict old entries, so supply a durable `replayGuard` keyed by authenticated
|
||||||
|
signer and message ID when replay protection must survive eviction, reloads, or
|
||||||
|
multiple receiver processes. The guard also receives authenticated
|
||||||
|
`createdAt` metadata, which is not part of the replay key.
|
||||||
|
`subscribeProtected` uses the same opening and verification path:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const unsubscribe = client.subscribeProtected(
|
||||||
|
"ProtectedMessage",
|
||||||
|
(message, frame) => handleMessage(message.data, frame),
|
||||||
|
{
|
||||||
|
recipient: { id: recipientId, keyring: recipientKeyring },
|
||||||
|
resolveSignerPublicKeys: (id) => signerDirectory.get(id) ?? [],
|
||||||
|
signaturePolicy: "dual",
|
||||||
|
signaturePurpose: 0x40,
|
||||||
|
encryptionPurpose: 0x41,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Each `subscribeProtected` registration owns its own bounded default replay
|
||||||
|
guard, so multiple handlers receive the same raw frame through the WASM
|
||||||
|
fan-out dispatcher. Pass the same caller-owned `replayGuard` deliberately when
|
||||||
|
several subscriptions should share replay state.
|
||||||
|
|
||||||
|
### Sealed Relay Messages
|
||||||
|
|
||||||
|
`sendSealedRelay` uses the reserved opaque `Relay` communication type. Its
|
||||||
|
inner message type must be an application communication type, not an MTP
|
||||||
|
control type. The outer frame contains no sender and exposes only the next-hop
|
||||||
|
receiver. The
|
||||||
|
signed relay metadata contains the generic `signerId`, `finalRecipientId`,
|
||||||
|
`messageId`, `createdAt`, application `metadata`, and an opaque encrypted
|
||||||
|
content value. `createdAt` is generated as Unix epoch milliseconds. For
|
||||||
|
example, `2026-08-11T12:00:00.000Z` is `1786449600000`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const data = { Content: "hello" };
|
||||||
|
|
||||||
|
await client.sendSealedRelay("ProtectedMessage", data, {
|
||||||
|
finalRecipientId,
|
||||||
|
nextHopId,
|
||||||
|
metadataRecipients: [
|
||||||
|
relayPublicKey,
|
||||||
|
recipientPublicKey,
|
||||||
|
],
|
||||||
|
contentRecipients: [
|
||||||
|
recipientPublicKey,
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
ExampleMetadata: "routing context",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
client.subscribeSealedRelay(
|
||||||
|
"ProtectedMessage",
|
||||||
|
(message, frame) => handleMessage(message.data, frame),
|
||||||
|
{
|
||||||
|
recipient: {
|
||||||
|
id: finalRecipientId,
|
||||||
|
keyring: recipientKeyring,
|
||||||
|
},
|
||||||
|
expectedSignerId: signerId,
|
||||||
|
resolveSignerPublicKeys: () => [senderPublicKey],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The caller supplies the exact metadata and content recipient sets; the SDK
|
||||||
|
does not infer application topology. Set `signaturePolicy: "dual"` to require
|
||||||
|
hybrid signatures explicitly, and install a durable `replayGuard` so a valid
|
||||||
|
`(signerId, messageId)` is dispatched only once.
|
||||||
|
|
||||||
|
Each sealed-relay or metadata subscription likewise gets an independent
|
||||||
|
bounded default guard. This preserves fan-out when multiple handlers inspect
|
||||||
|
the same outer `Relay` frame; an explicitly supplied guard is shared by the
|
||||||
|
subscriptions that receive it.
|
||||||
|
|
||||||
|
Applications choose between direct protected delivery and sealed relay based
|
||||||
|
on topology and metadata-access requirements. Prefer `sendProtected` for a
|
||||||
|
direct destination. Use `sendSealedRelay` when a next hop must route or store a
|
||||||
|
message and the application needs metadata recipients to differ from content
|
||||||
|
recipients. Neither construction requires connection authentication, although
|
||||||
|
the host can associate an authenticated connection with its registered MTP
|
||||||
|
identity.
|
||||||
|
|
||||||
|
For metadata-only access, call `openRelayMetadata` or subscribe with
|
||||||
|
`subscribeRelayMetadata`. These operations authenticate the metadata and
|
||||||
|
expose `encryptedContent` for forwarding without attempting content
|
||||||
|
decryption. A final recipient calls `openRelayContent` after metadata
|
||||||
|
verification; the returned `MTPVerifiedRelayContent` includes the application
|
||||||
|
type and data plus `signerId`, `finalRecipientId`, `messageId`, `createdAt`,
|
||||||
|
and generic metadata fields. These are authenticated protected identities, not
|
||||||
|
the clear outer sender and next-hop receiver.
|
||||||
|
Relay content inherits the authenticated metadata's `signaturePolicy` when no
|
||||||
|
content override is supplied. A different content policy is rejected so the
|
||||||
|
two relay layers cannot be verified under conflicting rules.
|
||||||
|
Metadata passed to a `subscribeRelayMetadata` handler is callback-scoped and is
|
||||||
|
disposed after the handler resolves. Do not retain it for a later
|
||||||
|
`openRelayContent` call; use `openRelayMetadata` directly when a longer-lived
|
||||||
|
verified capability is needed, and call `dispose()` when finished.
|
||||||
|
When signer key history is used, `signerPublicKeys` exposes the trusted
|
||||||
|
candidates, `matchedSignerKeyIndex` identifies the key that verified the
|
||||||
|
metadata, and `matchedSignerPublicKey` returns that exact bundle.
|
||||||
|
|
||||||
|
Protected receive operations accept an optional `recipient` decryption
|
||||||
|
identity. Its `keyring` controls decryption and its optional `id` is used only
|
||||||
|
for final-recipient validation. The identity is independent from connection
|
||||||
|
authentication. Metadata opening does not require the identity ID to match the
|
||||||
|
clear next-hop receiver, so a forwarded frame can be opened by a metadata
|
||||||
|
recipient or final recipient with the appropriate keyring. When `recipient` is
|
||||||
|
omitted, stored registered credentials remain the convenience fallback.
|
||||||
|
|
||||||
|
To open values encrypted for a rotated recipient, provide `keyringHistory` on
|
||||||
|
the decryption identity. The current `keyring` is tried first, followed by
|
||||||
|
history entries from newest to oldest. Exact duplicate byte sequences are
|
||||||
|
removed without changing the caller's input arrays. An empty current keyring
|
||||||
|
or an empty history entry is rejected.
|
||||||
|
|
||||||
|
Generic MTP `DataValue` inputs accept `bigint` for exact integer values. An
|
||||||
|
integral JavaScript `number` outside the safe-integer range is rejected, so it
|
||||||
|
cannot silently become an imprecise float. Use `bigint` for large signed or
|
||||||
|
unsigned integers.
|
||||||
|
|
||||||
|
For streams, prefer `createEncryptedPipe` and `acceptEncryptedPipe`; they bind
|
||||||
|
the actual pipe ID and local identity automatically. The lower-level
|
||||||
|
`initiateMTPPipeSession` API also accepts multiple recipient bundles for a
|
||||||
|
group bootstrap. Group membership changes require a fresh session ID and
|
||||||
|
recipient set. Live calls that need forward secrecy can use the exported
|
||||||
|
duplex `initiateMTPForwardSecurePipeSession` and
|
||||||
|
`acceptMTPForwardSecurePipeSession` helpers.
|
||||||
|
|
||||||
|
The convenience pipe methods intentionally require registered client
|
||||||
|
credentials because they use the connection's registered identity as the
|
||||||
|
endpoint identity. Use the lower-level session functions when transport
|
||||||
|
authentication and cryptographic endpoint identity must remain independent.
|
||||||
|
|
||||||
|
Receive-side signature policy is independent from the recipient keyring. Use
|
||||||
|
`signaturePolicy` on protected receive and encrypted-pipe accept operations,
|
||||||
|
or configure `defaultSignatureVerificationPolicy` on the client. The sender's
|
||||||
|
`signatureSuite` selects how local values are signed and is a separate choice.
|
||||||
|
Both sender and receiver default to Ed25519; `dual` is always an explicit
|
||||||
|
choice on each side.
|
||||||
|
|
||||||
|
### Native and Browser Certificate Checks
|
||||||
|
|
||||||
|
WebTransport certificate pins must match the server certificate hash. A pin mismatch is a TLS failure, not an MTP authentication failure. Check the browser network panel, endpoint origin, and WebTransport CONNECT path before inspecting frames.
|
||||||
|
|
||||||
## Credentials And Storage
|
## Credentials And Storage
|
||||||
|
|
||||||
Authenticated connections need stable key material. Pass `credentials` when you already have a client ID and serialized keyring, or pass a small `storage` object and let the SDK persist credentials after registration.
|
Authenticated connections need stable key material. Pass `credentials` when you already have a client ID and serialized keyring, or pass a small `storage` object and let the SDK persist credentials after registration.
|
||||||
|
|
@ -148,6 +411,30 @@ If hashes are omitted, the browser uses its normal TLS root store.
|
||||||
|
|
||||||
`maxMessageSize` caps inbound and outbound MTP frames before buffering/sending.
|
`maxMessageSize` caps inbound and outbound MTP frames before buffering/sending.
|
||||||
`authTimeoutMs` bounds connect/login/register promises at the SDK layer.
|
`authTimeoutMs` bounds connect/login/register promises at the SDK layer.
|
||||||
|
`requestTimeoutMs` sets the default timeout for `request()` calls; a request can override it with `timeoutMs` in its options.
|
||||||
|
|
||||||
|
## Streams
|
||||||
|
|
||||||
|
The browser client uses one WebTransport session per `MTPClient` instance.
|
||||||
|
`send()`, `request()`, and `subscribe()` all operate over that session; the SDK does not expose browser stream objects directly.
|
||||||
|
|
||||||
|
Use the normal message APIs to send and receive over that session:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const client = await MTPClient.create({ url, hostPublicKey });
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
const unsubscribe = client.subscribe("SomeType", (message) => {
|
||||||
|
console.log(message.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.send("SomeType", { value: "hello" });
|
||||||
|
unsubscribe();
|
||||||
|
```
|
||||||
|
|
||||||
|
Internally, each outbound MTP frame is written to a new WebTransport unidirectional stream as a four-byte big-endian length followed by the frame, then that stream is closed. Incoming frames are read from the session's incoming unidirectional streams. The reader accepts both one-frame streams and native peers that place several frames on a persistent stream, so browser and native clients interoperate without stream configuration.
|
||||||
|
|
||||||
|
The SDK owns stream lifetime and framing. Do not create browser streams for MTP frames yourself through the SDK. For direct generated bindings, use `client.raw.client` or import `WasmClient` from `mtp/raw`; a `WasmClient` still owns one active WebTransport session, so create another instance for an independent connection.
|
||||||
|
|
||||||
## Sending, Requests, Subscriptions, And Pings
|
## Sending, Requests, Subscriptions, And Pings
|
||||||
|
|
||||||
|
|
@ -167,7 +454,7 @@ await client.send("SomeType", { value: "hello" }, {
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
`request` sends one frame and resolves with the parsed response carrying the same frame id. `responseType` is validated after the id match:
|
`request` sends one frame and resolves with the parsed response carrying the same frame id. If the matching response has a different `responseType`, the promise rejects with a response-type error. A timeout rejects the promise and removes the pending request:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const response = await client.request(
|
const response = await client.request(
|
||||||
|
|
@ -187,7 +474,61 @@ const unsubscribe = client.subscribe("SomeType", (message) => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
```
|
```
|
||||||
|
|
||||||
Protocol pings are real MTP `Ping` frames sent by the WASM client, not just transport keepalives:
|
### Zod request and response schemas
|
||||||
|
|
||||||
|
Applications can provide their request and response schemas once when creating
|
||||||
|
the client. MTP uses `parseAsync`, so synchronous schemas, async refinements,
|
||||||
|
defaults, coercions, and transforms all work. MTP has no runtime dependency on
|
||||||
|
Zod; the application supplies its preferred Zod version.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { z } from "zod";
|
||||||
|
import { MTPClient, MTPValidationError } from "mtp";
|
||||||
|
|
||||||
|
const schemas = {
|
||||||
|
GetUser: {
|
||||||
|
request: z.object({ UserId: z.number().int().positive() }),
|
||||||
|
response: z.object({
|
||||||
|
UserId: z.number().int().positive(),
|
||||||
|
Display: z.string(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = await MTPClient.create({
|
||||||
|
url,
|
||||||
|
schemas,
|
||||||
|
throwProtocolErrors: true,
|
||||||
|
onValidationError(error) {
|
||||||
|
console.error(error.messageType, error.cause);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await client.request("GetUser", { UserId: 42 });
|
||||||
|
console.log(response.data.Display);
|
||||||
|
```
|
||||||
|
|
||||||
|
Request schemas run before frame encoding and transmission. Their transformed
|
||||||
|
output is sent. Response schemas run after request correlation, and their
|
||||||
|
transformed output replaces `frame.data`; `frame.raw`, when present, remains the
|
||||||
|
original wire frame. Invalid requests and responses reject with
|
||||||
|
`MTPValidationError`. Invalid subscription messages do not reach the handler
|
||||||
|
and are reported through `onValidationError`.
|
||||||
|
|
||||||
|
`throwProtocolErrors: true` converts correlated `Error*` frames into
|
||||||
|
`MTPProtocolError`. It defaults to `false` for compatibility.
|
||||||
|
|
||||||
|
`MTPProxyConnection` applies the same schema registry to another TypeScript
|
||||||
|
request/subscription transport, such as a Tauri command and event proxy:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const connection = new MTPProxyConnection(adapter, {
|
||||||
|
schemas,
|
||||||
|
throwProtocolErrors: true,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Protocol ping behavior is defined in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive). The SDK configuration is:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
await MTPClient.create({
|
await MTPClient.create({
|
||||||
|
|
@ -198,14 +539,139 @@ await MTPClient.create({
|
||||||
|
|
||||||
Use `pings: true` for the default interval.
|
Use `pings: true` for the default interval.
|
||||||
|
|
||||||
|
## Pipes
|
||||||
|
|
||||||
|
Pipes are byte-oriented streams over WebTransport. The `PipeRequest` type and
|
||||||
|
description are clear transport metadata; raw stream bytes are not protected
|
||||||
|
by MTP. For sensitive calls, files, or application streams, wrap the accepted
|
||||||
|
pipe with `MTPEncryptedPipeWriter` or `MTPEncryptedPipeReader`.
|
||||||
|
|
||||||
|
### Outgoing Pipes
|
||||||
|
|
||||||
|
`createPipe` sends a `PipeRequest` frame and returns a handle. Call `wait()` to block until the remote peer accepts or denies:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handle = await client.createPipe("file-transfer");
|
||||||
|
|
||||||
|
const writer = await handle.wait();
|
||||||
|
if (writer == null) {
|
||||||
|
console.log("host denied the pipe");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writer.write(new Uint8Array([0x01, 0x02, 0x03]));
|
||||||
|
await writer.write(chunk);
|
||||||
|
await writer.close();
|
||||||
|
```
|
||||||
|
|
||||||
|
`writer.close()` sends a QUIC stream FIN. `writer.abort()` resets the stream abruptly. Each `write` resolves when the chunk has been handed to the transport; it does not wait for the peer to consume it.
|
||||||
|
|
||||||
|
### Encrypted Pipe Records
|
||||||
|
|
||||||
|
`initiateMTPPipeSession` and `acceptMTPPipeSession` perform the signed/KEM
|
||||||
|
protected pipe-session offer and return the encrypted record wrapper. The
|
||||||
|
offer binds the session ID, pipe ID, endpoint IDs, direction, and purpose. Do
|
||||||
|
not derive the initial chain key from the clear description or pipe ID alone.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
initiateMTPPipeSession,
|
||||||
|
} from "mtp";
|
||||||
|
|
||||||
|
const encryptedWriter = await initiateMTPPipeSession(
|
||||||
|
writer,
|
||||||
|
{
|
||||||
|
sessionId: new TextEncoder().encode(`file-transfer/${writer.pipeId}`),
|
||||||
|
pipeId: writer.pipeId,
|
||||||
|
senderId: ownClientId,
|
||||||
|
recipientId: hostClientId,
|
||||||
|
purpose: 0x40,
|
||||||
|
direction: 0,
|
||||||
|
},
|
||||||
|
ownKeyring,
|
||||||
|
hostPublicKeyBundle,
|
||||||
|
);
|
||||||
|
await encryptedWriter.writeRecord(chunk);
|
||||||
|
await encryptedWriter.close();
|
||||||
|
```
|
||||||
|
|
||||||
|
`writeRecord` and `readRecord` use XChaCha20-Poly1305 with ordered sequence
|
||||||
|
numbers bound to the session context. Each record advances an HKDF chain and
|
||||||
|
uses a one-use message key. Record insertion, removal, reordering, or
|
||||||
|
modification fails authentication. The wrapper is intentionally separate from
|
||||||
|
the raw `PipeWriter`/`PipeReader` transport primitives.
|
||||||
|
|
||||||
|
The handle and writer expose `pipeId` and `description`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
console.log(handle.pipeId, handle.description);
|
||||||
|
console.log(writer.pipeId);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Incoming Pipes
|
||||||
|
|
||||||
|
Set a handler to receive pipe requests from the remote peer:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
client.setOnPipeRequest((request) => {
|
||||||
|
console.log("incoming pipe", request.pipeId, request.description);
|
||||||
|
// accept or deny asynchronously
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Accept a request to receive a `PipeReader`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
client.setOnPipeRequest(async (request) => {
|
||||||
|
if (request.description === "file-transfer") {
|
||||||
|
const reader = await client.acceptPipe(request.pipeId);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const chunk = await reader.read();
|
||||||
|
if (chunk == null) break; // stream closed by peer
|
||||||
|
processChunk(chunk);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await client.denyPipe(request.pipeId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`reader.read()` resolves with a `Uint8Array` or `null` when the peer closes the stream. The reader exposes `pipeId` and `description`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
console.log(reader.pipeId, reader.description);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pipe Handshake
|
||||||
|
|
||||||
|
1. The initiator calls `createPipe(description)`; the SDK sends a `PipeRequest` frame with a random `pipeId` and the description.
|
||||||
|
2. The receiver's `setOnPipeRequest` callback fires with `{ pipeId, description }`.
|
||||||
|
3. The receiver calls `acceptPipe(pipeId)`; the SDK sends a `PipeResponse` with `Accepted = true` and opens a new unidirectional stream for byte transport.
|
||||||
|
4. The initiator's `handle.wait()` resolves with a `PipeWriter` bound to that stream. Sensitive applications then perform their signed/encrypted session-key setup and construct an encrypted record wrapper.
|
||||||
|
5. If the receiver calls `denyPipe(pipeId)`, `handle.wait()` resolves with `null`.
|
||||||
|
|
||||||
|
Pipes share the same WebTransport session as message frames; they do not need a separate connection.
|
||||||
|
|
||||||
## Logger Events
|
## Logger Events
|
||||||
|
|
||||||
The SDK logger receives parsed events:
|
The SDK logger receives parsed events:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
type MTPLogEvent =
|
type MTPLogEvent =
|
||||||
| { hint: "info" | "warning"; type: string; data: unknown }
|
| {
|
||||||
| { hint: "error"; type: string | "error"; error: string };
|
hint: "info" | "warning";
|
||||||
|
type: string;
|
||||||
|
data: unknown;
|
||||||
|
direction?: "send" | "recv";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
hint: "error";
|
||||||
|
type: string | "error";
|
||||||
|
error: string;
|
||||||
|
data?: unknown;
|
||||||
|
direction?: "send" | "recv";
|
||||||
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Incoming non-error frames and sent frames are logged as `info`. Error frames and transport errors are logged as `error`.
|
Incoming non-error frames and sent frames are logged as `info`. Error frames and transport errors are logged as `error`.
|
||||||
|
|
@ -240,16 +706,46 @@ config.free();
|
||||||
Raw callbacks receive parsed frames, not application-specific SDK objects:
|
Raw callbacks receive parsed frames, not application-specific SDK objects:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
interface ParsedEncryptedValue {
|
||||||
|
kind: "encrypted";
|
||||||
|
encryptionType: number;
|
||||||
|
purpose: number;
|
||||||
|
recipientCount: number;
|
||||||
|
encoded: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedSignedValue {
|
||||||
|
kind: "signed";
|
||||||
|
signatureType: number;
|
||||||
|
purpose: number;
|
||||||
|
signerId: bigint;
|
||||||
|
value: ParsedDataValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedDataValue =
|
||||||
|
| boolean
|
||||||
|
| number
|
||||||
|
| bigint
|
||||||
|
| string
|
||||||
|
| Uint8Array
|
||||||
|
| ParsedDataValue[]
|
||||||
|
| { [key: string]: ParsedDataValue }
|
||||||
|
| ParsedEncryptedValue
|
||||||
|
| ParsedSignedValue
|
||||||
|
| null;
|
||||||
|
|
||||||
interface ParsedFrame {
|
interface ParsedFrame {
|
||||||
id?: number;
|
id?: number;
|
||||||
type: string;
|
type: string;
|
||||||
sender?: bigint;
|
sender?: bigint;
|
||||||
receiver?: bigint;
|
receiver?: bigint;
|
||||||
data: Record<string, unknown>;
|
data: ParsedDataValue;
|
||||||
raw: Uint8Array;
|
raw: Uint8Array;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Frames
|
||||||
|
|
||||||
Raw message helpers that remain available include:
|
Raw message helpers that remain available include:
|
||||||
|
|
||||||
- `build_frame(messageType, data, options?)`
|
- `build_frame(messageType, data, options?)`
|
||||||
|
|
@ -268,6 +764,8 @@ const parsed = codec.decode(frame);
|
||||||
const display = codec.format(frame);
|
const display = codec.format(frame);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Crypto
|
||||||
|
|
||||||
Raw crypto and key helpers include:
|
Raw crypto and key helpers include:
|
||||||
|
|
||||||
- `ed25519_generate()`
|
- `ed25519_generate()`
|
||||||
|
|
@ -275,9 +773,13 @@ Raw crypto and key helpers include:
|
||||||
- `keyring_generate()`
|
- `keyring_generate()`
|
||||||
- `keyring_from_ed25519(secretKey, publicKey)`
|
- `keyring_from_ed25519(secretKey, publicKey)`
|
||||||
- `WasmKeyring.from_bytes(bytes)` and `keyring.to_bytes()`
|
- `WasmKeyring.from_bytes(bytes)` and `keyring.to_bytes()`
|
||||||
|
- `keyring.validate_encryption()` for envelope decryption roles
|
||||||
|
- `keyring.validate_full()` for complete hybrid identities
|
||||||
- `WasmPublicKeyBundle.from_bytes(bytes)` and `bundle.to_bytes()`
|
- `WasmPublicKeyBundle.from_bytes(bytes)` and `bundle.to_bytes()`
|
||||||
- `WasmEd25519Signer`
|
- `WasmEd25519Signer`
|
||||||
- `WasmChaCha20Poly1305`
|
- `WasmChaCha20Poly1305`
|
||||||
|
- `sign_data_value_with_keyring` and `verify_data_value_with_policy` (both require an explicit signature suite), plus `encrypt_data_value`, `encrypt_data_value_for_recipients`, and `decrypt_data_value`
|
||||||
|
- `parse_data_value` and `encode_data_value`
|
||||||
- `wasm_sha256`, `wasm_sha256_double`, `wasm_hkdf_expand`, and `wasm_derive_encryption_key`
|
- `wasm_sha256`, `wasm_sha256_double`, `wasm_hkdf_expand`, and `wasm_derive_encryption_key`
|
||||||
|
|
||||||
Raw authenticated login and registration map directly to the Rust WASM layer:
|
Raw authenticated login and registration map directly to the Rust WASM layer:
|
||||||
|
|
@ -300,4 +802,25 @@ const confirmedId = await rawClient.auth_connect(
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Pipes
|
||||||
|
|
||||||
|
The raw `WasmClient` exposes the same pipe operations as the SDK wrapper. The shared lifecycle is in [Pipes](PIPES.md); raw bindings use snake_case names.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
rawClient.set_on_pipe_request((event) => {
|
||||||
|
void rawClient.accept_pipe(event.pipeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handle = await rawClient.create_pipe("file-transfer");
|
||||||
|
const writer = await handle.wait();
|
||||||
|
if (writer) {
|
||||||
|
await writer.write(chunk);
|
||||||
|
await writer.close();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
A `WasmClient` manages one active WebTransport session. Create a new instance for independent connections, and call `free()` or `[Symbol.dispose]()` on raw WASM objects when you want to release memory eagerly.
|
A `WasmClient` manages one active WebTransport session. Create a new instance for independent connections, and call `free()` or `[Symbol.dispose]()` on raw WASM objects when you want to release memory eagerly.
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
A `WasmClient` owns one active WebTransport session. Create a separate client for each independent connection. Call `free()` or `[Symbol.dispose]()` on raw WASM objects when the application no longer needs them. SDK session and encrypted secret persistence are documented in [Security](SECURITY.md#browser-end-to-end-encryption).
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,78 @@
|
||||||
|
#################################################################################
|
||||||
|
# This is an example, overwrite it for your project to register your own types. #
|
||||||
|
#################################################################################
|
||||||
|
|
||||||
# The version a Client should use
|
# The version a Client should use
|
||||||
protocol_version: "0.0"
|
protocol_version: "0.0"
|
||||||
|
|
||||||
# Note that markers 0 to 31 are reserved for default use, manually working with them is not recommended
|
# Note that markers 0 to 31 are reserved for default use, manually working with them is not recommended
|
||||||
# Fixed CommunicationType markers are:
|
# Fixed CommunicationType markers are:
|
||||||
# Error: 0
|
# Identification: 0
|
||||||
# ErrorParsing: 1
|
# IdentificationResponse: 1
|
||||||
# ErrorBadVersion: 2
|
# Register: 2
|
||||||
# Disconnect: 3
|
# RegisterResponse: 3
|
||||||
# Redirect: 4
|
# Challenge: 4
|
||||||
# Shutdown: 5
|
# ChallengeResponse: 5
|
||||||
# BadRequest: 6
|
# Ping: 6
|
||||||
# Unauthorized: 7
|
# Pong: 7
|
||||||
# Forbidden: 8
|
# Disconnect: 8
|
||||||
# NotFound: 9
|
# Redirect: 9
|
||||||
# TooManyRequests: 10
|
# Shutdown: 10
|
||||||
# InternalServerError: 11
|
# Error: 11
|
||||||
# BadGateway: 12
|
# ErrorParsing: 12
|
||||||
# ServiceUnavailable: 13
|
# ErrorBadVersion: 13
|
||||||
# GatewayTimeout: 14
|
# BadRequest: 14
|
||||||
# Identification: 15
|
# Unauthorized: 15
|
||||||
# IdentificationResponse: 16
|
# Forbidden: 16
|
||||||
# Register: 17
|
# NotFound: 17
|
||||||
# RegisterResponse: 18
|
# TooManyRequests: 18
|
||||||
# Ping: 19
|
# InternalServerError: 19
|
||||||
# Pong: 20
|
# BadGateway: 20
|
||||||
|
# ServiceUnavailable: 21
|
||||||
|
# GatewayTimeout: 22
|
||||||
|
# Relay: 26
|
||||||
|
# PipeRequest: 23
|
||||||
|
# PipeResponse: 24
|
||||||
|
# PipeAbort: 25
|
||||||
#
|
#
|
||||||
# Fixed Data Type markers are:
|
# Fixed Data Type markers are:
|
||||||
# Error: 0
|
# Version: 0
|
||||||
# ErrorParsing: 1
|
# id: 1
|
||||||
# ErrorMessage: 2
|
# ClientNonce: 2
|
||||||
# Version: 3
|
# ServerNonce: 3
|
||||||
# Description: 4
|
# PublicKeys: 4
|
||||||
# Timestamp: 5
|
# Signature: 5
|
||||||
# Id: 6
|
# PqSignature: 6
|
||||||
# ClientNonce: 7
|
# Description: 7
|
||||||
# ServerNonce: 8
|
# Connected: 8
|
||||||
# PublicKeys: 9
|
# Timestamp: 9
|
||||||
# Signature: 10
|
# Error: 10
|
||||||
# Connected: 11
|
# ErrorParsing: 11
|
||||||
|
# ErrorMessage: 12
|
||||||
|
# Accepted: 13,
|
||||||
|
# RequirePq: 14
|
||||||
|
# MessageId: 15
|
||||||
|
# FinalRecipientId: 18
|
||||||
|
# CreatedAt: 21
|
||||||
|
# MessageType: 22
|
||||||
|
# Content: 23
|
||||||
|
# Metadata: 24
|
||||||
|
# RelayVersion: 25
|
||||||
|
# ProtectedVersion: 26
|
||||||
#
|
#
|
||||||
# If a Type can't be used it will be mapped to 0
|
# Types absent from a protocol version cannot be encoded for that version.
|
||||||
|
|
||||||
type_maps:
|
type_maps:
|
||||||
"0.0": # Protocol version 0.0
|
"0.0": # Protocol version 0.0
|
||||||
CommunicationTypes:
|
CommunicationTypes:
|
||||||
|
ProtectedMessage: 32
|
||||||
|
AlternateMessage: 33
|
||||||
DataTypes:
|
DataTypes:
|
||||||
ExampleType: 32
|
ExampleType: 32
|
||||||
"1.0":
|
"1.0":
|
||||||
CommunicationTypes:
|
CommunicationTypes:
|
||||||
|
ProtectedMessage: 32
|
||||||
|
AlternateMessage: 33
|
||||||
DataTypes:
|
DataTypes:
|
||||||
# If a v0.0 client connects
|
# If a v0.0 client connects
|
||||||
# - the server can't use "AnotherType"
|
# - the server can't use "AnotherType"
|
||||||
|
|
@ -57,6 +82,8 @@ type_maps:
|
||||||
SomeType: 34
|
SomeType: 34
|
||||||
"2.0":
|
"2.0":
|
||||||
CommunicationTypes:
|
CommunicationTypes:
|
||||||
|
ProtectedMessage: 32
|
||||||
|
AlternateMessage: 33
|
||||||
DataTypes:
|
DataTypes:
|
||||||
# If a v0.0 client connects
|
# If a v0.0 client connects
|
||||||
# - the server can't use "AnotherType"
|
# - the server can't use "AnotherType"
|
||||||
|
|
|
||||||
5
example/.gitignore
vendored
5
example/.gitignore
vendored
|
|
@ -10,3 +10,8 @@ web-client/node_modules
|
||||||
web-client/public/host_public_key_bundle.hex
|
web-client/public/host_public_key_bundle.hex
|
||||||
web-client/public/mtp_dev_cert_hash.txt
|
web-client/public/mtp_dev_cert_hash.txt
|
||||||
web-client/dist/
|
web-client/dist/
|
||||||
|
|
||||||
|
client.id
|
||||||
|
*.mk
|
||||||
|
*.mpkb
|
||||||
|
metrics/
|
||||||
|
|
|
||||||
1345
example/Cargo.lock
generated
1345
example/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"server",
|
"server",
|
||||||
"client",
|
"client", "keygen",
|
||||||
]
|
]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
|
||||||
1
example/client.id
Normal file
1
example/client.id
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
1000
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "client"
|
name = "client"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|
@ -8,7 +8,9 @@ name = "client"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mtp = { version = "0.1.0", path = "../../", features = ["client", "crypto"] }
|
mtp = { version = "0.3.0", path = "../../", features = ["client", "crypto", "files", "pipes", "raw"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
rand = "0.10.1"
|
||||||
|
tracing-subscriber = "0.3.23"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
hex = "0.4"
|
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,70 @@
|
||||||
use std::fs;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
use mtp::client::{ClientConfig, MTPClient, MTPConnection};
|
use mtp::client::{ClientConfig, MTPClient, MTPConnection};
|
||||||
use mtp::crypto::{Ed25519Signer, Keyring, MlDsaSigner, PublicKeyBundle};
|
use mtp::crypto::{Keyring, PublicKeyBundle};
|
||||||
|
use mtp::files::{load_keyring_raw, save_keyring_raw};
|
||||||
|
|
||||||
pub async fn connect_or_register(
|
pub async fn connect_or_register(
|
||||||
mut config: ClientConfig,
|
mut config: ClientConfig,
|
||||||
host_public_key: PublicKeyBundle,
|
host_public_key: PublicKeyBundle,
|
||||||
client_key_path: &str,
|
key_prefix: &str,
|
||||||
) -> Result<(MTPConnection, Keyring), Box<dyn std::error::Error>> {
|
) -> Result<(MTPConnection, Keyring, String, Duration), Box<dyn std::error::Error>> {
|
||||||
if let Ok(data) = fs::read_to_string(client_key_path) {
|
let keyring_path = format!("{key_prefix}.mk");
|
||||||
let json: serde_json::Value = serde_json::from_str(&data)?;
|
let id_path = format!("{key_prefix}.id");
|
||||||
let client_id = json["client_id"].as_u64().expect("Invalid client_id");
|
|
||||||
let keyring = Keyring::from_bytes(&hex::decode(
|
|
||||||
json["keyring"].as_str().expect("Missing keyring"),
|
|
||||||
)?)?;
|
|
||||||
|
|
||||||
println!("Loaded client keys (ID: {})", client_id);
|
let file_load_started = Instant::now();
|
||||||
|
if let (Ok(keyring), Ok(id)) = (
|
||||||
config.client_id = client_id;
|
load_keyring_raw(&keyring_path),
|
||||||
let conn = MTPClient::auth_connect(config, &keyring, &host_public_key).await?;
|
fs::read_to_string(&id_path).await,
|
||||||
println!("Authenticated (version {})", conn.version);
|
) {
|
||||||
Ok((conn, keyring))
|
let client_id: u64 = id.trim().parse()?;
|
||||||
} else {
|
println!(
|
||||||
println!("No existing keys found: registering new client");
|
"Loaded client keys (ID: {client_id}) in {:?}",
|
||||||
|
file_load_started.elapsed()
|
||||||
let (_ed_signer, sig_sk, sig_pk) = Ed25519Signer::generate();
|
|
||||||
let (_pq_signer, sig_pq_sk, sig_pq_pk) = MlDsaSigner::generate();
|
|
||||||
let keyring = Keyring::new(
|
|
||||||
mtp::crypto::KemPublicKey::new(vec![]),
|
|
||||||
mtp::crypto::KemPrivateKey::new(vec![]),
|
|
||||||
sig_pq_pk,
|
|
||||||
sig_pq_sk,
|
|
||||||
sig_pk,
|
|
||||||
sig_sk,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let keyring_bytes = keyring.to_bytes();
|
config.client_id = client_id;
|
||||||
|
let 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
|
||||||
|
);
|
||||||
|
return Ok((conn, keyring, "connect".into(), auth_duration));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 conn = MTPClient::auth_register(config, &keyring, &host_public_key).await?;
|
||||||
println!("Registered with ID: {}", conn.client_id);
|
let reg_duration = reg_started.elapsed();
|
||||||
|
println!("Registered with ID: {} in {:?}", conn.client_id, reg_duration);
|
||||||
|
|
||||||
let json = serde_json::json!({
|
save_keyring_raw(&keyring, &keyring_path)?;
|
||||||
"client_id": conn.client_id,
|
fs::write(&id_path, conn.client_id.to_string()).await?;
|
||||||
"keyring": hex::encode(&keyring_bytes),
|
println!("Saved client keys -> {keyring_path}");
|
||||||
});
|
|
||||||
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, "register".into(), reg_duration))
|
||||||
Ok((conn, keyring))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open a guest transport even when the caller already owns registered
|
||||||
|
/// credentials. The credentials stay with the caller for protected signing.
|
||||||
|
pub async fn connect_unauthenticated(
|
||||||
|
config: ClientConfig,
|
||||||
|
) -> Result<MTPConnection, Box<dyn std::error::Error>> {
|
||||||
|
let conn = MTPClient::connect(config).await?;
|
||||||
|
if conn.auth_state != mtp::client::AuthState::Unauthenticated {
|
||||||
|
return Err("guest connection did not report Unauthenticated state".into());
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"Opened unauthenticated transport with host-assigned guest ID {}",
|
||||||
|
conn.client_id
|
||||||
|
);
|
||||||
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod metrics;
|
||||||
mod messages;
|
mod messages;
|
||||||
|
mod pipes;
|
||||||
|
mod protected;
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use mtp::client::ClientConfig;
|
use mtp::client::{AuthState, ClientConfig};
|
||||||
use mtp::crypto::{KemPublicKey, PublicKeyBundle, SignaturePqPublicKey, SignaturePublicKey};
|
use mtp::files::load_public_key_bundle;
|
||||||
|
|
||||||
fn dev_cert_path() -> String {
|
fn dev_cert_path() -> String {
|
||||||
std::env::var("MTP_DEV_CERT").unwrap_or_else(|_| {
|
std::env::var("MTP_DEV_CERT").unwrap_or_else(|_| {
|
||||||
|
|
@ -19,37 +23,118 @@ fn dev_cert_path() -> String {
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
let cert_path = dev_cert_path();
|
let cert_path = dev_cert_path();
|
||||||
let cert_pem = fs::read(&cert_path).unwrap_or_else(|e| {
|
let cert_pem = fs::read(&cert_path).unwrap_or_else(|e| {
|
||||||
panic!(
|
panic!(
|
||||||
"Missing TLS certificate at {cert_path}: enter the Nix shell first or run the server to generate it: {e}"
|
"Missing TLS certificate at {cert_path}: enter the Nix shell first or run the server to generate it: {e}"
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
let host_public_key = PublicKeyBundle::new(
|
let host_public_key = match load_public_key_bundle("host.mpkb") {
|
||||||
KemPublicKey::new(
|
Ok(bundle) => bundle,
|
||||||
fs::read("host_enc_kem_pk.bin")
|
Err(e) => {
|
||||||
.expect("Missing host_enc_kem_pk.bin: run server first"),
|
return Err(
|
||||||
),
|
format!("Missing host.mpkb: run the server first to export it ({e})").into(),
|
||||||
SignaturePqPublicKey::new(
|
|
||||||
fs::read("host_sig_pq_pk.bin")
|
|
||||||
.expect("Missing host_sig_pq_pk.bin: run server first"),
|
|
||||||
),
|
|
||||||
SignaturePublicKey::new(
|
|
||||||
fs::read("host_sig_pk.bin").expect("Missing host_sig_pk.bin: run server first"),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut client_metrics = metrics::ClientMetrics::load("metrics/client_sessions.json");
|
||||||
|
|
||||||
println!("Connecting to 127.0.0.1:8080 ...");
|
println!("Connecting to 127.0.0.1:8080 ...");
|
||||||
|
|
||||||
let config = ClientConfig::new("https://127.0.0.1:8080")
|
let config = ClientConfig::new("https://127.0.0.1:8080")
|
||||||
.with_pinned_pem(cert_pem)
|
.with_pinned_pem(cert_pem.clone())
|
||||||
.with_description("MTP example client");
|
.with_description("MTP example client");
|
||||||
|
|
||||||
let server_bundle = host_public_key.clone();
|
let server_bundle = host_public_key.clone();
|
||||||
let (conn, keyring) =
|
let (conn, keyring, auth_method, auth_duration) =
|
||||||
auth::connect_or_register(config, host_public_key, "client_keys.json").await?;
|
match auth::connect_or_register(config, host_public_key, "client").await {
|
||||||
messages::send_and_receive(&conn, &keyring, &server_bundle).await?;
|
Ok(result) => result,
|
||||||
|
Err(e) => {
|
||||||
|
let mut builder = metrics::SessionBuilder::new("failed", Duration::from_secs(0));
|
||||||
|
builder.set_error(e.to_string());
|
||||||
|
client_metrics.record_session(builder.build());
|
||||||
|
client_metrics.save("metrics/client_sessions.json");
|
||||||
|
client_metrics.build_overview("metrics/client_overview.json");
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut builder = metrics::SessionBuilder::new(&auth_method, auth_duration);
|
||||||
|
|
||||||
|
if conn.auth_state != AuthState::Authenticated {
|
||||||
|
return Err("authenticated example connection did not report Authenticated state".into());
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"Receive connection A: authenticated client {}",
|
||||||
|
conn.client_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let unauthenticated_config = ClientConfig::new("https://127.0.0.1:8080")
|
||||||
|
.with_pinned_pem(cert_pem.clone())
|
||||||
|
.with_description("MTP example unauthenticated sender");
|
||||||
|
let unauthenticated_conn = auth::connect_unauthenticated(unauthenticated_config).await?;
|
||||||
|
println!(
|
||||||
|
"Send connection B: unauthenticated guest transport ID {}",
|
||||||
|
unauthenticated_conn.client_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let direct_roundtrip = protected::send_direct_protected(
|
||||||
|
&unauthenticated_conn,
|
||||||
|
conn.client_id,
|
||||||
|
&keyring,
|
||||||
|
&server_bundle,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
println!(
|
||||||
|
"Protected signer {} was accepted through unauthenticated connection B",
|
||||||
|
conn.client_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let relay_roundtrip = protected::send_sealed_relay(
|
||||||
|
&unauthenticated_conn,
|
||||||
|
conn.client_id,
|
||||||
|
&keyring,
|
||||||
|
&server_bundle,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
println!(
|
||||||
|
"Sealed relay round-trip completed in {:.3}ms",
|
||||||
|
relay_roundtrip.as_secs_f64() * 1000.0
|
||||||
|
);
|
||||||
|
|
||||||
|
unauthenticated_conn.sender.close().await;
|
||||||
|
|
||||||
|
let roundtrip = messages::send_and_receive(&conn, &keyring, &server_bundle).await?;
|
||||||
|
builder.set_message_roundtrip(roundtrip);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Direct protected round-trip: {:.3}ms",
|
||||||
|
direct_roundtrip.as_secs_f64() * 1000.0
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("\n--- Pipe demo ---");
|
||||||
|
let pipe_results = pipes::run_pipe_demo(&conn, 1).await?;
|
||||||
|
for result in &pipe_results {
|
||||||
|
builder.add_pipe_result(result.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_record = builder.build();
|
||||||
|
println!(
|
||||||
|
"\nSession {} complete: auth={}ms, msg_roundtrip={}ms, pipes={} results, pipe_bytes={}",
|
||||||
|
session_record.session_id,
|
||||||
|
session_record.auth_duration_ms,
|
||||||
|
session_record.message_roundtrip_ms,
|
||||||
|
session_record.pipe_results.len(),
|
||||||
|
session_record.total_pipe_bytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
client_metrics.record_session(session_record);
|
||||||
|
client_metrics.save("metrics/client_sessions.json");
|
||||||
|
client_metrics.build_overview("metrics/client_overview.json");
|
||||||
|
|
||||||
|
conn.sender.close().await;
|
||||||
println!("\nDone");
|
println!("\nDone");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,59 @@
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use mtp::client::MTPConnection;
|
use mtp::client::MTPConnection;
|
||||||
|
use mtp::codec::ProtectionPurpose;
|
||||||
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||||
use mtp::crypto::{Ed25519Signer, EncryptionType, Keyring, PublicKeyBundle, SigAlgorithm};
|
use mtp::crypto::{Ed25519Signer, Keyring, PublicKeyBundle};
|
||||||
use mtp::type_map::TypeMap;
|
use mtp::type_map::TypeMap;
|
||||||
|
|
||||||
pub fn build_demo_message(
|
pub fn build_demo_message(
|
||||||
client_id: u64,
|
client_id: u64,
|
||||||
keyring: &Keyring,
|
keyring: &Keyring,
|
||||||
server_bundle: &PublicKeyBundle,
|
server_bundle: &PublicKeyBundle,
|
||||||
) -> CommunicationValue {
|
) -> Result<CommunicationValue, Box<dyn std::error::Error>> {
|
||||||
// Encrypt to the server's KEM public key; the server decrypts with its keyring.
|
// Encrypt to the server's KEM public key; the server decrypts with its keyring.
|
||||||
let enc_type = EncryptionType::MlKemChaCha20Poly1305;
|
let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)?;
|
||||||
let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)
|
|
||||||
.expect("Ed25519 signer from keyring");
|
|
||||||
|
|
||||||
let tm = TypeMap::latest();
|
let tm = TypeMap::latest();
|
||||||
|
let version_id = DataType::Version
|
||||||
|
.try_to_id(&tm)
|
||||||
|
.ok_or_else(|| std::io::Error::other("Version is absent from the type map"))?;
|
||||||
|
let id_id = DataType::Id
|
||||||
|
.try_to_id(&tm)
|
||||||
|
.ok_or_else(|| std::io::Error::other("Id is absent from the type map"))?;
|
||||||
|
|
||||||
let inner_enc = DataValue::Container(vec![
|
let inner_enc = DataValue::Container(vec![
|
||||||
(DataType::Version.to_id(&tm), DataValue::Str("secret inner data".into())),
|
(version_id, DataValue::Str("secret inner data".into())),
|
||||||
(DataType::Id.to_id(&tm), DataValue::UnsignedNumber(42)),
|
(id_id, DataValue::UnsignedNumber(42)),
|
||||||
]);
|
]);
|
||||||
let mut dv_enc = inner_enc;
|
let dv_enc = inner_enc.encrypt_for(
|
||||||
dv_enc.encrypt_container(enc_type, server_bundle, b"demo-aad");
|
std::slice::from_ref(server_bundle),
|
||||||
|
ProtectionPurpose::from(1),
|
||||||
|
)?;
|
||||||
|
|
||||||
let inner_sig = DataValue::Container(vec![
|
let inner_sig = DataValue::Container(vec![
|
||||||
(DataType::Version.to_id(&tm), DataValue::Str("signed by client".into())),
|
(version_id, DataValue::Str("signed by client".into())),
|
||||||
(DataType::Id.to_id(&tm), DataValue::UnsignedNumber(99)),
|
(id_id, DataValue::UnsignedNumber(99)),
|
||||||
]);
|
]);
|
||||||
let mut dv_sig = inner_sig;
|
let dv_sig = inner_sig.sign(client_id, ProtectionPurpose::from(2), &signer)?;
|
||||||
dv_sig.sign_container(SigAlgorithm::ED25519, &signer);
|
|
||||||
|
|
||||||
let inner_sec = DataValue::Container(vec![
|
let inner_sec = DataValue::Container(vec![
|
||||||
(
|
(
|
||||||
DataType::Version.to_id(&tm),
|
version_id,
|
||||||
DataValue::Str("signed+encrypted payload".into()),
|
DataValue::Str("signed+encrypted payload".into()),
|
||||||
),
|
),
|
||||||
(DataType::Id.to_id(&tm), DataValue::UnsignedNumber(7)),
|
(id_id, DataValue::UnsignedNumber(7)),
|
||||||
]);
|
]);
|
||||||
let mut dv_sec = inner_sec;
|
let dv_sec = inner_sec
|
||||||
dv_sec.sign_and_encrypt_container(SigAlgorithm::ED25519, &signer, enc_type, server_bundle, b"demo-aad");
|
.sign(client_id, ProtectionPurpose::from(3), &signer)?
|
||||||
|
.encrypt_for(
|
||||||
|
std::slice::from_ref(server_bundle),
|
||||||
|
ProtectionPurpose::from(4),
|
||||||
|
)?;
|
||||||
|
|
||||||
let timestamp = std::time::SystemTime::now()
|
let timestamp = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)?
|
||||||
.unwrap()
|
.as_millis();
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
let msg = CommunicationValue::new(CommunicationType::Ping)
|
let msg = CommunicationValue::new(CommunicationType::Ping)
|
||||||
.add_typed_default(
|
.add_typed_default(
|
||||||
|
|
@ -51,11 +62,11 @@ pub fn build_demo_message(
|
||||||
)
|
)
|
||||||
.add_typed_default(
|
.add_typed_default(
|
||||||
DataType::Timestamp,
|
DataType::Timestamp,
|
||||||
DataValue::UnsignedNumber(timestamp as u128),
|
DataValue::UnsignedNumber(timestamp),
|
||||||
)
|
)
|
||||||
.add_typed_default(DataType::Data, DataValue::Str("Hello, MTP!".into()))
|
.add_typed_default(DataType::Data, DataValue::Str("Hello, MTP!".into()))
|
||||||
.add_typed_default(DataType::Flags, DataValue::BoolTrue)
|
.add_typed_default(DataType::Flags, DataValue::BoolTrue)
|
||||||
.add_typed_default(DataType::Value, DataValue::Float(2, 12345))
|
.add_typed_default(DataType::Value, DataValue::Float(1_234_500.0))
|
||||||
.add_typed_default(
|
.add_typed_default(
|
||||||
DataType::BinaryData,
|
DataType::BinaryData,
|
||||||
DataValue::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42]),
|
DataValue::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42]),
|
||||||
|
|
@ -72,25 +83,32 @@ pub fn build_demo_message(
|
||||||
.add_typed_default(DataType::SignedPayload, dv_sig)
|
.add_typed_default(DataType::SignedPayload, dv_sig)
|
||||||
.add_typed_default(DataType::SecurePayload, dv_sec)
|
.add_typed_default(DataType::SecurePayload, dv_sec)
|
||||||
.with_sender(client_id);
|
.with_sender(client_id);
|
||||||
msg
|
Ok(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_and_receive(
|
pub async fn send_and_receive(
|
||||||
conn: &MTPConnection,
|
conn: &MTPConnection,
|
||||||
keyring: &Keyring,
|
keyring: &Keyring,
|
||||||
server_bundle: &PublicKeyBundle,
|
server_bundle: &PublicKeyBundle,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<Duration, Box<dyn std::error::Error>> {
|
||||||
let msg = build_demo_message(conn.client_id, keyring, server_bundle);
|
let msg = build_demo_message(conn.client_id, keyring, server_bundle)?;
|
||||||
println!("Sending: {msg}");
|
println!("Sending: {msg}");
|
||||||
|
let start = Instant::now();
|
||||||
conn.sender.send(&msg).await?;
|
conn.sender.send(&msg).await?;
|
||||||
|
|
||||||
match conn.receiver.receive().await {
|
match conn.receive().await {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
|
let roundtrip = start.elapsed();
|
||||||
println!("Received: {resp}");
|
println!("Received: {resp}");
|
||||||
|
println!(
|
||||||
|
"Message round-trip: {:.3}ms",
|
||||||
|
roundtrip.as_secs_f64() * 1000.0
|
||||||
|
);
|
||||||
|
Ok(roundtrip)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Receive error: {e}");
|
||||||
|
Err(e.into())
|
||||||
}
|
}
|
||||||
Err(e) => eprintln!("Receive error: {e}"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.sender.close();
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
558
example/client/src/metrics.rs
Normal file
558
example/client/src/metrics.rs
Normal file
|
|
@ -0,0 +1,558 @@
|
||||||
|
use mtp::common::unix_time_millis;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn now_epoch_secs() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
fn now_epoch_millis() -> u64 {
|
||||||
|
unix_time_millis().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_session_id() -> String {
|
||||||
|
let ts = now_epoch_secs();
|
||||||
|
let rand_part: u32 = rand::random();
|
||||||
|
format!("{ts}-{rand_part:08x}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Persisted data types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct PipeResult {
|
||||||
|
pub size: usize,
|
||||||
|
pub iteration: usize,
|
||||||
|
pub total_ms: f64,
|
||||||
|
pub data_only_ms: f64,
|
||||||
|
pub bytes_matched: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ClientSessionRecord {
|
||||||
|
pub session_id: String,
|
||||||
|
pub timestamp: u64,
|
||||||
|
pub auth_method: String,
|
||||||
|
pub auth_duration_ms: f64,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub message_roundtrip_ms: f64,
|
||||||
|
pub pipe_results: Vec<PipeResult>,
|
||||||
|
pub total_pipe_bytes: u64,
|
||||||
|
pub overall_pipe_avg_total_ms: f64,
|
||||||
|
pub overall_pipe_avg_data_ms: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ClientAggregateStats {
|
||||||
|
pub total_sessions: u64,
|
||||||
|
pub auth_failures: u64,
|
||||||
|
pub avg_auth_duration_ms: f64,
|
||||||
|
pub avg_message_roundtrip_ms: f64,
|
||||||
|
pub avg_pipe_total_ms: f64,
|
||||||
|
pub avg_pipe_data_ms: f64,
|
||||||
|
pub total_pipe_bytes: u64,
|
||||||
|
pub avg_pipe_throughput_mbps: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ClientOverview {
|
||||||
|
pub total_sessions: u64,
|
||||||
|
pub aggregate: ClientAggregateStats,
|
||||||
|
pub sessions: Vec<ClientSessionRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ClientMetricsFile {
|
||||||
|
pub sessions: Vec<ClientSessionRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Live metrics state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct ClientMetrics {
|
||||||
|
sessions: Vec<ClientSessionRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientMetrics {
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
sessions: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(path: &str) -> Self {
|
||||||
|
let file = std::fs::read_to_string(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str::<ClientMetricsFile>(&s).ok());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
sessions: file.map(|f| f.sessions).unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, path: &str) {
|
||||||
|
let data = ClientMetricsFile {
|
||||||
|
sessions: self.sessions.clone(),
|
||||||
|
};
|
||||||
|
if let Some(parent) = Path::new(path).parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(&data).unwrap_or_default();
|
||||||
|
let _ = std::fs::write(path, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_session(&mut self, record: ClientSessionRecord) {
|
||||||
|
self.sessions.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_overview(&self, overview_path: &str) {
|
||||||
|
let total = self.sessions.len() as u64;
|
||||||
|
|
||||||
|
if total == 0 {
|
||||||
|
let overview = ClientOverview {
|
||||||
|
total_sessions: 0,
|
||||||
|
aggregate: ClientAggregateStats::default(),
|
||||||
|
sessions: Vec::new(),
|
||||||
|
};
|
||||||
|
if let Some(parent) = Path::new(overview_path).parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(&overview).unwrap_or_default();
|
||||||
|
let _ = std::fs::write(overview_path, json);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut auth_sum: f64 = 0.0;
|
||||||
|
let mut msg_sum: f64 = 0.0;
|
||||||
|
let mut pipe_total_sum: f64 = 0.0;
|
||||||
|
let mut pipe_data_sum: f64 = 0.0;
|
||||||
|
let mut total_pipe_bytes: u64 = 0;
|
||||||
|
let mut total_pipe_duration_secs: f64 = 0.0;
|
||||||
|
let mut auth_failures: u64 = 0;
|
||||||
|
let mut success_count: u64 = 0;
|
||||||
|
|
||||||
|
for s in &self.sessions {
|
||||||
|
if s.error.is_some() {
|
||||||
|
auth_failures += 1;
|
||||||
|
} else {
|
||||||
|
success_count += 1;
|
||||||
|
auth_sum += s.auth_duration_ms;
|
||||||
|
msg_sum += s.message_roundtrip_ms;
|
||||||
|
pipe_total_sum += s.overall_pipe_avg_total_ms;
|
||||||
|
pipe_data_sum += s.overall_pipe_avg_data_ms;
|
||||||
|
total_pipe_bytes += s.total_pipe_bytes;
|
||||||
|
for pr in &s.pipe_results {
|
||||||
|
total_pipe_duration_secs += pr.total_ms / 1000.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let divisor = if success_count > 0 { success_count } else { 1 };
|
||||||
|
|
||||||
|
let aggregate = ClientAggregateStats {
|
||||||
|
total_sessions: total,
|
||||||
|
auth_failures,
|
||||||
|
avg_auth_duration_ms: auth_sum / divisor as f64,
|
||||||
|
avg_message_roundtrip_ms: msg_sum / divisor as f64,
|
||||||
|
avg_pipe_total_ms: pipe_total_sum / divisor as f64,
|
||||||
|
avg_pipe_data_ms: pipe_data_sum / divisor as f64,
|
||||||
|
total_pipe_bytes,
|
||||||
|
avg_pipe_throughput_mbps: if total_pipe_duration_secs > 0.0 {
|
||||||
|
(total_pipe_bytes as f64 / 1_048_576.0) / total_pipe_duration_secs
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let overview = ClientOverview {
|
||||||
|
total_sessions: total,
|
||||||
|
aggregate,
|
||||||
|
sessions: self.sessions.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(parent) = Path::new(overview_path).parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(&overview).unwrap_or_default();
|
||||||
|
let _ = std::fs::write(overview_path, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Builder for constructing a session record piece by piece
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct SessionBuilder {
|
||||||
|
session_id: String,
|
||||||
|
timestamp: u64,
|
||||||
|
auth_method: String,
|
||||||
|
auth_duration_ms: f64,
|
||||||
|
error: Option<String>,
|
||||||
|
message_roundtrip_ms: f64,
|
||||||
|
pipe_results: Vec<PipeResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionBuilder {
|
||||||
|
pub fn new(auth_method: &str, auth_duration: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
session_id: generate_session_id(),
|
||||||
|
timestamp: now_epoch_millis(),
|
||||||
|
auth_method: auth_method.to_string(),
|
||||||
|
auth_duration_ms: auth_duration.as_secs_f64() * 1000.0,
|
||||||
|
error: None,
|
||||||
|
message_roundtrip_ms: 0.0,
|
||||||
|
pipe_results: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_error(&mut self, error: String) {
|
||||||
|
self.error = Some(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_message_roundtrip(&mut self, duration: Duration) {
|
||||||
|
self.message_roundtrip_ms = duration.as_secs_f64() * 1000.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_pipe_result(&mut self, result: PipeResult) {
|
||||||
|
self.pipe_results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(self) -> ClientSessionRecord {
|
||||||
|
let total_pipe_bytes: u64 = self.pipe_results.iter().map(|r| r.size as u64).sum();
|
||||||
|
|
||||||
|
let overall_pipe_avg_total_ms = if self.pipe_results.is_empty() {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
self.pipe_results.iter().map(|r| r.total_ms).sum::<f64>()
|
||||||
|
/ self.pipe_results.len() as f64
|
||||||
|
};
|
||||||
|
|
||||||
|
let overall_pipe_avg_data_ms = if self.pipe_results.is_empty() {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
self.pipe_results
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.data_only_ms)
|
||||||
|
.sum::<f64>()
|
||||||
|
/ self.pipe_results.len() as f64
|
||||||
|
};
|
||||||
|
|
||||||
|
ClientSessionRecord {
|
||||||
|
session_id: self.session_id,
|
||||||
|
timestamp: self.timestamp,
|
||||||
|
auth_method: self.auth_method,
|
||||||
|
auth_duration_ms: self.auth_duration_ms,
|
||||||
|
error: self.error,
|
||||||
|
message_roundtrip_ms: self.message_roundtrip_ms,
|
||||||
|
pipe_results: self.pipe_results,
|
||||||
|
total_pipe_bytes,
|
||||||
|
overall_pipe_avg_total_ms,
|
||||||
|
overall_pipe_avg_data_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
fn tmp_path(name: &str) -> String {
|
||||||
|
let dir = std::env::temp_dir().join("mtp_client_metrics_test");
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
dir.join(name).to_str().unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pipe_result_roundtrip() {
|
||||||
|
let pr = PipeResult {
|
||||||
|
size: 1024,
|
||||||
|
iteration: 0,
|
||||||
|
total_ms: 5.5,
|
||||||
|
data_only_ms: 3.2,
|
||||||
|
bytes_matched: true,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&pr).unwrap();
|
||||||
|
let decoded: PipeResult = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(pr, decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_session_roundtrip() {
|
||||||
|
let record = ClientSessionRecord {
|
||||||
|
session_id: "test-session".into(),
|
||||||
|
timestamp: 12345,
|
||||||
|
auth_method: "connect".into(),
|
||||||
|
auth_duration_ms: 42.5,
|
||||||
|
error: None,
|
||||||
|
message_roundtrip_ms: 10.3,
|
||||||
|
pipe_results: vec![
|
||||||
|
PipeResult {
|
||||||
|
size: 64,
|
||||||
|
iteration: 0,
|
||||||
|
total_ms: 1.0,
|
||||||
|
data_only_ms: 0.5,
|
||||||
|
bytes_matched: true,
|
||||||
|
},
|
||||||
|
PipeResult {
|
||||||
|
size: 256,
|
||||||
|
iteration: 0,
|
||||||
|
total_ms: 2.0,
|
||||||
|
data_only_ms: 1.0,
|
||||||
|
bytes_matched: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total_pipe_bytes: 320,
|
||||||
|
overall_pipe_avg_total_ms: 1.5,
|
||||||
|
overall_pipe_avg_data_ms: 0.75,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&record).unwrap();
|
||||||
|
let decoded: ClientSessionRecord = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(record, decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_metrics_load_missing() {
|
||||||
|
let metrics = ClientMetrics::load("/nonexistent/path.json");
|
||||||
|
assert!(metrics.sessions.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_client_sessions() {
|
||||||
|
let path = tmp_path("multi_session.json");
|
||||||
|
let mut metrics = ClientMetrics::load(&path);
|
||||||
|
|
||||||
|
for i in 0..3 {
|
||||||
|
let mut builder = SessionBuilder::new("connect", Duration::from_millis(10 + i));
|
||||||
|
builder.set_message_roundtrip(Duration::from_millis(5 + i));
|
||||||
|
builder.add_pipe_result(PipeResult {
|
||||||
|
size: 64,
|
||||||
|
iteration: 0,
|
||||||
|
total_ms: 1.0 + i as f64,
|
||||||
|
data_only_ms: 0.5 + i as f64 * 0.5,
|
||||||
|
bytes_matched: true,
|
||||||
|
});
|
||||||
|
metrics.record_session(builder.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics.save(&path);
|
||||||
|
|
||||||
|
let metrics2 = ClientMetrics::load(&path);
|
||||||
|
assert_eq!(metrics2.sessions.len(), 3);
|
||||||
|
assert_eq!(metrics2.sessions[0].auth_method, "connect");
|
||||||
|
assert_eq!(metrics2.sessions[1].pipe_results[0].size, 64);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_overview_stats() {
|
||||||
|
let mut metrics = ClientMetrics::new();
|
||||||
|
|
||||||
|
for i in 0..4 {
|
||||||
|
let mut builder = SessionBuilder::new("connect", Duration::from_millis(20));
|
||||||
|
builder.set_message_roundtrip(Duration::from_millis(10 + i as u64));
|
||||||
|
builder.add_pipe_result(PipeResult {
|
||||||
|
size: 256,
|
||||||
|
iteration: 0,
|
||||||
|
total_ms: 2.0,
|
||||||
|
data_only_ms: 1.0,
|
||||||
|
bytes_matched: true,
|
||||||
|
});
|
||||||
|
metrics.record_session(builder.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
let overview_path = tmp_path("client_overview.json");
|
||||||
|
metrics.build_overview(&overview_path);
|
||||||
|
|
||||||
|
let json = std::fs::read_to_string(&overview_path).unwrap();
|
||||||
|
let overview: ClientOverview = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(overview.total_sessions, 4);
|
||||||
|
assert_eq!(overview.aggregate.avg_auth_duration_ms, 20.0);
|
||||||
|
assert_eq!(overview.aggregate.avg_message_roundtrip_ms, 11.5);
|
||||||
|
assert_eq!(overview.aggregate.avg_pipe_total_ms, 2.0);
|
||||||
|
assert_eq!(overview.aggregate.avg_pipe_data_ms, 1.0);
|
||||||
|
assert_eq!(overview.aggregate.total_pipe_bytes, 1024);
|
||||||
|
assert!(overview.aggregate.avg_pipe_throughput_mbps > 0.0);
|
||||||
|
assert_eq!(overview.sessions.len(), 4);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&overview_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_overview_empty() {
|
||||||
|
let metrics = ClientMetrics::new();
|
||||||
|
let overview_path = tmp_path("client_empty_overview.json");
|
||||||
|
metrics.build_overview(&overview_path);
|
||||||
|
|
||||||
|
let json = std::fs::read_to_string(&overview_path).unwrap();
|
||||||
|
let overview: ClientOverview = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(overview.total_sessions, 0);
|
||||||
|
assert!(overview.sessions.is_empty());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&overview_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_failure_recording() {
|
||||||
|
let path = tmp_path("auth_failure.json");
|
||||||
|
let overview_path = tmp_path("auth_failure_overview.json");
|
||||||
|
|
||||||
|
let mut metrics = ClientMetrics::load(&path);
|
||||||
|
|
||||||
|
// Successful session
|
||||||
|
let mut b1 = SessionBuilder::new("connect", Duration::from_millis(42));
|
||||||
|
b1.set_message_roundtrip(Duration::from_millis(10));
|
||||||
|
metrics.record_session(b1.build());
|
||||||
|
|
||||||
|
// Failed auth session
|
||||||
|
let mut b2 = SessionBuilder::new("connect", Duration::from_millis(5000));
|
||||||
|
b2.set_error("authentication timed out".into());
|
||||||
|
metrics.record_session(b2.build());
|
||||||
|
|
||||||
|
// Another successful session
|
||||||
|
let mut b3 = SessionBuilder::new("register", Duration::from_millis(100));
|
||||||
|
b3.set_message_roundtrip(Duration::from_millis(8));
|
||||||
|
metrics.record_session(b3.build());
|
||||||
|
|
||||||
|
metrics.save(&path);
|
||||||
|
let metrics2 = ClientMetrics::load(&path);
|
||||||
|
assert_eq!(metrics2.sessions.len(), 3);
|
||||||
|
assert!(metrics2.sessions[0].error.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
metrics2.sessions[1].error.as_deref(),
|
||||||
|
Some("authentication timed out")
|
||||||
|
);
|
||||||
|
assert!(metrics2.sessions[2].error.is_none());
|
||||||
|
|
||||||
|
metrics2.build_overview(&overview_path);
|
||||||
|
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
|
||||||
|
let overview: ClientOverview = serde_json::from_str(&overview_json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(overview.total_sessions, 3);
|
||||||
|
assert_eq!(overview.aggregate.auth_failures, 1);
|
||||||
|
// Averages should only count successful sessions
|
||||||
|
assert!((overview.aggregate.avg_auth_duration_ms - 71.0).abs() < 0.01); // (42+100)/2
|
||||||
|
assert!((overview.aggregate.avg_message_roundtrip_ms - 9.0).abs() < 0.01); // (10+8)/2
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = std::fs::remove_file(&overview_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Integration-style tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn make_pr(size: usize, iteration: usize, total_ms: f64, data_only_ms: f64) -> PipeResult {
|
||||||
|
PipeResult {
|
||||||
|
size,
|
||||||
|
iteration,
|
||||||
|
total_ms,
|
||||||
|
data_only_ms,
|
||||||
|
bytes_matched: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_full_client_lifecycle() {
|
||||||
|
let path = tmp_path("client_lifecycle.json");
|
||||||
|
let overview_path = tmp_path("client_lifecycle_overview.json");
|
||||||
|
|
||||||
|
let mut metrics = ClientMetrics::load(&path);
|
||||||
|
|
||||||
|
let mut b1 = SessionBuilder::new("connect", Duration::from_millis(42));
|
||||||
|
b1.set_message_roundtrip(Duration::from_millis(10));
|
||||||
|
b1.add_pipe_result(make_pr(64, 0, 1.5, 0.8));
|
||||||
|
b1.add_pipe_result(make_pr(256, 0, 2.5, 1.2));
|
||||||
|
metrics.record_session(b1.build());
|
||||||
|
|
||||||
|
let mut b2 = SessionBuilder::new("register", Duration::from_millis(150));
|
||||||
|
b2.set_message_roundtrip(Duration::from_millis(15));
|
||||||
|
b2.add_pipe_result(make_pr(64, 0, 2.0, 1.0));
|
||||||
|
b2.add_pipe_result(make_pr(1024, 0, 5.0, 3.0));
|
||||||
|
metrics.record_session(b2.build());
|
||||||
|
|
||||||
|
metrics.save(&path);
|
||||||
|
let metrics2 = ClientMetrics::load(&path);
|
||||||
|
assert_eq!(metrics2.sessions.len(), 2);
|
||||||
|
|
||||||
|
let s1 = &metrics2.sessions[0];
|
||||||
|
assert_eq!(s1.auth_method, "connect");
|
||||||
|
assert!((s1.auth_duration_ms - 42.0).abs() < 0.01);
|
||||||
|
assert!((s1.message_roundtrip_ms - 10.0).abs() < 0.01);
|
||||||
|
assert_eq!(s1.pipe_results.len(), 2);
|
||||||
|
assert_eq!(s1.total_pipe_bytes, 320);
|
||||||
|
assert!((s1.overall_pipe_avg_total_ms - 2.0).abs() < 0.01);
|
||||||
|
assert!((s1.overall_pipe_avg_data_ms - 1.0).abs() < 0.01);
|
||||||
|
|
||||||
|
let s2 = &metrics2.sessions[1];
|
||||||
|
assert_eq!(s2.auth_method, "register");
|
||||||
|
assert_eq!(s2.pipe_results.len(), 2);
|
||||||
|
assert_eq!(s2.total_pipe_bytes, 1088);
|
||||||
|
|
||||||
|
metrics2.build_overview(&overview_path);
|
||||||
|
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
|
||||||
|
let overview: ClientOverview = serde_json::from_str(&overview_json).unwrap();
|
||||||
|
assert_eq!(overview.total_sessions, 2);
|
||||||
|
assert!((overview.aggregate.avg_auth_duration_ms - 96.0).abs() < 0.01);
|
||||||
|
assert!((overview.aggregate.avg_message_roundtrip_ms - 12.5).abs() < 0.01);
|
||||||
|
assert_eq!(overview.aggregate.total_pipe_bytes, 1408);
|
||||||
|
assert!(overview.aggregate.avg_pipe_throughput_mbps > 0.0);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = std::fs::remove_file(&overview_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cross_session_accumulation() {
|
||||||
|
let path = tmp_path("client_accumulate.json");
|
||||||
|
let overview_path = tmp_path("client_accumulate_overview.json");
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut metrics = ClientMetrics::load(&path);
|
||||||
|
let mut b = SessionBuilder::new("connect", Duration::from_millis(30));
|
||||||
|
b.set_message_roundtrip(Duration::from_millis(8));
|
||||||
|
b.add_pipe_result(make_pr(64, 0, 1.0, 0.5));
|
||||||
|
metrics.record_session(b.build());
|
||||||
|
metrics.save(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut metrics = ClientMetrics::load(&path);
|
||||||
|
assert_eq!(metrics.sessions.len(), 1);
|
||||||
|
let mut b = SessionBuilder::new("register", Duration::from_millis(200));
|
||||||
|
b.set_message_roundtrip(Duration::from_millis(12));
|
||||||
|
b.add_pipe_result(make_pr(1024, 0, 4.0, 2.5));
|
||||||
|
metrics.record_session(b.build());
|
||||||
|
metrics.save(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let metrics = ClientMetrics::load(&path);
|
||||||
|
assert_eq!(metrics.sessions.len(), 2);
|
||||||
|
assert_eq!(metrics.sessions[0].auth_method, "connect");
|
||||||
|
assert_eq!(metrics.sessions[1].auth_method, "register");
|
||||||
|
|
||||||
|
metrics.build_overview(&overview_path);
|
||||||
|
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
|
||||||
|
let overview: ClientOverview = serde_json::from_str(&overview_json).unwrap();
|
||||||
|
assert_eq!(overview.total_sessions, 2);
|
||||||
|
assert!((overview.aggregate.avg_auth_duration_ms - 115.0).abs() < 0.01);
|
||||||
|
assert!((overview.aggregate.avg_message_roundtrip_ms - 10.0).abs() < 0.01);
|
||||||
|
assert_eq!(overview.aggregate.total_pipe_bytes, 1088);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = std::fs::remove_file(&overview_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
147
example/client/src/pipes.rs
Normal file
147
example/client/src/pipes.rs
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
use mtp::client::MTPConnection;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
use tokio::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use crate::metrics::PipeResult;
|
||||||
|
|
||||||
|
pub async fn run_pipe_demo(
|
||||||
|
conn: &MTPConnection,
|
||||||
|
iterations: usize,
|
||||||
|
) -> Result<Vec<PipeResult>, Box<dyn std::error::Error>> {
|
||||||
|
let sizes = [64, 256, 1024, 4096];
|
||||||
|
let mut all_elapsed = Vec::with_capacity(sizes.len() * iterations);
|
||||||
|
let mut all_data_only = Vec::with_capacity(sizes.len() * iterations);
|
||||||
|
let mut pipe_results = Vec::with_capacity(sizes.len() * iterations);
|
||||||
|
|
||||||
|
for (i, &size) in sizes.iter().enumerate() {
|
||||||
|
let mut size_elapsed = Vec::with_capacity(iterations);
|
||||||
|
let mut size_data_only = Vec::with_capacity(iterations);
|
||||||
|
|
||||||
|
for run in 0..iterations {
|
||||||
|
let random_bytes: Vec<u8> = (0..size).map(|_| rand::random::<u8>()).collect();
|
||||||
|
let description = format!("pipe-demo-{i}-run{run}");
|
||||||
|
println!(" [pipe {i}.{run}] creating pipe ({size} bytes): {description}");
|
||||||
|
|
||||||
|
let handle = conn.create_pipe(&description).await?;
|
||||||
|
let pipe_id = handle.pipe_id();
|
||||||
|
println!(" [pipe {i}.{run}] create_pipe returned (pipe_id={pipe_id})");
|
||||||
|
|
||||||
|
// Overall timer starts before any I/O
|
||||||
|
let overall_start = Instant::now();
|
||||||
|
|
||||||
|
// Channel to capture the instant the writer actually starts writing
|
||||||
|
let (write_start_tx, write_start_rx) = oneshot::channel();
|
||||||
|
|
||||||
|
let write_bytes = random_bytes.clone();
|
||||||
|
let writer_handle = tokio::spawn(async move {
|
||||||
|
println!(" [pipe {i}.{run}] writer: waiting for server accept ...");
|
||||||
|
match handle.wait().await {
|
||||||
|
Ok(Some(mut writer)) => {
|
||||||
|
// Record the instant we begin writing
|
||||||
|
let _ = write_start_tx.send(Instant::now());
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" [pipe {i}.{run}] writer: pipe accepted (pipe_id={pipe_id}), writing {} bytes ...",
|
||||||
|
write_bytes.len()
|
||||||
|
);
|
||||||
|
writer
|
||||||
|
.write_all(&write_bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| mtp::common::PipeError::IoError(e.to_string()))?;
|
||||||
|
writer
|
||||||
|
.finish()
|
||||||
|
.await
|
||||||
|
.map_err(|e| mtp::common::PipeError::IoError(e.to_string()))?;
|
||||||
|
Ok::<(), mtp::common::PipeError>(())
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
eprintln!(" [pipe {i}.{run}] writer: pipe denied by server");
|
||||||
|
Err(mtp::common::PipeError::Rejected)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" [pipe {i}.{run}] writer: error: {e}");
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
println!(" [pipe {i}.{run}] waiting for server's return pipe via receive_pipe() ...");
|
||||||
|
let pipe_req = conn.receive_pipe().await?;
|
||||||
|
println!(
|
||||||
|
" [pipe {i}.{run}] received return pipe: id={} desc={:?}",
|
||||||
|
pipe_req.id(),
|
||||||
|
pipe_req.description()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut reader = pipe_req.accept().await?;
|
||||||
|
println!(" [pipe {i}.{run}] return pipe accepted, reading data ...");
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
reader.read_to_end(&mut buf).await?;
|
||||||
|
let overall_elapsed = overall_start.elapsed();
|
||||||
|
|
||||||
|
// Receive the instant the writer started writing
|
||||||
|
let data_start = write_start_rx.await?;
|
||||||
|
let data_only_elapsed = Instant::now() - data_start;
|
||||||
|
|
||||||
|
match writer_handle.await {
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(e)) => eprintln!(" [pipe {i}.{run}] writer error: {e}"),
|
||||||
|
Err(e) => eprintln!(" [pipe {i}.{run}] writer task panicked: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let matches = buf == random_bytes;
|
||||||
|
println!(
|
||||||
|
" [pipe {i}.{run}] round-trip: {} bytes, \
|
||||||
|
total={:.3}ms, data-only={:.3}ms, match={matches}",
|
||||||
|
size,
|
||||||
|
overall_elapsed.as_secs_f64() * 1000.0,
|
||||||
|
data_only_elapsed.as_secs_f64() * 1000.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
pipe_results.push(PipeResult {
|
||||||
|
size,
|
||||||
|
iteration: run,
|
||||||
|
total_ms: overall_elapsed.as_secs_f64() * 1000.0,
|
||||||
|
data_only_ms: data_only_elapsed.as_secs_f64() * 1000.0,
|
||||||
|
bytes_matched: matches,
|
||||||
|
});
|
||||||
|
|
||||||
|
size_elapsed.push(overall_elapsed);
|
||||||
|
size_data_only.push(data_only_elapsed);
|
||||||
|
all_elapsed.push(overall_elapsed);
|
||||||
|
all_data_only.push(data_only_elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- per-size averages ----
|
||||||
|
let avg_total = average_duration(&size_elapsed);
|
||||||
|
let avg_data = average_duration(&size_data_only);
|
||||||
|
println!(
|
||||||
|
" [pipe {i}] AVERAGE for size {size}: \
|
||||||
|
total={avg_total:.3}ms, data-only={avg_data:.3}ms \
|
||||||
|
(over {iterations} runs)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- overall averages ----
|
||||||
|
let overall_total = average_duration(&all_elapsed);
|
||||||
|
let overall_data = average_duration(&all_data_only);
|
||||||
|
println!(
|
||||||
|
" [summary] OVERALL AVERAGE loopback time: \
|
||||||
|
total={overall_total:.3}ms, data-only={overall_data:.3}ms \
|
||||||
|
({} measurements)",
|
||||||
|
all_elapsed.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(pipe_results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: average a slice of Durations without overflowing.
|
||||||
|
fn average_duration(durations: &[Duration]) -> f64 {
|
||||||
|
if durations.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let sum_ms: f64 = durations.iter().map(|d| d.as_secs_f64() * 1000.0).sum();
|
||||||
|
sum_ms / durations.len() as f64
|
||||||
|
}
|
||||||
199
example/client/src/protected.rs
Normal file
199
example/client/src/protected.rs
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use mtp::client::MTPConnection;
|
||||||
|
use mtp::codec::{
|
||||||
|
CommunicationType, DataType, DataValue, ProtectionPolicy, ProtectedMessageBuilder,
|
||||||
|
ProtectionPurpose, RelayOpenOptions, SealedRelayBuilder, SignaturePolicy, TypeMap,
|
||||||
|
open_relay_content_with_limits_without_replay,
|
||||||
|
open_relay_metadata_without_replay,
|
||||||
|
};
|
||||||
|
use mtp::common::unix_time_millis;
|
||||||
|
use mtp::crypto::{Ed25519Signer, Keyring, PublicKeyBundle};
|
||||||
|
|
||||||
|
/// The direct protected example sends to the host as the destination MTP ID.
|
||||||
|
pub const DIRECT_DESTINATION_ID: u64 = 1;
|
||||||
|
|
||||||
|
/// The example host acts as the metadata relay and uses this stable MTP ID.
|
||||||
|
pub const METADATA_RELAY_ID: u64 = 1;
|
||||||
|
|
||||||
|
/// This keyring represents a final recipient independently of the transport
|
||||||
|
/// identity used by the example client.
|
||||||
|
pub const FINAL_RECIPIENT_ID: u64 = 7_002;
|
||||||
|
|
||||||
|
const DIRECT_SIGNATURE_PURPOSE: u8 = 0x40;
|
||||||
|
const DIRECT_ENCRYPTION_PURPOSE: u8 = 0x41;
|
||||||
|
const RELAY_SIGNATURE_POLICY: ProtectionPolicy = ProtectionPolicy {
|
||||||
|
signature: SignaturePolicy::Ed25519,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn type_id(
|
||||||
|
data_type: DataType,
|
||||||
|
type_map: &TypeMap,
|
||||||
|
) -> Result<mtp::codec::DataTypeId, Box<dyn std::error::Error>> {
|
||||||
|
data_type
|
||||||
|
.try_to_id(type_map)
|
||||||
|
.ok_or_else(|| format!("missing example data type mapping for {data_type}").into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn application_value(text: &str, number: u128) -> Result<DataValue, Box<dyn std::error::Error>> {
|
||||||
|
let type_map = TypeMap::latest();
|
||||||
|
Ok(DataValue::Container(vec![
|
||||||
|
(
|
||||||
|
type_id(DataType::ExampleText, &type_map)?,
|
||||||
|
DataValue::Str(text.to_owned()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
type_id(DataType::ExampleNumber, &type_map)?,
|
||||||
|
DataValue::UnsignedNumber(number),
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relay_metadata() -> Result<DataValue, Box<dyn std::error::Error>> {
|
||||||
|
let type_map = TypeMap::latest();
|
||||||
|
Ok(DataValue::Container(vec![
|
||||||
|
(
|
||||||
|
type_id(DataType::ExampleRole, &type_map)?,
|
||||||
|
DataValue::Str("metadata relay".into()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
type_id(DataType::ExampleMetadata, &type_map)?,
|
||||||
|
DataValue::Str("application metadata remains authenticated and opaque to MTP".into()),
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send an application value directly to the host without constructing a
|
||||||
|
/// Relay frame. The outer sender is deliberately absent so the example also
|
||||||
|
/// demonstrates that the protected signer is independent of transport auth.
|
||||||
|
pub async fn send_direct_protected(
|
||||||
|
conn: &MTPConnection,
|
||||||
|
signer_id: u64,
|
||||||
|
signer_keyring: &Keyring,
|
||||||
|
recipient_public_key: &PublicKeyBundle,
|
||||||
|
) -> Result<Duration, Box<dyn std::error::Error>> {
|
||||||
|
let signer = Ed25519Signer::new(&signer_keyring.sig_cl_secret_key)?;
|
||||||
|
let created_at = unix_time_millis()?;
|
||||||
|
let message_id = format!(
|
||||||
|
"example-direct-{created_at}-{}",
|
||||||
|
rand::random::<u32>()
|
||||||
|
);
|
||||||
|
let content = application_value("direct protected delivery", 40)?;
|
||||||
|
let frame = ProtectedMessageBuilder::new(
|
||||||
|
"ProtectedMessage",
|
||||||
|
content,
|
||||||
|
signer_id,
|
||||||
|
DIRECT_DESTINATION_ID,
|
||||||
|
&signer,
|
||||||
|
ProtectionPurpose::from(DIRECT_SIGNATURE_PURPOSE),
|
||||||
|
ProtectionPurpose::from(DIRECT_ENCRYPTION_PURPOSE),
|
||||||
|
)
|
||||||
|
.message_id(message_id)
|
||||||
|
.created_at(created_at)
|
||||||
|
.recipients(vec![recipient_public_key.clone()])
|
||||||
|
.type_map(&TypeMap::latest())
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Sending direct protected frame: type=ProtectedMessage receiver={} outer_sender=absent signer={signer_id}",
|
||||||
|
DIRECT_DESTINATION_ID
|
||||||
|
);
|
||||||
|
let started = Instant::now();
|
||||||
|
conn.sender.send(&frame).await?;
|
||||||
|
let response = conn.receive().await?;
|
||||||
|
if !response.is_type(CommunicationType::Pong) {
|
||||||
|
return Err(format!("direct protected response was not Pong: {response}").into());
|
||||||
|
}
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
println!(
|
||||||
|
"Direct protected value verified and acknowledged in {:.3}ms",
|
||||||
|
elapsed.as_secs_f64() * 1000.0
|
||||||
|
);
|
||||||
|
Ok(elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a sealed relay through the host, which can open metadata but cannot
|
||||||
|
/// decrypt the content. The final recipient is represented by a separate
|
||||||
|
/// keyring so the example does not conflate relay and content access.
|
||||||
|
pub async fn send_sealed_relay(
|
||||||
|
conn: &MTPConnection,
|
||||||
|
signer_id: u64,
|
||||||
|
signer_keyring: &Keyring,
|
||||||
|
metadata_relay_public_key: &PublicKeyBundle,
|
||||||
|
) -> Result<Duration, Box<dyn std::error::Error>> {
|
||||||
|
let type_map = TypeMap::latest();
|
||||||
|
let final_recipient_keyring = Keyring::generate();
|
||||||
|
let final_recipient_public_key = final_recipient_keyring.public_key_bundle();
|
||||||
|
let signer = Ed25519Signer::new(&signer_keyring.sig_cl_secret_key)?;
|
||||||
|
let created_at = unix_time_millis()?;
|
||||||
|
let message_id = format!("example-relay-{created_at}-{}", rand::random::<u32>());
|
||||||
|
let frame = SealedRelayBuilder::new(
|
||||||
|
"ProtectedMessage",
|
||||||
|
application_value("sealed relay delivery", 41)?,
|
||||||
|
signer_id,
|
||||||
|
FINAL_RECIPIENT_ID,
|
||||||
|
METADATA_RELAY_ID,
|
||||||
|
&signer,
|
||||||
|
)
|
||||||
|
.message_id(message_id)
|
||||||
|
.created_at(created_at)
|
||||||
|
.metadata(relay_metadata()?)
|
||||||
|
.metadata_recipients(vec![
|
||||||
|
metadata_relay_public_key.clone(),
|
||||||
|
final_recipient_public_key.clone(),
|
||||||
|
])
|
||||||
|
.content_recipients(vec![final_recipient_public_key])
|
||||||
|
.type_map(&type_map)
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Sending sealed relay: next_hop={} final_recipient={} metadata_recipients=2 content_recipients=1",
|
||||||
|
METADATA_RELAY_ID, FINAL_RECIPIENT_ID
|
||||||
|
);
|
||||||
|
let started = Instant::now();
|
||||||
|
conn.sender.send(&frame).await?;
|
||||||
|
let forwarded = conn.receive().await?;
|
||||||
|
if !forwarded.is_type(CommunicationType::Relay) {
|
||||||
|
return Err(format!("relay response was not Relay: {forwarded}").into());
|
||||||
|
}
|
||||||
|
if forwarded.sender().is_some() || forwarded.receiver() != Some(FINAL_RECIPIENT_ID) {
|
||||||
|
return Err("relay forwarding changed the sealed-sender boundary".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = open_relay_metadata_without_replay(
|
||||||
|
&forwarded,
|
||||||
|
&final_recipient_keyring,
|
||||||
|
signer_id,
|
||||||
|
&signer_keyring.public_key_bundle(),
|
||||||
|
RelayOpenOptions::new(RELAY_SIGNATURE_POLICY),
|
||||||
|
)?;
|
||||||
|
let application_metadata = metadata
|
||||||
|
.metadata()
|
||||||
|
.ok_or("forwarded relay metadata was missing")?;
|
||||||
|
let content = open_relay_content_with_limits_without_replay(
|
||||||
|
&metadata,
|
||||||
|
&[&final_recipient_keyring],
|
||||||
|
&[signer_keyring.public_key_bundle()],
|
||||||
|
Some(FINAL_RECIPIENT_ID),
|
||||||
|
RelayOpenOptions::new(RELAY_SIGNATURE_POLICY),
|
||||||
|
)?;
|
||||||
|
if content.message_type != "ProtectedMessage" {
|
||||||
|
return Err(format!("unexpected relay message type: {}", content.message_type).into());
|
||||||
|
}
|
||||||
|
let expected_metadata = relay_metadata()?;
|
||||||
|
if application_metadata != &expected_metadata {
|
||||||
|
return Err("relay application metadata changed during forwarding".into());
|
||||||
|
}
|
||||||
|
let expected_content = application_value("sealed relay delivery", 41)?;
|
||||||
|
if content.content != expected_content {
|
||||||
|
return Err("relay application content changed during forwarding".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
println!(
|
||||||
|
"Final recipient opened authenticated metadata and content in {:.3}ms (message_id={})",
|
||||||
|
elapsed.as_secs_f64() * 1000.0,
|
||||||
|
metadata.message_id()
|
||||||
|
);
|
||||||
|
Ok(elapsed)
|
||||||
|
}
|
||||||
7
example/keygen/Cargo.toml
Normal file
7
example/keygen/Cargo.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
[package]
|
||||||
|
name = "keygen"
|
||||||
|
version = "0.3.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
mtp = { version = "0.3.0", path = "../../", features = ["files", "raw"] }
|
||||||
40
example/keygen/src/main.rs
Normal file
40
example/keygen/src/main.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use mtp::crypto::Keyring;
|
||||||
|
use mtp::files::{
|
||||||
|
self, BUNDLE_EXTENSION, KEYRING_EXTENSION, load_keyring_raw, load_public_key_bundle,
|
||||||
|
save_keyring_raw, save_public_key_bundle,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn main() -> Result<(), files::FileError> {
|
||||||
|
let keyring_path = PathBuf::from(format!("keyring.{KEYRING_EXTENSION}"));
|
||||||
|
let bundle_path = PathBuf::from(format!("bundle.{BUNDLE_EXTENSION}"));
|
||||||
|
|
||||||
|
let keyring = Keyring::generate();
|
||||||
|
save_keyring_raw(&keyring, &keyring_path)?;
|
||||||
|
save_public_key_bundle(&keyring.public_key_bundle(), &bundle_path)?;
|
||||||
|
|
||||||
|
/* Read both back to confirm the files round-trip through the on-disk format. */
|
||||||
|
let loaded_keyring = load_keyring_raw(&keyring_path)?;
|
||||||
|
let loaded_bundle = load_public_key_bundle(&bundle_path)?;
|
||||||
|
assert_eq!(keyring.try_to_bytes()?, loaded_keyring.try_to_bytes()?);
|
||||||
|
let bundle_bytes = keyring.public_key_bundle().try_as_bytes()?;
|
||||||
|
let loaded_bundle_bytes = loaded_bundle.try_as_bytes()?;
|
||||||
|
assert_eq!(
|
||||||
|
bundle_bytes,
|
||||||
|
loaded_bundle_bytes
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"\nPrivateKeyRing (base64):\n{}",
|
||||||
|
keyring.try_to_base64()?
|
||||||
|
);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\nPublicKeyBundle (base64):\n{}",
|
||||||
|
loaded_bundle.try_to_base64()?
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("Wrote keyring -> {}", keyring_path.display());
|
||||||
|
println!("Wrote bundle -> {}", bundle_path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "server"
|
name = "server"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|
@ -8,9 +8,12 @@ name = "server"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mtp = { version = "0.1.0", path = "../../", features = ["crypto", "host"] }
|
mtp = { version = "0.3.0", path = "../../", features = ["crypto", "tls", "web-server", "files", "pipes", "raw"] }
|
||||||
rcgen = "0.14"
|
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
http = "1"
|
||||||
serde_json = { version = "1" }
|
serde_json = { version = "1" }
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
base64 = "0.22"
|
base64 = "0.23"
|
||||||
|
tracing-subscriber = "0.3.23"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
rand = "0.10.1"
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
use mtp::crypto::PublicKeyBundle;
|
use mtp::crypto::PublicKeyBundle;
|
||||||
|
|
||||||
pub fn load_client_db(
|
pub async fn load_client_db(
|
||||||
path: &str,
|
path: &str,
|
||||||
) -> Result<(Arc<Mutex<HashMap<u64, PublicKeyBundle>>>, Arc<Mutex<u64>>), Box<dyn std::error::Error>>
|
) -> Result<(Arc<Mutex<HashMap<u64, PublicKeyBundle>>>, Arc<Mutex<u64>>), Box<dyn std::error::Error>>
|
||||||
{
|
{
|
||||||
let clients_map = match fs::read_to_string(path) {
|
let clients_map = match fs::read_to_string(path).await {
|
||||||
Ok(data) => match serde_json::from_str(&data) {
|
Ok(data) => match serde_json::from_str(&data) {
|
||||||
Ok(clients) => clients,
|
Ok(clients) => clients,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -19,7 +19,12 @@ pub fn load_client_db(
|
||||||
Err(_) => HashMap::new(),
|
Err(_) => HashMap::new(),
|
||||||
};
|
};
|
||||||
let clients: Arc<Mutex<HashMap<u64, PublicKeyBundle>>> = Arc::new(Mutex::new(clients_map));
|
let clients: Arc<Mutex<HashMap<u64, PublicKeyBundle>>> = Arc::new(Mutex::new(clients_map));
|
||||||
let next_value = clients.lock().unwrap().keys().max().unwrap_or(&999) + 1;
|
let next_value = {
|
||||||
|
let guard = clients
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| std::io::Error::other("client database mutex poisoned"))?;
|
||||||
|
guard.keys().max().copied().unwrap_or(999) + 1
|
||||||
|
};
|
||||||
let next_id = Arc::new(Mutex::new(next_value));
|
let next_id = Arc::new(Mutex::new(next_value));
|
||||||
Ok((clients, next_id))
|
Ok((clients, next_id))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,282 @@
|
||||||
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, TypeMap};
|
use std::collections::HashMap;
|
||||||
use mtp::crypto::{
|
|
||||||
CryptoError, Keyring, SignaturePublicKey, SignatureScheme, verify_ed25519,
|
use mtp::codec::{
|
||||||
|
CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, InMemoryReplayGuard,
|
||||||
|
ProtectedOpenOptions, ProtectionPolicy, ProtectionPurpose, RelayOpenOptions, SignaturePolicy,
|
||||||
|
TypeMap,
|
||||||
|
forward_relay_frame, open_protected_with_checked,
|
||||||
|
open_relay_content_with_limits_without_replay,
|
||||||
|
open_relay_metadata_with_checked,
|
||||||
|
};
|
||||||
|
use mtp::crypto::{Keyring, PublicKeyBundle};
|
||||||
|
|
||||||
|
const DIRECT_DESTINATION_ID: u64 = 1;
|
||||||
|
const METADATA_RELAY_ID: u64 = 1;
|
||||||
|
const FINAL_RECIPIENT_ID: u64 = 7_002;
|
||||||
|
const DIRECT_SIGNATURE_PURPOSE: u8 = 0x40;
|
||||||
|
const DIRECT_ENCRYPTION_PURPOSE: u8 = 0x41;
|
||||||
|
const SIGNATURE_POLICY: ProtectionPolicy = ProtectionPolicy {
|
||||||
|
signature: SignaturePolicy::Ed25519,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct Ed25519Verifier(SignaturePublicKey);
|
fn resolve_signer_key(
|
||||||
|
signer_id: u64,
|
||||||
|
registered_clients: &HashMap<u64, PublicKeyBundle>,
|
||||||
|
) -> Option<PublicKeyBundle> {
|
||||||
|
registered_clients.get(&signer_id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
impl SignatureScheme for Ed25519Verifier {
|
fn pong(tm: &TypeMap, data: impl Into<String>) -> Result<CommunicationValue, String> {
|
||||||
fn sign(&self, _msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
let desc_id = DataTypeId(
|
||||||
Err(CryptoError::SigningFailed)
|
tm.data_id_enum(DataType::Description)
|
||||||
|
.ok_or("missing Description type mapping")?,
|
||||||
|
);
|
||||||
|
let ts_id = DataTypeId(
|
||||||
|
tm.data_id_enum(DataType::Timestamp)
|
||||||
|
.ok_or("missing Timestamp type mapping")?,
|
||||||
|
);
|
||||||
|
let data_id = DataTypeId(
|
||||||
|
tm.data_id_enum(DataType::Data)
|
||||||
|
.ok_or("missing Data type mapping")?,
|
||||||
|
);
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.as_millis();
|
||||||
|
|
||||||
|
CommunicationValue::from_comm(CommunicationType::Pong, tm)
|
||||||
|
.add_data(desc_id, DataValue::Str("MTP example response".into()))
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.add_data(ts_id, DataValue::UnsignedNumber(now))
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.add_data(data_id, DataValue::Str(data.into()))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError> {
|
|
||||||
verify_ed25519(&self.0, msg, signature)
|
fn process_direct_protected(
|
||||||
|
msg: &CommunicationValue,
|
||||||
|
tm: &TypeMap,
|
||||||
|
client_pk: Option<&PublicKeyBundle>,
|
||||||
|
registered_clients: &HashMap<u64, PublicKeyBundle>,
|
||||||
|
host_keyring: &Keyring,
|
||||||
|
accepted_messages: &mut InMemoryReplayGuard,
|
||||||
|
) -> Result<CommunicationValue, String> {
|
||||||
|
if msg.receiver() != Some(DIRECT_DESTINATION_ID) {
|
||||||
|
return Err(format!(
|
||||||
|
"direct protected frame was addressed to {:?}, expected destination {DIRECT_DESTINATION_ID}",
|
||||||
|
msg.receiver()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let opened = open_protected_with_checked(
|
||||||
|
msg,
|
||||||
|
std::slice::from_ref(&host_keyring),
|
||||||
|
None,
|
||||||
|
|signer_id| resolve_signer_key(signer_id, registered_clients).map(|key| vec![key]),
|
||||||
|
ProtectedOpenOptions::new(
|
||||||
|
Some(DIRECT_DESTINATION_ID),
|
||||||
|
ProtectionPurpose::from(DIRECT_SIGNATURE_PURPOSE),
|
||||||
|
ProtectionPurpose::from(DIRECT_ENCRYPTION_PURPOSE),
|
||||||
|
SIGNATURE_POLICY,
|
||||||
|
),
|
||||||
|
accepted_messages,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("direct protected message could not be authenticated: {e}"))?;
|
||||||
|
let signer_id = opened.signer_id;
|
||||||
|
let message_id = opened.message_id;
|
||||||
|
let value = opened
|
||||||
|
.content
|
||||||
|
.as_container()
|
||||||
|
.ok_or("direct protected application value is not a container")?;
|
||||||
|
|
||||||
|
let text_id = DataTypeId(
|
||||||
|
tm.data_id_enum(DataType::ExampleText)
|
||||||
|
.ok_or("missing ExampleText type mapping")?,
|
||||||
|
);
|
||||||
|
let number_id = DataTypeId(
|
||||||
|
tm.data_id_enum(DataType::ExampleNumber)
|
||||||
|
.ok_or("missing ExampleNumber type mapping")?,
|
||||||
|
);
|
||||||
|
let text = value
|
||||||
|
.iter()
|
||||||
|
.find(|(id, _)| *id == text_id)
|
||||||
|
.and_then(|(_, value)| value.as_str())
|
||||||
|
.ok_or("direct protected value is missing ExampleText")?;
|
||||||
|
let number = value
|
||||||
|
.iter()
|
||||||
|
.find(|(id, _)| *id == number_id)
|
||||||
|
.and_then(|(_, value)| value.as_unsigned_number())
|
||||||
|
.ok_or("direct protected value is missing ExampleNumber")?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" Direct protected message: signer={signer_id}, message_id={message_id}, transport_key_available={}, ExampleText={text:?}, ExampleNumber={number}",
|
||||||
|
client_pk.is_some()
|
||||||
|
);
|
||||||
|
if client_pk.is_none() {
|
||||||
|
println!(
|
||||||
|
" Protected signer was verified from the registered key map; transport is unauthenticated"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
pong(
|
||||||
|
tm,
|
||||||
|
format!("direct protected value verified for signer {signer_id}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_sealed_relay(
|
||||||
|
msg: &CommunicationValue,
|
||||||
|
registered_clients: &HashMap<u64, PublicKeyBundle>,
|
||||||
|
host_keyring: &Keyring,
|
||||||
|
accepted_messages: &mut InMemoryReplayGuard,
|
||||||
|
) -> Result<CommunicationValue, String> {
|
||||||
|
if msg.receiver() != Some(METADATA_RELAY_ID) {
|
||||||
|
return Err(format!(
|
||||||
|
"sealed relay next hop was {:?}, expected metadata relay {METADATA_RELAY_ID}",
|
||||||
|
msg.receiver()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = open_relay_metadata_with_checked(
|
||||||
|
msg,
|
||||||
|
std::slice::from_ref(&host_keyring),
|
||||||
|
None,
|
||||||
|
|signer_id| {
|
||||||
|
resolve_signer_key(signer_id, registered_clients).map(|key| vec![key])
|
||||||
|
},
|
||||||
|
RelayOpenOptions::new(SIGNATURE_POLICY),
|
||||||
|
accepted_messages,
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("metadata relay could not authenticate metadata: {e}"))?;
|
||||||
|
println!(
|
||||||
|
" Metadata relay opened message_id={} signer={} final_recipient={} metadata={:?}",
|
||||||
|
metadata.message_id(),
|
||||||
|
metadata.signer_id(),
|
||||||
|
metadata.final_recipient_id(),
|
||||||
|
metadata.metadata()
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" Metadata relay retained opaque encrypted content ({} bytes)",
|
||||||
|
metadata
|
||||||
|
.encrypted_content()
|
||||||
|
.to_bytes()
|
||||||
|
.map_err(|e| format!("opaque content serialization failed: {e}"))?
|
||||||
|
.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let content_result = open_relay_content_with_limits_without_replay(
|
||||||
|
&metadata,
|
||||||
|
&[host_keyring],
|
||||||
|
&[resolve_signer_key(metadata.signer_id(), registered_clients)
|
||||||
|
.ok_or("metadata signer key disappeared")?],
|
||||||
|
Some(FINAL_RECIPIENT_ID),
|
||||||
|
RelayOpenOptions::new(SIGNATURE_POLICY),
|
||||||
|
);
|
||||||
|
if content_result.is_ok() {
|
||||||
|
return Err("metadata relay unexpectedly decrypted final-recipient content".into());
|
||||||
|
}
|
||||||
|
println!(" Metadata relay cannot decrypt final-recipient content (expected)");
|
||||||
|
|
||||||
|
forward_relay_frame(msg, metadata.final_recipient_id())
|
||||||
|
.map_err(|e| format!("metadata relay forwarding failed: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn process_and_respond(
|
pub fn process_and_respond(
|
||||||
msg: &CommunicationValue,
|
msg: &CommunicationValue,
|
||||||
tm: &TypeMap,
|
tm: &TypeMap,
|
||||||
client_pk: Option<&mtp::crypto::PublicKeyBundle>,
|
client_pk: Option<&mtp::crypto::PublicKeyBundle>,
|
||||||
|
registered_clients: &HashMap<u64, PublicKeyBundle>,
|
||||||
host_keyring: &Keyring,
|
host_keyring: &Keyring,
|
||||||
) -> CommunicationValue {
|
accepted_direct_messages: &mut InMemoryReplayGuard,
|
||||||
let desc_id = DataTypeId(tm.data_id_enum(DataType::Description).unwrap());
|
accepted_relay_messages: &mut InMemoryReplayGuard,
|
||||||
let ts_id = DataTypeId(tm.data_id_enum(DataType::Timestamp).unwrap());
|
) -> Result<CommunicationValue, String> {
|
||||||
let data_id = DataTypeId(tm.data_id_enum(DataType::Data).unwrap());
|
if msg.is_type(CommunicationType::ProtectedMessage) {
|
||||||
let flags_id = DataTypeId(tm.data_id_enum(DataType::Flags).unwrap());
|
return process_direct_protected(
|
||||||
let value_id = DataTypeId(tm.data_id_enum(DataType::Value).unwrap());
|
msg,
|
||||||
let bin_id = DataTypeId(tm.data_id_enum(DataType::BinaryData).unwrap());
|
tm,
|
||||||
let items_id = DataTypeId(tm.data_id_enum(DataType::Items).unwrap());
|
client_pk,
|
||||||
let _enc_id = DataTypeId(tm.data_id_enum(DataType::EncryptedPayload).unwrap());
|
registered_clients,
|
||||||
let _sig_id = DataTypeId(tm.data_id_enum(DataType::SignedPayload).unwrap());
|
host_keyring,
|
||||||
let _secure_id = DataTypeId(tm.data_id_enum(DataType::SecurePayload).unwrap());
|
accepted_direct_messages,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if msg.is_type(CommunicationType::Relay) {
|
||||||
|
return process_sealed_relay(
|
||||||
|
msg,
|
||||||
|
registered_clients,
|
||||||
|
host_keyring,
|
||||||
|
accepted_relay_messages,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let description = msg.get_data(DataType::Description);
|
let desc_id = DataTypeId(
|
||||||
let timestamp = msg.get_data(DataType::Timestamp);
|
tm.data_id_enum(DataType::Description)
|
||||||
let data = msg.get_data(DataType::Data);
|
.ok_or("missing Description type mapping")?,
|
||||||
let flags = msg.get_data(DataType::Flags);
|
);
|
||||||
let value = msg.get_data(DataType::Value);
|
let ts_id = DataTypeId(
|
||||||
let binary = msg.get_data(DataType::BinaryData);
|
tm.data_id_enum(DataType::Timestamp)
|
||||||
let items = msg.get_data(DataType::Items);
|
.ok_or("missing Timestamp type mapping")?,
|
||||||
|
);
|
||||||
|
let data_id = DataTypeId(
|
||||||
|
tm.data_id_enum(DataType::Data)
|
||||||
|
.ok_or("missing Data type mapping")?,
|
||||||
|
);
|
||||||
|
let flags_id = DataTypeId(
|
||||||
|
tm.data_id_enum(DataType::Flags)
|
||||||
|
.ok_or("missing Flags type mapping")?,
|
||||||
|
);
|
||||||
|
let value_id = DataTypeId(
|
||||||
|
tm.data_id_enum(DataType::Value)
|
||||||
|
.ok_or("missing Value type mapping")?,
|
||||||
|
);
|
||||||
|
let bin_id = DataTypeId(
|
||||||
|
tm.data_id_enum(DataType::BinaryData)
|
||||||
|
.ok_or("missing BinaryData type mapping")?,
|
||||||
|
);
|
||||||
|
let items_id = DataTypeId(
|
||||||
|
tm.data_id_enum(DataType::Items)
|
||||||
|
.ok_or("missing Items type mapping")?,
|
||||||
|
);
|
||||||
|
let _enc_id = DataTypeId(
|
||||||
|
tm.data_id_enum(DataType::EncryptedPayload)
|
||||||
|
.ok_or("missing EncryptedPayload type mapping")?,
|
||||||
|
);
|
||||||
|
let _sig_id = DataTypeId(
|
||||||
|
tm.data_id_enum(DataType::SignedPayload)
|
||||||
|
.ok_or("missing SignedPayload type mapping")?,
|
||||||
|
);
|
||||||
|
let _secure_id = DataTypeId(
|
||||||
|
tm.data_id_enum(DataType::SecurePayload)
|
||||||
|
.ok_or("missing SecurePayload type mapping")?,
|
||||||
|
);
|
||||||
|
|
||||||
|
let description = msg
|
||||||
|
.get_data(DataType::Description)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(DataValue::Null);
|
||||||
|
let timestamp = msg
|
||||||
|
.get_data(DataType::Timestamp)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(DataValue::Null);
|
||||||
|
let data = msg
|
||||||
|
.get_data(DataType::Data)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(DataValue::Null);
|
||||||
|
let flags = msg
|
||||||
|
.get_data(DataType::Flags)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(DataValue::Null);
|
||||||
|
let value = msg
|
||||||
|
.get_data(DataType::Value)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(DataValue::Null);
|
||||||
|
let binary = msg
|
||||||
|
.get_data(DataType::BinaryData)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(DataValue::Null);
|
||||||
|
let items = msg
|
||||||
|
.get_data(DataType::Items)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(DataValue::Null);
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
" Description: {}",
|
" Description: {}",
|
||||||
|
|
@ -54,10 +293,8 @@ pub fn process_and_respond(
|
||||||
let mut sig_status = String::from("SignedPayload: not present");
|
let mut sig_status = String::from("SignedPayload: not present");
|
||||||
let mut secure_status = String::from("SecurePayload: not present");
|
let mut secure_status = String::from("SecurePayload: not present");
|
||||||
|
|
||||||
let enc = msg.get_data(DataType::EncryptedPayload);
|
if let Some(enc @ DataValue::Encrypted(_)) = msg.get_data(DataType::EncryptedPayload) {
|
||||||
if matches!(enc, DataValue::EncryptedContainer(_)) {
|
if let Ok(dv) = enc.decrypt(host_keyring, mtp::codec::ProtectionPurpose::from(1)) {
|
||||||
let mut dv = enc.clone();
|
|
||||||
if dv.decrypt_into_container(host_keyring, b"demo-aad").is_some() {
|
|
||||||
if let Some(entries) = dv.as_container() {
|
if let Some(entries) = dv.as_container() {
|
||||||
println!(" Decrypted EncryptedPayload: {:?}", entries);
|
println!(" Decrypted EncryptedPayload: {:?}", entries);
|
||||||
enc_status = format!("EncryptedPayload decrypted OK ({} entries)", entries.len());
|
enc_status = format!("EncryptedPayload decrypted OK ({} entries)", entries.len());
|
||||||
|
|
@ -68,13 +305,29 @@ pub fn process_and_respond(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let sig = msg.get_data(DataType::SignedPayload);
|
if let Some(sig @ DataValue::Signed(_)) = msg.get_data(DataType::SignedPayload) {
|
||||||
if matches!(sig, DataValue::SignedContainer(_)) {
|
|
||||||
if let Some(pk_bundle) = client_pk {
|
if let Some(pk_bundle) = client_pk {
|
||||||
let verifier = Ed25519Verifier(pk_bundle.sig_cl_public_key.clone());
|
let signer_id = sig.as_signed().map(|signed| signed.signer_id);
|
||||||
let mut dv = sig.clone();
|
if let Some(signer_id) = signer_id
|
||||||
if dv.verify_into_container(&verifier).is_some() {
|
&& sig
|
||||||
if let Some(entries) = dv.as_container() {
|
.verify_with_policy(
|
||||||
|
signer_id,
|
||||||
|
pk_bundle,
|
||||||
|
mtp::codec::ProtectionPurpose::from(2),
|
||||||
|
SIGNATURE_POLICY,
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
let dv = sig
|
||||||
|
.clone()
|
||||||
|
.into_verified_with_policy(
|
||||||
|
signer_id,
|
||||||
|
pk_bundle,
|
||||||
|
mtp::codec::ProtectionPurpose::from(2),
|
||||||
|
SIGNATURE_POLICY,
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
if let Some(entries) = dv.and_then(|value| value.as_container()) {
|
||||||
println!(" Verified SignedPayload: {:?}", entries);
|
println!(" Verified SignedPayload: {:?}", entries);
|
||||||
sig_status = format!("SignedPayload verified OK ({} entries)", entries.len());
|
sig_status = format!("SignedPayload verified OK ({} entries)", entries.len());
|
||||||
}
|
}
|
||||||
|
|
@ -88,15 +341,29 @@ pub fn process_and_respond(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let secure = msg.get_data(DataType::SecurePayload);
|
if let Some(secure @ DataValue::Encrypted(_)) = msg.get_data(DataType::SecurePayload) {
|
||||||
if matches!(secure, DataValue::SignedEncryptedContainer(_)) {
|
|
||||||
if let Some(pk_bundle) = client_pk {
|
if let Some(pk_bundle) = client_pk {
|
||||||
let verifier = Ed25519Verifier(pk_bundle.sig_cl_public_key.clone());
|
if let Ok(opened) = secure.decrypt(host_keyring, mtp::codec::ProtectionPurpose::from(4))
|
||||||
let mut dv = secure.clone();
|
&& let Some(signed) = opened.as_signed()
|
||||||
if dv.decrypt_signed_encrypted_container(host_keyring, b"demo-aad").is_some()
|
&& opened
|
||||||
&& dv.verify_into_container(&verifier).is_some()
|
.verify_with_policy(
|
||||||
|
signed.signer_id,
|
||||||
|
pk_bundle,
|
||||||
|
mtp::codec::ProtectionPurpose::from(3),
|
||||||
|
SIGNATURE_POLICY,
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
{
|
{
|
||||||
if let Some(entries) = dv.as_container() {
|
let signer_id = signed.signer_id;
|
||||||
|
let dv = opened
|
||||||
|
.into_verified_with_policy(
|
||||||
|
signer_id,
|
||||||
|
pk_bundle,
|
||||||
|
mtp::codec::ProtectionPurpose::from(3),
|
||||||
|
SIGNATURE_POLICY,
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
if let Some(entries) = dv.and_then(|value| value.as_container()) {
|
||||||
println!(" Verified SecurePayload: {:?}", entries);
|
println!(" Verified SecurePayload: {:?}", entries);
|
||||||
secure_status = format!(
|
secure_status = format!(
|
||||||
"SecurePayload decrypted+verified OK ({} entries)",
|
"SecurePayload decrypted+verified OK ({} entries)",
|
||||||
|
|
@ -115,12 +382,14 @@ pub fn process_and_respond(
|
||||||
|
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap()
|
.map_err(|e| e.to_string())?
|
||||||
.as_secs();
|
.as_millis();
|
||||||
|
|
||||||
CommunicationValue::from_comm(CommunicationType::Pong, tm)
|
let response = CommunicationValue::from_comm(CommunicationType::Pong, tm)
|
||||||
.add_data(desc_id, description.clone())
|
.add_data(desc_id, description)
|
||||||
.add_data(ts_id, DataValue::UnsignedNumber(now as u128))
|
.map_err(|e| e.to_string())?
|
||||||
|
.add_data(ts_id, DataValue::UnsignedNumber(now))
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
.add_data(
|
.add_data(
|
||||||
data_id,
|
data_id,
|
||||||
DataValue::Str(format!(
|
DataValue::Str(format!(
|
||||||
|
|
@ -128,8 +397,14 @@ pub fn process_and_respond(
|
||||||
enc_status, sig_status, secure_status
|
enc_status, sig_status, secure_status
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
.add_data(flags_id, flags.clone())
|
.map_err(|e| e.to_string())?
|
||||||
.add_data(value_id, value.clone())
|
.add_data(flags_id, flags)
|
||||||
.add_data(bin_id, binary.clone())
|
.map_err(|e| e.to_string())?
|
||||||
.add_data(items_id, items.clone())
|
.add_data(value_id, value)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.add_data(bin_id, binary)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.add_data(items_id, items)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,35 @@
|
||||||
use std::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
use mtp::crypto::kem::HybridKem;
|
use mtp::crypto::Keyring;
|
||||||
use mtp::crypto::{Ed25519Signer, Keyring, MlDsaSigner};
|
use mtp::files::{load_keyring_raw, save_keyring_raw, save_public_key_bundle};
|
||||||
|
|
||||||
|
/* Host id is fixed for the example; only the keyring itself is persisted. */
|
||||||
|
const HOST_ID: u64 = 1;
|
||||||
|
|
||||||
pub fn load_or_generate_host_keys(
|
pub fn load_or_generate_host_keys(
|
||||||
path: &str,
|
keyring_path: &str,
|
||||||
) -> Result<(u64, Keyring), Box<dyn std::error::Error>> {
|
) -> Result<(u64, Keyring), Box<dyn std::error::Error>> {
|
||||||
if let Ok(data) = fs::read_to_string(path) {
|
if let Ok(keyring) = load_keyring_raw(keyring_path) {
|
||||||
let json: serde_json::Value = serde_json::from_str(&data)?;
|
println!("Loaded host keyring from {keyring_path}");
|
||||||
let hid = json["host_id"].as_u64().unwrap_or(1);
|
return Ok((HOST_ID, keyring));
|
||||||
let keyring = Keyring::from_bytes(&hex::decode(json["keyring"].as_str().unwrap())?)?;
|
|
||||||
println!("Loaded host keys (ID: {})", hid);
|
|
||||||
return Ok((hid, keyring));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let (_ed_signer, sig_sk, sig_pk) = Ed25519Signer::generate();
|
let keyring = Keyring::generate();
|
||||||
let (_pq_signer, sig_pq_sk, sig_pq_pk) = MlDsaSigner::generate();
|
save_keyring_raw(&keyring, keyring_path)?;
|
||||||
let (kem_sk, kem_pk) = HybridKem::generate_keypair();
|
println!("Generated host keyring -> {keyring_path}");
|
||||||
let keyring = Keyring::new(kem_pk, kem_sk, sig_pq_pk, sig_pq_sk, sig_pk, sig_sk);
|
Ok((HOST_ID, keyring))
|
||||||
|
|
||||||
let json = serde_json::json!({
|
|
||||||
"host_id": 1,
|
|
||||||
"keyring": hex::encode(keyring.to_bytes()),
|
|
||||||
});
|
|
||||||
fs::write(path, serde_json::to_string_pretty(&json)?)?;
|
|
||||||
println!("Generated host keys -> {path}");
|
|
||||||
Ok((1u64, keyring))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn export_host_public_keys(host_keyring: &Keyring) -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn export_host_public_keys(
|
||||||
let public_key_bundle_hex = hex::encode(host_keyring.public_key_bundle().as_bytes());
|
host_keyring: &Keyring,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let bundle = host_keyring.public_key_bundle();
|
||||||
|
save_public_key_bundle(&bundle, "host.mpkb")?;
|
||||||
|
|
||||||
fs::write("host_public_key_bundle.hex", &public_key_bundle_hex)?;
|
/* The web client fetches the bundle as hex over HTTP. */
|
||||||
fs::create_dir_all("web-client/public")?;
|
let bundle_hex = hex::encode(bundle.try_as_bytes()?);
|
||||||
fs::write(
|
fs::write("host_public_key_bundle.hex", &bundle_hex).await?;
|
||||||
"web-client/public/host_public_key_bundle.hex",
|
fs::create_dir_all("web-client/public").await?;
|
||||||
&public_key_bundle_hex,
|
fs::write("web-client/public/host_public_key_bundle.hex", &bundle_hex).await?;
|
||||||
)?;
|
|
||||||
fs::write(
|
|
||||||
"host_enc_kem_pk.bin",
|
|
||||||
host_keyring.kem_public_key.as_bytes(),
|
|
||||||
)?;
|
|
||||||
fs::write("host_sig_pk.bin", host_keyring.sig_cl_public_key.as_bytes())?;
|
|
||||||
fs::write(
|
|
||||||
"host_sig_pq_pk.bin",
|
|
||||||
host_keyring.sig_pq_public_key.as_bytes(),
|
|
||||||
)?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
mod clients;
|
mod clients;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod keys;
|
mod keys;
|
||||||
|
mod metrics;
|
||||||
mod tls;
|
mod tls;
|
||||||
|
#[path = "web-server.rs"]
|
||||||
|
mod web_server;
|
||||||
|
|
||||||
use mtp::host::{HostConfig, MTPHost};
|
use mtp::host::{AuthenticationPolicy, AuthState, HostConfig};
|
||||||
use mtp::type_map::TypeMap;
|
use mtp::type_map::TypeMap;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const CONNECTION_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
|
||||||
|
const MAX_MESSAGES_PER_CONNECTION: u64 = 10_000;
|
||||||
|
|
||||||
fn dev_cert_paths() -> (String, String) {
|
fn dev_cert_paths() -> (String, String) {
|
||||||
let cert = std::env::var("MTP_DEV_CERT").unwrap_or_else(|_| {
|
let cert = std::env::var("MTP_DEV_CERT").unwrap_or_else(|_| {
|
||||||
|
|
@ -27,64 +34,99 @@ fn dev_cert_paths() -> (String, String) {
|
||||||
(cert, key)
|
(cert, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
async fn handle_pipe_loopback(
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
conn: &mtp::webserver::WebMTPConnection,
|
||||||
let (cert_path, key_path) = dev_cert_paths();
|
request: mtp::host::PipeRequest<
|
||||||
let (cert_pem, key_pem) = tls::load_or_generate_tls(&cert_path, &key_path)?;
|
mtp::webserver::WebMtpSender,
|
||||||
let cert_hash = tls::certificate_sha256_hex(&cert_pem)?;
|
mtp::webserver::WebMtpReceiver,
|
||||||
tls::export_webtransport_cert_hash(&cert_hash)?;
|
mtp::webserver::H3TransportReceiver,
|
||||||
println!("WebTransport certificate sha256: {cert_hash}");
|
>,
|
||||||
|
) -> Result<u64, Box<dyn std::error::Error>> {
|
||||||
|
let pipe_id = request.id();
|
||||||
|
println!(" [loopback] Accepting pipe {pipe_id} ...");
|
||||||
|
let mut reader = request.accept().await?;
|
||||||
|
|
||||||
let (_host_id, host_keyring) = keys::load_or_generate_host_keys("host_keys.json")?;
|
let return_pipe = conn.create_pipe("loopback").await?;
|
||||||
keys::export_host_public_keys(&host_keyring)?;
|
println!(
|
||||||
|
" [loopback] Requested return pipe {}; waiting for client acceptance ...",
|
||||||
// The keyring is moved into the host config; keep a copy for decrypting the
|
return_pipe.pipe_id()
|
||||||
// demo payloads clients encrypt to our KEM public key.
|
);
|
||||||
let decrypt_keyring = mtp::crypto::Keyring::from_bytes(&host_keyring.to_bytes())
|
let Some(mut writer) = return_pipe.wait().await? else {
|
||||||
.expect("re-load host keyring for decryption");
|
return Err("client denied the return pipe".into());
|
||||||
|
|
||||||
let (clients, next_id) = clients::load_client_db("clients.json")?;
|
|
||||||
|
|
||||||
let clients_for_get = clients.clone();
|
|
||||||
let get_existing_user = move |id: u64| {
|
|
||||||
let clients = clients_for_get.clone();
|
|
||||||
Box::pin(async move {
|
|
||||||
let result = clients.lock().unwrap().get(&id).cloned();
|
|
||||||
if result.is_some() {
|
|
||||||
println!("Auth lookup: client ID {id} found");
|
|
||||||
} else {
|
|
||||||
eprintln!("Auth lookup: unknown client ID {id}");
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}) as Pin<Box<dyn Future<Output = Option<mtp::crypto::PublicKeyBundle>> + Send>>
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let copied = tokio::io::copy(&mut reader, &mut writer).await?;
|
||||||
|
writer.finish_async().await?;
|
||||||
|
println!(" [loopback] Pipe {pipe_id} complete ({copied} bytes)");
|
||||||
|
Ok(copied)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
let (cert_path, key_path) = dev_cert_paths();
|
||||||
|
let (cert_pem, key_pem) = tls::load_or_generate_tls(&cert_path, &key_path).await?;
|
||||||
|
let cert_hash = tls::certificate_sha256_hex(&cert_pem).await?;
|
||||||
|
tls::export_webtransport_cert_hash(&cert_hash).await?;
|
||||||
|
println!("WebTransport certificate sha256: {cert_hash}");
|
||||||
|
|
||||||
|
let (_host_id, host_keyring) = keys::load_or_generate_host_keys("host.mk")?;
|
||||||
|
keys::export_host_public_keys(&host_keyring).await?;
|
||||||
|
|
||||||
|
let (clients, next_id) = clients::load_client_db("clients.json").await?;
|
||||||
|
let clients_for_get = clients.clone();
|
||||||
|
let get_existing_client = move |id: u64, _description: Option<String>| {
|
||||||
|
let clients = clients_for_get.clone();
|
||||||
|
Box::pin(async move { clients.lock().ok()?.get(&id).cloned() })
|
||||||
|
as Pin<Box<dyn Future<Output = Option<mtp::crypto::PublicKeyBundle>> + Send>>
|
||||||
|
};
|
||||||
let clients_for_register = clients.clone();
|
let clients_for_register = clients.clone();
|
||||||
let next_id_for_register = next_id.clone();
|
let next_id_for_register = next_id.clone();
|
||||||
let clients_path = "clients.json".to_string();
|
let complete_register = move |bundle: mtp::crypto::PublicKeyBundle,
|
||||||
let complete_register = move |bundle: mtp::crypto::PublicKeyBundle| {
|
_description: Option<String>| {
|
||||||
let db_arc = clients_for_register.clone();
|
let clients = clients_for_register.clone();
|
||||||
let nid_arc = next_id_for_register.clone();
|
let next_id = next_id_for_register.clone();
|
||||||
let path = clients_path.clone();
|
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let mut db = db_arc.lock().unwrap();
|
let id = {
|
||||||
let mut nid = nid_arc.lock().unwrap();
|
let mut next = next_id.lock().expect("client id mutex poisoned");
|
||||||
let id = *nid;
|
let id = *next;
|
||||||
*nid += 1;
|
*next += 1;
|
||||||
|
id
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = {
|
||||||
|
let mut db = clients.lock().expect("client database mutex poisoned");
|
||||||
db.insert(id, bundle);
|
db.insert(id, bundle);
|
||||||
match serde_json::to_string_pretty(&*db) {
|
serde_json::to_string_pretty(&*db).ok()
|
||||||
Ok(json) => match std::fs::write(&path, json) {
|
};
|
||||||
Ok(()) => {}
|
|
||||||
Err(e) => eprintln!("Failed to persist client database to {path}: {e}"),
|
if let Some(json) = json
|
||||||
},
|
&& let Err(error) = tokio::fs::write("clients.json", json).await
|
||||||
Err(e) => eprintln!("Failed to serialize client database after registering {id}: {e}"),
|
{
|
||||||
|
eprintln!("Failed to persist clients.json: {error}");
|
||||||
}
|
}
|
||||||
println!("Registered new client with ID: {}", id);
|
|
||||||
|
println!("Registered new client with ID: {id}");
|
||||||
id
|
id
|
||||||
}) as Pin<Box<dyn Future<Output = u64> + Send>>
|
}) as Pin<Box<dyn Future<Output = u64> + Send>>
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("Starting MTP server on port 8080 ...");
|
let decrypt_keyring_bytes = host_keyring.try_to_bytes()?;
|
||||||
|
let decrypt_keyring = Arc::new(
|
||||||
|
match mtp::crypto::Keyring::from_bytes(&decrypt_keyring_bytes) {
|
||||||
|
Ok(keyring) => keyring,
|
||||||
|
Err(e) => {
|
||||||
|
return Err(format!("failed to re-load host keyring for decryption: {e}").into());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let metrics = std::sync::Arc::new(metrics::ServerMetrics::load(
|
||||||
|
"metrics/server_sessions.json",
|
||||||
|
));
|
||||||
|
|
||||||
|
println!("Starting integrated MTP web server on port 8080 ...");
|
||||||
|
|
||||||
let config = HostConfig::new(
|
let config = HostConfig::new(
|
||||||
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
|
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
|
||||||
|
|
@ -92,43 +134,169 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
cert_pem,
|
cert_pem,
|
||||||
key_pem,
|
key_pem,
|
||||||
)
|
)
|
||||||
.with_authentication(host_keyring, get_existing_user, complete_register);
|
.with_authentication(
|
||||||
|
host_keyring,
|
||||||
|
Box::new(get_existing_client),
|
||||||
|
Box::new(complete_register),
|
||||||
|
)
|
||||||
|
.with_authentication_policy(AuthenticationPolicy::AllowAuthentication);
|
||||||
|
|
||||||
let mut host = MTPHost::new(config).await?;
|
let mut host = mtp::webserver::MTPWebServer::new(config, web_server::config()?).await?;
|
||||||
println!("Server listening on {}", host.local_addr());
|
println!("Server listening on https://{}", host.local_addr());
|
||||||
|
println!("TCP: HTTP/1.1 and HTTP/2");
|
||||||
|
println!("UDP: HTTP/3 and WebTransport");
|
||||||
|
|
||||||
while let Some(conn) = host.accept().await? {
|
loop {
|
||||||
let desc = conn
|
let conn = match host.accept().await {
|
||||||
.description
|
Ok(Some(conn)) => conn,
|
||||||
.as_deref()
|
Ok(None) => break,
|
||||||
.unwrap_or("(no description)");
|
Err(e) => {
|
||||||
|
let msg = e.to_string();
|
||||||
|
eprintln!("Accept error: {msg}");
|
||||||
|
metrics.record_accept_error();
|
||||||
|
metrics.save("metrics/server_sessions.json");
|
||||||
|
metrics.build_overview("metrics/server_overview.json");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let decrypt_keyring = Arc::clone(&decrypt_keyring);
|
||||||
|
let metrics = Arc::clone(&metrics);
|
||||||
|
let registered_clients = Arc::clone(&clients);
|
||||||
|
metrics.record_connection_version(&conn.version.to_string());
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let desc = conn.description.as_deref().unwrap_or("(no description)");
|
||||||
|
let connection_state = match &conn.auth_state {
|
||||||
|
AuthState::Authenticated => "authenticated client",
|
||||||
|
AuthState::Unauthenticated => "unauthenticated client",
|
||||||
|
AuthState::Pending => "pending client",
|
||||||
|
AuthState::Failed => "failed client",
|
||||||
|
};
|
||||||
println!(
|
println!(
|
||||||
"\n--- New connection (version {}, description: {desc}) ---",
|
"\n--- New connection (version {}, remote: {}, description: {desc}) ---",
|
||||||
conn.version
|
conn.version,
|
||||||
|
conn.remote_addr
|
||||||
|
.map(|addr| addr.to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".into())
|
||||||
);
|
);
|
||||||
println!("Client ID: {}", conn.client_id);
|
println!("Connection state: {connection_state}; MTP ID: {}", conn.client_id);
|
||||||
|
|
||||||
|
let mut session = metrics.start_session(conn.client_id, desc.to_string());
|
||||||
|
|
||||||
let tm: &TypeMap = conn.codec.registry().get(&conn.version).unwrap();
|
let tm: &TypeMap = conn.codec.registry().get(&conn.version).unwrap();
|
||||||
|
|
||||||
match conn.receiver.receive().await {
|
println!("Waiting for messages / pipe requests ...");
|
||||||
Ok(msg) => {
|
let mut pipe_open = true;
|
||||||
println!("Received: {msg}");
|
let mut message_open = true;
|
||||||
let response = handlers::process_and_respond(
|
let mut accepted_direct_messages = mtp::codec::InMemoryReplayGuard::default();
|
||||||
&msg,
|
let mut accepted_relay_messages = mtp::codec::InMemoryReplayGuard::default();
|
||||||
|
let mut exit_reason = "normal".to_string();
|
||||||
|
|
||||||
|
while pipe_open || message_open {
|
||||||
|
let activity = tokio::time::timeout(CONNECTION_IDLE_TIMEOUT, async {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
pipe_request = conn.receive_pipe(), if pipe_open => {
|
||||||
|
match pipe_request {
|
||||||
|
Ok(request) => {
|
||||||
|
match handle_pipe_loopback(&conn, request).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
session.record_pipe(bytes);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let msg = error.to_string();
|
||||||
|
if msg.contains("denied") {
|
||||||
|
session.record_pipe_denial();
|
||||||
|
}
|
||||||
|
eprintln!(" [loopback] Pipe error: {msg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(mtp::common::CommunicationError::StreamClosed)
|
||||||
|
| Err(mtp::common::CommunicationError::ClosedByPeer) => {
|
||||||
|
println!("Pipe channel closed normally");
|
||||||
|
pipe_open = false;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
println!("Pipe channel closed: {error}");
|
||||||
|
pipe_open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message = conn.receive(), if message_open => {
|
||||||
|
match message {
|
||||||
|
Ok(message) => {
|
||||||
|
println!("Received: {message}");
|
||||||
|
let msg_start = std::time::Instant::now();
|
||||||
|
let registered_clients = registered_clients
|
||||||
|
.lock()
|
||||||
|
.map(|clients| clients.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let result = handlers::process_and_respond(
|
||||||
|
&message,
|
||||||
tm,
|
tm,
|
||||||
conn.client_public_key.as_ref(),
|
conn.client_public_key.as_ref(),
|
||||||
|
®istered_clients,
|
||||||
&decrypt_keyring,
|
&decrypt_keyring,
|
||||||
|
&mut accepted_direct_messages,
|
||||||
|
&mut accepted_relay_messages,
|
||||||
);
|
);
|
||||||
|
let latency = msg_start.elapsed();
|
||||||
|
let ok = result.is_ok();
|
||||||
|
session.record_message(latency, ok);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(response) => {
|
||||||
println!("Sending: {response}");
|
println!("Sending: {response}");
|
||||||
conn.sender.send(&response).await?;
|
if let Err(error) = conn.sender.send(&response).await {
|
||||||
|
eprintln!("Send error: {error}");
|
||||||
|
session.record_send_error();
|
||||||
|
pipe_open = false;
|
||||||
|
message_open = false;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
}
|
||||||
eprintln!("Receive error: {e}");
|
Err(error) => {
|
||||||
|
eprintln!("Failed to build response: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(mtp::common::CommunicationError::StreamClosed)
|
||||||
|
| Err(mtp::common::CommunicationError::ClosedByPeer) => {
|
||||||
|
println!("Message channel closed normally");
|
||||||
|
message_open = false;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
println!("Message channel closed: {error}");
|
||||||
|
message_open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if activity.is_err() {
|
||||||
|
exit_reason = "idle timeout".to_string();
|
||||||
|
println!("Connection idle timeout reached");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if session.messages_received() >= MAX_MESSAGES_PER_CONNECTION {
|
||||||
|
exit_reason = "message limit".to_string();
|
||||||
|
println!("Connection message limit reached");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.sender.close();
|
let record = session.finish(exit_reason);
|
||||||
println!("Connection closed\n");
|
println!(
|
||||||
|
"Connection closed (messages: {}, pipes: {}, duration: {:.1}s)\n",
|
||||||
|
record.messages_received,
|
||||||
|
record.pipes_handled,
|
||||||
|
record.duration_secs
|
||||||
|
);
|
||||||
|
|
||||||
|
metrics.save("metrics/server_sessions.json");
|
||||||
|
metrics.build_overview("metrics/server_overview.json");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
885
example/server/src/metrics.rs
Normal file
885
example/server/src/metrics.rs
Normal file
|
|
@ -0,0 +1,885 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn now_epoch_secs() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_session_id() -> String {
|
||||||
|
let ts = now_epoch_secs();
|
||||||
|
let rand_part: u32 = rand::random();
|
||||||
|
format!("{ts}-{rand_part:08x}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Persisted data types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct SessionRecord {
|
||||||
|
pub session_id: String,
|
||||||
|
pub client_id: u64,
|
||||||
|
pub description: String,
|
||||||
|
pub start_time: u64,
|
||||||
|
pub end_time: u64,
|
||||||
|
pub duration_secs: f64,
|
||||||
|
pub messages_received: u64,
|
||||||
|
pub messages_ok: u64,
|
||||||
|
pub messages_failed: u64,
|
||||||
|
pub pipes_handled: u64,
|
||||||
|
pub pipe_bytes_copied: u64,
|
||||||
|
pub pipe_denials: u64,
|
||||||
|
pub send_errors: u64,
|
||||||
|
pub avg_message_latency_ms: f64,
|
||||||
|
pub max_message_latency_ms: f64,
|
||||||
|
pub exit_reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct AggregateStats {
|
||||||
|
pub total_connections: u64,
|
||||||
|
pub total_messages: u64,
|
||||||
|
pub total_messages_ok: u64,
|
||||||
|
pub total_messages_failed: u64,
|
||||||
|
pub total_pipes: u64,
|
||||||
|
pub total_pipe_bytes: u64,
|
||||||
|
pub total_pipe_denials: u64,
|
||||||
|
pub total_send_errors: u64,
|
||||||
|
pub total_accept_errors: u64,
|
||||||
|
pub avg_session_duration_secs: f64,
|
||||||
|
pub avg_messages_per_session: f64,
|
||||||
|
pub avg_pipes_per_session: f64,
|
||||||
|
pub avg_message_latency_ms: f64,
|
||||||
|
pub max_message_latency_ms: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Overview {
|
||||||
|
pub total_sessions: u64,
|
||||||
|
pub first_session_timestamp: u64,
|
||||||
|
pub last_session_timestamp: u64,
|
||||||
|
pub aggregate: AggregateStats,
|
||||||
|
pub connection_versions: HashMap<String, u64>,
|
||||||
|
pub sessions: Vec<SessionRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ServerMetricsFile {
|
||||||
|
pub total_connections: u64,
|
||||||
|
pub total_messages: u64,
|
||||||
|
pub total_messages_ok: u64,
|
||||||
|
pub total_messages_failed: u64,
|
||||||
|
pub total_pipes: u64,
|
||||||
|
pub total_pipe_bytes: u64,
|
||||||
|
pub total_pipe_denials: u64,
|
||||||
|
pub total_send_errors: u64,
|
||||||
|
pub total_accept_errors: u64,
|
||||||
|
pub connection_versions: HashMap<String, u64>,
|
||||||
|
pub sessions: Vec<SessionRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Live metrics state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Inner {
|
||||||
|
total_connections: u64,
|
||||||
|
total_messages: u64,
|
||||||
|
total_messages_ok: u64,
|
||||||
|
total_messages_failed: u64,
|
||||||
|
total_pipes: u64,
|
||||||
|
total_pipe_bytes: u64,
|
||||||
|
total_pipe_denials: u64,
|
||||||
|
total_send_errors: u64,
|
||||||
|
total_accept_errors: u64,
|
||||||
|
connection_versions: HashMap<String, u64>,
|
||||||
|
active_connections: u64,
|
||||||
|
completed_sessions: Vec<SessionRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ServerMetrics {
|
||||||
|
inner: Mutex<Inner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerMetrics {
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Mutex::new(Inner {
|
||||||
|
total_connections: 0,
|
||||||
|
total_messages: 0,
|
||||||
|
total_messages_ok: 0,
|
||||||
|
total_messages_failed: 0,
|
||||||
|
total_pipes: 0,
|
||||||
|
total_pipe_bytes: 0,
|
||||||
|
total_pipe_denials: 0,
|
||||||
|
total_send_errors: 0,
|
||||||
|
total_accept_errors: 0,
|
||||||
|
connection_versions: HashMap::new(),
|
||||||
|
active_connections: 0,
|
||||||
|
completed_sessions: Vec::new(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(path: &str) -> Self {
|
||||||
|
let file = std::fs::read_to_string(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str::<ServerMetricsFile>(&s).ok());
|
||||||
|
|
||||||
|
let mut inner = Inner {
|
||||||
|
total_connections: 0,
|
||||||
|
total_messages: 0,
|
||||||
|
total_messages_ok: 0,
|
||||||
|
total_messages_failed: 0,
|
||||||
|
total_pipes: 0,
|
||||||
|
total_pipe_bytes: 0,
|
||||||
|
total_pipe_denials: 0,
|
||||||
|
total_send_errors: 0,
|
||||||
|
total_accept_errors: 0,
|
||||||
|
connection_versions: HashMap::new(),
|
||||||
|
active_connections: 0,
|
||||||
|
completed_sessions: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(data) = file {
|
||||||
|
inner.total_connections = data.total_connections;
|
||||||
|
inner.total_messages = data.total_messages;
|
||||||
|
inner.total_messages_ok = data.total_messages_ok;
|
||||||
|
inner.total_messages_failed = data.total_messages_failed;
|
||||||
|
inner.total_pipes = data.total_pipes;
|
||||||
|
inner.total_pipe_bytes = data.total_pipe_bytes;
|
||||||
|
inner.total_pipe_denials = data.total_pipe_denials;
|
||||||
|
inner.total_send_errors = data.total_send_errors;
|
||||||
|
inner.total_accept_errors = data.total_accept_errors;
|
||||||
|
inner.connection_versions = data.connection_versions;
|
||||||
|
inner.completed_sessions = data.sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
inner: Mutex::new(inner),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, path: &str) {
|
||||||
|
let inner = self.inner.lock().unwrap();
|
||||||
|
let data = self.to_file(&inner);
|
||||||
|
if let Some(parent) = Path::new(path).parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(&data).unwrap_or_default();
|
||||||
|
let _ = std::fs::write(path, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_file(&self, inner: &Inner) -> ServerMetricsFile {
|
||||||
|
ServerMetricsFile {
|
||||||
|
total_connections: inner.total_connections,
|
||||||
|
total_messages: inner.total_messages,
|
||||||
|
total_messages_ok: inner.total_messages_ok,
|
||||||
|
total_messages_failed: inner.total_messages_failed,
|
||||||
|
total_pipes: inner.total_pipes,
|
||||||
|
total_pipe_bytes: inner.total_pipe_bytes,
|
||||||
|
total_pipe_denials: inner.total_pipe_denials,
|
||||||
|
total_send_errors: inner.total_send_errors,
|
||||||
|
total_accept_errors: inner.total_accept_errors,
|
||||||
|
connection_versions: inner.connection_versions.clone(),
|
||||||
|
sessions: inner.completed_sessions.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_session(&self, client_id: u64, description: String) -> SessionHandle<'_> {
|
||||||
|
let session_id = generate_session_id();
|
||||||
|
let start = Instant::now();
|
||||||
|
let start_time = now_epoch_secs();
|
||||||
|
|
||||||
|
self.inner.lock().unwrap().total_connections += 1;
|
||||||
|
self.inner.lock().unwrap().active_connections += 1;
|
||||||
|
|
||||||
|
SessionHandle {
|
||||||
|
metrics: self,
|
||||||
|
session_id,
|
||||||
|
client_id,
|
||||||
|
description,
|
||||||
|
start,
|
||||||
|
start_time,
|
||||||
|
messages_received: 0,
|
||||||
|
messages_ok: 0,
|
||||||
|
messages_failed: 0,
|
||||||
|
pipes_handled: 0,
|
||||||
|
pipe_bytes: 0,
|
||||||
|
pipe_denials: 0,
|
||||||
|
send_errors: 0,
|
||||||
|
latencies: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn snapshot(&self) -> ServerMetricsFile {
|
||||||
|
let inner = self.inner.lock().unwrap();
|
||||||
|
self.to_file(&inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_accept_error(&self) {
|
||||||
|
self.inner.lock().unwrap().total_accept_errors += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_connection_version(&self, version: &str) {
|
||||||
|
*self
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.connection_versions
|
||||||
|
.entry(version.to_string())
|
||||||
|
.or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_overview(&self, overview_path: &str) {
|
||||||
|
let inner = self.inner.lock().unwrap();
|
||||||
|
let sessions = &inner.completed_sessions;
|
||||||
|
let total = sessions.len() as u64;
|
||||||
|
|
||||||
|
if total == 0 {
|
||||||
|
let overview = Overview {
|
||||||
|
total_sessions: 0,
|
||||||
|
first_session_timestamp: 0,
|
||||||
|
last_session_timestamp: 0,
|
||||||
|
aggregate: AggregateStats::default(),
|
||||||
|
connection_versions: HashMap::new(),
|
||||||
|
sessions: Vec::new(),
|
||||||
|
};
|
||||||
|
if let Some(parent) = Path::new(overview_path).parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(&overview).unwrap_or_default();
|
||||||
|
let _ = std::fs::write(overview_path, json);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let first_ts = sessions.first().map(|s| s.start_time).unwrap_or(0);
|
||||||
|
let last_ts = sessions.last().map(|s| s.end_time).unwrap_or(0);
|
||||||
|
|
||||||
|
let total_duration: f64 = sessions.iter().map(|s| s.duration_secs).sum();
|
||||||
|
let total_msgs: u64 = sessions.iter().map(|s| s.messages_received).sum();
|
||||||
|
let total_pipes: u64 = sessions.iter().map(|s| s.pipes_handled).sum();
|
||||||
|
|
||||||
|
let mut max_latency: f64 = 0.0;
|
||||||
|
let mut latency_sum: f64 = 0.0;
|
||||||
|
let mut latency_count: u64 = 0;
|
||||||
|
for s in sessions {
|
||||||
|
if s.avg_message_latency_ms > 0.0 {
|
||||||
|
latency_sum += s.avg_message_latency_ms * s.messages_ok as f64;
|
||||||
|
latency_count += s.messages_ok;
|
||||||
|
}
|
||||||
|
if s.max_message_latency_ms > max_latency {
|
||||||
|
max_latency = s.max_message_latency_ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let aggregate = AggregateStats {
|
||||||
|
total_connections: inner.total_connections,
|
||||||
|
total_messages: inner.total_messages,
|
||||||
|
total_messages_ok: inner.total_messages_ok,
|
||||||
|
total_messages_failed: inner.total_messages_failed,
|
||||||
|
total_pipes: inner.total_pipes,
|
||||||
|
total_pipe_bytes: inner.total_pipe_bytes,
|
||||||
|
total_pipe_denials: inner.total_pipe_denials,
|
||||||
|
total_send_errors: inner.total_send_errors,
|
||||||
|
total_accept_errors: inner.total_accept_errors,
|
||||||
|
avg_session_duration_secs: total_duration / total as f64,
|
||||||
|
avg_messages_per_session: total_msgs as f64 / total as f64,
|
||||||
|
avg_pipes_per_session: total_pipes as f64 / total as f64,
|
||||||
|
avg_message_latency_ms: if latency_count > 0 {
|
||||||
|
latency_sum / latency_count as f64
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
},
|
||||||
|
max_message_latency_ms: max_latency,
|
||||||
|
};
|
||||||
|
|
||||||
|
let overview = Overview {
|
||||||
|
total_sessions: total,
|
||||||
|
first_session_timestamp: first_ts,
|
||||||
|
last_session_timestamp: last_ts,
|
||||||
|
aggregate,
|
||||||
|
connection_versions: inner.connection_versions.clone(),
|
||||||
|
sessions: sessions.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(parent) = Path::new(overview_path).parent() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(&overview).unwrap_or_default();
|
||||||
|
let _ = std::fs::write(overview_path, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_session(&self, record: SessionRecord) {
|
||||||
|
let mut inner = self.inner.lock().unwrap();
|
||||||
|
inner.active_connections -= 1;
|
||||||
|
inner.total_messages += record.messages_received;
|
||||||
|
inner.total_messages_ok += record.messages_ok;
|
||||||
|
inner.total_messages_failed += record.messages_failed;
|
||||||
|
inner.total_pipes += record.pipes_handled;
|
||||||
|
inner.total_pipe_bytes += record.pipe_bytes_copied;
|
||||||
|
inner.total_pipe_denials += record.pipe_denials;
|
||||||
|
inner.total_send_errors += record.send_errors;
|
||||||
|
inner.completed_sessions.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Session handle, local accumulators, no mutex contention during connection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct SessionHandle<'a> {
|
||||||
|
metrics: &'a ServerMetrics,
|
||||||
|
session_id: String,
|
||||||
|
client_id: u64,
|
||||||
|
description: String,
|
||||||
|
start: Instant,
|
||||||
|
start_time: u64,
|
||||||
|
messages_received: u64,
|
||||||
|
messages_ok: u64,
|
||||||
|
messages_failed: u64,
|
||||||
|
pipes_handled: u64,
|
||||||
|
pipe_bytes: u64,
|
||||||
|
pipe_denials: u64,
|
||||||
|
send_errors: u64,
|
||||||
|
latencies: Vec<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SessionHandle<'a> {
|
||||||
|
pub fn messages_received(&self) -> u64 {
|
||||||
|
self.messages_received
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_message(&mut self, latency: Duration, ok: bool) {
|
||||||
|
self.messages_received += 1;
|
||||||
|
if ok {
|
||||||
|
self.messages_ok += 1;
|
||||||
|
} else {
|
||||||
|
self.messages_failed += 1;
|
||||||
|
}
|
||||||
|
self.latencies.push(latency.as_secs_f64() * 1000.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_pipe(&mut self, bytes: u64) {
|
||||||
|
self.pipes_handled += 1;
|
||||||
|
self.pipe_bytes += bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_pipe_denial(&mut self) {
|
||||||
|
self.pipe_denials += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_send_error(&mut self) {
|
||||||
|
self.send_errors += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finish(self, exit_reason: String) -> SessionRecord {
|
||||||
|
let elapsed = self.start.elapsed();
|
||||||
|
let end_time = self.start_time + elapsed.as_secs();
|
||||||
|
|
||||||
|
let avg_latency = if self.latencies.is_empty() {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
self.latencies.iter().sum::<f64>() / self.latencies.len() as f64
|
||||||
|
};
|
||||||
|
let max_latency = self.latencies.iter().copied().fold(0.0_f64, f64::max);
|
||||||
|
|
||||||
|
let record = SessionRecord {
|
||||||
|
session_id: self.session_id,
|
||||||
|
client_id: self.client_id,
|
||||||
|
description: self.description,
|
||||||
|
start_time: self.start_time,
|
||||||
|
end_time,
|
||||||
|
duration_secs: elapsed.as_secs_f64(),
|
||||||
|
messages_received: self.messages_received,
|
||||||
|
messages_ok: self.messages_ok,
|
||||||
|
messages_failed: self.messages_failed,
|
||||||
|
pipes_handled: self.pipes_handled,
|
||||||
|
pipe_bytes_copied: self.pipe_bytes,
|
||||||
|
pipe_denials: self.pipe_denials,
|
||||||
|
send_errors: self.send_errors,
|
||||||
|
avg_message_latency_ms: avg_latency,
|
||||||
|
max_message_latency_ms: max_latency,
|
||||||
|
exit_reason,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.metrics.finish_session(record.clone());
|
||||||
|
record
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
fn tmp_path(name: &str) -> String {
|
||||||
|
let dir = std::env::temp_dir().join("mtp_server_metrics_test");
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
dir.join(name).to_str().unwrap().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_record_roundtrip() {
|
||||||
|
let record = SessionRecord {
|
||||||
|
session_id: "test-123".into(),
|
||||||
|
client_id: 1000,
|
||||||
|
description: "test session".into(),
|
||||||
|
start_time: 1000,
|
||||||
|
end_time: 1010,
|
||||||
|
duration_secs: 10.0,
|
||||||
|
messages_received: 5,
|
||||||
|
messages_ok: 4,
|
||||||
|
messages_failed: 1,
|
||||||
|
pipes_handled: 2,
|
||||||
|
pipe_bytes_copied: 4096,
|
||||||
|
pipe_denials: 0,
|
||||||
|
send_errors: 0,
|
||||||
|
avg_message_latency_ms: 1.5,
|
||||||
|
max_message_latency_ms: 3.0,
|
||||||
|
exit_reason: "normal".into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&record).unwrap();
|
||||||
|
let decoded: SessionRecord = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(record, decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_metrics_file_roundtrip() {
|
||||||
|
let file = ServerMetricsFile {
|
||||||
|
total_connections: 10,
|
||||||
|
total_messages: 50,
|
||||||
|
total_messages_ok: 48,
|
||||||
|
total_messages_failed: 2,
|
||||||
|
total_pipes: 5,
|
||||||
|
total_pipe_bytes: 20480,
|
||||||
|
total_pipe_denials: 1,
|
||||||
|
total_send_errors: 0,
|
||||||
|
total_accept_errors: 3,
|
||||||
|
connection_versions: HashMap::from([("2.0".into(), 8), ("1.0".into(), 2)]),
|
||||||
|
sessions: vec![
|
||||||
|
SessionRecord {
|
||||||
|
session_id: "s1".into(),
|
||||||
|
client_id: 1000,
|
||||||
|
description: "first".into(),
|
||||||
|
start_time: 100,
|
||||||
|
end_time: 110,
|
||||||
|
duration_secs: 10.0,
|
||||||
|
messages_received: 3,
|
||||||
|
messages_ok: 3,
|
||||||
|
messages_failed: 0,
|
||||||
|
pipes_handled: 1,
|
||||||
|
pipe_bytes_copied: 1024,
|
||||||
|
pipe_denials: 0,
|
||||||
|
send_errors: 0,
|
||||||
|
avg_message_latency_ms: 0.5,
|
||||||
|
max_message_latency_ms: 1.0,
|
||||||
|
exit_reason: "normal".into(),
|
||||||
|
},
|
||||||
|
SessionRecord {
|
||||||
|
session_id: "s2".into(),
|
||||||
|
client_id: 1001,
|
||||||
|
description: "second".into(),
|
||||||
|
start_time: 200,
|
||||||
|
end_time: 230,
|
||||||
|
duration_secs: 30.0,
|
||||||
|
messages_received: 7,
|
||||||
|
messages_ok: 6,
|
||||||
|
messages_failed: 1,
|
||||||
|
pipes_handled: 4,
|
||||||
|
pipe_bytes_copied: 19456,
|
||||||
|
pipe_denials: 1,
|
||||||
|
send_errors: 0,
|
||||||
|
avg_message_latency_ms: 2.0,
|
||||||
|
max_message_latency_ms: 5.0,
|
||||||
|
exit_reason: "idle timeout".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&file).unwrap();
|
||||||
|
let decoded: ServerMetricsFile = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(file.total_connections, decoded.total_connections);
|
||||||
|
assert_eq!(file.sessions.len(), decoded.sessions.len());
|
||||||
|
assert_eq!(file.sessions[0], decoded.sessions[0]);
|
||||||
|
assert_eq!(file.sessions[1], decoded.sessions[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_handle_lifecycle() {
|
||||||
|
let metrics = ServerMetrics::new();
|
||||||
|
let mut session = metrics.start_session(1000, "test".into());
|
||||||
|
|
||||||
|
session.record_message(Duration::from_millis(1), true);
|
||||||
|
session.record_message(Duration::from_millis(3), true);
|
||||||
|
session.record_message(Duration::from_millis(2), false);
|
||||||
|
session.record_pipe(512);
|
||||||
|
|
||||||
|
let record = session.finish("test exit".into());
|
||||||
|
|
||||||
|
assert_eq!(record.client_id, 1000);
|
||||||
|
assert_eq!(record.messages_received, 3);
|
||||||
|
assert_eq!(record.messages_ok, 2);
|
||||||
|
assert_eq!(record.messages_failed, 1);
|
||||||
|
assert_eq!(record.pipes_handled, 1);
|
||||||
|
assert_eq!(record.pipe_bytes_copied, 512);
|
||||||
|
assert!(record.avg_message_latency_ms > 0.0);
|
||||||
|
assert_eq!(record.max_message_latency_ms, 3.0);
|
||||||
|
assert_eq!(record.exit_reason, "test exit");
|
||||||
|
|
||||||
|
let snap = metrics.snapshot();
|
||||||
|
assert_eq!(snap.total_connections, 1);
|
||||||
|
assert_eq!(snap.total_messages, 3);
|
||||||
|
assert_eq!(snap.total_messages_ok, 2);
|
||||||
|
assert_eq!(snap.total_messages_failed, 1);
|
||||||
|
assert_eq!(snap.total_pipes, 1);
|
||||||
|
assert_eq!(snap.total_pipe_bytes, 512);
|
||||||
|
assert_eq!(snap.sessions.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_overview_generation() {
|
||||||
|
let metrics = ServerMetrics::new();
|
||||||
|
|
||||||
|
for i in 0..3 {
|
||||||
|
let mut session = metrics.start_session(1000 + i, format!("session {i}"));
|
||||||
|
for _ in 0..(i + 1) * 2 {
|
||||||
|
session.record_message(Duration::from_millis(1 + i), true);
|
||||||
|
}
|
||||||
|
session.record_pipe((i + 1) * 1000);
|
||||||
|
session.finish(format!("exit {i}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let overview_path = tmp_path("overview_test.json");
|
||||||
|
metrics.build_overview(&overview_path);
|
||||||
|
|
||||||
|
let json = std::fs::read_to_string(&overview_path).unwrap();
|
||||||
|
let overview: Overview = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(overview.total_sessions, 3);
|
||||||
|
assert!(overview.first_session_timestamp > 0);
|
||||||
|
assert!(overview.last_session_timestamp >= overview.first_session_timestamp);
|
||||||
|
assert_eq!(overview.aggregate.total_connections, 3);
|
||||||
|
assert_eq!(overview.aggregate.total_messages, 12); // 2+4+6
|
||||||
|
assert_eq!(overview.aggregate.total_pipes, 3);
|
||||||
|
assert_eq!(overview.aggregate.total_pipe_bytes, 6000); // 1000+2000+3000
|
||||||
|
assert!(overview.aggregate.avg_session_duration_secs >= 0.0);
|
||||||
|
assert_eq!(overview.sessions.len(), 3);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&overview_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_missing_file() {
|
||||||
|
let metrics = ServerMetrics::load("/nonexistent/path/metrics.json");
|
||||||
|
let snap = metrics.snapshot();
|
||||||
|
assert_eq!(snap.total_connections, 0);
|
||||||
|
assert!(snap.sessions.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_sessions_accumulate() {
|
||||||
|
let path = tmp_path("accumulate_test.json");
|
||||||
|
let metrics = ServerMetrics::load(&path);
|
||||||
|
|
||||||
|
for i in 0..5 {
|
||||||
|
let mut session = metrics.start_session(1000, format!("s{i}"));
|
||||||
|
session.record_message(Duration::from_millis(1), true);
|
||||||
|
session.record_pipe(100);
|
||||||
|
session.finish(format!("done {i}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics.save(&path);
|
||||||
|
|
||||||
|
let metrics2 = ServerMetrics::load(&path);
|
||||||
|
let snap = metrics2.snapshot();
|
||||||
|
assert_eq!(snap.total_connections, 5);
|
||||||
|
assert_eq!(snap.total_messages, 5);
|
||||||
|
assert_eq!(snap.total_messages_ok, 5);
|
||||||
|
assert_eq!(snap.total_pipes, 5);
|
||||||
|
assert_eq!(snap.total_pipe_bytes, 500);
|
||||||
|
assert_eq!(snap.sessions.len(), 5);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_overview_latencies() {
|
||||||
|
let metrics = ServerMetrics::new();
|
||||||
|
|
||||||
|
let mut s1 = metrics.start_session(1000, "s1".into());
|
||||||
|
s1.record_message(Duration::from_millis(2), true);
|
||||||
|
s1.record_message(Duration::from_millis(4), true);
|
||||||
|
s1.finish("done".into());
|
||||||
|
|
||||||
|
let mut s2 = metrics.start_session(1001, "s2".into());
|
||||||
|
s2.record_message(Duration::from_millis(1), true);
|
||||||
|
s2.finish("done".into());
|
||||||
|
|
||||||
|
let overview_path = tmp_path("latency_overview.json");
|
||||||
|
metrics.build_overview(&overview_path);
|
||||||
|
let json = std::fs::read_to_string(&overview_path).unwrap();
|
||||||
|
let overview: Overview = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
// s1 avg = 3.0, s2 avg = 1.0
|
||||||
|
// weighted avg = (3*2 + 1*1) / 3 = 7/3 ≈ 2.333
|
||||||
|
assert!(
|
||||||
|
(overview.aggregate.avg_message_latency_ms - 7.0 / 3.0).abs() < 0.01,
|
||||||
|
"avg latency: {}",
|
||||||
|
overview.aggregate.avg_message_latency_ms
|
||||||
|
);
|
||||||
|
assert_eq!(overview.aggregate.max_message_latency_ms, 4.0);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&overview_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_overview_empty() {
|
||||||
|
let metrics = ServerMetrics::new();
|
||||||
|
let overview_path = tmp_path("empty_overview.json");
|
||||||
|
metrics.build_overview(&overview_path);
|
||||||
|
|
||||||
|
let json = std::fs::read_to_string(&overview_path).unwrap();
|
||||||
|
let overview: Overview = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(overview.total_sessions, 0);
|
||||||
|
assert!(overview.sessions.is_empty());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&overview_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Integration-style tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_full_session_lifecycle() {
|
||||||
|
let path = tmp_path("lifecycle.json");
|
||||||
|
let overview_path = tmp_path("lifecycle_overview.json");
|
||||||
|
|
||||||
|
let metrics = ServerMetrics::load(&path);
|
||||||
|
|
||||||
|
let mut s1 = metrics.start_session(1000, "first".into());
|
||||||
|
s1.record_message(Duration::from_millis(1), true);
|
||||||
|
s1.record_message(Duration::from_millis(2), true);
|
||||||
|
let r1 = s1.finish("normal".into());
|
||||||
|
|
||||||
|
let mut s2 = metrics.start_session(1001, "second".into());
|
||||||
|
s2.record_message(Duration::from_millis(5), true);
|
||||||
|
s2.record_message(Duration::from_millis(3), false);
|
||||||
|
s2.record_pipe(2048);
|
||||||
|
s2.record_pipe(4096);
|
||||||
|
let r2 = s2.finish("idle timeout".into());
|
||||||
|
|
||||||
|
let mut s3 = metrics.start_session(1002, "third".into());
|
||||||
|
s3.record_pipe(1024);
|
||||||
|
let r3 = s3.finish("normal".into());
|
||||||
|
|
||||||
|
assert_eq!(r1.client_id, 1000);
|
||||||
|
assert_eq!(r1.messages_received, 2);
|
||||||
|
assert_eq!(r1.messages_ok, 2);
|
||||||
|
assert_eq!(r1.pipes_handled, 0);
|
||||||
|
|
||||||
|
assert_eq!(r2.client_id, 1001);
|
||||||
|
assert_eq!(r2.messages_received, 2);
|
||||||
|
assert_eq!(r2.messages_ok, 1);
|
||||||
|
assert_eq!(r2.messages_failed, 1);
|
||||||
|
assert_eq!(r2.pipes_handled, 2);
|
||||||
|
assert_eq!(r2.pipe_bytes_copied, 6144);
|
||||||
|
assert_eq!(r2.exit_reason, "idle timeout");
|
||||||
|
|
||||||
|
assert_eq!(r3.client_id, 1002);
|
||||||
|
assert_eq!(r3.messages_received, 0);
|
||||||
|
assert_eq!(r3.pipes_handled, 1);
|
||||||
|
assert_eq!(r3.pipe_bytes_copied, 1024);
|
||||||
|
|
||||||
|
let snap = metrics.snapshot();
|
||||||
|
assert_eq!(snap.total_connections, 3);
|
||||||
|
assert_eq!(snap.total_messages, 4);
|
||||||
|
assert_eq!(snap.total_messages_ok, 3);
|
||||||
|
assert_eq!(snap.total_messages_failed, 1);
|
||||||
|
assert_eq!(snap.total_pipes, 3);
|
||||||
|
assert_eq!(snap.total_pipe_bytes, 7168);
|
||||||
|
assert_eq!(snap.sessions.len(), 3);
|
||||||
|
|
||||||
|
metrics.save(&path);
|
||||||
|
let metrics2 = ServerMetrics::load(&path);
|
||||||
|
let snap2 = metrics2.snapshot();
|
||||||
|
assert_eq!(snap2.total_connections, 3);
|
||||||
|
assert_eq!(snap2.total_messages, 4);
|
||||||
|
assert_eq!(snap2.sessions.len(), 3);
|
||||||
|
assert_eq!(snap2.sessions[1].exit_reason, "idle timeout");
|
||||||
|
|
||||||
|
metrics2.build_overview(&overview_path);
|
||||||
|
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
|
||||||
|
let overview: Overview = serde_json::from_str(&overview_json).unwrap();
|
||||||
|
assert_eq!(overview.total_sessions, 3);
|
||||||
|
assert_eq!(overview.aggregate.total_connections, 3);
|
||||||
|
assert_eq!(overview.aggregate.total_messages, 4);
|
||||||
|
assert_eq!(overview.aggregate.total_messages_ok, 3);
|
||||||
|
assert_eq!(overview.aggregate.total_messages_failed, 1);
|
||||||
|
assert_eq!(overview.aggregate.total_pipes, 3);
|
||||||
|
assert_eq!(overview.aggregate.total_pipe_bytes, 7168);
|
||||||
|
assert!(overview.aggregate.avg_session_duration_secs >= 0.0);
|
||||||
|
assert!(overview.aggregate.avg_messages_per_session > 0.0);
|
||||||
|
assert_eq!(overview.sessions.len(), 3);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = std::fs::remove_file(&overview_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_overview_rebuild_accuracy() {
|
||||||
|
let path = tmp_path("accuracy.json");
|
||||||
|
let overview_path = tmp_path("accuracy_overview.json");
|
||||||
|
|
||||||
|
let metrics = ServerMetrics::load(&path);
|
||||||
|
|
||||||
|
for i in 0..10u32 {
|
||||||
|
let mut session = metrics.start_session(1000 + i as u64, format!("session {i}"));
|
||||||
|
let msg_count = (i + 1) * 2;
|
||||||
|
for j in 0..msg_count {
|
||||||
|
session.record_message(Duration::from_millis((j + 1) as u64), j % 3 != 0);
|
||||||
|
}
|
||||||
|
session.record_pipe((i as u64 + 1) * 512);
|
||||||
|
session.finish(format!("exit {i}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics.save(&path);
|
||||||
|
let metrics2 = ServerMetrics::load(&path);
|
||||||
|
metrics2.build_overview(&overview_path);
|
||||||
|
|
||||||
|
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
|
||||||
|
let overview: Overview = serde_json::from_str(&overview_json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(overview.total_sessions, 10);
|
||||||
|
assert_eq!(overview.aggregate.total_connections, 10);
|
||||||
|
assert_eq!(overview.aggregate.total_messages, 110);
|
||||||
|
assert_eq!(overview.aggregate.total_pipes, 10);
|
||||||
|
assert_eq!(overview.aggregate.total_pipe_bytes, 28160);
|
||||||
|
assert!(overview.aggregate.avg_session_duration_secs >= 0.0);
|
||||||
|
assert!((overview.aggregate.avg_messages_per_session - 11.0).abs() < 0.01);
|
||||||
|
assert!((overview.aggregate.avg_pipes_per_session - 1.0).abs() < 0.01);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = std::fs::remove_file(&overview_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_persistence_across_instances() {
|
||||||
|
let path = tmp_path("persistence.json");
|
||||||
|
let overview_path = tmp_path("persistence_overview.json");
|
||||||
|
|
||||||
|
{
|
||||||
|
let metrics = ServerMetrics::load(&path);
|
||||||
|
let mut s1 = metrics.start_session(1000, "inst1-s1".into());
|
||||||
|
s1.record_message(Duration::from_millis(10), true);
|
||||||
|
s1.record_pipe(100);
|
||||||
|
s1.finish("done".into());
|
||||||
|
|
||||||
|
let mut s2 = metrics.start_session(1001, "inst1-s2".into());
|
||||||
|
s2.record_message(Duration::from_millis(20), true);
|
||||||
|
s2.finish("done".into());
|
||||||
|
|
||||||
|
metrics.save(&path);
|
||||||
|
metrics.build_overview(&overview_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let metrics = ServerMetrics::load(&path);
|
||||||
|
let snap = metrics.snapshot();
|
||||||
|
assert_eq!(snap.sessions.len(), 2);
|
||||||
|
assert_eq!(snap.total_connections, 2);
|
||||||
|
|
||||||
|
let mut s3 = metrics.start_session(1002, "inst2-s1".into());
|
||||||
|
s3.record_message(Duration::from_millis(5), true);
|
||||||
|
s3.record_pipe(200);
|
||||||
|
s3.record_pipe(300);
|
||||||
|
s3.finish("done".into());
|
||||||
|
|
||||||
|
metrics.save(&path);
|
||||||
|
metrics.build_overview(&overview_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let metrics = ServerMetrics::load(&path);
|
||||||
|
let snap = metrics.snapshot();
|
||||||
|
assert_eq!(snap.sessions.len(), 3);
|
||||||
|
assert_eq!(snap.total_connections, 3);
|
||||||
|
assert_eq!(snap.total_messages, 3);
|
||||||
|
assert_eq!(snap.total_messages_ok, 3);
|
||||||
|
assert_eq!(snap.total_pipes, 3);
|
||||||
|
assert_eq!(snap.total_pipe_bytes, 600);
|
||||||
|
|
||||||
|
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
|
||||||
|
let overview: Overview = serde_json::from_str(&overview_json).unwrap();
|
||||||
|
assert_eq!(overview.total_sessions, 3);
|
||||||
|
assert_eq!(overview.sessions[0].description, "inst1-s1");
|
||||||
|
assert_eq!(overview.sessions[1].description, "inst1-s2");
|
||||||
|
assert_eq!(overview.sessions[2].description, "inst2-s1");
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = std::fs::remove_file(&overview_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_accept_errors_and_versions() {
|
||||||
|
let path = tmp_path("accept_errors.json");
|
||||||
|
let overview_path = tmp_path("accept_errors_overview.json");
|
||||||
|
|
||||||
|
let metrics = ServerMetrics::load(&path);
|
||||||
|
|
||||||
|
// Simulate 5 accept errors
|
||||||
|
for _ in 0..5 {
|
||||||
|
metrics.record_accept_error();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate connection versions
|
||||||
|
metrics.record_connection_version("2.0");
|
||||||
|
metrics.record_connection_version("2.0");
|
||||||
|
metrics.record_connection_version("1.0");
|
||||||
|
|
||||||
|
// A normal session with pipe denials and send errors
|
||||||
|
let mut s1 = metrics.start_session(1000, "normal".into());
|
||||||
|
s1.record_message(Duration::from_millis(1), true);
|
||||||
|
s1.record_pipe_denial();
|
||||||
|
s1.record_send_error();
|
||||||
|
s1.record_send_error();
|
||||||
|
s1.finish("done".into());
|
||||||
|
|
||||||
|
metrics.save(&path);
|
||||||
|
let metrics2 = ServerMetrics::load(&path);
|
||||||
|
let snap = metrics2.snapshot();
|
||||||
|
assert_eq!(snap.total_accept_errors, 5);
|
||||||
|
assert_eq!(snap.connection_versions["2.0"], 2);
|
||||||
|
assert_eq!(snap.connection_versions["1.0"], 1);
|
||||||
|
assert_eq!(snap.total_pipe_denials, 1);
|
||||||
|
assert_eq!(snap.total_send_errors, 2);
|
||||||
|
assert_eq!(snap.sessions.len(), 1);
|
||||||
|
assert_eq!(snap.sessions[0].pipe_denials, 1);
|
||||||
|
assert_eq!(snap.sessions[0].send_errors, 2);
|
||||||
|
|
||||||
|
metrics2.build_overview(&overview_path);
|
||||||
|
let overview_json = std::fs::read_to_string(&overview_path).unwrap();
|
||||||
|
let overview: Overview = serde_json::from_str(&overview_json).unwrap();
|
||||||
|
assert_eq!(overview.aggregate.total_accept_errors, 5);
|
||||||
|
assert_eq!(overview.aggregate.total_pipe_denials, 1);
|
||||||
|
assert_eq!(overview.aggregate.total_send_errors, 2);
|
||||||
|
assert_eq!(overview.connection_versions["2.0"], 2);
|
||||||
|
assert_eq!(overview.connection_versions["1.0"], 1);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = std::fs::remove_file(&overview_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,39 +1,34 @@
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
|
use std::path::Path;
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
pub fn load_or_generate_tls(
|
pub async fn load_or_generate_tls(
|
||||||
cert_path: &str,
|
cert_path: &str,
|
||||||
key_path: &str,
|
key_path: &str,
|
||||||
) -> Result<(Vec<u8>, Vec<u8>), Box<dyn std::error::Error>> {
|
) -> Result<(Vec<u8>, Vec<u8>), Box<dyn std::error::Error>> {
|
||||||
if let (Ok(c), Ok(k)) = (fs::read(cert_path), fs::read(key_path)) {
|
if let (Ok(c), Ok(k)) = (fs::read(cert_path).await, fs::read(key_path).await) {
|
||||||
println!("Using existing TLS cert from {cert_path}");
|
println!("Using existing TLS cert from {cert_path}");
|
||||||
return Ok((c, k));
|
return Ok((c, k));
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("Generating self-signed TLS certificate ...");
|
println!("Generating self-signed TLS certificate ...");
|
||||||
if let Some(parent) = Path::new(cert_path).parent() {
|
if let Some(parent) = Path::new(cert_path).parent() {
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent).await?;
|
||||||
}
|
}
|
||||||
if let Some(parent) = Path::new(key_path).parent() {
|
if let Some(parent) = Path::new(key_path).parent() {
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent).await?;
|
||||||
}
|
}
|
||||||
let key_pair = rcgen::KeyPair::generate()?;
|
|
||||||
let params = rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()])?;
|
|
||||||
let cert = params.self_signed(&key_pair)?;
|
|
||||||
|
|
||||||
let cert_str = cert.pem();
|
let (cert_pem, key_pem) = mtp::crypto::tls::generate_self_signed_cert("localhost")?;
|
||||||
let key_str = key_pair.serialize_pem();
|
|
||||||
|
|
||||||
fs::write(cert_path, cert_str.as_bytes())?;
|
fs::write(cert_path, &cert_pem).await?;
|
||||||
fs::write(key_path, key_str.as_bytes())?;
|
fs::write(key_path, &key_pem).await?;
|
||||||
println!("Wrote {cert_path} and {key_path}");
|
println!("Wrote {cert_path} and {key_path}");
|
||||||
|
|
||||||
Ok((cert_str.into_bytes(), key_str.into_bytes()))
|
Ok((cert_pem, key_pem))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn certificate_sha256_hex(cert: &[u8]) -> Result<String, Box<dyn std::error::Error>> {
|
pub async fn certificate_sha256_hex(cert: &[u8]) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
let der = if cert.starts_with(b"-----BEGIN CERTIFICATE-----") {
|
let der = if cert.starts_with(b"-----BEGIN CERTIFICATE-----") {
|
||||||
let pem = std::str::from_utf8(cert)?;
|
let pem = std::str::from_utf8(cert)?;
|
||||||
let base64 = pem
|
let base64 = pem
|
||||||
|
|
@ -48,14 +43,14 @@ pub fn certificate_sha256_hex(cert: &[u8]) -> Result<String, Box<dyn std::error:
|
||||||
Ok(hex::encode(mtp::crypto::sha256(&der)))
|
Ok(hex::encode(mtp::crypto::sha256(&der)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn export_webtransport_cert_hash(hash: &str) -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn export_webtransport_cert_hash(hash: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let public_dir = if Path::new("web-client").exists() {
|
let public_dir = if Path::new("web-client").exists() {
|
||||||
Path::new("web-client/public")
|
Path::new("web-client/public")
|
||||||
} else {
|
} else {
|
||||||
Path::new("example/web-client/public")
|
Path::new("example/web-client/public")
|
||||||
};
|
};
|
||||||
fs::create_dir_all(public_dir)?;
|
fs::create_dir_all(public_dir).await?;
|
||||||
fs::write(public_dir.join("mtp_dev_cert_hash.txt"), hash)?;
|
fs::write(public_dir.join("mtp_dev_cert_hash.txt"), hash).await?;
|
||||||
|
|
||||||
let dev_cert_dir = if Path::new("dev-cert").exists() {
|
let dev_cert_dir = if Path::new("dev-cert").exists() {
|
||||||
Path::new("dev-cert")
|
Path::new("dev-cert")
|
||||||
|
|
@ -63,7 +58,7 @@ pub fn export_webtransport_cert_hash(hash: &str) -> Result<(), Box<dyn std::erro
|
||||||
Path::new("example/dev-cert")
|
Path::new("example/dev-cert")
|
||||||
};
|
};
|
||||||
if dev_cert_dir.exists() {
|
if dev_cert_dir.exists() {
|
||||||
fs::write(dev_cert_dir.join("sha256.txt"), hash)?;
|
fs::write(dev_cert_dir.join("sha256.txt"), hash).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
130
example/server/src/web-server.rs
Normal file
130
example/server/src/web-server.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
use mtp::webserver::{HttpRequest, HttpResponse, RouteParams, WebServerConfig};
|
||||||
|
use std::{
|
||||||
|
path::{Component, Path, PathBuf},
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
|
async fn health(request: HttpRequest, response: HttpResponse) -> HttpResponse {
|
||||||
|
response
|
||||||
|
.header("content-type", "text/plain; charset=utf-8")
|
||||||
|
.body(format!("OK\nclient: {}\n", request.remote_addr))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn profile(
|
||||||
|
request: HttpRequest,
|
||||||
|
response: HttpResponse,
|
||||||
|
params: RouteParams,
|
||||||
|
) -> HttpResponse {
|
||||||
|
let Some(user) = params.get("user") else {
|
||||||
|
return response.body("missing user");
|
||||||
|
};
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"user": user,
|
||||||
|
"remote_addr": request.remote_addr.to_string(),
|
||||||
|
"profile": { "display_name": format!("Example user {user}"), "status": "active" }
|
||||||
|
});
|
||||||
|
response
|
||||||
|
.header("content-type", "application/json; charset=utf-8")
|
||||||
|
.body(body.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config() -> Result<WebServerConfig, mtp::webserver::RouterError> {
|
||||||
|
let root = Arc::new(web_client_dist());
|
||||||
|
if root.is_none() {
|
||||||
|
eprintln!(
|
||||||
|
"Web client build not found; requests will show setup instructions. Run `pnpm --dir example/web-client build`."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
WebServerConfig::new()
|
||||||
|
.route("/health", health)?
|
||||||
|
.route_pattern("/api/get/{user}/profile", profile)?
|
||||||
|
.fallback(move |request, response| {
|
||||||
|
let root = Arc::clone(&root);
|
||||||
|
async move { static_assets(request, response, root).await }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn web_client_dist() -> Option<PathBuf> {
|
||||||
|
[
|
||||||
|
PathBuf::from("web-client/dist"),
|
||||||
|
PathBuf::from("example/web-client/dist"),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.find(|path| path.join("index.html").is_file())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn static_assets(
|
||||||
|
request: HttpRequest,
|
||||||
|
response: HttpResponse,
|
||||||
|
root: Arc<Option<PathBuf>>,
|
||||||
|
) -> HttpResponse {
|
||||||
|
if request.method != http::Method::GET && request.method != http::Method::HEAD {
|
||||||
|
return response.status(http::StatusCode::METHOD_NOT_ALLOWED);
|
||||||
|
}
|
||||||
|
let Some(root) = root.as_ref() else {
|
||||||
|
return response
|
||||||
|
.status(http::StatusCode::SERVICE_UNAVAILABLE)
|
||||||
|
.header("content-type", "text/html; charset=utf-8")
|
||||||
|
.body("<!doctype html><title>MTP web client not built</title><p>Run <code>pnpm --dir example/web-client build</code>.</p>");
|
||||||
|
};
|
||||||
|
let relative = request.uri.path().trim_start_matches('/');
|
||||||
|
let path = Path::new(relative);
|
||||||
|
if relative.contains('\\')
|
||||||
|
|| path.components().any(|part| {
|
||||||
|
matches!(
|
||||||
|
part,
|
||||||
|
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return response
|
||||||
|
.status(http::StatusCode::BAD_REQUEST)
|
||||||
|
.body("Invalid path");
|
||||||
|
}
|
||||||
|
let requested = if relative.is_empty() {
|
||||||
|
root.join("index.html")
|
||||||
|
} else {
|
||||||
|
root.join(path)
|
||||||
|
};
|
||||||
|
let file = if requested.is_file() {
|
||||||
|
requested
|
||||||
|
} else if path.extension().is_none() {
|
||||||
|
root.join("index.html")
|
||||||
|
} else {
|
||||||
|
return response
|
||||||
|
.status(http::StatusCode::NOT_FOUND)
|
||||||
|
.body("Not found");
|
||||||
|
};
|
||||||
|
match tokio::fs::read(&file).await {
|
||||||
|
Ok(body) => {
|
||||||
|
let response = response.header("content-type", content_type(&file));
|
||||||
|
if request.method == http::Method::HEAD {
|
||||||
|
response.header("content-length", &body.len().to_string())
|
||||||
|
} else {
|
||||||
|
response.body(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => response
|
||||||
|
.status(http::StatusCode::NOT_FOUND)
|
||||||
|
.body("Not found"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_type(file: &Path) -> &'static str {
|
||||||
|
match file.extension().and_then(|extension| extension.to_str()) {
|
||||||
|
Some("html") => "text/html; charset=utf-8",
|
||||||
|
Some("js" | "mjs") => "text/javascript; charset=utf-8",
|
||||||
|
Some("css") => "text/css; charset=utf-8",
|
||||||
|
Some("wasm") => "application/wasm",
|
||||||
|
Some("svg") => "image/svg+xml",
|
||||||
|
Some("json" | "map") => "application/json",
|
||||||
|
Some("png") => "image/png",
|
||||||
|
Some("jpg" | "jpeg") => "image/jpeg",
|
||||||
|
Some("gif") => "image/gif",
|
||||||
|
Some("webp") => "image/webp",
|
||||||
|
Some("ico") => "image/x-icon",
|
||||||
|
Some("woff") => "font/woff",
|
||||||
|
Some("woff2") => "font/woff2",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,28 +1,22 @@
|
||||||
protocol_version: "1.0"
|
protocol_version: "3.0"
|
||||||
|
|
||||||
type_maps:
|
type_maps:
|
||||||
"0.0":
|
"3.0":
|
||||||
CommunicationTypes:
|
CommunicationTypes:
|
||||||
|
ProtectedMessage: 32
|
||||||
|
AlternateMessage: 33
|
||||||
DataTypes:
|
DataTypes:
|
||||||
"1.0":
|
|
||||||
CommunicationTypes:
|
|
||||||
DataTypes:
|
|
||||||
Data: 32
|
|
||||||
Flags: 33
|
Flags: 33
|
||||||
Value: 34
|
|
||||||
BinaryData: 35
|
|
||||||
Items: 36
|
|
||||||
EncryptedPayload: 37
|
|
||||||
SignedPayload: 38
|
|
||||||
SecurePayload: 39
|
|
||||||
"2.0":
|
|
||||||
CommunicationTypes:
|
|
||||||
DataTypes:
|
|
||||||
Data: 34
|
Data: 34
|
||||||
Flags: 33
|
|
||||||
Value: 35
|
Value: 35
|
||||||
BinaryData: 36
|
BinaryData: 36
|
||||||
Items: 37
|
Items: 37
|
||||||
EncryptedPayload: 38
|
EncryptedPayload: 38
|
||||||
SignedPayload: 39
|
SignedPayload: 39
|
||||||
SecurePayload: 40
|
SecurePayload: 40
|
||||||
|
CommunicationType: 41
|
||||||
|
DataType: 42
|
||||||
|
ExampleText: 43
|
||||||
|
ExampleNumber: 44
|
||||||
|
ExampleRole: 45
|
||||||
|
ExampleMetadata: 46
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,51 @@
|
||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>MTP Web Client</title>
|
<title>MTP Web Client</title>
|
||||||
<style>
|
<style>
|
||||||
body { background: #111; color: #eee; font-family: "Public Sans", sans-serif; }
|
body {
|
||||||
label, input, textarea { display: block; margin-bottom: 0.5rem; }
|
background: #111;
|
||||||
input, textarea, button { font-family: "Public Sans", sans-serif; }
|
color: #eee;
|
||||||
input, textarea { background: #222; color: #eee; }
|
font-family: "Public Sans", sans-serif;
|
||||||
#status, #key-status { white-space: pre-wrap; }
|
}
|
||||||
.state { color: #ff0; }
|
label,
|
||||||
.received { color: #0ff; }
|
input,
|
||||||
.error { color: #f00; }
|
textarea {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
button {
|
||||||
|
font-family: "Public Sans", sans-serif;
|
||||||
|
}
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
background: #222;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
#status,
|
||||||
|
#key-status {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.state {
|
||||||
|
color: #ff0;
|
||||||
|
}
|
||||||
|
.received {
|
||||||
|
color: #0ff;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #f00;
|
||||||
|
}
|
||||||
|
.pipe {
|
||||||
|
color: #0f0;
|
||||||
|
}
|
||||||
|
hr {
|
||||||
|
border-color: #444;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -21,17 +54,42 @@
|
||||||
<input id="server-url" value="https://127.0.0.1:8080" />
|
<input id="server-url" value="https://127.0.0.1:8080" />
|
||||||
|
|
||||||
<label for="host-public-key">Host public key bundle hex</label>
|
<label for="host-public-key">Host public key bundle hex</label>
|
||||||
<textarea id="host-public-key" placeholder="Paste PublicKeyBundle bytes as hex"></textarea>
|
<textarea
|
||||||
|
id="host-public-key"
|
||||||
|
placeholder="Paste PublicKeyBundle bytes as hex"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
<label for="client-credentials">Saved SDK credentials</label>
|
<label for="client-credentials">Saved SDK credentials</label>
|
||||||
<textarea id="client-credentials" readonly></textarea>
|
<textarea id="client-credentials" readonly></textarea>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button id="generate-keypair" type="button">Use new credentials</button>
|
<button id="generate-keypair" type="button">
|
||||||
|
Use new credentials
|
||||||
|
</button>
|
||||||
<button id="connect" type="button" disabled>Connect</button>
|
<button id="connect" type="button" disabled>Connect</button>
|
||||||
|
<button id="connect-unauthenticated" type="button" disabled>
|
||||||
|
Connect Unauthenticated
|
||||||
|
</button>
|
||||||
<button id="clear-keys" type="button">Clear saved keys</button>
|
<button id="clear-keys" type="button">Clear saved keys</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<h2>Pipe Demo</h2>
|
||||||
|
<div>
|
||||||
|
<button id="stream-mic" type="button" disabled>
|
||||||
|
Stream Microphone (pipe loopback)
|
||||||
|
</button>
|
||||||
|
<button id="stop-mic" type="button" disabled>
|
||||||
|
Stop Microphone
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="pipe-status"></div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<h2>Metrics</h2>
|
||||||
|
<div id="metrics"></div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
<div id="key-status">Initializing...</div>
|
<div id="key-status">Initializing...</div>
|
||||||
<div id="status"></div>
|
<div id="status"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "mtp-web-client",
|
"name": "mtp-web-client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@11.8.0",
|
"packageManager": "pnpm@11.8.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
"mtp": "workspace:*"
|
"mtp": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^7.0.0",
|
||||||
"vite": "^8.1.0"
|
"vite": "^8.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,33 @@
|
||||||
import { MTPClient } from "mtp";
|
import { MTPClient } from "mtp";
|
||||||
import type { MTPCredentialStorage, MTPLogEvent, ParsedFrame } from "mtp";
|
import type {
|
||||||
|
MTPCredentialStorage,
|
||||||
|
MTPLogEvent,
|
||||||
|
MTPPipeReader,
|
||||||
|
MTPPipeWriter,
|
||||||
|
ParsedFrame,
|
||||||
|
} from "mtp";
|
||||||
|
|
||||||
const STATUS = document.getElementById("status")!;
|
const STATUS = document.getElementById("status")!;
|
||||||
const KEY_STATUS = document.getElementById("key-status")!;
|
const KEY_STATUS = document.getElementById("key-status")!;
|
||||||
const SERVER_URL = document.getElementById("server-url") as HTMLInputElement;
|
const SERVER_URL = document.getElementById("server-url") as HTMLInputElement;
|
||||||
const HOST_PUBLIC_KEY = document.getElementById("host-public-key") as HTMLTextAreaElement;
|
const HOST_PUBLIC_KEY = document.getElementById(
|
||||||
const CLIENT_CREDENTIALS = document.getElementById("client-credentials") as HTMLTextAreaElement;
|
"host-public-key",
|
||||||
const GENERATE_KEYPAIR = document.getElementById("generate-keypair") as HTMLButtonElement;
|
) as HTMLTextAreaElement;
|
||||||
|
const CLIENT_CREDENTIALS = document.getElementById(
|
||||||
|
"client-credentials",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
const GENERATE_KEYPAIR = document.getElementById(
|
||||||
|
"generate-keypair",
|
||||||
|
) as HTMLButtonElement;
|
||||||
const CONNECT = document.getElementById("connect") as HTMLButtonElement;
|
const CONNECT = document.getElementById("connect") as HTMLButtonElement;
|
||||||
|
const CONNECT_UNAUTHENTICATED = document.getElementById(
|
||||||
|
"connect-unauthenticated",
|
||||||
|
) as HTMLButtonElement;
|
||||||
const CLEAR_KEYS = document.getElementById("clear-keys") as HTMLButtonElement;
|
const CLEAR_KEYS = document.getElementById("clear-keys") as HTMLButtonElement;
|
||||||
|
const STREAM_MIC = document.getElementById("stream-mic") as HTMLButtonElement;
|
||||||
|
const STOP_MIC = document.getElementById("stop-mic") as HTMLButtonElement;
|
||||||
|
const PIPE_STATUS = document.getElementById("pipe-status")!;
|
||||||
|
const METRICS = document.getElementById("metrics")!;
|
||||||
|
|
||||||
const CREDENTIALS_KEY = "mtp-web-client-credentials";
|
const CREDENTIALS_KEY = "mtp-web-client-credentials";
|
||||||
const HOST_PUBLIC_KEY_KEY = "mtp-web-client-host-public-key";
|
const HOST_PUBLIC_KEY_KEY = "mtp-web-client-host-public-key";
|
||||||
|
|
@ -16,12 +35,35 @@ const HOST_PUBLIC_KEY_KEY = "mtp-web-client-host-public-key";
|
||||||
type SavedKeys = {
|
type SavedKeys = {
|
||||||
clientId: string | null;
|
clientId: string | null;
|
||||||
keyring?: number[];
|
keyring?: number[];
|
||||||
keyringBytes?: number[];
|
|
||||||
hostPublicKey?: number[];
|
hostPublicKey?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
let clientId: bigint | null = null;
|
let clientId: bigint | null = null;
|
||||||
let devCertHash = "";
|
let devCertHash = "";
|
||||||
|
let activeClient: ReturnType<typeof createClient> extends Promise<infer T>
|
||||||
|
? T
|
||||||
|
: never;
|
||||||
|
let micStream: MediaStream | null = null;
|
||||||
|
let mediaRecorder: MediaRecorder | null = null;
|
||||||
|
let activePipeWriter: MTPPipeWriter | null = null;
|
||||||
|
let loopbackAudioContext: AudioContext | null = null;
|
||||||
|
let micStreamGeneration = 0;
|
||||||
|
let pipeSendCount = 0;
|
||||||
|
let pendingPipeReaders: MTPPipeReader[] = [];
|
||||||
|
let currentPipePingMs: number | null = null;
|
||||||
|
let lastPipeSendStartedAt = 0;
|
||||||
|
let currentPipeId: number | null = null;
|
||||||
|
let currentPipeDescription = "";
|
||||||
|
let currentPipeState = "idle";
|
||||||
|
let loopbackPlaybackCount = 0;
|
||||||
|
let hasPipeRequestHandler = false;
|
||||||
|
|
||||||
|
// ===== AUDIO LOOPBACK STATE =====
|
||||||
|
// We accumulate all chunks into a single Blob, then decode and play it
|
||||||
|
// when the pipe closes. decodeAudioData needs a complete file, not fragments.
|
||||||
|
let loopbackBlobParts: BlobPart[] = [];
|
||||||
|
let loopbackMimeType = "";
|
||||||
|
let loopbackAudioElement: HTMLAudioElement | null = null;
|
||||||
|
|
||||||
const credentialStorage: MTPCredentialStorage = {
|
const credentialStorage: MTPCredentialStorage = {
|
||||||
getItem: (key) => localStorage.getItem(key),
|
getItem: (key) => localStorage.getItem(key),
|
||||||
|
|
@ -36,6 +78,140 @@ function log(msg: string, cls = "") {
|
||||||
STATUS.appendChild(line);
|
STATUS.appendChild(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pipeLog(msg: string, cls = "pipe") {
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.textContent = msg;
|
||||||
|
if (cls) line.className = cls;
|
||||||
|
PIPE_STATUS.prepend(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMetric(name: string, value: string) {
|
||||||
|
const row = document.querySelector(`[data-metric="${name}"]`);
|
||||||
|
if (row) {
|
||||||
|
row.querySelector<HTMLElement>(".metric-value")!.textContent = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.dataset.metric = name;
|
||||||
|
wrapper.innerHTML = `<span class="metric-name"></span>: <span class="metric-value"></span>`;
|
||||||
|
wrapper.querySelector<HTMLElement>(".metric-name")!.textContent = name;
|
||||||
|
wrapper.querySelector<HTMLElement>(".metric-value")!.textContent = value;
|
||||||
|
METRICS.appendChild(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMetrics() {
|
||||||
|
setMetric(
|
||||||
|
"Current Pipe",
|
||||||
|
currentPipeId == null ? "none" : String(currentPipeId),
|
||||||
|
);
|
||||||
|
setMetric("Pipe State", currentPipeState);
|
||||||
|
setMetric("Pipe Description", currentPipeDescription || "n/a");
|
||||||
|
setMetric(
|
||||||
|
"Current Pipe Ping",
|
||||||
|
currentPipePingMs == null ? "n/a" : `${currentPipePingMs.toFixed(1)} ms`,
|
||||||
|
);
|
||||||
|
setMetric("Loopback Playback", String(loopbackPlaybackCount));
|
||||||
|
setMetric("Sent Chunks", String(pipeSendCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPipeState(
|
||||||
|
state: string,
|
||||||
|
details: Partial<{
|
||||||
|
pipeId: number | null;
|
||||||
|
description: string;
|
||||||
|
pingMs: number | null;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
if ("pipeId" in details) currentPipeId = details.pipeId ?? null;
|
||||||
|
if ("description" in details)
|
||||||
|
currentPipeDescription = details.description ?? "";
|
||||||
|
if ("pingMs" in details)
|
||||||
|
currentPipePingMs = details.pingMs ?? currentPipePingMs;
|
||||||
|
currentPipeState = state;
|
||||||
|
updateMetrics();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPipeId(handle: unknown): number | null {
|
||||||
|
if (handle && typeof handle === "object") {
|
||||||
|
const candidate = handle as Record<string, unknown>;
|
||||||
|
let value =
|
||||||
|
candidate.pipeId ??
|
||||||
|
candidate.pipe_id ??
|
||||||
|
candidate["pipe-id"] ??
|
||||||
|
candidate.id;
|
||||||
|
if (typeof value === "function") {
|
||||||
|
try {
|
||||||
|
value = value.call(handle);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== FIXED AUDIO LOOPBACK: accumulate chunks, play as single file =====
|
||||||
|
|
||||||
|
function startLoopbackAccumulation(mimeType: string) {
|
||||||
|
loopbackBlobParts = [];
|
||||||
|
loopbackMimeType = mimeType;
|
||||||
|
pipeLog("Loopback: accumulating audio chunks...");
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueLoopbackChunk(data: Uint8Array) {
|
||||||
|
loopbackBlobParts.push(data.slice());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishLoopbackPlayback() {
|
||||||
|
if (loopbackBlobParts.length === 0) {
|
||||||
|
pipeLog("Loopback: no chunks received.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop any previous playback
|
||||||
|
if (loopbackAudioElement) {
|
||||||
|
loopbackAudioElement.pause();
|
||||||
|
const src = loopbackAudioElement.src;
|
||||||
|
loopbackAudioElement.src = "";
|
||||||
|
if (src.startsWith("blob:")) {
|
||||||
|
URL.revokeObjectURL(src);
|
||||||
|
}
|
||||||
|
loopbackAudioElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concatenate all chunks into one Blob
|
||||||
|
const blob = new Blob(loopbackBlobParts, { type: loopbackMimeType });
|
||||||
|
loopbackBlobParts = [];
|
||||||
|
|
||||||
|
pipeLog(`Loopback: assembled ${blob.size} bytes, decoding...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
const audioContext = new AudioContext();
|
||||||
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||||
|
|
||||||
|
const source = audioContext.createBufferSource();
|
||||||
|
source.buffer = audioBuffer;
|
||||||
|
source.connect(audioContext.destination);
|
||||||
|
source.start();
|
||||||
|
|
||||||
|
loopbackPlaybackCount += 1;
|
||||||
|
updateMetrics();
|
||||||
|
pipeLog(`Loopback playback started (${audioBuffer.duration.toFixed(2)}s).`);
|
||||||
|
|
||||||
|
// Clean up audio context when done
|
||||||
|
source.onended = () => {
|
||||||
|
audioContext.close().catch(() => {});
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
pipeLog(`Loopback decode/playback failed: ${e}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderStructured(value: unknown): string {
|
function renderStructured(value: unknown): string {
|
||||||
return JSON.stringify(value, (_key, item) => {
|
return JSON.stringify(value, (_key, item) => {
|
||||||
if (typeof item === "bigint") {
|
if (typeof item === "bigint") {
|
||||||
|
|
@ -70,13 +246,16 @@ function setKeyStatus(msg: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function bytesToHex(bytes: Uint8Array): string {
|
function bytesToHex(bytes: Uint8Array): string {
|
||||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
|
||||||
|
"",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hexToBytes(value: string): Uint8Array {
|
function hexToBytes(value: string): Uint8Array {
|
||||||
const hex = value.replace(/[^0-9a-fA-F]/g, "");
|
const hex = value.replace(/[^0-9a-fA-F]/g, "");
|
||||||
if (hex.length === 0) throw new Error("host public key is required");
|
if (hex.length === 0) throw new Error("host public key is required");
|
||||||
if (hex.length % 2 !== 0) throw new Error("host public key hex has an odd length");
|
if (hex.length % 2 !== 0)
|
||||||
|
throw new Error("host public key hex has an odd length");
|
||||||
|
|
||||||
const bytes = new Uint8Array(hex.length / 2);
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
for (let i = 0; i < bytes.length; i += 1) {
|
for (let i = 0; i < bytes.length; i += 1) {
|
||||||
|
|
@ -87,7 +266,10 @@ function hexToBytes(value: string): Uint8Array {
|
||||||
|
|
||||||
function saveHostPublicKey() {
|
function saveHostPublicKey() {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(HOST_PUBLIC_KEY_KEY, bytesToHex(hexToBytes(HOST_PUBLIC_KEY.value)));
|
localStorage.setItem(
|
||||||
|
HOST_PUBLIC_KEY_KEY,
|
||||||
|
bytesToHex(hexToBytes(HOST_PUBLIC_KEY.value)),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem(HOST_PUBLIC_KEY_KEY);
|
localStorage.removeItem(HOST_PUBLIC_KEY_KEY);
|
||||||
}
|
}
|
||||||
|
|
@ -102,13 +284,15 @@ function loadKeys() {
|
||||||
|
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
CLIENT_CREDENTIALS.value = "";
|
CLIENT_CREDENTIALS.value = "";
|
||||||
setKeyStatus("No saved SDK credentials. The next connection will generate and store a reusable keyring.");
|
setKeyStatus(
|
||||||
|
"No saved SDK credentials. The next connection will generate and store a reusable keyring.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = JSON.parse(raw) as SavedKeys;
|
const data = JSON.parse(raw) as SavedKeys;
|
||||||
clientId = data.clientId ? BigInt(data.clientId) : null;
|
clientId = data.clientId ? BigInt(data.clientId) : null;
|
||||||
const keyringLength = (data.keyring ?? data.keyringBytes ?? []).length;
|
const keyringLength = (data.keyring ?? []).length;
|
||||||
CLIENT_CREDENTIALS.value = renderStructured({
|
CLIENT_CREDENTIALS.value = renderStructured({
|
||||||
clientId: data.clientId,
|
clientId: data.clientId,
|
||||||
keyringBytes: keyringLength,
|
keyringBytes: keyringLength,
|
||||||
|
|
@ -127,11 +311,13 @@ function loadKeys() {
|
||||||
|
|
||||||
async function loadHostPublicKey() {
|
async function loadHostPublicKey() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/host_public_key_bundle.hex", { cache: "no-store" });
|
const response = await fetch("/host_public_key_bundle.hex", {
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
|
|
||||||
const hostPublicKey = (await response.text()).trim();
|
const hostPublicKey = (await response.text()).trim();
|
||||||
if (!hostPublicKey) return;
|
if (!hostPublicKey || !/^[0-9a-f]+$/i.test(hostPublicKey)) return;
|
||||||
|
|
||||||
HOST_PUBLIC_KEY.value = hostPublicKey;
|
HOST_PUBLIC_KEY.value = hostPublicKey;
|
||||||
saveHostPublicKey();
|
saveHostPublicKey();
|
||||||
|
|
@ -143,11 +329,14 @@ async function loadHostPublicKey() {
|
||||||
|
|
||||||
async function loadDevCertHash() {
|
async function loadDevCertHash() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/mtp_dev_cert_hash.txt", { cache: "no-store" });
|
const response = await fetch(`/mtp_dev_cert_hash.txt?t=${Date.now()}`, {
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
|
|
||||||
devCertHash = (await response.text()).trim();
|
const hash = (await response.text()).trim();
|
||||||
if (devCertHash) {
|
if (/^[0-9a-f]{64}$/i.test(hash)) {
|
||||||
|
devCertHash = hash;
|
||||||
log(`Loaded WebTransport certificate hash: ${devCertHash}`);
|
log(`Loaded WebTransport certificate hash: ${devCertHash}`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -157,32 +346,23 @@ async function loadDevCertHash() {
|
||||||
|
|
||||||
async function initWasm() {
|
async function initWasm() {
|
||||||
log("Loading WASM module...");
|
log("Loading WASM module...");
|
||||||
await MTPClient.create({ url: SERVER_URL.value, storage: credentialStorage, credentialsStorageKey: CREDENTIALS_KEY });
|
await MTPClient.create({
|
||||||
|
url: SERVER_URL.value,
|
||||||
|
storage: credentialStorage,
|
||||||
|
credentialsStorageKey: CREDENTIALS_KEY,
|
||||||
|
});
|
||||||
const supported = MTPClient.isSupported();
|
const supported = MTPClient.isSupported();
|
||||||
log(`WASM loaded. WebTransport supported: ${supported}`);
|
log(`WASM loaded. WebTransport supported: ${supported}`);
|
||||||
CONNECT.disabled = !supported;
|
CONNECT.disabled = !supported;
|
||||||
|
CONNECT_UNAUTHENTICATED.disabled = !supported;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connect() {
|
async function createClient() {
|
||||||
STATUS.textContent = "";
|
|
||||||
|
|
||||||
if (!MTPClient.isSupported()) {
|
|
||||||
log("WebTransport is not supported in this browser.", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hostPk = hexToBytes(HOST_PUBLIC_KEY.value);
|
const hostPk = hexToBytes(HOST_PUBLIC_KEY.value);
|
||||||
saveHostPublicKey();
|
|
||||||
await loadDevCertHash();
|
await loadDevCertHash();
|
||||||
|
|
||||||
const serverUrl = SERVER_URL.value.trim();
|
const serverUrl = SERVER_URL.value.trim();
|
||||||
const serverCertificateHashes = devCertHash ? [`sha-256:${devCertHash}`] : undefined;
|
const serverCertificateHashes = devCertHash ? [devCertHash] : undefined;
|
||||||
if (serverCertificateHashes) {
|
|
||||||
log(`Pinning WebTransport certificate hash: ${serverCertificateHashes[0]}`);
|
|
||||||
} else {
|
|
||||||
log("No WebTransport certificate hash loaded; relying on browser trust store.", "state");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const client = await MTPClient.create({
|
const client = await MTPClient.create({
|
||||||
url: serverUrl,
|
url: serverUrl,
|
||||||
hostPublicKey: hostPk,
|
hostPublicKey: hostPk,
|
||||||
|
|
@ -191,10 +371,46 @@ async function connect() {
|
||||||
serverCertificateHashes,
|
serverCertificateHashes,
|
||||||
pings: { intervalMs: 30_000 },
|
pings: { intervalMs: 30_000 },
|
||||||
logger(event) {
|
logger(event) {
|
||||||
log(renderLoggerEvent(event), event.hint === "error" ? "error" : event.type === "state" ? "state" : "");
|
log(
|
||||||
|
renderLoggerEvent(event),
|
||||||
|
event.hint === "error"
|
||||||
|
? "error"
|
||||||
|
: event.type === "state"
|
||||||
|
? "state"
|
||||||
|
: "",
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
STATUS.textContent = "";
|
||||||
|
PIPE_STATUS.textContent = "";
|
||||||
|
|
||||||
|
if (!MTPClient.isSupported()) {
|
||||||
|
log("WebTransport is not supported in this browser.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveHostPublicKey();
|
||||||
|
await loadDevCertHash();
|
||||||
|
|
||||||
|
const serverUrl = SERVER_URL.value.trim();
|
||||||
|
const serverCertificateHashes = devCertHash ? [devCertHash] : undefined;
|
||||||
|
if (serverCertificateHashes) {
|
||||||
|
log(`Pinning WebTransport certificate hash: ${serverCertificateHashes[0]}`);
|
||||||
|
} else {
|
||||||
|
log(
|
||||||
|
"No WebTransport certificate hash loaded; relying on browser trust store.",
|
||||||
|
"state",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await createClient();
|
||||||
|
activeClient = client;
|
||||||
|
|
||||||
client.subscribe("Pong", (frame: ParsedFrame) => {
|
client.subscribe("Pong", (frame: ParsedFrame) => {
|
||||||
log(`Subscribed Pong: ${formatParsedFrame(frame)}`, "received");
|
log(`Subscribed Pong: ${formatParsedFrame(frame)}`, "received");
|
||||||
});
|
});
|
||||||
|
|
@ -202,34 +418,325 @@ async function connect() {
|
||||||
const activeClientId = await client.auth();
|
const activeClientId = await client.auth();
|
||||||
clientId = activeClientId;
|
clientId = activeClientId;
|
||||||
loadKeys();
|
loadKeys();
|
||||||
log(`Connected as client ${activeClientId}`);
|
log(`Connected as authenticated client ${activeClientId}`);
|
||||||
|
|
||||||
log("\nSending typed Ping...");
|
log("\nSending typed Ping...");
|
||||||
await client.send("Ping", {
|
await client.send(
|
||||||
|
"Ping",
|
||||||
|
{
|
||||||
Description: "MTP web client send ping",
|
Description: "MTP web client send ping",
|
||||||
Timestamp: BigInt(Date.now()),
|
Timestamp: BigInt(Date.now()),
|
||||||
}, { sender: activeClientId });
|
},
|
||||||
|
{ sender: activeClientId },
|
||||||
|
);
|
||||||
log("Typed Ping sent.");
|
log("Typed Ping sent.");
|
||||||
|
|
||||||
log("\nRequesting Pong by Ping frame id...");
|
log("\nRequesting Pong by Ping frame id...");
|
||||||
const response = await client.request("Ping", {
|
const response = await client.request(
|
||||||
|
"Ping",
|
||||||
|
{
|
||||||
Description: "MTP web client request ping",
|
Description: "MTP web client request ping",
|
||||||
Timestamp: BigInt(Date.now()),
|
Timestamp: BigInt(Date.now()),
|
||||||
}, { sender: activeClientId, responseType: "Pong" });
|
},
|
||||||
|
{ sender: activeClientId, responseType: "Pong" },
|
||||||
|
);
|
||||||
log(`Request response: ${formatParsedFrame(response)}`, "received");
|
log(`Request response: ${formatParsedFrame(response)}`, "received");
|
||||||
|
|
||||||
log("\nClient running. Waiting for incoming messages...");
|
log("\nClient running. Waiting for incoming messages...");
|
||||||
|
STREAM_MIC.disabled = false;
|
||||||
|
log("\nPipe demo ready. Click 'Stream Microphone' to start.", "pipe");
|
||||||
|
updateMetrics();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(`[error] ${error}`, "error");
|
log(`[error] ${error}`, "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function connectUnauthenticated() {
|
||||||
|
STATUS.textContent = "";
|
||||||
|
PIPE_STATUS.textContent = "";
|
||||||
|
|
||||||
|
if (!MTPClient.isSupported()) {
|
||||||
|
log("WebTransport is not supported in this browser.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveHostPublicKey();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = await createClient();
|
||||||
|
activeClient = client;
|
||||||
|
const storedIdentity = client.credentials?.clientId;
|
||||||
|
await client.connectUnauthenticated();
|
||||||
|
|
||||||
|
clientId = storedIdentity ?? clientId;
|
||||||
|
loadKeys();
|
||||||
|
log(
|
||||||
|
`Connected over an unauthenticated transport (guest connection). Stored protection identity ${storedIdentity == null ? "not registered" : `${storedIdentity} retained`}.`,
|
||||||
|
);
|
||||||
|
log(
|
||||||
|
"The explicit connectUnauthenticated() path did not delete or replace stored credentials.",
|
||||||
|
"state",
|
||||||
|
);
|
||||||
|
STREAM_MIC.disabled = false;
|
||||||
|
log("\nPipe demo ready. Click 'Stream Microphone' to start.", "pipe");
|
||||||
|
updateMetrics();
|
||||||
|
} catch (error) {
|
||||||
|
log(`[error] ${error}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startMicStreaming() {
|
||||||
|
if (!activeClient) {
|
||||||
|
pipeLog("No active client connection.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
} catch (e) {
|
||||||
|
pipeLog(`Microphone access denied: ${e}`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
STREAM_MIC.disabled = true;
|
||||||
|
STOP_MIC.disabled = false;
|
||||||
|
setPipeState("creating", { pipeId: null, description: "mic-audio" });
|
||||||
|
pipeLog("Microphone acquired. Creating pipe ...");
|
||||||
|
pipeLog(
|
||||||
|
"Microphone monitoring is off; playback will use the server loopback.",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasPipeRequestHandler) {
|
||||||
|
activeClient.setOnPipeRequest(async (request) => {
|
||||||
|
pipeLog(
|
||||||
|
`Incoming return pipe: id=${request.pipeId} desc=${request.description}`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const reader = await activeClient!.acceptPipe(request.pipeId);
|
||||||
|
pendingPipeReaders.push(reader);
|
||||||
|
pipeLog(
|
||||||
|
`Pipe accepted. Streaming return pipe (pipe id=${getPipeId(reader) ?? "unknown"}) ...`,
|
||||||
|
);
|
||||||
|
readLoopbackPipe(reader);
|
||||||
|
} catch (e) {
|
||||||
|
pipeLog(`Failed to accept return pipe: ${e}`, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
hasPipeRequestHandler = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = await activeClient.createPipe("mic-audio");
|
||||||
|
const pipeId = getPipeId(handle);
|
||||||
|
setPipeState("waiting-for-accept", { pipeId, description: "mic-audio" });
|
||||||
|
pipeLog(
|
||||||
|
`Pipe created (id=${pipeId ?? "unknown"}). Waiting for server to accept ...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const writer = await handle.wait();
|
||||||
|
if (!writer) {
|
||||||
|
setPipeState("denied", { pipeId, description: "mic-audio" });
|
||||||
|
pipeLog("Pipe denied by server.", "error");
|
||||||
|
stopMicStreaming();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activePipeWriter = writer;
|
||||||
|
const streamGeneration = ++micStreamGeneration;
|
||||||
|
|
||||||
|
setPipeState("streaming", {
|
||||||
|
pipeId: getPipeId(writer) ?? pipeId,
|
||||||
|
description: "mic-audio",
|
||||||
|
});
|
||||||
|
pipeLog(
|
||||||
|
`Pipe accepted. Streaming microphone (pipe id=${getPipeId(writer) ?? pipeId ?? "unknown"}) ...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stream microphone audio via MediaRecorder
|
||||||
|
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
|
||||||
|
? "audio/webm;codecs=opus"
|
||||||
|
: "audio/webm";
|
||||||
|
const recorder = new MediaRecorder(micStream, { mimeType });
|
||||||
|
mediaRecorder = recorder;
|
||||||
|
|
||||||
|
recorder.ondataavailable = async (event) => {
|
||||||
|
// A final chunk can be queued before recorder.stop(). Do not use the
|
||||||
|
// captured writer unless this is still the current active stream.
|
||||||
|
if (
|
||||||
|
event.data.size === 0 ||
|
||||||
|
!activeClient ||
|
||||||
|
micStreamGeneration !== streamGeneration ||
|
||||||
|
mediaRecorder !== recorder ||
|
||||||
|
activePipeWriter !== writer
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pipeSendCount++;
|
||||||
|
const chunkNum = pipeSendCount;
|
||||||
|
|
||||||
|
try {
|
||||||
|
lastPipeSendStartedAt = performance.now();
|
||||||
|
const buffer = await event.data.arrayBuffer();
|
||||||
|
const data = new Uint8Array(buffer);
|
||||||
|
// arrayBuffer() yields, so shutdown may have happened meanwhile.
|
||||||
|
if (
|
||||||
|
micStreamGeneration !== streamGeneration ||
|
||||||
|
mediaRecorder !== recorder ||
|
||||||
|
activePipeWriter !== writer
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await writer.write(data);
|
||||||
|
currentPipePingMs = performance.now() - lastPipeSendStartedAt;
|
||||||
|
updateMetrics();
|
||||||
|
} catch (e) {
|
||||||
|
pipeLog(` [chunk ${chunkNum}] send error: ${e}`, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.start(200); // emit data every 200ms
|
||||||
|
updateMetrics();
|
||||||
|
pipeLog("Streaming started (200ms chunks).");
|
||||||
|
}
|
||||||
|
async function readLoopbackPipe(reader: MTPPipeReader) {
|
||||||
|
const startTime = performance.now();
|
||||||
|
let totalBytes = 0;
|
||||||
|
let chunkCount = 0;
|
||||||
|
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
|
||||||
|
? "audio/webm;codecs=opus"
|
||||||
|
: "audio/webm";
|
||||||
|
|
||||||
|
try {
|
||||||
|
loopbackBlobParts = [];
|
||||||
|
loopbackMimeType = mimeType;
|
||||||
|
pipeLog("Loopback: accumulating chunks...");
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const data = await reader.read();
|
||||||
|
if (data == null) break; // EOF
|
||||||
|
totalBytes += data.length;
|
||||||
|
chunkCount++;
|
||||||
|
loopbackBlobParts.push(data.slice());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
pipeLog(` Return pipe read error: ${e}`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = performance.now() - startTime;
|
||||||
|
pipeLog(
|
||||||
|
` Return pipe complete: ${chunkCount} chunks, ${totalBytes} bytes, ` +
|
||||||
|
`delay=${elapsed.toFixed(1)}ms`,
|
||||||
|
);
|
||||||
|
setPipeState("loopback-ready", { pingMs: elapsed });
|
||||||
|
|
||||||
|
// Decode and play the complete recording
|
||||||
|
if (loopbackBlobParts.length > 0) {
|
||||||
|
try {
|
||||||
|
const blob = new Blob(loopbackBlobParts, { type: loopbackMimeType });
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
|
||||||
|
if (!loopbackAudioContext) {
|
||||||
|
loopbackAudioContext = new AudioContext();
|
||||||
|
}
|
||||||
|
const audioBuffer =
|
||||||
|
await loopbackAudioContext.decodeAudioData(arrayBuffer);
|
||||||
|
|
||||||
|
const source = loopbackAudioContext.createBufferSource();
|
||||||
|
source.buffer = audioBuffer;
|
||||||
|
source.connect(loopbackAudioContext.destination);
|
||||||
|
source.start();
|
||||||
|
|
||||||
|
loopbackPlaybackCount += 1;
|
||||||
|
updateMetrics();
|
||||||
|
pipeLog(
|
||||||
|
`Loopback playback started (${audioBuffer.duration.toFixed(2)}s).`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
pipeLog(`Loopback decode failed: ${e}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up the reader from the pending list
|
||||||
|
const idx = pendingPipeReaders.indexOf(reader);
|
||||||
|
if (idx >= 0) pendingPipeReaders.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== CRITICAL FIX: stopMicStreaming must capture the final chunk =====
|
||||||
|
async function stopMicStreaming() {
|
||||||
|
micStreamGeneration++;
|
||||||
|
const recorder = mediaRecorder;
|
||||||
|
mediaRecorder = null;
|
||||||
|
const writer = activePipeWriter;
|
||||||
|
activePipeWriter = null;
|
||||||
|
|
||||||
|
// STOPPING STRATEGY:
|
||||||
|
// 1. Request a final dataavailable event by calling requestData() if needed,
|
||||||
|
// then stop(). The final event contains the WebM trailer.
|
||||||
|
// 2. Wait for that final event to be processed (it writes through the pipe).
|
||||||
|
// 3. Only THEN close the pipe writer.
|
||||||
|
|
||||||
|
if (recorder) {
|
||||||
|
// Create a promise that resolves when the final dataavailable fires
|
||||||
|
const finalChunkPromise = new Promise<void>((resolve) => {
|
||||||
|
const originalHandler = recorder.ondataavailable;
|
||||||
|
recorder.ondataavailable = async (event) => {
|
||||||
|
// Call the original handler first so the chunk gets written to the pipe
|
||||||
|
if (originalHandler) {
|
||||||
|
await originalHandler.call(recorder, event);
|
||||||
|
}
|
||||||
|
// The final chunk from stop() has a 'type' but no special marker.
|
||||||
|
// MediaRecorder state will be 'inactive' after the final event.
|
||||||
|
if (recorder.state === "inactive") {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (recorder.state !== "inactive") {
|
||||||
|
recorder.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait up to 1 second for the final chunk to be captured and written
|
||||||
|
await Promise.race([
|
||||||
|
finalChunkPromise,
|
||||||
|
new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("final chunk timeout")), 1000),
|
||||||
|
),
|
||||||
|
]).catch(() => {
|
||||||
|
pipeLog("Warning: final chunk may not have been captured", "error");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (writer) {
|
||||||
|
try {
|
||||||
|
await writer.close();
|
||||||
|
} catch (e) {
|
||||||
|
pipeLog(`Pipe close error: ${e}`, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (micStream) {
|
||||||
|
micStream.getTracks().forEach((track) => track.stop());
|
||||||
|
micStream = null;
|
||||||
|
}
|
||||||
|
pendingPipeReaders = [];
|
||||||
|
setPipeState("stopped", {
|
||||||
|
pipeId: currentPipeId,
|
||||||
|
description: currentPipeDescription,
|
||||||
|
});
|
||||||
|
|
||||||
|
STREAM_MIC.disabled = false;
|
||||||
|
STOP_MIC.disabled = true;
|
||||||
|
pipeLog("Microphone streaming stopped.");
|
||||||
|
}
|
||||||
|
|
||||||
GENERATE_KEYPAIR.addEventListener("click", () => {
|
GENERATE_KEYPAIR.addEventListener("click", () => {
|
||||||
try {
|
try {
|
||||||
clientId = null;
|
clientId = null;
|
||||||
localStorage.removeItem(CREDENTIALS_KEY);
|
localStorage.removeItem(CREDENTIALS_KEY);
|
||||||
CLIENT_CREDENTIALS.value = "";
|
CLIENT_CREDENTIALS.value = "";
|
||||||
setKeyStatus("Cleared saved credentials. The next connection will generate a new reusable keyring.");
|
setKeyStatus(
|
||||||
|
"Cleared saved credentials. The next connection will generate a new reusable keyring.",
|
||||||
|
);
|
||||||
log("Cleared saved SDK credentials.");
|
log("Cleared saved SDK credentials.");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(`Credential reset failed: ${e}`, "error");
|
log(`Credential reset failed: ${e}`, "error");
|
||||||
|
|
@ -248,6 +755,13 @@ CONNECT.addEventListener("click", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
CONNECT_UNAUTHENTICATED.addEventListener("click", () => {
|
||||||
|
connectUnauthenticated().catch((e) => {
|
||||||
|
log(`Unauthenticated connection failed: ${e}`, "error");
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
CLEAR_KEYS.addEventListener("click", () => {
|
CLEAR_KEYS.addEventListener("click", () => {
|
||||||
clientId = null;
|
clientId = null;
|
||||||
CLIENT_CREDENTIALS.value = "";
|
CLIENT_CREDENTIALS.value = "";
|
||||||
|
|
@ -257,6 +771,17 @@ CLEAR_KEYS.addEventListener("click", () => {
|
||||||
log("Cleared saved SDK credentials and host public key.");
|
log("Cleared saved SDK credentials and host public key.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
STREAM_MIC.addEventListener("click", () => {
|
||||||
|
startMicStreaming().catch((e) => {
|
||||||
|
pipeLog(`Pipe streaming error: ${e}`, "error");
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
STOP_MIC.addEventListener("click", () => {
|
||||||
|
stopMicStreaming();
|
||||||
|
});
|
||||||
|
|
||||||
HOST_PUBLIC_KEY.addEventListener("change", saveHostPublicKey);
|
HOST_PUBLIC_KEY.addEventListener("change", saveHostPublicKey);
|
||||||
|
|
||||||
initWasm()
|
initWasm()
|
||||||
|
|
@ -268,3 +793,5 @@ initWasm()
|
||||||
log(`Fatal error: ${e}`, "error");
|
log(`Fatal error: ${e}`, "error");
|
||||||
console.error(e);
|
console.error(e);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateMetrics();
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,41 @@
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig, type Plugin } from 'vite';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { mtp } from 'mtp/vite';
|
import { mtp } from 'mtp/vite';
|
||||||
|
|
||||||
const devCertDir = path.resolve(__dirname, '../dev-cert');
|
const exampleDir = path.resolve(__dirname, '..');
|
||||||
|
const devCertDir = path.join(exampleDir, 'dev-cert');
|
||||||
const certPath = process.env.MTP_DEV_CERT ?? path.join(devCertDir, 'cert.pem');
|
const certPath = process.env.MTP_DEV_CERT ?? path.join(devCertDir, 'cert.pem');
|
||||||
const keyPath = process.env.MTP_DEV_KEY ?? path.join(devCertDir, 'key.pem');
|
const keyPath = process.env.MTP_DEV_KEY ?? path.join(devCertDir, 'key.pem');
|
||||||
|
|
||||||
const hasDevCert = fs.existsSync(certPath) && fs.existsSync(keyPath);
|
const hasDevCert = fs.existsSync(certPath) && fs.existsSync(keyPath);
|
||||||
|
|
||||||
|
const devFiles: Record<string, string> = {
|
||||||
|
'/host_public_key_bundle.hex': path.join(exampleDir, 'host_public_key_bundle.hex'),
|
||||||
|
'/mtp_dev_cert_hash.txt': path.join(devCertDir, 'sha256.txt'),
|
||||||
|
};
|
||||||
|
|
||||||
|
function devFileServe(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'dev-file-serve',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
const target = devFiles[req.url?.split('?')[0] ?? ''];
|
||||||
|
if (!target) return next();
|
||||||
|
|
||||||
|
fs.readFile(target, (err, data) => {
|
||||||
|
if (err) return next();
|
||||||
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.end(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [mtp({ typeMaps: '../../example/type-maps.yaml' })],
|
plugins: [mtp({ typeMaps: '../../example/type-maps.yaml' }), devFileServe()],
|
||||||
server: {
|
server: {
|
||||||
https: hasDevCert
|
https: hasDevCert
|
||||||
? {
|
? {
|
||||||
|
|
|
||||||
22
files/Cargo.toml
Normal file
22
files/Cargo.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
[package]
|
||||||
|
name = "mtp-files"
|
||||||
|
version = "0.3.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Only the plain key types (`Keyring`, `PublicKeyBundle`, `CryptoError`) are
|
||||||
|
# needed here; those are always compiled, so no crypto features are required.
|
||||||
|
mtp-crypto = { version = "0.3.0", path = "../crypto", default-features = false, features = ["chacha20poly1305", "hkdf", "password-kdf"] }
|
||||||
|
rand = "0.10.2"
|
||||||
|
|
||||||
|
thiserror = "2"
|
||||||
|
zeroize = "1.9"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Plain private-key files are only needed by migration tooling and tests.
|
||||||
|
raw = []
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# Enable suite implementations for bundle-loading tests without adding them to
|
||||||
|
# the normal files-library dependency surface.
|
||||||
|
mtp-crypto = { version = "0.3.0", path = "../crypto", features = ["mlkem-tls"] }
|
||||||
460
files/src/lib.rs
Normal file
460
files/src/lib.rs
Normal file
|
|
@ -0,0 +1,460 @@
|
||||||
|
/*
|
||||||
|
* On-disk storage for methanium key material.
|
||||||
|
*
|
||||||
|
* `.mk` files hold a passphrase-protected Keyring and are written atomically
|
||||||
|
* with owner-only permissions (0600) on Unix. `.mpkb` files hold a
|
||||||
|
* PublicKeyBundle (public keys only) and are safe to share. Each file opens
|
||||||
|
* with a 4-byte magic that doubles as a type tag, so a bundle never loads as a
|
||||||
|
* keyring, followed by a version byte.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use mtp_crypto::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305};
|
||||||
|
use rand::RngExt;
|
||||||
|
use thiserror::Error;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
pub use mtp_crypto::{CryptoError, Keyring, PublicKeyBundle};
|
||||||
|
|
||||||
|
/// File extension for a stored [`Keyring`].
|
||||||
|
pub const KEYRING_EXTENSION: &str = "mk";
|
||||||
|
/// File extension for a stored [`PublicKeyBundle`].
|
||||||
|
pub const BUNDLE_EXTENSION: &str = "mpkb";
|
||||||
|
|
||||||
|
/* Container layout: magic (4 bytes) || version (1 byte) || payload. */
|
||||||
|
const KEYRING_MAGIC: [u8; 4] = *b"MTMK"; /* Methanium Keyring */
|
||||||
|
const BUNDLE_MAGIC: [u8; 4] = *b"MPKB"; /* Methanium Public Key Bundle */
|
||||||
|
const RAW_FORMAT_VERSION: u8 = 1;
|
||||||
|
const PROTECTED_FORMAT_VERSION: u8 = 3;
|
||||||
|
const BUNDLE_FORMAT_VERSION: u8 = 1;
|
||||||
|
const HEADER_LEN: usize = 4 + 1;
|
||||||
|
const SALT_LEN: usize = 32;
|
||||||
|
const KDF_ID_ARGON2ID: u8 = 1;
|
||||||
|
const ARGON2_MEMORY_KIB: u32 = 19 * 1024;
|
||||||
|
const ARGON2_ITERATIONS: u32 = 2;
|
||||||
|
const ARGON2_LANES: u32 = 1;
|
||||||
|
const PROTECTED_PARAMS_LEN: usize = 1 + 4 + 4 + 4 + SALT_LEN;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum FileError {
|
||||||
|
#[error("io error: {0}")]
|
||||||
|
Io(#[from] io::Error),
|
||||||
|
#[error("crypto error: {0}")]
|
||||||
|
Crypto(#[from] CryptoError),
|
||||||
|
#[error("not a valid methanium {expected} file (bad magic)")]
|
||||||
|
BadMagic { expected: &'static str },
|
||||||
|
#[error("unsupported {kind} format version {found}")]
|
||||||
|
UnsupportedVersion { kind: &'static str, found: u8 },
|
||||||
|
#[error("file is truncated: {0} bytes, need at least {HEADER_LEN}")]
|
||||||
|
Truncated(usize),
|
||||||
|
#[error("passphrase must not be empty")]
|
||||||
|
EmptyPassphrase,
|
||||||
|
#[error(
|
||||||
|
"keyring is stored in the unprotected raw format; use load_keyring_raw only for trusted development or migration"
|
||||||
|
)]
|
||||||
|
UnprotectedKeyring,
|
||||||
|
#[error("keyring is passphrase-protected and cannot be loaded as raw")]
|
||||||
|
ProtectedKeyring,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode(magic: [u8; 4], version: u8, payload: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(HEADER_LEN + payload.len());
|
||||||
|
out.extend_from_slice(&magic);
|
||||||
|
out.push(version);
|
||||||
|
out.extend_from_slice(payload);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode<'a>(
|
||||||
|
bytes: &'a [u8],
|
||||||
|
magic: [u8; 4],
|
||||||
|
kind: &'static str,
|
||||||
|
) -> Result<(u8, &'a [u8]), FileError> {
|
||||||
|
if bytes.len() < HEADER_LEN {
|
||||||
|
return Err(FileError::Truncated(bytes.len()));
|
||||||
|
}
|
||||||
|
if bytes[..4] != magic {
|
||||||
|
return Err(FileError::BadMagic { expected: kind });
|
||||||
|
}
|
||||||
|
Ok((bytes[4], &bytes[HEADER_LEN..]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The temporary secret file is owner-only from the instant it is created. */
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn create_secret_file(path: &Path) -> io::Result<fs::File> {
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
|
||||||
|
fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.open(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn create_secret_file(path: &Path) -> io::Result<fs::File> {
|
||||||
|
fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temporary_path(path: &Path, attempt: u64) -> io::Result<PathBuf> {
|
||||||
|
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))?;
|
||||||
|
let mut temporary_name = name.to_os_string();
|
||||||
|
temporary_name.push(format!(
|
||||||
|
".tmp-{}-{}-{attempt}",
|
||||||
|
std::process::id(),
|
||||||
|
TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
Ok(parent.join(temporary_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn sync_parent_directory(path: &Path) -> io::Result<()> {
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.filter(|parent| !parent.as_os_str().is_empty())
|
||||||
|
.unwrap_or_else(|| Path::new("."));
|
||||||
|
fs::File::open(parent)?.sync_all()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn sync_parent_directory(_path: &Path) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_secret_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
let (temporary, mut file) = (0..100)
|
||||||
|
.find_map(|attempt| {
|
||||||
|
let temporary = temporary_path(path, attempt).ok()?;
|
||||||
|
match create_secret_file(&temporary) {
|
||||||
|
Ok(file) => Some(Ok((temporary, file))),
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => None,
|
||||||
|
Err(error) => Some(Err(error)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.transpose()?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
io::Error::new(io::ErrorKind::AlreadyExists, "no temporary name available")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
|
||||||
|
drop(file);
|
||||||
|
let _ = fs::remove_file(&temporary);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
drop(file);
|
||||||
|
if let Err(error) = fs::rename(&temporary, path) {
|
||||||
|
let _ = fs::remove_file(&temporary);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
sync_parent_directory(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_key(
|
||||||
|
passphrase: &[u8],
|
||||||
|
salt: &[u8],
|
||||||
|
memory_kib: u32,
|
||||||
|
iterations: u32,
|
||||||
|
lanes: u32,
|
||||||
|
) -> Result<Zeroizing<[u8; 32]>, FileError> {
|
||||||
|
if salt.len() != SALT_LEN {
|
||||||
|
return Err(FileError::Crypto(CryptoError::KdfError));
|
||||||
|
}
|
||||||
|
Ok(Zeroizing::new(mtp_crypto::derive_password_key(
|
||||||
|
passphrase, salt, memory_kib, iterations, lanes,
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protected_header_aad(parameters: &[u8]) -> Vec<u8> {
|
||||||
|
let mut aad = Vec::with_capacity(HEADER_LEN + parameters.len());
|
||||||
|
aad.extend_from_slice(&KEYRING_MAGIC);
|
||||||
|
aad.push(PROTECTED_FORMAT_VERSION);
|
||||||
|
aad.extend_from_slice(parameters);
|
||||||
|
aad
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a keyring encrypted with XChaCha20-Poly1305 under Argon2id.
|
||||||
|
pub fn save_keyring(
|
||||||
|
keyring: &Keyring,
|
||||||
|
path: impl AsRef<Path>,
|
||||||
|
passphrase: &[u8],
|
||||||
|
) -> Result<(), FileError> {
|
||||||
|
if passphrase.is_empty() {
|
||||||
|
return Err(FileError::EmptyPassphrase);
|
||||||
|
}
|
||||||
|
let mut salt = [0u8; SALT_LEN];
|
||||||
|
rand::rng().fill(&mut salt);
|
||||||
|
let key = derive_key(
|
||||||
|
passphrase,
|
||||||
|
&salt,
|
||||||
|
ARGON2_MEMORY_KIB,
|
||||||
|
ARGON2_ITERATIONS,
|
||||||
|
ARGON2_LANES,
|
||||||
|
)?;
|
||||||
|
let mut parameters = Vec::with_capacity(PROTECTED_PARAMS_LEN);
|
||||||
|
parameters.push(KDF_ID_ARGON2ID);
|
||||||
|
parameters.extend_from_slice(&ARGON2_MEMORY_KIB.to_be_bytes());
|
||||||
|
parameters.extend_from_slice(&ARGON2_ITERATIONS.to_be_bytes());
|
||||||
|
parameters.extend_from_slice(&ARGON2_LANES.to_be_bytes());
|
||||||
|
parameters.extend_from_slice(&salt);
|
||||||
|
let cipher = ChaCha20Poly1305::new(*key);
|
||||||
|
let plaintext = keyring.try_to_bytes()?;
|
||||||
|
let encrypted = cipher.encrypt(&plaintext, &protected_header_aad(¶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<Path>, passphrase: &[u8]) -> Result<Keyring, FileError> {
|
||||||
|
if passphrase.is_empty() {
|
||||||
|
return Err(FileError::EmptyPassphrase);
|
||||||
|
}
|
||||||
|
let bytes = fs::read(path)?;
|
||||||
|
let (version, payload) = decode(&bytes, KEYRING_MAGIC, "keyring")?;
|
||||||
|
if version == RAW_FORMAT_VERSION {
|
||||||
|
return Err(FileError::UnprotectedKeyring);
|
||||||
|
}
|
||||||
|
if version != PROTECTED_FORMAT_VERSION {
|
||||||
|
return Err(FileError::UnsupportedVersion {
|
||||||
|
kind: "keyring",
|
||||||
|
found: version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if payload.len() < PROTECTED_PARAMS_LEN {
|
||||||
|
return Err(FileError::Truncated(bytes.len()));
|
||||||
|
}
|
||||||
|
if payload[0] != KDF_ID_ARGON2ID {
|
||||||
|
return Err(FileError::UnsupportedVersion {
|
||||||
|
kind: "keyring KDF",
|
||||||
|
found: payload[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let memory_kib = u32::from_be_bytes(payload[1..5].try_into().unwrap());
|
||||||
|
let iterations = u32::from_be_bytes(payload[5..9].try_into().unwrap());
|
||||||
|
let lanes = u32::from_be_bytes(payload[9..13].try_into().unwrap());
|
||||||
|
let salt = &payload[13..PROTECTED_PARAMS_LEN];
|
||||||
|
let encrypted = payload
|
||||||
|
.get(PROTECTED_PARAMS_LEN..)
|
||||||
|
.ok_or(FileError::Truncated(bytes.len()))?;
|
||||||
|
let key = derive_key(passphrase, salt, memory_kib, iterations, lanes)?;
|
||||||
|
let cipher = ChaCha20Poly1305::new(*key);
|
||||||
|
let plaintext = Zeroizing::new(cipher.decrypt(
|
||||||
|
encrypted,
|
||||||
|
&protected_header_aad(&payload[..PROTECTED_PARAMS_LEN]),
|
||||||
|
)?);
|
||||||
|
Ok(Keyring::from_bytes(&plaintext)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly save the legacy plaintext format for tests and development.
|
||||||
|
#[cfg(any(test, feature = "raw"))]
|
||||||
|
pub fn save_keyring_raw(keyring: &Keyring, path: impl AsRef<Path>) -> Result<(), FileError> {
|
||||||
|
let payload = keyring.try_to_bytes()?;
|
||||||
|
let bytes = Zeroizing::new(encode(KEYRING_MAGIC, RAW_FORMAT_VERSION, &payload));
|
||||||
|
write_secret_atomic(path.as_ref(), &bytes)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly load the legacy plaintext format for tests and development.
|
||||||
|
#[cfg(any(test, feature = "raw"))]
|
||||||
|
pub fn load_keyring_raw(path: impl AsRef<Path>) -> Result<Keyring, FileError> {
|
||||||
|
let bytes = Zeroizing::new(fs::read(path)?);
|
||||||
|
let (version, payload) = decode(&bytes, KEYRING_MAGIC, "keyring")?;
|
||||||
|
if version == PROTECTED_FORMAT_VERSION {
|
||||||
|
return Err(FileError::ProtectedKeyring);
|
||||||
|
}
|
||||||
|
if version != RAW_FORMAT_VERSION {
|
||||||
|
return Err(FileError::UnsupportedVersion {
|
||||||
|
kind: "keyring",
|
||||||
|
found: version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Keyring::from_bytes(payload)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_public_key_bundle(
|
||||||
|
bundle: &PublicKeyBundle,
|
||||||
|
path: impl AsRef<Path>,
|
||||||
|
) -> Result<(), FileError> {
|
||||||
|
let bundle_bytes = bundle.try_as_bytes()?;
|
||||||
|
let bytes = encode(BUNDLE_MAGIC, BUNDLE_FORMAT_VERSION, &bundle_bytes);
|
||||||
|
fs::write(path, bytes)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_public_key_bundle(path: impl AsRef<Path>) -> Result<PublicKeyBundle, FileError> {
|
||||||
|
let bytes = fs::read(path)?;
|
||||||
|
let (version, payload) = decode(&bytes, BUNDLE_MAGIC, "public key bundle")?;
|
||||||
|
if version != BUNDLE_FORMAT_VERSION {
|
||||||
|
return Err(FileError::UnsupportedVersion {
|
||||||
|
kind: "public key bundle",
|
||||||
|
found: version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(PublicKeyBundle::from_bytes_validated(payload)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use mtp_crypto::keypair::{
|
||||||
|
KEM_PUBLIC_KEY_LEN, KemPrivateKey, KemPublicKey, SIG_CL_PUBLIC_KEY_LEN,
|
||||||
|
SIG_PQ_PUBLIC_KEY_LEN, SignaturePqPrivateKey, SignaturePqPublicKey, SignaturePrivateKey,
|
||||||
|
SignaturePublicKey,
|
||||||
|
};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
||||||
|
fn temp_path(ext: &str) -> PathBuf {
|
||||||
|
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||||
|
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let mut path = std::env::temp_dir();
|
||||||
|
path.push(format!("mtp-files-test-{}-{n}.{ext}", std::process::id()));
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_keyring() -> Keyring {
|
||||||
|
Keyring::new(
|
||||||
|
KemPublicKey::new(vec![1u8; KEM_PUBLIC_KEY_LEN]),
|
||||||
|
KemPrivateKey::new(vec![2u8; 32]),
|
||||||
|
SignaturePqPublicKey::new(vec![3u8; SIG_PQ_PUBLIC_KEY_LEN]),
|
||||||
|
SignaturePqPrivateKey::new(vec![4u8; 64]),
|
||||||
|
SignaturePublicKey::new(vec![5u8; SIG_CL_PUBLIC_KEY_LEN]),
|
||||||
|
SignaturePrivateKey::new(vec![6u8; 32]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keyring_save_load_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let path = temp_path(KEYRING_EXTENSION);
|
||||||
|
let keyring = sample_keyring();
|
||||||
|
save_keyring(&keyring, &path, b"correct horse battery staple")?;
|
||||||
|
let loaded = load_keyring(&path, b"correct horse battery staple")?;
|
||||||
|
assert_eq!(keyring.try_to_bytes()?, loaded.try_to_bytes()?);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bundle_save_load_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let path = temp_path(BUNDLE_EXTENSION);
|
||||||
|
let bundle = Keyring::generate().public_key_bundle();
|
||||||
|
save_public_key_bundle(&bundle, &path)?;
|
||||||
|
let loaded = load_public_key_bundle(&path)?;
|
||||||
|
assert_eq!(bundle.try_as_bytes()?, loaded.try_as_bytes()?);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loading_bundle_as_keyring_fails_on_magic() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let path = temp_path(BUNDLE_EXTENSION);
|
||||||
|
let bundle = Keyring::generate().public_key_bundle();
|
||||||
|
save_public_key_bundle(&bundle, &path)?;
|
||||||
|
assert!(matches!(
|
||||||
|
load_keyring(&path, b"passphrase"),
|
||||||
|
Err(FileError::BadMagic { .. })
|
||||||
|
));
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncated_file_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let path = temp_path(KEYRING_EXTENSION);
|
||||||
|
fs::write(&path, b"MT")?;
|
||||||
|
assert!(matches!(
|
||||||
|
load_keyring(&path, b"passphrase"),
|
||||||
|
Err(FileError::Truncated(2))
|
||||||
|
));
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn keyring_file_is_owner_only() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let path = temp_path(KEYRING_EXTENSION);
|
||||||
|
save_keyring(&sample_keyring(), &path, b"passphrase")?;
|
||||||
|
let mode = fs::metadata(&path)?.permissions().mode();
|
||||||
|
assert_eq!(mode & 0o777, 0o600);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_passphrase_cannot_load_keyring() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let path = temp_path(KEYRING_EXTENSION);
|
||||||
|
save_keyring(&sample_keyring(), &path, b"right passphrase")?;
|
||||||
|
assert!(matches!(
|
||||||
|
load_keyring(&path, b"wrong passphrase"),
|
||||||
|
Err(FileError::Crypto(CryptoError::DecryptionFailed))
|
||||||
|
));
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raw_keyring_requires_explicit_api() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let path = temp_path(KEYRING_EXTENSION);
|
||||||
|
let keyring = sample_keyring();
|
||||||
|
save_keyring_raw(&keyring, &path)?;
|
||||||
|
assert!(matches!(
|
||||||
|
load_keyring(&path, b"passphrase"),
|
||||||
|
Err(FileError::UnprotectedKeyring)
|
||||||
|
));
|
||||||
|
let loaded = load_keyring_raw(&path)?;
|
||||||
|
assert_eq!(keyring.try_to_bytes()?, loaded.try_to_bytes()?);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protected_keyring_is_not_plaintext() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let path = temp_path(KEYRING_EXTENSION);
|
||||||
|
let keyring = sample_keyring();
|
||||||
|
let serialized = keyring.try_to_bytes()?;
|
||||||
|
save_keyring(&keyring, &path, b"passphrase")?;
|
||||||
|
let stored = fs::read(&path)?;
|
||||||
|
assert!(
|
||||||
|
!stored
|
||||||
|
.windows(serialized.len())
|
||||||
|
.any(|window| window == serialized.as_slice())
|
||||||
|
);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protected_header_parameters_are_authenticated() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let path = temp_path(KEYRING_EXTENSION);
|
||||||
|
save_keyring(&sample_keyring(), &path, b"passphrase")?;
|
||||||
|
let mut stored = fs::read(&path)?;
|
||||||
|
// The iteration count begins after the file header, KDF identifier,
|
||||||
|
// and memory parameter: MTMK || version || KDF || memory.
|
||||||
|
stored[5 + 1 + 4 + 3] ^= 1;
|
||||||
|
fs::write(&path, stored)?;
|
||||||
|
assert!(matches!(
|
||||||
|
load_keyring(&path, b"passphrase"),
|
||||||
|
Err(FileError::Crypto(CryptoError::DecryptionFailed))
|
||||||
|
));
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
38
flake.nix
38
flake.nix
|
|
@ -1,29 +1,32 @@
|
||||||
{
|
{
|
||||||
description = "MTP - Methanium Transport Protocol";
|
description = "MTP - Methanium Transport Protocol";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = {
|
outputs =
|
||||||
|
{
|
||||||
self,
|
self,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
rust-overlay,
|
rust-overlay,
|
||||||
}: let
|
}:
|
||||||
|
let
|
||||||
systems = [
|
systems = [
|
||||||
"aarch64-darwin"
|
"aarch64-darwin"
|
||||||
"aarch64-linux"
|
"aarch64-linux"
|
||||||
"x86_64-darwin"
|
"x86_64-darwin"
|
||||||
"x86_64-linux"
|
"x86_64-linux"
|
||||||
];
|
];
|
||||||
eachSystem = f:
|
eachSystem =
|
||||||
|
f:
|
||||||
nixpkgs.lib.foldl' nixpkgs.lib.recursiveUpdate { } (
|
nixpkgs.lib.foldl' nixpkgs.lib.recursiveUpdate { } (
|
||||||
map (system: nixpkgs.lib.mapAttrs (_: value: { ${system} = value; }) (f system)) systems
|
map (system: nixpkgs.lib.mapAttrs (_: value: { ${system} = value; }) (f system)) systems
|
||||||
);
|
);
|
||||||
in
|
in
|
||||||
eachSystem (
|
eachSystem (
|
||||||
system: let
|
system:
|
||||||
|
let
|
||||||
overlays = [ rust-overlay.overlays.default ];
|
overlays = [ rust-overlay.overlays.default ];
|
||||||
pkgs = import nixpkgs { inherit system overlays; };
|
pkgs = import nixpkgs { inherit system overlays; };
|
||||||
|
|
||||||
|
|
@ -55,7 +58,15 @@
|
||||||
|
|
||||||
buildAll = pkgs.writeShellApplication {
|
buildAll = pkgs.writeShellApplication {
|
||||||
name = "mtp-build-all";
|
name = "mtp-build-all";
|
||||||
runtimeInputs = [rustToolchain pkgs.cargo-deny pkgs.wasm-pack pkgs.pnpm pkgs.coreutils clippyCheck macheteCheck];
|
runtimeInputs = [
|
||||||
|
rustToolchain
|
||||||
|
pkgs.cargo-deny
|
||||||
|
pkgs.wasm-pack
|
||||||
|
pkgs.pnpm
|
||||||
|
pkgs.coreutils
|
||||||
|
clippyCheck
|
||||||
|
macheteCheck
|
||||||
|
];
|
||||||
text = ''
|
text = ''
|
||||||
export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}"
|
export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}"
|
||||||
|
|
||||||
|
|
@ -63,24 +74,33 @@
|
||||||
cargo fmt --all --check
|
cargo fmt --all --check
|
||||||
cargo b
|
cargo b
|
||||||
cargo test --workspace --exclude mtp-wasm --all-features
|
cargo test --workspace --exclude mtp-wasm --all-features
|
||||||
|
MTP_TYPE_MAPS="$PWD/example/type-maps.yaml" \
|
||||||
cargo check --manifest-path example/Cargo.toml --workspace --all-targets --all-features
|
cargo check --manifest-path example/Cargo.toml --workspace --all-targets --all-features
|
||||||
mtp-clippy
|
mtp-clippy
|
||||||
mtp-machete
|
mtp-machete
|
||||||
pnpm run dup
|
|
||||||
pnpm run build
|
pnpm run build
|
||||||
|
RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack test --node wasm
|
||||||
|
pnpm run test:e2e
|
||||||
|
pnpm run test:secrets
|
||||||
|
pnpm run test:types
|
||||||
|
pnpm run test:boundary
|
||||||
pnpm --filter mtp-web-client run build
|
pnpm --filter mtp-web-client run build
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
healthCheck = pkgs.writeShellApplication {
|
healthCheck = pkgs.writeShellApplication {
|
||||||
name = "mtp-health";
|
name = "mtp-health";
|
||||||
runtimeInputs = [clippyCheck macheteCheck];
|
runtimeInputs = [
|
||||||
|
clippyCheck
|
||||||
|
macheteCheck
|
||||||
|
];
|
||||||
text = ''
|
text = ''
|
||||||
mtp-clippy
|
mtp-clippy
|
||||||
mtp-machete
|
mtp-machete
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
in {
|
in
|
||||||
|
{
|
||||||
devShells = {
|
devShells = {
|
||||||
default = pkgs.mkShell {
|
default = pkgs.mkShell {
|
||||||
name = "mtp-dev";
|
name = "mtp-dev";
|
||||||
|
|
|
||||||
2
host/Cargo.lock
generated
2
host/Cargo.lock
generated
|
|
@ -4,4 +4,4 @@ version = 4
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "host"
|
name = "host"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,21 @@
|
||||||
[package]
|
[package]
|
||||||
name = "mtp-host"
|
name = "mtp-host"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mtp-common = { version = "0.1.0", path = "../common" }
|
mtp-common = { version = "0.3.0", path = "../common" }
|
||||||
mtp-codec = { version = "0.1.0", path = "../codec", features = ["registry"] }
|
mtp-codec = { version = "0.3.0", path = "../codec", features = ["registry"] }
|
||||||
mtp-transport = { version = "0.1.0", path = "../transport", features = ["host"] }
|
mtp-transport = { version = "0.3.0", path = "../transport", features = ["host"] }
|
||||||
mtp-crypto = { version = "0.1.0", path = "../crypto", optional = true }
|
mtp-crypto = { version = "0.3.0", path = "../crypto", optional = true }
|
||||||
rand = "0.8"
|
rand = "0.10"
|
||||||
tokio = { version = "1", features = ["time"] }
|
thiserror = "2"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt", "time", "sync"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
wtransport = "0.7"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|
||||||
crypto = ["dep:mtp-crypto", "mtp-codec/crypto"]
|
crypto = ["dep:mtp-crypto", "mtp-codec/crypto"]
|
||||||
|
|
||||||
|
pipes = ["mtp-common/pipes", "mtp-transport/pipes"]
|
||||||
|
|
|
||||||
525
host/src/config.rs
Normal file
525
host/src/config.rs
Normal file
|
|
@ -0,0 +1,525 @@
|
||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
use std::collections::HashMap;
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
use std::collections::HashSet;
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
use std::pin::Pin;
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
use std::time::{Duration as StdDuration, Instant};
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
use tokio::time::Duration;
|
||||||
|
|
||||||
|
pub use mtp_transport::Policy;
|
||||||
|
|
||||||
|
/// Callback that looks up a registered client by ID.
|
||||||
|
///
|
||||||
|
/// Called during login to retrieve a client's public key bundle for signature
|
||||||
|
/// verification, and also during guest ID generation to check whether a random
|
||||||
|
/// candidate collides with a registered client. When used for collision
|
||||||
|
/// checking the `description` argument is `None`.
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub type GetExistingClient = Box<
|
||||||
|
dyn Fn(
|
||||||
|
u64,
|
||||||
|
Option<String>,
|
||||||
|
)
|
||||||
|
-> Pin<Box<dyn std::future::Future<Output = Option<mtp_crypto::PublicKeyBundle>> + Send>>
|
||||||
|
+ Send
|
||||||
|
+ Sync,
|
||||||
|
>;
|
||||||
|
|
||||||
|
/// Callback that assigns a guest (unauthenticated) client ID.
|
||||||
|
///
|
||||||
|
/// Return `Some(id)` to accept the guest with the given full-width `u64` ID, or
|
||||||
|
/// `None` to reject the connection.
|
||||||
|
///
|
||||||
|
/// When set to `None` on `HostConfig`, the built-in generator produces a random
|
||||||
|
/// full-width non-zero ID that avoids collisions with registered clients and
|
||||||
|
/// currently connected guests.
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub type GuestIdGenerator =
|
||||||
|
Box<dyn Fn() -> Pin<Box<dyn std::future::Future<Output = Option<u64>> + Send>> + Send + Sync>;
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
/// Callback that commits a new registration and returns its non-zero ID.
|
||||||
|
///
|
||||||
|
/// The host serializes registration commits and remembers successful identity
|
||||||
|
/// assignments for the lifetime of the host. Applications that need retry
|
||||||
|
/// recovery across a host restart should also configure [`FindRegisteredClient`]
|
||||||
|
/// to look up the public identity in persistent storage.
|
||||||
|
pub type CompleteRegister = Box<
|
||||||
|
dyn Fn(
|
||||||
|
mtp_crypto::PublicKeyBundle,
|
||||||
|
Option<String>,
|
||||||
|
) -> Pin<Box<dyn std::future::Future<Output = u64> + Send>>
|
||||||
|
+ Send
|
||||||
|
+ Sync,
|
||||||
|
>;
|
||||||
|
|
||||||
|
/// Callback that recovers an existing registration by its public identity.
|
||||||
|
///
|
||||||
|
/// Returning an ID makes a registration retry idempotent: the host can send
|
||||||
|
/// the same final response when the original response was lost after the
|
||||||
|
/// application committed the registration. Returning `None` asks the host to
|
||||||
|
/// invoke [`CompleteRegister`] for a new registration.
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub type FindRegisteredClient = Box<
|
||||||
|
dyn Fn(
|
||||||
|
mtp_crypto::PublicKeyBundle,
|
||||||
|
Option<String>,
|
||||||
|
) -> Pin<Box<dyn std::future::Future<Output = Option<u64>> + Send>>
|
||||||
|
+ Send
|
||||||
|
+ Sync,
|
||||||
|
>;
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AuthenticationPolicy {
|
||||||
|
ForceAuthentication,
|
||||||
|
AllowAuthentication,
|
||||||
|
Unauthenticated,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transport-supplied identity used to scope authentication attempt limits.
|
||||||
|
/// Concrete hosts should populate these fields from the accepted connection;
|
||||||
|
/// the zero/empty defaults exist only for transport-neutral callers.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct AuthenticationContext {
|
||||||
|
pub peer_network_identity: Option<String>,
|
||||||
|
pub connection_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct AuthenticationAttempt {
|
||||||
|
pub peer_network_identity: Option<String>,
|
||||||
|
pub connection_id: u64,
|
||||||
|
pub claimed_client_id: Option<u64>,
|
||||||
|
pub registration: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AuthenticationLimitError {
|
||||||
|
#[error("authentication limiter storage is unavailable")]
|
||||||
|
Store,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub trait AuthenticationAttemptLimiter: Send + Sync {
|
||||||
|
fn allow(&self, context: &AuthenticationAttempt) -> Result<bool, AuthenticationLimitError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||||
|
enum AuthenticationLimitKey {
|
||||||
|
Peer(String),
|
||||||
|
Connection(u64),
|
||||||
|
Client(u64),
|
||||||
|
Registration,
|
||||||
|
Global,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct InMemoryAuthenticationAttemptLimiter {
|
||||||
|
max_attempts: usize,
|
||||||
|
window: StdDuration,
|
||||||
|
max_keys: usize,
|
||||||
|
by_peer: bool,
|
||||||
|
by_connection: bool,
|
||||||
|
by_client: bool,
|
||||||
|
by_registration: bool,
|
||||||
|
attempts: Mutex<HashMap<AuthenticationLimitKey, VecDeque<Instant>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
impl InMemoryAuthenticationAttemptLimiter {
|
||||||
|
pub fn new(max_attempts: usize, window: StdDuration) -> Self {
|
||||||
|
Self {
|
||||||
|
max_attempts,
|
||||||
|
window,
|
||||||
|
max_keys: 100_000,
|
||||||
|
by_peer: true,
|
||||||
|
by_connection: true,
|
||||||
|
by_client: true,
|
||||||
|
by_registration: true,
|
||||||
|
attempts: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_keys(
|
||||||
|
mut self,
|
||||||
|
by_peer: bool,
|
||||||
|
by_connection: bool,
|
||||||
|
by_client: bool,
|
||||||
|
by_registration: bool,
|
||||||
|
) -> Self {
|
||||||
|
self.by_peer = by_peer;
|
||||||
|
self.by_connection = by_connection;
|
||||||
|
self.by_client = by_client;
|
||||||
|
self.by_registration = by_registration;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_max_keys(mut self, max_keys: usize) -> Self {
|
||||||
|
self.max_keys = max_keys.max(1);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keys(&self, context: &AuthenticationAttempt) -> Vec<AuthenticationLimitKey> {
|
||||||
|
let mut keys = Vec::with_capacity(5);
|
||||||
|
if self.by_peer
|
||||||
|
&& let Some(peer) = context.peer_network_identity.as_ref()
|
||||||
|
{
|
||||||
|
keys.push(AuthenticationLimitKey::Peer(peer.clone()));
|
||||||
|
}
|
||||||
|
if self.by_connection && context.connection_id != 0 {
|
||||||
|
keys.push(AuthenticationLimitKey::Connection(context.connection_id));
|
||||||
|
}
|
||||||
|
if self.by_client
|
||||||
|
&& let Some(client_id) = context.claimed_client_id
|
||||||
|
{
|
||||||
|
keys.push(AuthenticationLimitKey::Client(client_id));
|
||||||
|
}
|
||||||
|
if self.by_registration && context.registration {
|
||||||
|
keys.push(AuthenticationLimitKey::Registration);
|
||||||
|
}
|
||||||
|
// Keep one global bucket as a backstop when an attacker varies the
|
||||||
|
// claimed client ID or presents no peer/connection identity.
|
||||||
|
keys.push(AuthenticationLimitKey::Global);
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
impl AuthenticationAttemptLimiter for InMemoryAuthenticationAttemptLimiter {
|
||||||
|
fn allow(&self, context: &AuthenticationAttempt) -> Result<bool, AuthenticationLimitError> {
|
||||||
|
if self.max_attempts == 0 {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let now = Instant::now();
|
||||||
|
let cutoff = now.checked_sub(self.window);
|
||||||
|
let keys = self.keys(context);
|
||||||
|
let mut attempts = self
|
||||||
|
.attempts
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| AuthenticationLimitError::Store)?;
|
||||||
|
|
||||||
|
for key in &keys {
|
||||||
|
if let Some(history) = attempts.get_mut(key) {
|
||||||
|
while history
|
||||||
|
.front()
|
||||||
|
.is_some_and(|timestamp| cutoff.is_some_and(|cutoff| *timestamp <= cutoff))
|
||||||
|
{
|
||||||
|
history.pop_front();
|
||||||
|
}
|
||||||
|
if history.len() >= self.max_attempts {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in keys {
|
||||||
|
if !attempts.contains_key(&key)
|
||||||
|
&& attempts.len() >= self.max_keys
|
||||||
|
&& let Some(oldest) = attempts.keys().next().cloned()
|
||||||
|
{
|
||||||
|
attempts.remove(&oldest);
|
||||||
|
}
|
||||||
|
attempts.entry(key).or_default().push_back(now);
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct HostConfig {
|
||||||
|
pub ip: IpAddr,
|
||||||
|
pub port: u16,
|
||||||
|
pub tls_fullchain: Vec<u8>,
|
||||||
|
pub tls_key: Vec<u8>,
|
||||||
|
|
||||||
|
pub policy: Policy,
|
||||||
|
pub send_pongs: bool,
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub authentication_policy: AuthenticationPolicy,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
authentication_policy_explicit: bool,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub auth_timeout: Duration,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub require_pq: bool,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub host_keyring: mtp_crypto::Keyring,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub get_existing_client: GetExistingClient,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub(crate) active_guest_ids: Arc<Mutex<HashSet<u64>>>,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub(crate) registration_ids: Arc<Mutex<HashMap<Vec<u8>, u64>>>,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub(crate) registration_lock: Arc<tokio::sync::Mutex<()>>,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub guest_id_generator: Option<GuestIdGenerator>,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub complete_register: CompleteRegister,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub find_registered_client: Option<FindRegisteredClient>,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub auth_limiter: Arc<dyn AuthenticationAttemptLimiter>,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub conceal_authentication_identities: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostConfig {
|
||||||
|
pub fn new(ip: IpAddr, port: u16, tls_fullchain: Vec<u8>, tls_key: Vec<u8>) -> Self {
|
||||||
|
Self {
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
tls_fullchain,
|
||||||
|
tls_key,
|
||||||
|
policy: Policy::default(),
|
||||||
|
send_pongs: true,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
authentication_policy: AuthenticationPolicy::Unauthenticated,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
authentication_policy_explicit: false,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
auth_timeout: Duration::from_secs(30),
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
require_pq: true,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
host_keyring: mtp_crypto::Keyring::new(
|
||||||
|
mtp_crypto::KemPublicKey::new(Vec::new()),
|
||||||
|
mtp_crypto::KemPrivateKey::new(Vec::new()),
|
||||||
|
mtp_crypto::SignaturePqPublicKey::new(Vec::new()),
|
||||||
|
mtp_crypto::SignaturePqPrivateKey::new(Vec::new()),
|
||||||
|
mtp_crypto::SignaturePublicKey::new(Vec::new()),
|
||||||
|
mtp_crypto::SignaturePrivateKey::new(Vec::new()),
|
||||||
|
),
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
get_existing_client: Box::new(|_, _| Box::pin(async { None })),
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
active_guest_ids: Arc::new(Mutex::new(HashSet::new())),
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
registration_ids: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
registration_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
guest_id_generator: None,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
complete_register: Box::new(|_, _| Box::pin(async { 0 })),
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
find_registered_client: None,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
auth_limiter: Arc::new(InMemoryAuthenticationAttemptLimiter::new(
|
||||||
|
32,
|
||||||
|
StdDuration::from_secs(60),
|
||||||
|
)),
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
conceal_authentication_identities: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_policy(mut self, policy: Policy) -> Self {
|
||||||
|
self.policy = policy;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_pongs(mut self, send_pongs: bool) -> Self {
|
||||||
|
self.send_pongs = send_pongs;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub fn with_authentication(
|
||||||
|
mut self,
|
||||||
|
host_keyring: mtp_crypto::Keyring,
|
||||||
|
get_existing_client: GetExistingClient,
|
||||||
|
complete_register: CompleteRegister,
|
||||||
|
) -> Self {
|
||||||
|
if !self.authentication_policy_explicit {
|
||||||
|
self.authentication_policy = AuthenticationPolicy::ForceAuthentication;
|
||||||
|
}
|
||||||
|
self.host_keyring = host_keyring;
|
||||||
|
self.get_existing_client = Box::new(get_existing_client);
|
||||||
|
self.complete_register = Box::new(complete_register);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub fn with_authentication_policy(mut self, policy: AuthenticationPolicy) -> Self {
|
||||||
|
self.authentication_policy = policy;
|
||||||
|
self.authentication_policy_explicit = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub fn with_auth_timeout(mut self, timeout: Duration) -> Self {
|
||||||
|
self.auth_timeout = timeout;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub fn with_require_pq(mut self, require_pq: bool) -> Self {
|
||||||
|
self.require_pq = require_pq;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub fn with_guest_id_generator(mut self, generator: GuestIdGenerator) -> Self {
|
||||||
|
self.guest_id_generator = Some(generator);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure the lookup used to make registration retries idempotent.
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub fn with_registration_lookup(mut self, lookup: FindRegisteredClient) -> Self {
|
||||||
|
self.find_registered_client = Some(lookup);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub fn with_authentication_limiter(
|
||||||
|
mut self,
|
||||||
|
limiter: Arc<dyn AuthenticationAttemptLimiter>,
|
||||||
|
) -> Self {
|
||||||
|
self.auth_limiter = limiter;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub fn with_authentication_identity_concealment(mut self, conceal: bool) -> Self {
|
||||||
|
self.conceal_authentication_identities = conceal;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "crypto"))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authentication_attempt_limiter_rejects_repeated_attempts() {
|
||||||
|
let limiter = InMemoryAuthenticationAttemptLimiter::new(1, StdDuration::from_secs(60))
|
||||||
|
.with_keys(false, true, false, false);
|
||||||
|
let attempt = AuthenticationAttempt {
|
||||||
|
peer_network_identity: None,
|
||||||
|
connection_id: 9,
|
||||||
|
claimed_client_id: Some(42),
|
||||||
|
registration: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(limiter.allow(&attempt).expect("first attempt decision"));
|
||||||
|
assert!(!limiter.allow(&attempt).expect("second attempt decision"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authentication_attempt_limiter_can_scope_registration_separately() {
|
||||||
|
let limiter = InMemoryAuthenticationAttemptLimiter::new(2, StdDuration::from_secs(60))
|
||||||
|
.with_keys(false, false, false, true);
|
||||||
|
let login = AuthenticationAttempt {
|
||||||
|
peer_network_identity: None,
|
||||||
|
connection_id: 1,
|
||||||
|
claimed_client_id: None,
|
||||||
|
registration: false,
|
||||||
|
};
|
||||||
|
let registration = AuthenticationAttempt {
|
||||||
|
registration: true,
|
||||||
|
..login.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(limiter.allow(&login).expect("login attempt decision"));
|
||||||
|
assert!(
|
||||||
|
limiter
|
||||||
|
.allow(®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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
392
host/src/connection.rs
Normal file
392
host/src/connection.rs
Normal file
|
|
@ -0,0 +1,392 @@
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
use mtp_codec::{CommunicationType, DataType, DataValue};
|
||||||
|
use mtp_codec::{CommunicationValue, Version, registry::VersionedCodec};
|
||||||
|
use mtp_common::CommunicationError;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
use std::sync::Arc;
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
use tokio::sync::{Mutex, mpsc};
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
use crate::error::random_client_id;
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
use crate::pipe::{
|
||||||
|
PendingCreationGuard, PipeDispatcher, PipeReceiver, PipeRequest, PipeSender,
|
||||||
|
is_expired_creation, run_dispatcher,
|
||||||
|
};
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
use mtp_transport::Policy;
|
||||||
|
|
||||||
|
mod connection_capability {
|
||||||
|
pub trait Sealed {}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait MtpSenderLike: connection_capability::Sealed + Clone + Send + Sync {}
|
||||||
|
pub trait MtpReceiverLike: connection_capability::Sealed + Clone + Send + Sync {
|
||||||
|
fn receive_message(
|
||||||
|
&self,
|
||||||
|
) -> impl std::future::Future<Output = Result<CommunicationValue, CommunicationError>> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl connection_capability::Sealed for mtp_transport::Sender {}
|
||||||
|
impl MtpSenderLike for mtp_transport::Sender {}
|
||||||
|
impl connection_capability::Sealed for mtp_transport::Receiver {}
|
||||||
|
impl MtpReceiverLike for mtp_transport::Receiver {
|
||||||
|
async fn receive_message(&self) -> Result<CommunicationValue, CommunicationError> {
|
||||||
|
self.receive().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl<C: mtp_transport::TransportConnection> connection_capability::Sealed
|
||||||
|
for mtp_transport::GenericSender<C>
|
||||||
|
{
|
||||||
|
}
|
||||||
|
impl<C: mtp_transport::TransportConnection> MtpSenderLike for mtp_transport::GenericSender<C> {}
|
||||||
|
impl<C: mtp_transport::TransportConnection> connection_capability::Sealed
|
||||||
|
for mtp_transport::GenericReceiver<C>
|
||||||
|
{
|
||||||
|
}
|
||||||
|
impl<C: mtp_transport::TransportConnection> MtpReceiverLike for mtp_transport::GenericReceiver<C> {
|
||||||
|
async fn receive_message(&self) -> Result<CommunicationValue, CommunicationError> {
|
||||||
|
self.receive().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MTPConnection<
|
||||||
|
S = mtp_transport::Sender,
|
||||||
|
R = mtp_transport::Receiver,
|
||||||
|
P = wtransport::RecvStream,
|
||||||
|
> {
|
||||||
|
pub version: Version,
|
||||||
|
pub codec: VersionedCodec,
|
||||||
|
pub sender: S,
|
||||||
|
pub receiver: R,
|
||||||
|
/// The WebTransport request path used to establish this connection.
|
||||||
|
///
|
||||||
|
/// Legacy `MTPHost` connections do not have an HTTP router in front of
|
||||||
|
/// them, so they always use the root path. Alternative hosts can retain
|
||||||
|
/// the CONNECT request path when constructing an MTP connection.
|
||||||
|
pub path: String,
|
||||||
|
/// The address of the peer that established this connection, when exposed
|
||||||
|
/// by the underlying transport.
|
||||||
|
pub remote_addr: Option<SocketAddr>,
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
pub(crate) app_rx: Mutex<mpsc::Receiver<Result<CommunicationValue, CommunicationError>>>,
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
pub(crate) pipe_req_rx: Mutex<mpsc::Receiver<PipeRequest<S, R, P>>>,
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
pub(crate) pipe_dispatcher: Arc<PipeDispatcher<P>>,
|
||||||
|
#[cfg(not(feature = "pipes"))]
|
||||||
|
pub(crate) _pipe_stream: std::marker::PhantomData<P>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub(crate) _dispatcher_task: tokio::task::JoinHandle<()>,
|
||||||
|
/// Keeps an outer server admission permit alive for this MTP session.
|
||||||
|
/// Native hosts leave it empty; WebTransport hosts use it to make the
|
||||||
|
/// configured connection limit cover the session lifetime.
|
||||||
|
pub(crate) _connection_guard: Option<tokio::sync::OwnedSemaphorePermit>,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub auth_state: crate::error::AuthState,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub client_id: u64,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub client_public_key: Option<mtp_crypto::PublicKeyBundle>,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub(crate) guest_id_lease: Option<crate::engine::GuestIdLease>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S, R, P> MTPConnection<S, R, P> {
|
||||||
|
/// Keep an outer server admission permit until this connection is dropped.
|
||||||
|
pub fn set_connection_guard(&mut self, guard: tokio::sync::OwnedSemaphorePermit) {
|
||||||
|
self._connection_guard = Some(guard);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub fn set_guest_id_lease(&mut self, lease: Option<crate::engine::GuestIdLease>) {
|
||||||
|
self.guest_id_lease = lease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
impl<S, R, P> MTPConnection<S, R, P>
|
||||||
|
where
|
||||||
|
S: PipeSender,
|
||||||
|
R: PipeReceiver<P>,
|
||||||
|
P: tokio::io::AsyncRead + Send + Unpin + 'static,
|
||||||
|
{
|
||||||
|
/// Construct an MTP connection from an alternative transport backend.
|
||||||
|
///
|
||||||
|
/// Native `MTPHost` users continue to receive the default
|
||||||
|
/// `MTPConnection<Sender, Receiver>` type. HTTP/3 WebTransport hosts use
|
||||||
|
/// this constructor with their stream adapters while retaining the shared
|
||||||
|
/// version, codec, path, and metadata representation.
|
||||||
|
pub fn from_transport_parts(
|
||||||
|
version: Version,
|
||||||
|
codec: VersionedCodec,
|
||||||
|
sender: S,
|
||||||
|
receiver: R,
|
||||||
|
path: String,
|
||||||
|
description: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self::from_transport_parts_with_remote_addr(
|
||||||
|
version,
|
||||||
|
codec,
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
path,
|
||||||
|
description,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_transport_parts_with_remote_addr(
|
||||||
|
version: Version,
|
||||||
|
codec: VersionedCodec,
|
||||||
|
sender: S,
|
||||||
|
receiver: R,
|
||||||
|
path: String,
|
||||||
|
description: Option<String>,
|
||||||
|
remote_addr: Option<SocketAddr>,
|
||||||
|
) -> Self {
|
||||||
|
let policy = Arc::new(Policy::default());
|
||||||
|
let receiver_queue_capacity = policy.receiver_queue_capacity.max(1);
|
||||||
|
let (app_tx, app_rx) = mpsc::channel(receiver_queue_capacity);
|
||||||
|
let (pipe_req_tx, pipe_req_rx) = mpsc::channel(receiver_queue_capacity);
|
||||||
|
let dispatcher = Arc::new(PipeDispatcher {
|
||||||
|
pending_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
|
expired_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
|
pending_pipes: Mutex::new(std::collections::HashMap::new()),
|
||||||
|
policy,
|
||||||
|
type_map: codec.type_map().clone(),
|
||||||
|
});
|
||||||
|
let task = tokio::spawn(run_dispatcher(
|
||||||
|
receiver.clone(),
|
||||||
|
sender.clone(),
|
||||||
|
app_tx,
|
||||||
|
pipe_req_tx,
|
||||||
|
dispatcher.clone(),
|
||||||
|
));
|
||||||
|
Self {
|
||||||
|
version,
|
||||||
|
codec,
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
path,
|
||||||
|
remote_addr,
|
||||||
|
app_rx: Mutex::new(app_rx),
|
||||||
|
pipe_req_rx: Mutex::new(pipe_req_rx),
|
||||||
|
pipe_dispatcher: dispatcher,
|
||||||
|
description,
|
||||||
|
_dispatcher_task: task,
|
||||||
|
_connection_guard: None,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
auth_state: crate::error::AuthState::Unauthenticated,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
client_id: random_client_id(),
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
client_public_key: None,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
guest_id_lease: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct an MTP connection with an explicit policy for pipe dispatch.
|
||||||
|
// The shared transport constructor keeps its argument order aligned with
|
||||||
|
// `from_transport_parts_with_remote_addr`; policy is required only here.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn from_transport_parts_with_policy(
|
||||||
|
version: Version,
|
||||||
|
codec: VersionedCodec,
|
||||||
|
sender: S,
|
||||||
|
receiver: R,
|
||||||
|
path: String,
|
||||||
|
description: Option<String>,
|
||||||
|
remote_addr: Option<SocketAddr>,
|
||||||
|
policy: Arc<Policy>,
|
||||||
|
) -> Self {
|
||||||
|
let receiver_queue_capacity = policy.receiver_queue_capacity.max(1);
|
||||||
|
let (app_tx, app_rx) = mpsc::channel(receiver_queue_capacity);
|
||||||
|
let (pipe_req_tx, pipe_req_rx) = mpsc::channel(receiver_queue_capacity);
|
||||||
|
let dispatcher = Arc::new(PipeDispatcher {
|
||||||
|
pending_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
|
expired_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
|
pending_pipes: Mutex::new(std::collections::HashMap::new()),
|
||||||
|
policy,
|
||||||
|
type_map: codec.type_map().clone(),
|
||||||
|
});
|
||||||
|
let task = tokio::spawn(run_dispatcher(
|
||||||
|
receiver.clone(),
|
||||||
|
sender.clone(),
|
||||||
|
app_tx,
|
||||||
|
pipe_req_tx,
|
||||||
|
dispatcher.clone(),
|
||||||
|
));
|
||||||
|
Self {
|
||||||
|
version,
|
||||||
|
codec,
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
path,
|
||||||
|
remote_addr,
|
||||||
|
app_rx: Mutex::new(app_rx),
|
||||||
|
pipe_req_rx: Mutex::new(pipe_req_rx),
|
||||||
|
pipe_dispatcher: dispatcher,
|
||||||
|
description,
|
||||||
|
_dispatcher_task: task,
|
||||||
|
_connection_guard: None,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
auth_state: crate::error::AuthState::Unauthenticated,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
client_id: random_client_id(),
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
client_public_key: None,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
guest_id_lease: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "pipes"))]
|
||||||
|
impl<S, R, P> MTPConnection<S, R, P> {
|
||||||
|
pub fn from_transport_parts(
|
||||||
|
version: Version,
|
||||||
|
codec: VersionedCodec,
|
||||||
|
sender: S,
|
||||||
|
receiver: R,
|
||||||
|
path: String,
|
||||||
|
description: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self::from_transport_parts_with_remote_addr(
|
||||||
|
version,
|
||||||
|
codec,
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
path,
|
||||||
|
description,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_transport_parts_with_remote_addr(
|
||||||
|
version: Version,
|
||||||
|
codec: VersionedCodec,
|
||||||
|
sender: S,
|
||||||
|
receiver: R,
|
||||||
|
path: String,
|
||||||
|
description: Option<String>,
|
||||||
|
remote_addr: Option<SocketAddr>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
version,
|
||||||
|
codec,
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
path,
|
||||||
|
remote_addr,
|
||||||
|
description,
|
||||||
|
_pipe_stream: std::marker::PhantomData,
|
||||||
|
_dispatcher_task: tokio::spawn(async {}),
|
||||||
|
_connection_guard: None,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
auth_state: crate::error::AuthState::Unauthenticated,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
client_id: random_client_id(),
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
client_public_key: None,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
guest_id_lease: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "pipes"))]
|
||||||
|
impl<S: MtpSenderLike, R: MtpReceiverLike> MTPConnection<S, R> {
|
||||||
|
pub async fn receive(&self) -> Result<CommunicationValue, CommunicationError> {
|
||||||
|
let mut message = self.receiver.receive_message().await?;
|
||||||
|
message.set_type_map(self.codec.type_map());
|
||||||
|
Ok(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
impl<S, R, P> MTPConnection<S, R, P>
|
||||||
|
where
|
||||||
|
S: PipeSender,
|
||||||
|
P: tokio::io::AsyncRead + Send + Unpin + 'static,
|
||||||
|
{
|
||||||
|
pub async fn receive(&self) -> Result<CommunicationValue, CommunicationError> {
|
||||||
|
let mut rx = self.app_rx.lock().await;
|
||||||
|
match rx.recv().await {
|
||||||
|
Some(Ok(mut message)) => {
|
||||||
|
message.set_type_map(self.codec.type_map());
|
||||||
|
Ok(message)
|
||||||
|
}
|
||||||
|
Some(Err(error)) => Err(error),
|
||||||
|
None => Err(CommunicationError::StreamClosed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_pipe(
|
||||||
|
&self,
|
||||||
|
description: &str,
|
||||||
|
) -> Result<crate::pipe::PipeHandle<S, P>, mtp_common::PipeError> {
|
||||||
|
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let pipe_id = {
|
||||||
|
let mut pending = self
|
||||||
|
.pipe_dispatcher
|
||||||
|
.pending_creations
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| mtp_common::PipeError::ConnectionClosed)?;
|
||||||
|
let pipe_id = loop {
|
||||||
|
let candidate = rand::random::<u32>();
|
||||||
|
if candidate != 0
|
||||||
|
&& !pending.contains_key(&candidate)
|
||||||
|
&& !is_expired_creation(&self.pipe_dispatcher, candidate)
|
||||||
|
{
|
||||||
|
break candidate;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let token = Arc::new(());
|
||||||
|
pending.insert(
|
||||||
|
pipe_id,
|
||||||
|
crate::pipe::PendingCreation {
|
||||||
|
token: token.clone(),
|
||||||
|
sender: response_tx,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
drop(pending);
|
||||||
|
(pipe_id, token)
|
||||||
|
};
|
||||||
|
let (pipe_id, token) = pipe_id;
|
||||||
|
let mut creation_guard =
|
||||||
|
PendingCreationGuard::new(self.pipe_dispatcher.clone(), pipe_id, token.clone());
|
||||||
|
|
||||||
|
let request = CommunicationValue::new_with_type_map(
|
||||||
|
CommunicationType::PipeRequest,
|
||||||
|
self.codec.type_map(),
|
||||||
|
)
|
||||||
|
.with_id(pipe_id)
|
||||||
|
.add_typed_default(DataType::Description, DataValue::Str(description.into()));
|
||||||
|
if let Err(error) = self.sender.send_pipe_message(&request).await {
|
||||||
|
return Err(mtp_common::PipeError::from(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
creation_guard.disarm();
|
||||||
|
Ok(crate::pipe::PipeHandle {
|
||||||
|
pipe_id,
|
||||||
|
description: description.to_owned(),
|
||||||
|
sender: self.sender.clone(),
|
||||||
|
response_rx,
|
||||||
|
dispatcher: self.pipe_dispatcher.clone(),
|
||||||
|
token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn receive_pipe(&self) -> Result<PipeRequest<S, R, P>, CommunicationError> {
|
||||||
|
self.pipe_req_rx
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.recv()
|
||||||
|
.await
|
||||||
|
.ok_or(CommunicationError::StreamClosed)
|
||||||
|
}
|
||||||
|
}
|
||||||
1306
host/src/engine.rs
Executable file
1306
host/src/engine.rs
Executable file
File diff suppressed because it is too large
Load diff
58
host/src/error.rs
Normal file
58
host/src/error.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
use mtp_codec::Version;
|
||||||
|
use mtp_common::CommunicationError;
|
||||||
|
use std::{error::Error, fmt};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use mtp_codec::{CommunicationValue, DataType, DataValue};
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub(crate) fn random_client_id() -> u64 {
|
||||||
|
rand::random::<u64>()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn extract_version(msg: &CommunicationValue) -> Option<Version> {
|
||||||
|
match msg.get_data(DataType::Version) {
|
||||||
|
Some(DataValue::Str(s)) => Version::parse(s.as_str()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum AcceptError {
|
||||||
|
Receive(CommunicationError),
|
||||||
|
MissingVersion,
|
||||||
|
UnsupportedVersion(Version),
|
||||||
|
AuthenticationFailed(String),
|
||||||
|
AuthenticationTimedOut,
|
||||||
|
Send(CommunicationError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for AcceptError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Receive(error) => write!(f, "failed to receive opening message: {error}"),
|
||||||
|
Self::MissingVersion => write!(
|
||||||
|
f,
|
||||||
|
"opening message did not include a valid protocol version"
|
||||||
|
),
|
||||||
|
Self::UnsupportedVersion(version) => {
|
||||||
|
write!(f, "unsupported protocol version: {version}")
|
||||||
|
}
|
||||||
|
Self::AuthenticationFailed(reason) => write!(f, "authentication failed: {reason}"),
|
||||||
|
Self::AuthenticationTimedOut => write!(f, "authentication handshake timed out"),
|
||||||
|
Self::Send(error) => write!(f, "failed to send handshake message: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for AcceptError {}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum AuthState {
|
||||||
|
Unauthenticated,
|
||||||
|
Pending,
|
||||||
|
Authenticated,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
334
host/src/handshake.rs
Normal file
334
host/src/handshake.rs
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
use mtp_codec::registry::Registry;
|
||||||
|
#[cfg(not(feature = "crypto"))]
|
||||||
|
use mtp_codec::{
|
||||||
|
Version,
|
||||||
|
registry::{Registry, VersionedCodec},
|
||||||
|
};
|
||||||
|
use mtp_transport::{Receiver, Sender};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use crate::config::{AuthenticationContext, HostConfig};
|
||||||
|
use crate::connection::MTPConnection;
|
||||||
|
use crate::engine::HandshakeEngine;
|
||||||
|
use crate::error::AcceptError;
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
use crate::pipe::PipeDispatcher;
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
use crate::pipe::run_dispatcher;
|
||||||
|
|
||||||
|
pub struct MTPHost {
|
||||||
|
pub(crate) transport: mtp_transport::Host,
|
||||||
|
pub(crate) context: Arc<HandshakeContext>,
|
||||||
|
pub(crate) handshakes: tokio::task::JoinSet<Result<Option<MTPConnection>, AcceptError>>,
|
||||||
|
pub(crate) transport_closed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct HandshakeContext {
|
||||||
|
pub(crate) registry: Registry,
|
||||||
|
pub(crate) config: Arc<HostConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MTPHost {
|
||||||
|
pub async fn new(config: HostConfig) -> Result<Self, mtp_common::CommunicationError> {
|
||||||
|
let registry = Registry::builtin();
|
||||||
|
|
||||||
|
let transport = mtp_transport::host(
|
||||||
|
config.ip,
|
||||||
|
config.port,
|
||||||
|
config.tls_fullchain.clone(),
|
||||||
|
config.tls_key.clone(),
|
||||||
|
config.policy,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
transport,
|
||||||
|
context: Arc::new(HandshakeContext {
|
||||||
|
registry,
|
||||||
|
config: Arc::new(config),
|
||||||
|
}),
|
||||||
|
handshakes: tokio::task::JoinSet::new(),
|
||||||
|
transport_closed: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn accept(&mut self) -> Result<Option<MTPConnection>, AcceptError> {
|
||||||
|
loop {
|
||||||
|
if self.transport_closed {
|
||||||
|
return match self.handshakes.join_next().await {
|
||||||
|
Some(Ok(result)) => result,
|
||||||
|
Some(Err(error)) => Err(AcceptError::AuthenticationFailed(format!(
|
||||||
|
"handshake task failed: {error}"
|
||||||
|
))),
|
||||||
|
None => Ok(None),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.handshakes.is_empty() {
|
||||||
|
let incoming_started = Instant::now();
|
||||||
|
match self.transport.next().await {
|
||||||
|
Some((sender, receiver)) => {
|
||||||
|
tracing::debug!(elapsed = ?incoming_started.elapsed(), "host accept loop: dispatch authentication handshake");
|
||||||
|
let context = self.context.clone();
|
||||||
|
self.handshakes.spawn(async move {
|
||||||
|
let handshake_started = Instant::now();
|
||||||
|
let result = context.accept_pair_timed(sender, receiver).await;
|
||||||
|
tracing::debug!(elapsed = ?handshake_started.elapsed(), success = result.is_ok(), "host accept loop: authentication handshake finished");
|
||||||
|
result
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.transport_closed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
completed = self.handshakes.join_next() => {
|
||||||
|
if let Some(completed) = completed {
|
||||||
|
return completed.unwrap_or_else(|error| {
|
||||||
|
Err(AcceptError::AuthenticationFailed(format!(
|
||||||
|
"handshake task failed: {error}"
|
||||||
|
)))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
incoming = self.transport.next() => {
|
||||||
|
match incoming {
|
||||||
|
Some((sender, receiver)) => {
|
||||||
|
tracing::debug!("host accept loop: dispatch authentication handshake");
|
||||||
|
let context = self.context.clone();
|
||||||
|
self.handshakes
|
||||||
|
.spawn(async move {
|
||||||
|
let handshake_started = Instant::now();
|
||||||
|
let result = context.accept_pair_timed(sender, receiver).await;
|
||||||
|
tracing::debug!(elapsed = ?handshake_started.elapsed(), success = result.is_ok(), "host accept loop: authentication handshake finished");
|
||||||
|
result
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None => self.transport_closed = true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn local_addr(&self) -> std::net::SocketAddr {
|
||||||
|
self.transport.local_addr()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn registry(&self) -> &Registry {
|
||||||
|
&self.context.registry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HandshakeContext {
|
||||||
|
async fn accept_pair_timed(
|
||||||
|
&self,
|
||||||
|
sender: Sender,
|
||||||
|
receiver: Receiver,
|
||||||
|
) -> Result<Option<MTPConnection>, AcceptError> {
|
||||||
|
let engine = HandshakeEngine::new(self.registry.clone(), self.config.clone());
|
||||||
|
let authentication_context = AuthenticationContext {
|
||||||
|
peer_network_identity: sender
|
||||||
|
.handle()
|
||||||
|
.remote_addr()
|
||||||
|
.map(|address| address.to_string()),
|
||||||
|
connection_id: sender.handle().connection_id(),
|
||||||
|
};
|
||||||
|
let result = engine
|
||||||
|
.accept_with_context(&sender, &receiver, authentication_context)
|
||||||
|
.await?;
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
{
|
||||||
|
Ok(Some(self.connection_from_handshake_result(
|
||||||
|
sender, receiver, result,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "crypto"))]
|
||||||
|
{
|
||||||
|
Ok(Some(self.connection_from_parts(
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
result.negotiated_version,
|
||||||
|
result.codec,
|
||||||
|
result.description,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub(crate) fn connection_from_handshake_result(
|
||||||
|
&self,
|
||||||
|
sender: Sender,
|
||||||
|
receiver: Receiver,
|
||||||
|
result: crate::engine::HandshakeResult,
|
||||||
|
) -> MTPConnection {
|
||||||
|
let remote_addr = sender.handle().remote_addr();
|
||||||
|
receiver.set_max_message_size(self.config.policy.max_message_size);
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
let type_map = result.codec.type_map().clone();
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
{
|
||||||
|
if self.config.send_pongs {
|
||||||
|
receiver.respond_to_pings(sender.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let receiver_queue_capacity = self.config.policy.receiver_queue_capacity.max(1);
|
||||||
|
let (app_tx, app_rx) = mpsc::channel(receiver_queue_capacity);
|
||||||
|
let (pipe_req_tx, pipe_req_rx) = mpsc::channel(receiver_queue_capacity);
|
||||||
|
|
||||||
|
let dispatcher = Arc::new(PipeDispatcher {
|
||||||
|
pending_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
|
expired_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
|
pending_pipes: tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
|
policy: Arc::new(self.config.policy),
|
||||||
|
type_map: type_map.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let dispatcher_clone = dispatcher.clone();
|
||||||
|
let receiver_clone = receiver.clone();
|
||||||
|
let sender_clone = sender.clone();
|
||||||
|
let task = tokio::spawn(run_dispatcher(
|
||||||
|
receiver_clone,
|
||||||
|
sender_clone,
|
||||||
|
app_tx,
|
||||||
|
pipe_req_tx,
|
||||||
|
dispatcher_clone,
|
||||||
|
));
|
||||||
|
|
||||||
|
MTPConnection {
|
||||||
|
version: result.negotiated_version,
|
||||||
|
codec: result.codec,
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
path: "/".to_string(),
|
||||||
|
remote_addr,
|
||||||
|
app_rx: tokio::sync::Mutex::new(app_rx),
|
||||||
|
pipe_req_rx: tokio::sync::Mutex::new(pipe_req_rx),
|
||||||
|
pipe_dispatcher: dispatcher,
|
||||||
|
description: result.description,
|
||||||
|
_dispatcher_task: task,
|
||||||
|
_connection_guard: None,
|
||||||
|
auth_state: result.auth_state,
|
||||||
|
client_id: result.client_id,
|
||||||
|
client_public_key: result.client_public_key,
|
||||||
|
guest_id_lease: result.guest_id_lease,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "pipes"))]
|
||||||
|
{
|
||||||
|
if self.config.send_pongs {
|
||||||
|
receiver.respond_to_pings(sender.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = tokio::spawn(async {});
|
||||||
|
|
||||||
|
MTPConnection {
|
||||||
|
version: result.negotiated_version,
|
||||||
|
codec: result.codec,
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
path: "/".to_string(),
|
||||||
|
remote_addr,
|
||||||
|
_pipe_stream: std::marker::PhantomData,
|
||||||
|
description: result.description,
|
||||||
|
_dispatcher_task: task,
|
||||||
|
_connection_guard: None,
|
||||||
|
auth_state: result.auth_state,
|
||||||
|
client_id: result.client_id,
|
||||||
|
client_public_key: result.client_public_key,
|
||||||
|
guest_id_lease: result.guest_id_lease,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "crypto"))]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) fn connection_from_parts(
|
||||||
|
&self,
|
||||||
|
sender: Sender,
|
||||||
|
receiver: Receiver,
|
||||||
|
version: Version,
|
||||||
|
codec: VersionedCodec,
|
||||||
|
description: Option<String>,
|
||||||
|
) -> MTPConnection {
|
||||||
|
let remote_addr = sender.handle().remote_addr();
|
||||||
|
receiver.set_max_message_size(self.config.policy.max_message_size);
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
let type_map = codec.type_map().clone();
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
{
|
||||||
|
if self.config.send_pongs {
|
||||||
|
receiver.respond_to_pings(sender.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let receiver_queue_capacity = self.config.policy.receiver_queue_capacity.max(1);
|
||||||
|
let (app_tx, app_rx) = mpsc::channel(receiver_queue_capacity);
|
||||||
|
let (pipe_req_tx, pipe_req_rx) = mpsc::channel(receiver_queue_capacity);
|
||||||
|
|
||||||
|
let dispatcher = Arc::new(PipeDispatcher {
|
||||||
|
pending_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
|
expired_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
|
pending_pipes: tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
|
policy: Arc::new(self.config.policy),
|
||||||
|
type_map,
|
||||||
|
});
|
||||||
|
|
||||||
|
let dispatcher_clone = dispatcher.clone();
|
||||||
|
let receiver_clone = receiver.clone();
|
||||||
|
let sender_clone = sender.clone();
|
||||||
|
let task = tokio::spawn(run_dispatcher(
|
||||||
|
receiver_clone,
|
||||||
|
sender_clone,
|
||||||
|
app_tx,
|
||||||
|
pipe_req_tx,
|
||||||
|
dispatcher_clone,
|
||||||
|
));
|
||||||
|
|
||||||
|
MTPConnection {
|
||||||
|
version,
|
||||||
|
codec,
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
path: "/".to_string(),
|
||||||
|
remote_addr,
|
||||||
|
app_rx: tokio::sync::Mutex::new(app_rx),
|
||||||
|
pipe_req_rx: tokio::sync::Mutex::new(pipe_req_rx),
|
||||||
|
pipe_dispatcher: dispatcher,
|
||||||
|
description,
|
||||||
|
_dispatcher_task: task,
|
||||||
|
_connection_guard: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "pipes"))]
|
||||||
|
{
|
||||||
|
if self.config.send_pongs {
|
||||||
|
receiver.respond_to_pings(sender.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = tokio::spawn(async {});
|
||||||
|
|
||||||
|
MTPConnection {
|
||||||
|
version,
|
||||||
|
codec,
|
||||||
|
sender,
|
||||||
|
receiver,
|
||||||
|
path: "/".to_string(),
|
||||||
|
remote_addr,
|
||||||
|
_pipe_stream: std::marker::PhantomData,
|
||||||
|
description,
|
||||||
|
_dispatcher_task: task,
|
||||||
|
_connection_guard: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
887
host/src/lib.rs
887
host/src/lib.rs
|
|
@ -1,829 +1,78 @@
|
||||||
use mtp_codec::{
|
pub mod config;
|
||||||
CommunicationValue, DataType, DataValue, Version,
|
pub mod connection;
|
||||||
registry::{Registry, VersionedCodec},
|
pub mod engine;
|
||||||
};
|
pub mod error;
|
||||||
use mtp_common::CommunicationError;
|
pub mod handshake;
|
||||||
use mtp_transport::{Policy, Receiver, Sender};
|
#[cfg(feature = "pipes")]
|
||||||
use std::net::IpAddr;
|
pub mod pipe;
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
use std::pin::Pin;
|
|
||||||
use std::{error::Error, fmt};
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
use tokio::time::Duration;
|
|
||||||
|
|
||||||
/* ---- async callback type aliases ---- */
|
pub use MTPConnection as Connection;
|
||||||
#[cfg(feature = "crypto")]
|
pub use MTPHost as Host;
|
||||||
type GetExistingUser = Box<
|
pub use config::HostConfig;
|
||||||
dyn Fn(
|
pub use config::Policy;
|
||||||
u64,
|
pub use connection::{MTPConnection, MtpReceiverLike, MtpSenderLike};
|
||||||
)
|
pub use engine::{HandshakeEngine, HandshakeReceiver, HandshakeResult, HandshakeSender};
|
||||||
-> Pin<Box<dyn std::future::Future<Output = Option<mtp_crypto::PublicKeyBundle>> + Send>>
|
pub use error::AcceptError;
|
||||||
+ Send
|
pub use handshake::MTPHost;
|
||||||
+ Sync,
|
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")]
|
#[cfg(feature = "crypto")]
|
||||||
type CompleteRegister = Box<
|
pub use config::{
|
||||||
dyn Fn(mtp_crypto::PublicKeyBundle) -> Pin<Box<dyn std::future::Future<Output = u64> + Send>>
|
AuthenticationAttempt, AuthenticationAttemptLimiter, AuthenticationContext,
|
||||||
+ Send
|
AuthenticationLimitError, AuthenticationPolicy, CompleteRegister, FindRegisteredClient,
|
||||||
+ Sync,
|
GetExistingClient, GuestIdGenerator, InMemoryAuthenticationAttemptLimiter,
|
||||||
>;
|
};
|
||||||
|
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
pub use error::AuthState;
|
||||||
pub enum AuthenticationPolicy {
|
|
||||||
ForceAuthentication,
|
|
||||||
AllowAuthentication,
|
|
||||||
Unauthenticated,
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Host configuration. */
|
|
||||||
pub struct HostConfig {
|
|
||||||
pub ip: IpAddr,
|
|
||||||
pub port: u16,
|
|
||||||
pub tls_fullchain: Vec<u8>,
|
|
||||||
pub tls_key: Vec<u8>,
|
|
||||||
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
pub authentication_policy: AuthenticationPolicy,
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
pub auth_timeout: Duration,
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
pub host_keyring: mtp_crypto::Keyring,
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
pub get_existing_user: GetExistingUser,
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
pub complete_register: CompleteRegister,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HostConfig {
|
|
||||||
pub fn new(ip: IpAddr, port: u16, tls_fullchain: Vec<u8>, tls_key: Vec<u8>) -> Self {
|
|
||||||
Self {
|
|
||||||
ip,
|
|
||||||
port,
|
|
||||||
tls_fullchain,
|
|
||||||
tls_key,
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
authentication_policy: AuthenticationPolicy::Unauthenticated,
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
auth_timeout: Duration::from_secs(30),
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
host_keyring: mtp_crypto::Keyring::new(
|
|
||||||
mtp_crypto::KemPublicKey::new(Vec::new()),
|
|
||||||
mtp_crypto::KemPrivateKey::new(Vec::new()),
|
|
||||||
mtp_crypto::SignaturePqPublicKey::new(Vec::new()),
|
|
||||||
mtp_crypto::SignaturePqPrivateKey::new(Vec::new()),
|
|
||||||
mtp_crypto::SignaturePublicKey::new(Vec::new()),
|
|
||||||
mtp_crypto::SignaturePrivateKey::new(Vec::new()),
|
|
||||||
),
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
get_existing_user: Box::new(|_| Box::pin(async { None })),
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
complete_register: Box::new(|_| Box::pin(async { 0 })),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
pub fn with_authentication(
|
|
||||||
mut self,
|
|
||||||
host_keyring: mtp_crypto::Keyring,
|
|
||||||
get_existing_user: impl Fn(
|
|
||||||
u64,
|
|
||||||
) -> Pin<
|
|
||||||
Box<dyn std::future::Future<Output = Option<mtp_crypto::PublicKeyBundle>> + Send>,
|
|
||||||
> + Send
|
|
||||||
+ Sync
|
|
||||||
+ 'static,
|
|
||||||
complete_register: impl Fn(
|
|
||||||
mtp_crypto::PublicKeyBundle,
|
|
||||||
) -> Pin<Box<dyn std::future::Future<Output = u64> + Send>>
|
|
||||||
+ Send
|
|
||||||
+ Sync
|
|
||||||
+ 'static,
|
|
||||||
) -> Self {
|
|
||||||
self.authentication_policy = AuthenticationPolicy::ForceAuthentication;
|
|
||||||
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_authentication_policy(mut self, policy: AuthenticationPolicy) -> Self {
|
|
||||||
self.authentication_policy = policy;
|
|
||||||
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,
|
|
||||||
pub description: Option<String>,
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
pub auth_state: AuthState,
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
pub client_id: u64,
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
pub client_public_key: Option<mtp_crypto::PublicKeyBundle>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/* High-level MTP host with built-in version negotiation. */
|
|
||||||
pub struct MTPHost {
|
|
||||||
transport: mtp_transport::Host,
|
|
||||||
registry: Registry,
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
config: HostConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MTPHost {
|
|
||||||
pub async fn new(config: HostConfig) -> Result<Self, CommunicationError> {
|
|
||||||
let registry = Registry::builtin();
|
|
||||||
|
|
||||||
let transport = mtp_transport::host(
|
|
||||||
config.ip,
|
|
||||||
config.port,
|
|
||||||
config.tls_fullchain.clone(),
|
|
||||||
config.tls_key.clone(),
|
|
||||||
Policy::default(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
transport,
|
|
||||||
registry,
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
config,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Accept an incoming connection, negotiate the protocol version,
|
|
||||||
* and return a ready-to-use `MTPConnection`.
|
|
||||||
*
|
|
||||||
* Returns `Ok(None)` if the listener is closed. Handshake and version
|
|
||||||
* negotiation failures are returned explicitly.
|
|
||||||
*/
|
|
||||||
pub async fn accept(&mut self) -> Result<Option<MTPConnection>, AcceptError> {
|
|
||||||
let (sender, receiver) = match self.transport.next().await {
|
|
||||||
Some(pair) => pair,
|
|
||||||
None => return Ok(None),
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
match self.config.authentication_policy {
|
|
||||||
AuthenticationPolicy::ForceAuthentication => {
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
AuthenticationPolicy::AllowAuthentication => {
|
|
||||||
return self.accept_allow_auth(sender, receiver).await;
|
|
||||||
}
|
|
||||||
AuthenticationPolicy::Unauthenticated => {
|
|
||||||
let first_msg = match receiver.receive().await {
|
|
||||||
Ok(m) => m,
|
|
||||||
Err(e) => return Err(AcceptError::Receive(e)),
|
|
||||||
};
|
|
||||||
if first_msg.get_type()
|
|
||||||
== mtp_codec::CommunicationType::Register.to_id(&mtp_codec::TypeMap::latest())
|
|
||||||
{
|
|
||||||
sender.close();
|
|
||||||
return Err(AcceptError::AuthenticationFailed(
|
|
||||||
"authentication not allowed on this host".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
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());
|
|
||||||
let description = match first_msg.get_data(DataType::Description) {
|
|
||||||
DataValue::Str(s) => Some(s.clone()),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
Ok(Some(MTPConnection {
|
|
||||||
version: negotiated,
|
|
||||||
codec,
|
|
||||||
sender,
|
|
||||||
receiver,
|
|
||||||
description,
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
auth_state: AuthState::Unauthenticated,
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
client_id: rand::random(),
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
client_public_key: None,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-crypto fallback: no authentication feature, just read and respond.
|
|
||||||
#[cfg(not(feature = "crypto"))]
|
|
||||||
{
|
|
||||||
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());
|
|
||||||
let description = match first_msg.get_data(DataType::Description) {
|
|
||||||
DataValue::Str(s) => Some(s.clone()),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
return Ok(Some(MTPConnection {
|
|
||||||
version: negotiated,
|
|
||||||
codec,
|
|
||||||
sender,
|
|
||||||
receiver,
|
|
||||||
description,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn local_addr(&self) -> std::net::SocketAddr {
|
|
||||||
self.transport.local_addr()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn registry(&self) -> &Registry {
|
|
||||||
&self.registry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
enum Flow {
|
|
||||||
Login {
|
|
||||||
id: u64,
|
|
||||||
bundle: mtp_crypto::PublicKeyBundle,
|
|
||||||
},
|
|
||||||
Register {
|
|
||||||
bundle: mtp_crypto::PublicKeyBundle,
|
|
||||||
pk_bytes: Vec<u8>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
impl MTPHost {
|
|
||||||
/*
|
|
||||||
* Mutually-authenticated handshake with a server-issued challenge.
|
|
||||||
*
|
|
||||||
* 1. C -> H : Identification { version, id } (or Register { version, public_keys })
|
|
||||||
* 2. H -> C : Challenge { server_challenge, host_sig }
|
|
||||||
* 3. C -> H : ChallengeResponse { client_nonce, sig }
|
|
||||||
* 4. H -> C : IdentificationResponse / RegisterResponse { connected, id, sig }
|
|
||||||
*
|
|
||||||
* The client's authenticating signature (step 3) covers `server_challenge`,
|
|
||||||
* a fresh value generated here in step 2 and kept on this task's stack for
|
|
||||||
* the lifetime of the connection. It is therefore one-time per connection
|
|
||||||
* with no shared replay state, and a captured proof cannot be replayed on
|
|
||||||
* any other connection.
|
|
||||||
*/
|
|
||||||
async fn accept_authenticated(
|
|
||||||
&mut self,
|
|
||||||
sender: Sender,
|
|
||||||
receiver: Receiver,
|
|
||||||
) -> Result<Option<MTPConnection>, AcceptError> {
|
|
||||||
use mtp_crypto::PublicKeyBundle;
|
|
||||||
|
|
||||||
let tm = mtp_codec::TypeMap::latest();
|
|
||||||
|
|
||||||
// ===== 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 description = match hello.get_data(DataType::Description) {
|
|
||||||
DataValue::Str(s) => Some(s.clone()),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
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(),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
self.complete_auth_handshake(
|
|
||||||
sender,
|
|
||||||
receiver,
|
|
||||||
flow,
|
|
||||||
response_type,
|
|
||||||
&version_str,
|
|
||||||
client_version,
|
|
||||||
description,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Steps 2-4 of the authenticated handshake, shared by both ForceAuthentication
|
|
||||||
* and AllowAuthentication. Takes the already-parsed hello (step 1) via `flow`,
|
|
||||||
* `version_str`, and `client_version`.
|
|
||||||
*/
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
async fn complete_auth_handshake(
|
|
||||||
&self,
|
|
||||||
sender: Sender,
|
|
||||||
receiver: Receiver,
|
|
||||||
flow: Flow,
|
|
||||||
response_type: mtp_codec::CommunicationType,
|
|
||||||
version_str: &str,
|
|
||||||
client_version: Version,
|
|
||||||
description: Option<String>,
|
|
||||||
) -> Result<Option<MTPConnection>, AcceptError> {
|
|
||||||
use mtp_crypto::{
|
|
||||||
Ed25519Signer, MlDsaSigner, SignatureScheme, auth, verify_ed25519, verify_ml_dsa,
|
|
||||||
};
|
|
||||||
|
|
||||||
let tm = mtp_codec::TypeMap::latest();
|
|
||||||
let pq_enabled = !self
|
|
||||||
.config
|
|
||||||
.host_keyring
|
|
||||||
.sig_pq_secret_key
|
|
||||||
.as_bytes()
|
|
||||||
.is_empty();
|
|
||||||
|
|
||||||
let host_sign = |payload: &[u8]| -> Result<(Vec<u8>, Vec<u8>), AcceptError> {
|
|
||||||
let signer = Ed25519Signer::new(&self.config.host_keyring.sig_cl_secret_key)
|
|
||||||
.map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?;
|
|
||||||
let sig = signer
|
|
||||||
.sign(payload)
|
|
||||||
.map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?;
|
|
||||||
let pq_sig = if pq_enabled {
|
|
||||||
let pq = MlDsaSigner::new(
|
|
||||||
&self.config.host_keyring.sig_pq_secret_key,
|
|
||||||
&self.config.host_keyring.sig_pq_public_key,
|
|
||||||
)
|
|
||||||
.map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?;
|
|
||||||
pq.sign(payload)
|
|
||||||
.map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
Ok((sig, pq_sig))
|
|
||||||
};
|
|
||||||
|
|
||||||
let challenge_id = match &flow {
|
|
||||||
Flow::Login { id, .. } => *id,
|
|
||||||
Flow::Register { .. } => 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== Step 2: issue a fresh, host-signed challenge =====
|
|
||||||
let server_challenge: u128 = rand::random();
|
|
||||||
let (chal_sig, chal_pq_sig) =
|
|
||||||
host_sign(&auth::challenge_payload(challenge_id, server_challenge))?;
|
|
||||||
|
|
||||||
let mut challenge_msg = CommunicationValue::new(mtp_codec::CommunicationType::Challenge)
|
|
||||||
.add_typed_default(
|
|
||||||
DataType::ServerNonce,
|
|
||||||
DataValue::UnsignedNumber(server_challenge),
|
|
||||||
)
|
|
||||||
.add_typed_default(DataType::Signature, DataValue::Bytes(chal_sig));
|
|
||||||
if pq_enabled {
|
|
||||||
challenge_msg = challenge_msg
|
|
||||||
.add_typed_default(DataType::PqSignature, DataValue::Bytes(chal_pq_sig));
|
|
||||||
}
|
|
||||||
if let Err(e) = sender.send(&challenge_msg).await {
|
|
||||||
sender.close();
|
|
||||||
return Err(AcceptError::Send(e));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Step 3: receive and verify the client's proof =====
|
|
||||||
let proof = match receiver.receive().await {
|
|
||||||
Ok(m) => m,
|
|
||||||
Err(e) => {
|
|
||||||
sender.close();
|
|
||||||
return Err(AcceptError::Receive(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if proof.get_type() != mtp_codec::CommunicationType::ChallengeResponse.to_id(&tm) {
|
|
||||||
sender.close();
|
|
||||||
return Err(AcceptError::AuthenticationFailed(
|
|
||||||
"missing challenge response".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let client_nonce = match proof.get_data(DataType::ClientNonce) {
|
|
||||||
DataValue::UnsignedNumber(n) => *n,
|
|
||||||
_ => {
|
|
||||||
sender.close();
|
|
||||||
return Err(AcceptError::AuthenticationFailed(
|
|
||||||
"missing client nonce".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let sig_bytes = match proof.get_data(DataType::Signature) {
|
|
||||||
DataValue::Bytes(b) => b.clone(),
|
|
||||||
_ => {
|
|
||||||
sender.close();
|
|
||||||
return Err(AcceptError::AuthenticationFailed(
|
|
||||||
"missing challenge signature".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let pq_sig_bytes: Vec<u8> = match proof.get_data(DataType::PqSignature) {
|
|
||||||
DataValue::Bytes(b) => b.clone(),
|
|
||||||
_ => vec![],
|
|
||||||
};
|
|
||||||
|
|
||||||
let (proof_payload, bundle) = match &flow {
|
|
||||||
Flow::Login { id, bundle } => (
|
|
||||||
auth::login_proof_payload(version_str, *id, server_challenge, client_nonce),
|
|
||||||
bundle,
|
|
||||||
),
|
|
||||||
Flow::Register {
|
|
||||||
bundle, pk_bytes, ..
|
|
||||||
} => (
|
|
||||||
auth::register_proof_payload(version_str, pk_bytes, server_challenge, client_nonce),
|
|
||||||
bundle,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
let proof_ok = verify_ed25519(&bundle.sig_cl_public_key, &proof_payload, &sig_bytes)
|
|
||||||
.is_ok()
|
|
||||||
&& (pq_sig_bytes.is_empty()
|
|
||||||
|| verify_ml_dsa(&bundle.sig_pq_public_key, &proof_payload, &pq_sig_bytes).is_ok());
|
|
||||||
|
|
||||||
if !proof_ok {
|
|
||||||
let rejection = CommunicationValue::new(response_type)
|
|
||||||
.add_typed_default(DataType::Connected, DataValue::BoolFalse);
|
|
||||||
let _ = sender.send(&rejection).await;
|
|
||||||
sender.close();
|
|
||||||
return Err(AcceptError::AuthenticationFailed(
|
|
||||||
"client proof signature invalid".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proof verified: resolve the assigned id and retain the client's bundle.
|
|
||||||
let (assigned_id, client_bundle) = match flow {
|
|
||||||
Flow::Login { id, bundle } => (id, bundle),
|
|
||||||
Flow::Register { bundle, .. } => {
|
|
||||||
let new_id = (self.config.complete_register)(bundle.clone()).await;
|
|
||||||
(new_id, bundle)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== Step 4: send the host's final confirmation =====
|
|
||||||
let (host_sig, host_pq_sig) = host_sign(&auth::host_final_payload(
|
|
||||||
assigned_id,
|
|
||||||
client_nonce,
|
|
||||||
server_challenge,
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let mut response = CommunicationValue::new(response_type)
|
|
||||||
.add_typed_default(DataType::Connected, DataValue::BoolTrue)
|
|
||||||
.add_typed_default(DataType::Id, DataValue::UnsignedNumber(assigned_id as u128))
|
|
||||||
.add_typed_default(
|
|
||||||
DataType::ClientNonce,
|
|
||||||
DataValue::UnsignedNumber(client_nonce),
|
|
||||||
)
|
|
||||||
.add_typed_default(DataType::Signature, DataValue::Bytes(host_sig));
|
|
||||||
if pq_enabled {
|
|
||||||
response =
|
|
||||||
response.add_typed_default(DataType::PqSignature, DataValue::Bytes(host_pq_sig));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = sender.send(&response).await {
|
|
||||||
sender.close();
|
|
||||||
return Err(AcceptError::Send(e));
|
|
||||||
}
|
|
||||||
if let Err(e) = sender.finish_stream().await {
|
|
||||||
sender.close();
|
|
||||||
return Err(AcceptError::Send(e));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== Version negotiation =====
|
|
||||||
let negotiated = match self
|
|
||||||
.registry
|
|
||||||
.negotiate(std::slice::from_ref(&client_version))
|
|
||||||
{
|
|
||||||
Some(v) => v,
|
|
||||||
None => {
|
|
||||||
sender.close();
|
|
||||||
return Err(AcceptError::UnsupportedVersion(client_version));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let codec = VersionedCodec::new(self.registry.clone());
|
|
||||||
|
|
||||||
Ok(Some(MTPConnection {
|
|
||||||
version: negotiated,
|
|
||||||
codec,
|
|
||||||
sender,
|
|
||||||
receiver,
|
|
||||||
description,
|
|
||||||
auth_state: AuthState::Authenticated,
|
|
||||||
client_id: assigned_id,
|
|
||||||
client_public_key: Some(client_bundle),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Allow-authentication accept: clients may connect with or without
|
|
||||||
* authentication. Register messages always trigger the auth handshake.
|
|
||||||
* Identification with a known client-id triggers login; otherwise the
|
|
||||||
* client is treated as unauthenticated with a random id.
|
|
||||||
*/
|
|
||||||
async fn accept_allow_auth(
|
|
||||||
&mut self,
|
|
||||||
sender: Sender,
|
|
||||||
receiver: Receiver,
|
|
||||||
) -> Result<Option<MTPConnection>, AcceptError> {
|
|
||||||
use mtp_crypto::PublicKeyBundle;
|
|
||||||
|
|
||||||
let tm = mtp_codec::TypeMap::latest();
|
|
||||||
|
|
||||||
let hello = match receiver.receive().await {
|
|
||||||
Ok(m) => m,
|
|
||||||
Err(e) => 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 description = match hello.get_data(DataType::Description) {
|
|
||||||
DataValue::Str(s) => Some(s.clone()),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Register → authenticated registration.
|
|
||||||
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(|_| {
|
|
||||||
sender.close();
|
|
||||||
AcceptError::AuthenticationFailed("invalid public key bundle".into())
|
|
||||||
})?,
|
|
||||||
_ => {
|
|
||||||
sender.close();
|
|
||||||
return Err(AcceptError::AuthenticationFailed(
|
|
||||||
"missing public keys".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let pk_bytes = bundle.as_bytes();
|
|
||||||
return self
|
|
||||||
.complete_auth_handshake(
|
|
||||||
sender,
|
|
||||||
receiver,
|
|
||||||
Flow::Register { bundle, pk_bytes },
|
|
||||||
mtp_codec::CommunicationType::RegisterResponse,
|
|
||||||
&version_str,
|
|
||||||
client_version,
|
|
||||||
description,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Identification with a known client-id → login.
|
|
||||||
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,
|
|
||||||
_ => 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
if cid > 0
|
|
||||||
&& let Some(bundle) = (self.config.get_existing_user)(cid).await
|
|
||||||
{
|
|
||||||
return self
|
|
||||||
.complete_auth_handshake(
|
|
||||||
sender,
|
|
||||||
receiver,
|
|
||||||
Flow::Login { id: cid, bundle },
|
|
||||||
mtp_codec::CommunicationType::IdentificationResponse,
|
|
||||||
&version_str,
|
|
||||||
client_version,
|
|
||||||
description,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown (or zero) client id → unauthenticated connection.
|
|
||||||
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());
|
|
||||||
return Ok(Some(MTPConnection {
|
|
||||||
version: negotiated,
|
|
||||||
codec,
|
|
||||||
sender,
|
|
||||||
receiver,
|
|
||||||
description,
|
|
||||||
auth_state: AuthState::Unauthenticated,
|
|
||||||
client_id: rand::random(),
|
|
||||||
client_public_key: None,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
sender.close();
|
|
||||||
Err(AcceptError::AuthenticationFailed(
|
|
||||||
"unexpected message type".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Extract the protocol version from an initial `CommunicationValue`.
|
|
||||||
*
|
|
||||||
* The client's first message must contain a `Version` data entry
|
|
||||||
* (reserved ID 3) mapping to `DataValue::Str("major.minor")`.
|
|
||||||
*/
|
|
||||||
fn extract_version(msg: &CommunicationValue) -> Option<Version> {
|
|
||||||
let value = msg.get_data(DataType::Version);
|
|
||||||
match value {
|
|
||||||
DataValue::Str(s) => Version::parse(s.as_str()),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================ TESTS ================================ */
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
#[cfg(not(feature = "pipes"))]
|
||||||
|
use mtp_codec::registry::VersionedCodec;
|
||||||
|
use mtp_codec::{CommunicationType, DataType, DataValue};
|
||||||
|
|
||||||
|
#[cfg(not(feature = "pipes"))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
struct AlternateSender;
|
||||||
|
|
||||||
|
#[cfg(not(feature = "pipes"))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
struct AlternateReceiver;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn version_extraction() {
|
fn version_extraction() {
|
||||||
let tm = mtp_codec::TypeMap::latest();
|
let tm = mtp_codec::TypeMap::latest();
|
||||||
let msg = mtp_codec::CommunicationValue::from_comm(
|
let msg = mtp_codec::CommunicationValue::from_comm(CommunicationType::Identification, &tm)
|
||||||
mtp_codec::CommunicationType::Identification,
|
.add_typed(DataType::Version, &tm, DataValue::Str("3.0".to_string()));
|
||||||
&tm,
|
let version = error::extract_version(&msg);
|
||||||
)
|
assert_eq!(version, Some(mtp_codec::Version(3, 0)));
|
||||||
.add_data(
|
|
||||||
DataType::Version.to_id(&tm),
|
|
||||||
DataValue::Str("2.0".to_string()),
|
|
||||||
);
|
|
||||||
let version = extract_version(&msg);
|
|
||||||
assert_eq!(version, Some(Version(2, 0)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn version_extraction_returns_none_for_missing() {
|
fn version_extraction_returns_none_for_missing() {
|
||||||
let tm = mtp_codec::TypeMap::latest();
|
let tm = mtp_codec::TypeMap::latest();
|
||||||
let msg = mtp_codec::CommunicationValue::from_comm(
|
let msg = mtp_codec::CommunicationValue::from_comm(CommunicationType::Identification, &tm);
|
||||||
mtp_codec::CommunicationType::Identification,
|
assert!(error::extract_version(&msg).is_none());
|
||||||
&tm,
|
|
||||||
);
|
|
||||||
assert!(extract_version(&msg).is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn version_extraction_bad_format() {
|
fn version_extraction_bad_format() {
|
||||||
let tm = mtp_codec::TypeMap::latest();
|
let tm = mtp_codec::TypeMap::latest();
|
||||||
let msg = mtp_codec::CommunicationValue::from_comm(
|
let msg = mtp_codec::CommunicationValue::from_comm(CommunicationType::Identification, &tm)
|
||||||
mtp_codec::CommunicationType::Identification,
|
.add_typed(DataType::Version, &tm, DataValue::UnsignedNumber(42));
|
||||||
&tm,
|
assert!(error::extract_version(&msg).is_none());
|
||||||
)
|
|
||||||
.add_data(DataType::Version.to_id(&tm), DataValue::UnsignedNumber(42));
|
|
||||||
assert!(extract_version(&msg).is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
|
|
@ -832,4 +81,34 @@ mod tests {
|
||||||
assert_ne!(AuthState::Unauthenticated, AuthState::Authenticated);
|
assert_ne!(AuthState::Unauthenticated, AuthState::Authenticated);
|
||||||
assert_ne!(AuthState::Pending, AuthState::Authenticated);
|
assert_ne!(AuthState::Pending, AuthState::Authenticated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_config_pongs_default_to_enabled() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let config = HostConfig::new("127.0.0.1".parse()?, 4433, Vec::new(), Vec::new());
|
||||||
|
assert!(config.send_pongs);
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
assert!(config.require_pq);
|
||||||
|
assert!(!config.with_pongs(false).send_pongs);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "pipes"))]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn alternative_transports_use_the_shared_connection_type() {
|
||||||
|
let registry = Registry::builtin();
|
||||||
|
let version = mtp_codec::Version(3, 0);
|
||||||
|
let codec = VersionedCodec::for_version(registry, version.clone()).unwrap();
|
||||||
|
let connection: MTPConnection<AlternateSender, AlternateReceiver> =
|
||||||
|
MTPConnection::from_transport_parts(
|
||||||
|
version.clone(),
|
||||||
|
codec,
|
||||||
|
AlternateSender,
|
||||||
|
AlternateReceiver,
|
||||||
|
"/mtp".into(),
|
||||||
|
Some("browser".into()),
|
||||||
|
);
|
||||||
|
assert_eq!(connection.version, version);
|
||||||
|
assert_eq!(connection.path, "/mtp");
|
||||||
|
assert_eq!(connection.description.as_deref(), Some("browser"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
536
host/src/pipe.rs
Normal file
536
host/src/pipe.rs
Normal file
|
|
@ -0,0 +1,536 @@
|
||||||
|
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap};
|
||||||
|
use mtp_common::{CommunicationError, PipeError};
|
||||||
|
use mtp_transport::{PipeReader, PipeWriter, Policy, TransportEvent};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::Mutex as StdMutex;
|
||||||
|
use tokio::sync::{Mutex, mpsc};
|
||||||
|
|
||||||
|
/// The sender operations needed by the transport-independent pipe protocol.
|
||||||
|
pub trait PipeSender: Clone + Send + Sync + 'static {
|
||||||
|
type Writer: tokio::io::AsyncWrite + Send + Unpin + 'static;
|
||||||
|
|
||||||
|
fn send_pipe_message(
|
||||||
|
&self,
|
||||||
|
message: &CommunicationValue,
|
||||||
|
) -> impl std::future::Future<Output = Result<(), CommunicationError>> + Send;
|
||||||
|
|
||||||
|
fn open_pipe_stream(
|
||||||
|
&self,
|
||||||
|
pipe_id: u32,
|
||||||
|
description: &str,
|
||||||
|
) -> impl std::future::Future<Output = Result<PipeWriter<Self::Writer>, CommunicationError>> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The receiver operations needed by the transport-independent pipe protocol.
|
||||||
|
pub trait PipeReceiver<P>: Clone + Send + Sync + 'static
|
||||||
|
where
|
||||||
|
P: tokio::io::AsyncRead + Send + Unpin + 'static,
|
||||||
|
{
|
||||||
|
fn expect_pipe(&self, pipe_id: u32) -> Result<(), CommunicationError>;
|
||||||
|
|
||||||
|
fn cancel_expected_pipe(&self, pipe_id: u32);
|
||||||
|
|
||||||
|
fn receive_pipe_event(
|
||||||
|
&self,
|
||||||
|
) -> impl std::future::Future<Output = Result<TransportEvent<P>, CommunicationError>> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PipeSender for mtp_transport::Sender {
|
||||||
|
type Writer = wtransport::SendStream;
|
||||||
|
|
||||||
|
async fn send_pipe_message(
|
||||||
|
&self,
|
||||||
|
message: &CommunicationValue,
|
||||||
|
) -> Result<(), CommunicationError> {
|
||||||
|
self.send(message).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_pipe_stream(
|
||||||
|
&self,
|
||||||
|
pipe_id: u32,
|
||||||
|
description: &str,
|
||||||
|
) -> Result<PipeWriter<Self::Writer>, CommunicationError> {
|
||||||
|
self.open_pipe(pipe_id, description).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PipeReceiver<wtransport::RecvStream> for mtp_transport::Receiver {
|
||||||
|
fn expect_pipe(&self, pipe_id: u32) -> Result<(), CommunicationError> {
|
||||||
|
self.expect_pipe(pipe_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_expected_pipe(&self, pipe_id: u32) {
|
||||||
|
self.cancel_expected_pipe(pipe_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive_pipe_event(
|
||||||
|
&self,
|
||||||
|
) -> Result<TransportEvent<wtransport::RecvStream>, CommunicationError> {
|
||||||
|
self.receive_event().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C> PipeSender for mtp_transport::GenericSender<C>
|
||||||
|
where
|
||||||
|
C: mtp_transport::TransportConnection,
|
||||||
|
C::SendStream: tokio::io::AsyncWrite + Send + Unpin + 'static,
|
||||||
|
{
|
||||||
|
type Writer = C::SendStream;
|
||||||
|
|
||||||
|
async fn send_pipe_message(
|
||||||
|
&self,
|
||||||
|
message: &CommunicationValue,
|
||||||
|
) -> Result<(), CommunicationError> {
|
||||||
|
self.send(message).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_pipe_stream(
|
||||||
|
&self,
|
||||||
|
pipe_id: u32,
|
||||||
|
description: &str,
|
||||||
|
) -> Result<PipeWriter<Self::Writer>, CommunicationError> {
|
||||||
|
self.open_pipe(pipe_id, description).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C> PipeReceiver<C::RecvStream> for mtp_transport::GenericReceiver<C>
|
||||||
|
where
|
||||||
|
C: mtp_transport::TransportConnection,
|
||||||
|
C::RecvStream: tokio::io::AsyncRead + Send + Unpin + 'static,
|
||||||
|
{
|
||||||
|
fn expect_pipe(&self, pipe_id: u32) -> Result<(), CommunicationError> {
|
||||||
|
self.expect_pipe(pipe_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_expected_pipe(&self, pipe_id: u32) {
|
||||||
|
self.cancel_expected_pipe(pipe_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive_pipe_event(
|
||||||
|
&self,
|
||||||
|
) -> Result<TransportEvent<C::RecvStream>, CommunicationError> {
|
||||||
|
self.receive_event().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PipeHandle<S: PipeSender, P = wtransport::RecvStream> {
|
||||||
|
pub(crate) pipe_id: u32,
|
||||||
|
pub(crate) description: String,
|
||||||
|
pub(crate) sender: S,
|
||||||
|
pub(crate) response_rx: tokio::sync::oneshot::Receiver<Result<bool, PipeError>>,
|
||||||
|
pub(crate) dispatcher: Arc<PipeDispatcher<P>>,
|
||||||
|
pub(crate) token: Arc<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S, P> PipeHandle<S, P>
|
||||||
|
where
|
||||||
|
S: PipeSender,
|
||||||
|
P: tokio::io::AsyncRead + Send + Unpin + 'static,
|
||||||
|
{
|
||||||
|
pub fn pipe_id(&self) -> u32 {
|
||||||
|
self.pipe_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn description(&self) -> &str {
|
||||||
|
&self.description
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn wait(mut self) -> Result<Option<PipeWriter<S::Writer>>, PipeError> {
|
||||||
|
let response =
|
||||||
|
tokio::time::timeout(self.dispatcher.policy.read_timeout, &mut self.response_rx).await;
|
||||||
|
match response {
|
||||||
|
Ok(Ok(Ok(true))) => self
|
||||||
|
.sender
|
||||||
|
.open_pipe_stream(self.pipe_id, &self.description)
|
||||||
|
.await
|
||||||
|
.map(Some)
|
||||||
|
.map_err(PipeError::from),
|
||||||
|
Ok(Ok(Ok(false))) => Ok(None),
|
||||||
|
Ok(Ok(Err(error))) => {
|
||||||
|
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
Ok(Err(_)) => {
|
||||||
|
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
|
||||||
|
Err(PipeError::StreamClosed)
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
|
||||||
|
Err(PipeError::HandshakeTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S, P> Drop for PipeHandle<S, P>
|
||||||
|
where
|
||||||
|
S: PipeSender,
|
||||||
|
{
|
||||||
|
fn drop(&mut self) {
|
||||||
|
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PipeRequest<S, R, P> {
|
||||||
|
pub(crate) pipe_id: u32,
|
||||||
|
pub(crate) description: String,
|
||||||
|
pub(crate) sender: S,
|
||||||
|
pub(crate) receiver: R,
|
||||||
|
pub(crate) dispatcher: Arc<PipeDispatcher<P>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExpectedPipeGuard<R, P>
|
||||||
|
where
|
||||||
|
R: PipeReceiver<P>,
|
||||||
|
P: tokio::io::AsyncRead + Send + Unpin + 'static,
|
||||||
|
{
|
||||||
|
receiver: R,
|
||||||
|
pipe_id: u32,
|
||||||
|
armed: bool,
|
||||||
|
_stream: std::marker::PhantomData<P>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R, P> ExpectedPipeGuard<R, P>
|
||||||
|
where
|
||||||
|
R: PipeReceiver<P>,
|
||||||
|
P: tokio::io::AsyncRead + Send + Unpin + 'static,
|
||||||
|
{
|
||||||
|
fn new(receiver: R, pipe_id: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
receiver,
|
||||||
|
pipe_id,
|
||||||
|
armed: true,
|
||||||
|
_stream: std::marker::PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disarm(&mut self) {
|
||||||
|
self.armed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R, P> Drop for ExpectedPipeGuard<R, P>
|
||||||
|
where
|
||||||
|
R: PipeReceiver<P>,
|
||||||
|
P: tokio::io::AsyncRead + Send + Unpin + 'static,
|
||||||
|
{
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.armed {
|
||||||
|
self.receiver.cancel_expected_pipe(self.pipe_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S, R, P> PipeRequest<S, R, P>
|
||||||
|
where
|
||||||
|
S: PipeSender,
|
||||||
|
R: PipeReceiver<P>,
|
||||||
|
P: tokio::io::AsyncRead + Send + Unpin + 'static,
|
||||||
|
{
|
||||||
|
pub fn id(&self) -> u32 {
|
||||||
|
self.pipe_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn description(&self) -> &str {
|
||||||
|
&self.description
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn accept(self) -> Result<PipeReader<P>, PipeError> {
|
||||||
|
self.receiver
|
||||||
|
.expect_pipe(self.pipe_id)
|
||||||
|
.map_err(PipeError::from)?;
|
||||||
|
let mut expected_pipe = ExpectedPipeGuard::<R, P>::new(self.receiver.clone(), self.pipe_id);
|
||||||
|
let (pipe_tx, pipe_rx) = tokio::sync::oneshot::channel();
|
||||||
|
self.dispatcher
|
||||||
|
.pending_pipes
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(self.pipe_id, pipe_tx);
|
||||||
|
|
||||||
|
let response = CommunicationValue::new_with_type_map(
|
||||||
|
CommunicationType::PipeResponse,
|
||||||
|
&self.dispatcher.type_map,
|
||||||
|
)
|
||||||
|
.with_id(self.pipe_id)
|
||||||
|
.add_typed_default(DataType::Accepted, DataValue::BoolTrue);
|
||||||
|
if let Err(error) = self.sender.send_pipe_message(&response).await {
|
||||||
|
self.dispatcher
|
||||||
|
.pending_pipes
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.remove(&self.pipe_id);
|
||||||
|
return Err(PipeError::from(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
match tokio::time::timeout(self.dispatcher.policy.read_timeout, pipe_rx).await {
|
||||||
|
Ok(Ok(reader)) => {
|
||||||
|
expected_pipe.disarm();
|
||||||
|
Ok(reader)
|
||||||
|
}
|
||||||
|
Ok(Err(_)) => {
|
||||||
|
self.dispatcher
|
||||||
|
.pending_pipes
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.remove(&self.pipe_id);
|
||||||
|
Err(PipeError::StreamClosed)
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
self.dispatcher
|
||||||
|
.pending_pipes
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.remove(&self.pipe_id);
|
||||||
|
Err(PipeError::HandshakeTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn deny(self) -> Result<(), PipeError> {
|
||||||
|
let response = CommunicationValue::new_with_type_map(
|
||||||
|
CommunicationType::PipeResponse,
|
||||||
|
&self.dispatcher.type_map,
|
||||||
|
)
|
||||||
|
.with_id(self.pipe_id)
|
||||||
|
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
|
||||||
|
self.sender
|
||||||
|
.send_pipe_message(&response)
|
||||||
|
.await
|
||||||
|
.map_err(PipeError::from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct PipeDispatcher<P> {
|
||||||
|
pub(crate) pending_creations: StdMutex<HashMap<u32, PendingCreation>>,
|
||||||
|
pub(crate) expired_creations: StdMutex<HashMap<u32, tokio::time::Instant>>,
|
||||||
|
pub(crate) pending_pipes: Mutex<HashMap<u32, tokio::sync::oneshot::Sender<PipeReader<P>>>>,
|
||||||
|
pub(crate) policy: Arc<Policy>,
|
||||||
|
pub(crate) type_map: TypeMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct PendingCreation {
|
||||||
|
pub(crate) token: Arc<()>,
|
||||||
|
pub(crate) sender: tokio::sync::oneshot::Sender<Result<bool, PipeError>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct PendingCreationGuard<P> {
|
||||||
|
dispatcher: Arc<PipeDispatcher<P>>,
|
||||||
|
pipe_id: u32,
|
||||||
|
token: Arc<()>,
|
||||||
|
armed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<P> PendingCreationGuard<P> {
|
||||||
|
pub(crate) fn new(dispatcher: Arc<PipeDispatcher<P>>, pipe_id: u32, token: Arc<()>) -> Self {
|
||||||
|
Self {
|
||||||
|
dispatcher,
|
||||||
|
pipe_id,
|
||||||
|
token,
|
||||||
|
armed: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn disarm(&mut self) {
|
||||||
|
self.armed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<P> Drop for PendingCreationGuard<P> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.armed {
|
||||||
|
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPIRED_CREATION_TOMBSTONE_TTL: tokio::time::Duration = tokio::time::Duration::from_secs(60);
|
||||||
|
const MAX_EXPIRED_CREATION_TOMBSTONES: usize = 1024;
|
||||||
|
|
||||||
|
pub(crate) fn expire_pending_creation<P>(
|
||||||
|
dispatcher: &PipeDispatcher<P>,
|
||||||
|
pipe_id: u32,
|
||||||
|
token: &Arc<()>,
|
||||||
|
) {
|
||||||
|
let removed = dispatcher
|
||||||
|
.pending_creations
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut pending| {
|
||||||
|
if pending
|
||||||
|
.get(&pipe_id)
|
||||||
|
.is_some_and(|entry| Arc::ptr_eq(&entry.token, token))
|
||||||
|
{
|
||||||
|
pending.remove(&pipe_id);
|
||||||
|
Some(())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if removed.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(mut expired) = dispatcher.expired_creations.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let now = tokio::time::Instant::now();
|
||||||
|
expired.retain(|_, expires_at| *expires_at > now);
|
||||||
|
if expired.len() >= MAX_EXPIRED_CREATION_TOMBSTONES
|
||||||
|
&& let Some(oldest) = expired
|
||||||
|
.iter()
|
||||||
|
.min_by_key(|(_, expires_at)| **expires_at)
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
{
|
||||||
|
expired.remove(&oldest);
|
||||||
|
}
|
||||||
|
expired.insert(pipe_id, now + EXPIRED_CREATION_TOMBSTONE_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn consume_expired_creation<P>(dispatcher: &PipeDispatcher<P>, pipe_id: u32) -> bool {
|
||||||
|
let Ok(mut expired) = dispatcher.expired_creations.lock() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let now = tokio::time::Instant::now();
|
||||||
|
expired.retain(|_, expires_at| *expires_at > now);
|
||||||
|
expired.remove(&pipe_id).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_expired_creation<P>(dispatcher: &PipeDispatcher<P>, pipe_id: u32) -> bool {
|
||||||
|
let Ok(mut expired) = dispatcher.expired_creations.lock() else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let now = tokio::time::Instant::now();
|
||||||
|
expired.retain(|_, expires_at| *expires_at > now);
|
||||||
|
expired.contains_key(&pipe_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn fail_pending_creations<P>(
|
||||||
|
dispatcher: &PipeDispatcher<P>,
|
||||||
|
error: &CommunicationError,
|
||||||
|
) {
|
||||||
|
let pending = dispatcher
|
||||||
|
.pending_creations
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.map(|mut pending| std::mem::take(&mut *pending));
|
||||||
|
if let Some(pending) = pending {
|
||||||
|
let error = PipeError::from(error.clone());
|
||||||
|
for (_, pending) in pending {
|
||||||
|
let _ = pending.sender.send(Err(error.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(mut expired) = dispatcher.expired_creations.lock() {
|
||||||
|
expired.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn fail_pending_pipes<P>(dispatcher: &PipeDispatcher<P>) {
|
||||||
|
dispatcher.pending_pipes.lock().await.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn run_dispatcher<S, R, P>(
|
||||||
|
receiver: R,
|
||||||
|
sender: S,
|
||||||
|
app_tx: mpsc::Sender<Result<CommunicationValue, CommunicationError>>,
|
||||||
|
pipe_req_tx: mpsc::Sender<PipeRequest<S, R, P>>,
|
||||||
|
dispatcher: Arc<PipeDispatcher<P>>,
|
||||||
|
) where
|
||||||
|
S: PipeSender,
|
||||||
|
R: PipeReceiver<P>,
|
||||||
|
P: tokio::io::AsyncRead + Send + Unpin + 'static,
|
||||||
|
{
|
||||||
|
loop {
|
||||||
|
match receiver.receive_pipe_event().await {
|
||||||
|
Ok(TransportEvent::Message(message)) => {
|
||||||
|
if message.is_type(CommunicationType::PipeRequest) {
|
||||||
|
let Some(pipe_id) = message.id().filter(|id| *id != 0) else {
|
||||||
|
let error = CommunicationError::Other(
|
||||||
|
"PipeRequest frame must contain a non-zero id".into(),
|
||||||
|
);
|
||||||
|
if app_tx.send(Err(error)).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let request = PipeRequest {
|
||||||
|
pipe_id,
|
||||||
|
description: message
|
||||||
|
.get_str(DataType::Description)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_owned(),
|
||||||
|
sender: sender.clone(),
|
||||||
|
receiver: receiver.clone(),
|
||||||
|
dispatcher: dispatcher.clone(),
|
||||||
|
};
|
||||||
|
let _ = pipe_req_tx.send(request).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if message.is_type(CommunicationType::PipeResponse) {
|
||||||
|
let Some(pipe_id) = message.id().filter(|id| *id != 0) else {
|
||||||
|
let error = CommunicationError::Other(
|
||||||
|
"PipeResponse frame must contain a non-zero id".into(),
|
||||||
|
);
|
||||||
|
if app_tx.send(Err(error)).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let pending = dispatcher
|
||||||
|
.pending_creations
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.and_then(|mut pending| pending.remove(&pipe_id));
|
||||||
|
if let Some(entry) = pending {
|
||||||
|
let _ = entry
|
||||||
|
.sender
|
||||||
|
.send(Ok(message.get_bool(DataType::Accepted).unwrap_or(false)));
|
||||||
|
} else if consume_expired_creation(&dispatcher, pipe_id) {
|
||||||
|
tracing::debug!(pipe_id, "ignored late pipe creation response");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !matches!(message.id(), Some(id) if id != 0)
|
||||||
|
&& message
|
||||||
|
.get_type_name()
|
||||||
|
.is_some_and(|name| name.ends_with("Response"))
|
||||||
|
{
|
||||||
|
let error = CommunicationError::Other(
|
||||||
|
"response frame must contain a non-zero id".into(),
|
||||||
|
);
|
||||||
|
if app_tx.send(Err(error)).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if app_tx.send(Ok(message)).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(TransportEvent::Pipe(reader)) => {
|
||||||
|
let pipe_id = reader.pipe_id();
|
||||||
|
let mut pending = dispatcher.pending_pipes.lock().await;
|
||||||
|
if let Some(reply) = pending.remove(&pipe_id) {
|
||||||
|
let _ = reply.send(reader);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
drop(pending);
|
||||||
|
let request = PipeRequest {
|
||||||
|
pipe_id,
|
||||||
|
description: reader.description().to_owned(),
|
||||||
|
sender: sender.clone(),
|
||||||
|
receiver: receiver.clone(),
|
||||||
|
dispatcher: dispatcher.clone(),
|
||||||
|
};
|
||||||
|
let _ = pipe_req_tx.send(request).await;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
fail_pending_creations(&dispatcher, &error);
|
||||||
|
fail_pending_pipes(&dispatcher).await;
|
||||||
|
if app_tx.send(Err(error)).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
mtp-webserver/Cargo.toml
Normal file
36
mtp-webserver/Cargo.toml
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
[package]
|
||||||
|
name = "mtp-webserver"
|
||||||
|
version = "0.3.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
mtp-common = { version = "0.3.0", path = "../common" }
|
||||||
|
mtp-codec = { version = "0.3.0", path = "../codec", features = ["registry"] }
|
||||||
|
mtp-host = { version = "0.3.0", path = "../host" }
|
||||||
|
mtp-transport = { version = "0.3.0", path = "../transport" }
|
||||||
|
mtp-crypto = { version = "0.3.0", path = "../crypto" }
|
||||||
|
bytes = "1"
|
||||||
|
http = "1"
|
||||||
|
tokio = { version = "1", features = ["io-util", "macros", "net", "rt", "sync", "time"] }
|
||||||
|
hyper = { version = "1", features = ["server", "http1", "http2"] }
|
||||||
|
hyper-util = { version = "0.1", features = ["server", "http1", "http2", "tokio"] }
|
||||||
|
http-body-util = "0.1"
|
||||||
|
tokio-rustls = "0.26"
|
||||||
|
tokio-stream = "0.1"
|
||||||
|
h3 = "0.0.8"
|
||||||
|
h3-quinn = { version = "0.0.10", features = ["datagram"] }
|
||||||
|
h3-webtransport = "0.1.2"
|
||||||
|
quinn = "0.11"
|
||||||
|
rustls = "0.23"
|
||||||
|
tracing = "0.1"
|
||||||
|
thiserror = "2"
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
rcgen = "0.14"
|
||||||
|
hyper = { version = "1", features = ["client", "http2"] }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
crypto = ["mtp-host/crypto"]
|
||||||
|
pipes = ["mtp-host/pipes", "mtp-transport/pipes"]
|
||||||
41
mtp-webserver/src/error.rs
Normal file
41
mtp-webserver/src/error.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
use mtp_common::CommunicationError;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// Unified error type for the webserver transport adapter.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum WebServerError {
|
||||||
|
Transport(CommunicationError),
|
||||||
|
WebTransport(String),
|
||||||
|
Tls(String),
|
||||||
|
Http(String),
|
||||||
|
PayloadTooLarge,
|
||||||
|
NotFound(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for WebServerError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Transport(e) => write!(f, "transport error: {e}"),
|
||||||
|
Self::WebTransport(msg) => write!(f, "webtransport error: {msg}"),
|
||||||
|
Self::Tls(msg) => write!(f, "TLS error: {msg}"),
|
||||||
|
Self::Http(msg) => write!(f, "HTTP error: {msg}"),
|
||||||
|
Self::PayloadTooLarge => write!(f, "HTTP request body is too large"),
|
||||||
|
Self::NotFound(route) => write!(f, "route not found: {route}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for WebServerError {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Transport(e) => Some(e),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CommunicationError> for WebServerError {
|
||||||
|
fn from(e: CommunicationError) -> Self {
|
||||||
|
Self::Transport(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue