Compare commits
157 changed files with 10127 additions and 38554 deletions
|
|
@ -1,5 +1,5 @@
|
|||
[env]
|
||||
MTP_TYPE_MAPS = { value = "example/type-maps.yaml", relative = true }
|
||||
MTP_TYPE_MAPS = { value = "example-type-maps.yaml", relative = true }
|
||||
|
||||
# web-sys's WebTransport* bindings are behind unstable APIs, gated by this cfg.
|
||||
# Scoped to the wasm32 target so it applies to the wasm crate however cargo is
|
||||
|
|
|
|||
1
.envrc
1
.envrc
|
|
@ -1 +0,0 @@
|
|||
use flake
|
||||
|
|
@ -7,14 +7,18 @@ on:
|
|||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
NIX_CONFIG: experimental-features = nix-command flakes
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
name: checks
|
||||
runs-on: nixos
|
||||
steps:
|
||||
- name: Install node
|
||||
run: nix profile add nixpkgs#nodejs_24
|
||||
|
||||
- name: Checkout
|
||||
uses: https://data.forgejo.org/actions/checkout@v7
|
||||
uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Run checks
|
||||
run: |
|
||||
|
|
@ -29,6 +33,7 @@ jobs:
|
|||
cargo machete
|
||||
|
||||
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
|
||||
|
|
@ -36,9 +41,6 @@ jobs:
|
|||
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
|
||||
|
|
|
|||
|
|
@ -14,18 +14,27 @@ on:
|
|||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
NIX_CONFIG: experimental-features = nix-command flakes
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: nixos
|
||||
steps:
|
||||
- name: Install node & bun
|
||||
run: nix profile add nixpkgs#nodejs_24 nixpkgs#bun
|
||||
|
||||
- name: Check out repo
|
||||
uses: https://data.forgejo.org/actions/checkout@v7
|
||||
uses: https://data.forgejo.org/actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Install cc linker, sed & jq
|
||||
run: nix profile add nixpkgs#stdenv.cc nixpkgs#gnused nixpkgs#jq
|
||||
|
||||
- name: Build all
|
||||
run: bun build:all
|
||||
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -5,5 +5,3 @@ node_modules/
|
|||
dist/
|
||||
*.tgz
|
||||
wasm/pkg/
|
||||
web_client/
|
||||
.direnv
|
||||
|
|
|
|||
567
Cargo.lock
generated
567
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
29
Cargo.toml
29
Cargo.toml
|
|
@ -45,24 +45,24 @@ resolver = "3"
|
|||
# =============================================================================
|
||||
[package]
|
||||
name = "mtp"
|
||||
version = "0.3.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
# --- always-on core ---
|
||||
mtp-common = { version = "0.3.0", path = "common" }
|
||||
mtp-type-map = { version = "0.3.0", path = "type-map" }
|
||||
mtp-codec = { version = "0.3.0", path = "codec" }
|
||||
mtp-common = { version = "0.2.0", path = "common" }
|
||||
mtp-type-map = { version = "0.2.0", path = "type-map" }
|
||||
mtp-codec = { version = "0.2.0", path = "codec" }
|
||||
# --- optional, behind features ---
|
||||
mtp-crypto = { version = "0.3.0", path = "crypto", optional = true, features = [
|
||||
mtp-crypto = { version = "0.2.0", path = "crypto", optional = true, features = [
|
||||
"serde",
|
||||
"mlkem-tls",
|
||||
] }
|
||||
mtp-host = { version = "0.3.0", path = "host", optional = true }
|
||||
mtp-client = { version = "0.3.0", path = "client", optional = true }
|
||||
mtp-files = { version = "0.3.0", path = "files", optional = true }
|
||||
mtp-webserver = { version = "0.3.0", path = "mtp-webserver", optional = true }
|
||||
mtp-transport = { version = "0.3.0", path = "transport", optional = true }
|
||||
mtp-host = { version = "0.2.0", path = "host", optional = true }
|
||||
mtp-client = { version = "0.2.0", path = "client", optional = true }
|
||||
mtp-files = { version = "0.2.0", path = "files", optional = true }
|
||||
mtp-webserver = { version = "0.2.0", path = "mtp-webserver", optional = true }
|
||||
mtp-transport = { version = "0.2.0", path = "transport", optional = true }
|
||||
|
||||
[features]
|
||||
# Serialization
|
||||
|
|
@ -96,10 +96,6 @@ pipes = ["mtp-common/pipes", "mtp-codec/pipes", "mtp-transport?/pipes", "mtp-hos
|
|||
# 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"]
|
||||
|
||||
|
|
@ -113,5 +109,10 @@ tls = ["crypto", "mtp-crypto?/tls"]
|
|||
# Requires MTP_INSECURE_TLS=1 at runtime.
|
||||
insecure-tls = ["dep:mtp-transport", "mtp-transport?/insecure-tls"]
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
rcgen = "0.14"
|
||||
rand = "0.10.1"
|
||||
|
||||
[package.metadata.cargo-machete]
|
||||
ignored = ["mtp-transport"]
|
||||
|
|
|
|||
27
README.md
27
README.md
|
|
@ -47,33 +47,22 @@ Feature summary:
|
|||
|
||||
| Feature | Pulls in | Enables |
|
||||
| --- | --- | --- |
|
||||
| `serde` | Crypto serialization support | Serde implementations for crypto key types |
|
||||
| `crypto` | `mtp::crypto` | AEAD, signatures, KEM, KDF, hashing, and connection authentication support |
|
||||
| `host` | `mtp::host` | Native QUIC host and version negotiation |
|
||||
| `client` | `mtp::client` | Native QUIC client connections |
|
||||
| `transport` | `mtp-transport` dependency | Low-level transport support; enabled automatically by `host` and `client` |
|
||||
| `pipes` | Pipe support in transport, host, client, and web server | Raw and encrypted byte streams |
|
||||
| `files` | `mtp::files` | `.mk` keyrings and `.mpkb` public bundles; also enables `crypto` |
|
||||
| `raw` | Raw file APIs | Legacy plaintext keyring migration APIs |
|
||||
| `web-server` | `mtp::webserver` | HTTPS server with HTTP/1.1, HTTP/2, HTTP/3, and WebTransport MTP sessions |
|
||||
| `full-server` | Native host and web-server surface | `host`, `web-server`, `crypto`, and `pipes` together |
|
||||
| `tls` | `mtp::crypto::tls` | Development self-signed certificate generation |
|
||||
| `insecure-tls` | Lower-level transport | Development-only certificate verification bypass, gated by `MTP_INSECURE_TLS=1` |
|
||||
| `crypto` | `mtp::crypto` | AEAD, signatures, KEM, KDF, hashing |
|
||||
| `host` | `mtp::host`, codec registry | QUIC host and version negotiation |
|
||||
| `client` | `mtp::client` | QUIC client connections |
|
||||
| `webserver` | `mtp::webserver` | HTTPS server with HTTP/1.1, HTTP/2, HTTP/3, and WebTransport MTP sessions |
|
||||
|
||||
The core modules always available from the facade are `codec`, `common`, and
|
||||
`type_map`. Native `client` and `host` modules re-export the transport policy
|
||||
types; the low-level transport crate is not exposed as `mtp::transport`. See the [native client](./docs/NATIVE-CLIENT.md) and [native host](./docs/NATIVE-HOST.md)
|
||||
The core crates are always available: `codec`, `transport`, `common`, and `type_map`. See the [native client](./docs/NATIVE-CLIENT.md) and [native host](./docs/NATIVE-HOST.md)
|
||||
guides for configuration and usage. See [Security](./docs/SECURITY.md) for security boundaries.
|
||||
|
||||
## Sub-crates
|
||||
|
||||
The `mtp` facade re-exports the following modules:
|
||||
`mtp::codec`, `mtp::common`, `mtp::type_map`, `mtp::crypto`, `mtp::host`,
|
||||
`mtp::client`, `mtp::files`, and `mtp::webserver` when their features are enabled.
|
||||
`mtp::codec`, `mtp::transport`, `mtp::common`, `mtp::type_map`, `mtp::crypto`, `mtp::host`, and `mtp::client`.
|
||||
|
||||
### Codec
|
||||
|
||||
The codec encodes and decodes MTP frames using Communication Types and Data Types resolved through a version-specific type map. It supports self-delimiting containers, integers, booleans, floats, strings, arrays, bytes, null values, and composable `Signed<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.
|
||||
The codec encodes and decodes MTP frames using Communication Types and Data Types resolved through a version-specific type map. It supports containers, integers, booleans, floats, strings, arrays, bytes, null values, and optional signed or encrypted containers. See [Type Map](./docs/TYPE-MAP.md) for mapping configuration and [Connector](./docs/CONNECTOR.md) for negotiated codecs.
|
||||
|
||||
### Transport
|
||||
|
||||
|
|
@ -101,7 +90,7 @@ The type-map build script reads YAML and generates `CommunicationType` and `Data
|
|||
|
||||
### Crypto
|
||||
|
||||
`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).
|
||||
`mtp-crypto` provides AEAD encryption, Ed25519 and ML-DSA-65 signatures, X25519 plus ML-KEM-768 hybrid KEM support, HKDF, SHA-256, keyrings, encrypted containers, and certificate generation for development. Feature flags and security boundaries: [Security](./docs/SECURITY.md).
|
||||
|
||||
## Examples
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
[package]
|
||||
name = "mtp-client"
|
||||
version = "0.3.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
mtp-common = { version = "0.3.0", path = "../common" }
|
||||
mtp-codec = { version = "0.3.0", path = "../codec", features = ["registry"] }
|
||||
mtp-transport = { version = "0.3.0", path = "../transport" }
|
||||
mtp-crypto = { version = "0.3.0", path = "../crypto", optional = true }
|
||||
mtp-common = { version = "0.2.0", path = "../common" }
|
||||
mtp-codec = { version = "0.2.0", path = "../codec" }
|
||||
mtp-transport = { version = "0.2.0", path = "../transport" }
|
||||
mtp-crypto = { version = "0.2.0", path = "../crypto", optional = true }
|
||||
rand = "0.10.1"
|
||||
tokio = { version = "1", features = ["rt", "sync", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
mtp-host = { version = "0.3.0", path = "../host" }
|
||||
mtp-transport = { version = "0.3.0", path = "../transport", features = ["host"] }
|
||||
mtp-host = { version = "0.2.0", path = "../host" }
|
||||
mtp-transport = { version = "0.2.0", path = "../transport", features = ["host"] }
|
||||
rcgen = "0.14"
|
||||
|
||||
[features]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use mtp_codec::{CommunicationValue, Version, registry::VersionedCodec};
|
||||
use mtp_codec::{CommunicationValue, Version};
|
||||
#[cfg(feature = "pipes")]
|
||||
use mtp_codec::{DataType, DataValue};
|
||||
use mtp_common::CommunicationError;
|
||||
|
|
@ -13,15 +13,10 @@ 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>,
|
||||
|
|
@ -50,19 +45,12 @@ impl MTPConnection {
|
|||
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()))?;
|
||||
let request_id = request.get_id();
|
||||
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(());
|
||||
|
|
@ -98,7 +86,7 @@ impl MTPConnection {
|
|||
result?
|
||||
}
|
||||
Err(_) => {
|
||||
crate::pipe::expire_pending_request(&self.pipe_dispatcher, request_id, &token)
|
||||
crate::pipe::remove_pending_request(&self.pipe_dispatcher, request_id, &token)
|
||||
.await;
|
||||
return Err(CommunicationError::Other(format!(
|
||||
"request {request_id} timed out after {:?}",
|
||||
|
|
@ -108,7 +96,7 @@ impl MTPConnection {
|
|||
};
|
||||
|
||||
if let Some(expected) = expected_response {
|
||||
let expected_type = expected.try_to_id(self.codec.type_map());
|
||||
let expected_type = expected.try_to_id(&mtp_codec::TypeMap::latest());
|
||||
if Some(response.get_type()) != expected_type {
|
||||
return Err(CommunicationError::Other(format!(
|
||||
"unexpected response type: expected {:?}, got {:?}; parsed {}",
|
||||
|
|
@ -137,54 +125,28 @@ impl MTPConnection {
|
|||
&self,
|
||||
description: &str,
|
||||
) -> Result<crate::pipe::PipeHandle, mtp_common::PipeError> {
|
||||
let pipe_id = rand::random::<u32>();
|
||||
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));
|
||||
{
|
||||
let mut pending = self.pipe_dispatcher.pending_creations.lock().await;
|
||||
pending.insert(pipe_id, tx);
|
||||
}
|
||||
|
||||
creation_guard.disarm();
|
||||
let request = CommunicationValue::new(mtp_codec::CommunicationType::PipeRequest)
|
||||
.with_id(pipe_id)
|
||||
.add_typed_default(DataType::Description, DataValue::Str(description.into()));
|
||||
|
||||
self.sender
|
||||
.send(&request)
|
||||
.await
|
||||
.map_err(mtp_common::PipeError::from)?;
|
||||
|
||||
Ok(crate::pipe::PipeHandle {
|
||||
pipe_id,
|
||||
description: description.to_string(),
|
||||
sender: self.sender.clone(),
|
||||
response_rx: rx,
|
||||
dispatcher: self.pipe_dispatcher.clone(),
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -197,47 +159,28 @@ impl MTPConnection {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn connection_from_parts(
|
||||
pub(crate) 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;
|
||||
let ping = start_ping_session(&config, sender.clone(), &receiver);
|
||||
|
||||
#[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,
|
||||
config.policy.receiver_queue_capacity,
|
||||
);
|
||||
let (pipe_req_tx, pipe_req_rx) = mpsc::channel::<PipeRequest>(receiver_queue_capacity);
|
||||
let (pipe_req_tx, pipe_req_rx) =
|
||||
mpsc::channel::<PipeRequest>(config.policy.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_creations: Mutex::new(std::collections::HashMap::new()),
|
||||
pending_pipes: Mutex::new(std::collections::HashMap::new()),
|
||||
policy: Arc::new(config.policy),
|
||||
});
|
||||
|
|
@ -254,7 +197,6 @@ pub(crate) async fn connection_from_parts(
|
|||
|
||||
MTPConnection {
|
||||
version,
|
||||
codec,
|
||||
sender,
|
||||
receiver,
|
||||
app_rx: Mutex::new(app_rx),
|
||||
|
|
@ -274,21 +216,16 @@ pub(crate) async fn connection_from_parts(
|
|||
|
||||
#[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,
|
||||
config.policy.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),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, Version};
|
||||
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, Version};
|
||||
use mtp_common::CommunicationError;
|
||||
|
||||
pub(crate) fn unexpected_response_type_error(
|
||||
|
|
@ -24,7 +24,7 @@ pub(crate) async fn verify_host_challenge(
|
|||
use mtp_crypto::{auth, verify_ed25519};
|
||||
|
||||
let sig = match challenge.get_data(DataType::Signature) {
|
||||
Some(DataValue::Bytes(b)) => b.clone(),
|
||||
DataValue::Bytes(b) => b.clone(),
|
||||
_ => {
|
||||
return Err(CommunicationError::AuthenticationFailed(
|
||||
"Missing host challenge signature".into(),
|
||||
|
|
@ -32,11 +32,11 @@ pub(crate) async fn verify_host_challenge(
|
|||
}
|
||||
};
|
||||
let pq_sig = match challenge.get_data(DataType::PqSignature) {
|
||||
Some(DataValue::Bytes(b)) => b.clone(),
|
||||
DataValue::Bytes(b) => b.clone(),
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
let host_requires_pq = challenge.get_data(DataType::RequirePq) == Some(&DataValue::BoolTrue);
|
||||
let host_requires_pq = challenge.get_data(DataType::RequirePq) == &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(),
|
||||
|
|
@ -80,7 +80,7 @@ pub(crate) async fn verify_host_final(
|
|||
use mtp_crypto::{auth, verify_ed25519};
|
||||
|
||||
match response.get_data(DataType::ClientNonce) {
|
||||
Some(DataValue::UnsignedNumber(n)) if *n == client_nonce => {}
|
||||
DataValue::UnsignedNumber(n) if *n == client_nonce => {}
|
||||
_ => {
|
||||
return Err(CommunicationError::AuthenticationFailed(
|
||||
"Nonce mismatch".into(),
|
||||
|
|
@ -89,7 +89,7 @@ pub(crate) async fn verify_host_final(
|
|||
}
|
||||
|
||||
let sig = match response.get_data(DataType::Signature) {
|
||||
Some(DataValue::Bytes(b)) => b.clone(),
|
||||
DataValue::Bytes(b) => b.clone(),
|
||||
_ => {
|
||||
return Err(CommunicationError::AuthenticationFailed(
|
||||
"Missing signature".into(),
|
||||
|
|
@ -97,7 +97,7 @@ pub(crate) async fn verify_host_final(
|
|||
}
|
||||
};
|
||||
let pq_sig = match response.get_data(DataType::PqSignature) {
|
||||
Some(DataValue::Bytes(b)) => b.clone(),
|
||||
DataValue::Bytes(b) => b.clone(),
|
||||
_ => vec![],
|
||||
};
|
||||
if require_pq && pq_sig.is_empty() {
|
||||
|
|
@ -130,8 +130,8 @@ pub(crate) fn check_connected(
|
|||
reject_msg: &str,
|
||||
) -> Result<(), CommunicationError> {
|
||||
match response.get_data(DataType::Connected) {
|
||||
Some(DataValue::BoolTrue) => Ok(()),
|
||||
Some(DataValue::BoolFalse) => Err(CommunicationError::AuthenticationFailed(
|
||||
DataValue::BoolTrue => Ok(()),
|
||||
DataValue::BoolFalse => Err(CommunicationError::AuthenticationFailed(
|
||||
response
|
||||
.get_str(DataType::ErrorMessage)
|
||||
.unwrap_or(reject_msg)
|
||||
|
|
@ -147,7 +147,7 @@ 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(|| {
|
||||
DataValue::Str(version) => Version::parse(version).ok_or_else(|| {
|
||||
CommunicationError::AuthenticationFailed(
|
||||
"Host returned an invalid negotiated protocol version".into(),
|
||||
)
|
||||
|
|
@ -162,18 +162,16 @@ 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),
|
||||
);
|
||||
let mut proof = CommunicationValue::new(CommunicationType::ChallengeResponse)
|
||||
.add_typed_default(
|
||||
DataType::ClientNonce,
|
||||
DataValue::UnsignedNumber(client_nonce),
|
||||
);
|
||||
|
||||
if keys.sig_pq_secret_key.as_bytes().is_empty() {
|
||||
let signature = signer
|
||||
|
|
@ -215,14 +213,14 @@ pub(crate) async fn receive_verified_challenge(
|
|||
}
|
||||
|
||||
let server_challenge = match challenge.get_data(DataType::ServerNonce) {
|
||||
Some(DataValue::UnsignedNumber(n)) => *n,
|
||||
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 {
|
||||
if challenge.get_data(DataType::RequirePq) == &DataValue::BoolTrue && !client_has_pq_key {
|
||||
return Err(CommunicationError::AuthenticationFailed(
|
||||
"Host requires post-quantum authentication but the client PQ key is absent".into(),
|
||||
));
|
||||
|
|
|
|||
|
|
@ -31,22 +31,20 @@ mod error {
|
|||
}
|
||||
}
|
||||
|
||||
use mtp_codec::{
|
||||
CommunicationValue, DataType, DataValue, PROTOCOL_VERSION, Version,
|
||||
registry::{Registry, VersionedCodec},
|
||||
};
|
||||
use mtp_codec::{CommunicationValue, DataType, DataValue, PROTOCOL_VERSION, Version};
|
||||
use mtp_common::{CommunicationError, HandshakeOutcome, RejectionReason};
|
||||
|
||||
use connection::connection_from_parts;
|
||||
|
||||
fn parse_handshake_response(
|
||||
response: &CommunicationValue,
|
||||
type_map: &mtp_codec::TypeMap,
|
||||
) -> Result<HandshakeOutcome, CommunicationError> {
|
||||
let bad_version = mtp_codec::CommunicationType::ErrorBadVersion.try_to_id(type_map);
|
||||
let tm = mtp_codec::TypeMap::latest();
|
||||
|
||||
let bad_version = mtp_codec::CommunicationType::ErrorBadVersion.try_to_id(&tm);
|
||||
if Some(response.get_type()) == bad_version {
|
||||
let supported_versions = match response.get_data(DataType::Version) {
|
||||
Some(DataValue::Str(v)) if !v.is_empty() => v.split(',').map(String::from).collect(),
|
||||
DataValue::Str(v) if !v.is_empty() => v.split(',').map(String::from).collect(),
|
||||
_ => vec![],
|
||||
};
|
||||
return Ok(HandshakeOutcome::Rejected {
|
||||
|
|
@ -55,7 +53,7 @@ fn parse_handshake_response(
|
|||
}
|
||||
|
||||
let expected = mtp_codec::CommunicationType::IdentificationResponse
|
||||
.try_to_id(type_map)
|
||||
.try_to_id(&tm)
|
||||
.ok_or_else(|| {
|
||||
CommunicationError::Other("IdentificationResponse is absent from the type map".into())
|
||||
})?;
|
||||
|
|
@ -70,9 +68,9 @@ fn parse_handshake_response(
|
|||
}
|
||||
|
||||
match response.get_data(DataType::Connected) {
|
||||
Some(DataValue::BoolTrue) => {
|
||||
DataValue::BoolTrue => {
|
||||
let version = match response.get_data(DataType::Version) {
|
||||
Some(DataValue::Str(v)) => v.clone(),
|
||||
DataValue::Str(v) => v.clone(),
|
||||
_ => {
|
||||
return Err(CommunicationError::Other(
|
||||
"host omitted the negotiated version".into(),
|
||||
|
|
@ -80,21 +78,15 @@ fn parse_handshake_response(
|
|||
}
|
||||
};
|
||||
let assigned_id = match response.get_data(DataType::Id) {
|
||||
Some(DataValue::UnsignedNumber(n)) => u64::try_from(*n).map_err(|_| {
|
||||
CommunicationError::Other("host returned an out-of-range client id".into())
|
||||
})?,
|
||||
_ => {
|
||||
return Err(CommunicationError::Other(
|
||||
"host omitted the assigned client id".into(),
|
||||
));
|
||||
}
|
||||
DataValue::UnsignedNumber(n) => *n as u64,
|
||||
_ => 0,
|
||||
};
|
||||
Ok(HandshakeOutcome::Accepted {
|
||||
version,
|
||||
assigned_id,
|
||||
})
|
||||
}
|
||||
Some(DataValue::BoolFalse) => {
|
||||
DataValue::BoolFalse => {
|
||||
let detail = response
|
||||
.get_str(DataType::ErrorMessage)
|
||||
.unwrap_or("host rejected the connection")
|
||||
|
|
@ -107,34 +99,20 @@ fn parse_handshake_response(
|
|||
}
|
||||
}
|
||||
|
||||
fn codec_for_version(version: &Version) -> Result<VersionedCodec, CommunicationError> {
|
||||
VersionedCodec::for_version(Registry::builtin(), version.clone()).ok_or_else(|| {
|
||||
CommunicationError::Other(format!(
|
||||
"host returned unsupported protocol version {version}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub struct MTPClient;
|
||||
|
||||
impl MTPClient {
|
||||
pub async fn connect(config: ClientConfig) -> Result<MTPConnection, CommunicationError> {
|
||||
let (sender, receiver) =
|
||||
mtp_transport::connect(&config.url, config.server_cert(), config.policy).await?;
|
||||
let opening_codec = codec_for_version(&PROTOCOL_VERSION)?;
|
||||
sender.set_type_map(opening_codec.type_map()).await;
|
||||
receiver.set_type_map(opening_codec.type_map()).await;
|
||||
|
||||
let version_str = format!("{}", PROTOCOL_VERSION);
|
||||
let mut ident = CommunicationValue::new_with_type_map(
|
||||
mtp_codec::CommunicationType::Identification,
|
||||
opening_codec.type_map(),
|
||||
)
|
||||
.add_typed_default(DataType::Version, DataValue::Str(version_str))
|
||||
.add_typed_default(
|
||||
DataType::Id,
|
||||
DataValue::UnsignedNumber(config.client_id.into()),
|
||||
);
|
||||
let mut ident = CommunicationValue::new(mtp_codec::CommunicationType::Identification)
|
||||
.add_typed_default(DataType::Version, DataValue::Str(version_str))
|
||||
.add_typed_default(
|
||||
DataType::Id,
|
||||
DataValue::UnsignedNumber(config.client_id.into()),
|
||||
);
|
||||
if let Some(desc) = &config.description {
|
||||
ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone()));
|
||||
}
|
||||
|
|
@ -142,47 +120,31 @@ impl MTPClient {
|
|||
sender.send(&ident).await?;
|
||||
|
||||
let response = receiver.receive().await?;
|
||||
let outcome = parse_handshake_response(&response, opening_codec.type_map())?;
|
||||
let (negotiated, assigned_id) = match outcome {
|
||||
mtp_common::HandshakeOutcome::Accepted {
|
||||
version,
|
||||
assigned_id,
|
||||
} => (
|
||||
Version::parse(&version).ok_or_else(|| {
|
||||
let outcome = parse_handshake_response(&response)?;
|
||||
let negotiated = match outcome {
|
||||
mtp_common::HandshakeOutcome::Accepted { version, .. } => Version::parse(&version)
|
||||
.ok_or_else(|| {
|
||||
CommunicationError::Other("host returned an invalid negotiated version".into())
|
||||
})?,
|
||||
assigned_id,
|
||||
),
|
||||
mtp_common::HandshakeOutcome::Rejected { reason } => {
|
||||
sender.close().await;
|
||||
return Err(CommunicationError::Other(reason.to_string()));
|
||||
}
|
||||
};
|
||||
#[cfg(not(feature = "crypto"))]
|
||||
let _ = assigned_id;
|
||||
if negotiated != PROTOCOL_VERSION {
|
||||
sender.close().await;
|
||||
return Err(CommunicationError::Other(
|
||||
"host selected a protocol version the client did not offer".into(),
|
||||
));
|
||||
}
|
||||
let codec = codec_for_version(&negotiated)?;
|
||||
|
||||
#[cfg(feature = "crypto")]
|
||||
let client_id = assigned_id;
|
||||
let client_id = config.client_id;
|
||||
#[cfg(feature = "crypto")]
|
||||
return Ok(connection_from_parts(
|
||||
config,
|
||||
sender,
|
||||
receiver,
|
||||
negotiated,
|
||||
codec,
|
||||
error::AuthState::Unauthenticated,
|
||||
client_id,
|
||||
)
|
||||
.await);
|
||||
));
|
||||
#[cfg(not(feature = "crypto"))]
|
||||
Ok(connection_from_parts(config, sender, receiver, negotiated, codec).await)
|
||||
Ok(connection_from_parts(config, sender, receiver, negotiated))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -217,27 +179,15 @@ impl MTPClient {
|
|||
let (sender, receiver) =
|
||||
mtp_transport::connect(&config.url, config.server_cert(), config.policy).await?;
|
||||
|
||||
let handshake_codec = codec_for_version(&PROTOCOL_VERSION)?;
|
||||
let tm = handshake_codec.type_map().clone();
|
||||
sender.set_type_map(&tm).await;
|
||||
receiver.set_type_map(&tm).await;
|
||||
let tm = mtp_codec::TypeMap::latest();
|
||||
let version_str = format!("{}", PROTOCOL_VERSION);
|
||||
let public_key_bytes = keys
|
||||
.public_key_bundle()
|
||||
.try_as_bytes()
|
||||
.map_err(|error| CommunicationError::ParseError(error.to_string()))?;
|
||||
|
||||
let mut ident =
|
||||
CommunicationValue::new_with_type_map(CommunicationType::Identification, &tm)
|
||||
.add_typed_default(DataType::Version, DataValue::Str(version_str.clone()))
|
||||
.add_typed_default(
|
||||
DataType::Id,
|
||||
DataValue::UnsignedNumber(config.client_id as u128),
|
||||
)
|
||||
// This capability marker lets a non-crypto host reject an
|
||||
// authentication attempt instead of treating it as a plain
|
||||
// unauthenticated connection.
|
||||
.add_typed_default(DataType::PublicKeys, DataValue::Bytes(public_key_bytes));
|
||||
let mut ident = CommunicationValue::new(CommunicationType::Identification)
|
||||
.add_typed_default(DataType::Version, DataValue::Str(version_str.clone()))
|
||||
.add_typed_default(
|
||||
DataType::Id,
|
||||
DataValue::UnsignedNumber(config.client_id as u128),
|
||||
);
|
||||
if let Some(desc) = &config.description {
|
||||
ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone()));
|
||||
}
|
||||
|
|
@ -272,14 +222,14 @@ impl MTPClient {
|
|||
client_nonce,
|
||||
);
|
||||
|
||||
let proof =
|
||||
match crypto::signed_challenge_response(keys, proof_payload, client_nonce, &tm).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
sender.close().await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let proof = match crypto::signed_challenge_response(keys, proof_payload, client_nonce).await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
sender.close().await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if let Err(e) = sender.send(&proof).await {
|
||||
sender.close().await;
|
||||
return Err(e);
|
||||
|
|
@ -325,45 +275,15 @@ impl MTPClient {
|
|||
return Err(e);
|
||||
}
|
||||
|
||||
let assigned_id = match response.get_data(DataType::Id) {
|
||||
Some(DataValue::UnsignedNumber(id)) => u64::try_from(*id).map_err(|_| {
|
||||
CommunicationError::AuthenticationFailed(
|
||||
"host returned an out-of-range client id".into(),
|
||||
)
|
||||
})?,
|
||||
_ => {
|
||||
sender.close().await;
|
||||
return Err(CommunicationError::AuthenticationFailed(
|
||||
"host omitted the authenticated client id".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
if assigned_id != config.client_id {
|
||||
sender.close().await;
|
||||
return Err(CommunicationError::AuthenticationFailed(
|
||||
"host returned a different authenticated client id".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let negotiated = crypto::negotiated_version(&response)?;
|
||||
if negotiated != PROTOCOL_VERSION {
|
||||
sender.close().await;
|
||||
return Err(CommunicationError::AuthenticationFailed(
|
||||
"host selected a protocol version the client did not offer".into(),
|
||||
));
|
||||
}
|
||||
let codec = codec_for_version(&negotiated)?;
|
||||
let client_id = config.client_id;
|
||||
Ok(connection_from_parts(
|
||||
config,
|
||||
sender,
|
||||
receiver,
|
||||
negotiated,
|
||||
codec,
|
||||
crypto::negotiated_version(&response)?,
|
||||
error::AuthState::Authenticated,
|
||||
client_id,
|
||||
)
|
||||
.await)
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn auth_register(
|
||||
|
|
@ -410,17 +330,12 @@ impl MTPClient {
|
|||
let (sender, receiver) =
|
||||
mtp_transport::connect(&config.url, config.server_cert(), config.policy).await?;
|
||||
|
||||
let handshake_codec = codec_for_version(&PROTOCOL_VERSION)?;
|
||||
let tm = handshake_codec.type_map().clone();
|
||||
sender.set_type_map(&tm).await;
|
||||
receiver.set_type_map(&tm).await;
|
||||
let tm = mtp_codec::TypeMap::latest();
|
||||
let version_str = format!("{}", PROTOCOL_VERSION);
|
||||
let pk_bundle = keys.public_key_bundle();
|
||||
let pk_bytes = pk_bundle
|
||||
.try_as_bytes()
|
||||
.map_err(|error| CommunicationError::ParseError(error.to_string()))?;
|
||||
let pk_bytes = pk_bundle.as_bytes();
|
||||
|
||||
let mut register = CommunicationValue::new_with_type_map(CommunicationType::Register, &tm)
|
||||
let mut register = CommunicationValue::new(CommunicationType::Register)
|
||||
.add_typed_default(DataType::Version, DataValue::Str(version_str.clone()))
|
||||
.add_typed_default(DataType::PublicKeys, DataValue::Bytes(pk_bytes.clone()));
|
||||
if let Some(desc) = &config.description {
|
||||
|
|
@ -458,14 +373,14 @@ impl MTPClient {
|
|||
client_nonce,
|
||||
);
|
||||
|
||||
let proof =
|
||||
match crypto::signed_challenge_response(keys, proof_payload, client_nonce, &tm).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
sender.close().await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let proof = match crypto::signed_challenge_response(keys, proof_payload, client_nonce).await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
sender.close().await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if let Err(e) = sender.send(&proof).await {
|
||||
sender.close().await;
|
||||
return Err(e);
|
||||
|
|
@ -496,11 +411,7 @@ impl MTPClient {
|
|||
return Err(e);
|
||||
}
|
||||
let assigned_id = match response.get_data(DataType::Id) {
|
||||
Some(DataValue::UnsignedNumber(n)) => u64::try_from(*n).map_err(|_| {
|
||||
CommunicationError::AuthenticationFailed(
|
||||
"host returned an out-of-range client id".into(),
|
||||
)
|
||||
})?,
|
||||
DataValue::UnsignedNumber(n) => *n as u64,
|
||||
_ => {
|
||||
sender.close().await;
|
||||
return Err(CommunicationError::AuthenticationFailed(
|
||||
|
|
@ -522,24 +433,14 @@ impl MTPClient {
|
|||
return Err(e);
|
||||
}
|
||||
|
||||
let negotiated = crypto::negotiated_version(&response)?;
|
||||
if negotiated != PROTOCOL_VERSION {
|
||||
sender.close().await;
|
||||
return Err(CommunicationError::AuthenticationFailed(
|
||||
"host selected a protocol version the client did not offer".into(),
|
||||
));
|
||||
}
|
||||
let codec = codec_for_version(&negotiated)?;
|
||||
Ok(connection_from_parts(
|
||||
config,
|
||||
sender,
|
||||
receiver,
|
||||
negotiated,
|
||||
codec,
|
||||
crypto::negotiated_version(&response)?,
|
||||
error::AuthState::Authenticated,
|
||||
assigned_id,
|
||||
)
|
||||
.await)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -600,13 +501,8 @@ mod tests {
|
|||
|
||||
let dispatcher = pipe::PipeDispatcher {
|
||||
pending_requests: Mutex::new(HashMap::new()),
|
||||
expired_requests: Mutex::new(HashMap::new()),
|
||||
#[cfg(feature = "pipes")]
|
||||
type_map: mtp_codec::TypeMap::latest(),
|
||||
#[cfg(feature = "pipes")]
|
||||
pending_creations: std::sync::Mutex::new(HashMap::new()),
|
||||
#[cfg(feature = "pipes")]
|
||||
expired_creations: std::sync::Mutex::new(HashMap::new()),
|
||||
pending_creations: Mutex::new(HashMap::new()),
|
||||
#[cfg(feature = "pipes")]
|
||||
pending_pipes: Mutex::new(HashMap::new()),
|
||||
#[cfg(feature = "pipes")]
|
||||
|
|
@ -624,49 +520,11 @@ mod tests {
|
|||
|
||||
let unrelated = CommunicationValue::new(mtp_codec::CommunicationType::Ping).with_id(8);
|
||||
assert!(pipe::route_message(unrelated, &app_tx, &dispatcher).await);
|
||||
assert_eq!(app_rx.recv().await.unwrap().unwrap().id(), Some(8));
|
||||
assert_eq!(app_rx.recv().await.unwrap().unwrap().get_id(), 8);
|
||||
|
||||
let response = CommunicationValue::new(mtp_codec::CommunicationType::Pong).with_id(7);
|
||||
assert!(pipe::route_message(response, &app_tx, &dispatcher).await);
|
||||
assert_eq!(response_rx.await.unwrap().unwrap().id(), Some(7));
|
||||
assert!(app_rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_expired_request_response_is_consumed() {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
let dispatcher = pipe::PipeDispatcher {
|
||||
pending_requests: Mutex::new(HashMap::new()),
|
||||
expired_requests: Mutex::new(HashMap::new()),
|
||||
#[cfg(feature = "pipes")]
|
||||
type_map: mtp_codec::TypeMap::latest(),
|
||||
#[cfg(feature = "pipes")]
|
||||
pending_creations: std::sync::Mutex::new(HashMap::new()),
|
||||
#[cfg(feature = "pipes")]
|
||||
expired_creations: std::sync::Mutex::new(HashMap::new()),
|
||||
#[cfg(feature = "pipes")]
|
||||
pending_pipes: Mutex::new(HashMap::new()),
|
||||
#[cfg(feature = "pipes")]
|
||||
policy: Arc::new(Policy::default()),
|
||||
};
|
||||
let token = Arc::new(());
|
||||
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
|
||||
dispatcher.pending_requests.lock().await.insert(
|
||||
9,
|
||||
pipe::PendingRequest {
|
||||
token: token.clone(),
|
||||
sender: response_tx,
|
||||
},
|
||||
);
|
||||
pipe::expire_pending_request(&dispatcher, 9, &token).await;
|
||||
|
||||
let (app_tx, mut app_rx) = tokio::sync::mpsc::channel(1);
|
||||
let response = CommunicationValue::new(mtp_codec::CommunicationType::Pong).with_id(9);
|
||||
assert!(pipe::route_message(response, &app_tx, &dispatcher).await);
|
||||
assert!(response_rx.await.is_err());
|
||||
assert_eq!(response_rx.await.unwrap().unwrap().get_id(), 7);
|
||||
assert!(app_rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
use rand::RngExt;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio::time::{Duration, Instant};
|
||||
|
||||
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap};
|
||||
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||
use mtp_transport::{Receiver, Sender};
|
||||
|
||||
pub(crate) struct PingSession {
|
||||
|
|
@ -11,38 +12,6 @@ pub(crate) struct PingSession {
|
|||
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)
|
||||
|
|
@ -55,33 +24,29 @@ impl Drop for PingSession {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn start_ping_session(
|
||||
pub(crate) 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 (pong_tx, mut pong_rx) = mpsc::unbounded_channel();
|
||||
receiver.observe_pongs(pong_tx);
|
||||
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();
|
||||
let mut pending = HashMap::new();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
|
|
@ -91,9 +56,7 @@ pub(crate) async fn start_ping_session(
|
|||
}
|
||||
}
|
||||
_ = 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 {
|
||||
if max_missed_pings > 0 && !pending.is_empty() && pending.len() >= max_missed_pings {
|
||||
sender.close().await;
|
||||
break;
|
||||
}
|
||||
|
|
@ -104,11 +67,7 @@ pub(crate) async fn start_ping_session(
|
|||
tokio::time::sleep(Duration::from_millis(extra)).await;
|
||||
}
|
||||
|
||||
let mut ping = CommunicationValue::new_with_type_map(
|
||||
CommunicationType::Ping,
|
||||
&type_map,
|
||||
)
|
||||
.with_sender(client_id);
|
||||
let mut ping = CommunicationValue::new(CommunicationType::Ping);
|
||||
if ping_timestamp {
|
||||
let sent_at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
|
@ -119,25 +78,18 @@ pub(crate) async fn start_ping_session(
|
|||
DataValue::UnsignedNumber(sent_at),
|
||||
);
|
||||
}
|
||||
let Some(id) = ping.id() else {
|
||||
sender.close().await;
|
||||
break;
|
||||
};
|
||||
ping_receiver.set_expected_pong_id(Some(id)).await;
|
||||
let id = ping.get_id();
|
||||
if sender.send(&ping).await.is_err() {
|
||||
ping_receiver.set_expected_pong_id(None).await;
|
||||
sender.close().await;
|
||||
break;
|
||||
}
|
||||
tracker.sent(id);
|
||||
pending.insert(id, Instant::now());
|
||||
}
|
||||
pong = pong_rx.recv() => match pong {
|
||||
Some(pong) => {
|
||||
if let Some(id) = pong.id()
|
||||
&& let Some(ping) = tracker.received(id)
|
||||
{
|
||||
if let Some(sent_at) = pending.remove(&pong.get_id()) {
|
||||
let mut last_ping = ping_state.lock().await;
|
||||
*last_ping = Some(ping);
|
||||
*last_ping = Some(sent_at.elapsed());
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
|
|
@ -148,32 +100,3 @@ pub(crate) async fn start_ping_session(
|
|||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
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};
|
||||
|
|
@ -23,8 +18,6 @@ pub struct PipeHandle {
|
|||
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")]
|
||||
|
|
@ -37,11 +30,9 @@ impl PipeHandle {
|
|||
&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))) => {
|
||||
pub async fn wait(self) -> Result<Option<mtp_transport::PipeWriter>, PipeError> {
|
||||
match self.response_rx.await {
|
||||
Ok(Ok(true)) => {
|
||||
let writer = self
|
||||
.sender
|
||||
.open_pipe(self.pipe_id, &self.description)
|
||||
|
|
@ -49,70 +40,21 @@ impl PipeHandle {
|
|||
.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)
|
||||
}
|
||||
Ok(Ok(false)) => Ok(None),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(_) => Err(PipeError::StreamClosed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 {
|
||||
|
|
@ -124,63 +66,28 @@ impl PipeRequest {
|
|||
}
|
||||
|
||||
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 resp = CommunicationValue::new(CommunicationType::PipeResponse)
|
||||
.with_id(self.pipe_id)
|
||||
.add_typed_default(DataType::Accepted, DataValue::BoolTrue);
|
||||
self.sender.send(&resp).await.map_err(PipeError::from)?;
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
tokio::time::timeout(timeout, pipe_rx)
|
||||
.await
|
||||
.map_err(|_| PipeError::HandshakeTimeout)?
|
||||
.map_err(|_| PipeError::StreamClosed)
|
||||
}
|
||||
|
||||
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);
|
||||
let resp = CommunicationValue::new(CommunicationType::PipeResponse)
|
||||
.with_id(self.pipe_id)
|
||||
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
|
||||
self.sender.send(&resp).await.map_err(PipeError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -191,54 +98,11 @@ pub(crate) struct PendingRequest {
|
|||
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>>,
|
||||
pub(crate) pending_creations:
|
||||
Mutex<HashMap<u32, tokio::sync::oneshot::Sender<Result<bool, PipeError>>>>,
|
||||
#[cfg(feature = "pipes")]
|
||||
pub(crate) pending_pipes:
|
||||
Mutex<HashMap<u32, tokio::sync::oneshot::Sender<mtp_transport::PipeReader>>>,
|
||||
|
|
@ -246,116 +110,19 @@ pub(crate) struct PipeDispatcher {
|
|||
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;
|
||||
}
|
||||
let pending = dispatcher
|
||||
.pending_requests
|
||||
.lock()
|
||||
.await
|
||||
.remove(&msg.get_id());
|
||||
if let Some(tx) = pending {
|
||||
let _ = tx.sender.send(Ok(msg));
|
||||
return true;
|
||||
}
|
||||
|
||||
app_tx.send(Ok(msg)).await.is_ok()
|
||||
|
|
@ -368,43 +135,6 @@ pub(crate) async fn fail_pending_requests(dispatcher: &PipeDispatcher, error: Co
|
|||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
@ -419,13 +149,6 @@ pub(crate) async fn remove_pending_request(
|
|||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
@ -434,51 +157,31 @@ pub(crate) async fn run_dispatcher(
|
|||
pipe_req_tx: mpsc::Sender<PipeRequest>,
|
||||
dispatcher: Arc<PipeDispatcher>,
|
||||
) {
|
||||
let pipe_req_type = CommunicationType::PipeRequest.try_to_id(&mtp_codec::TypeMap::latest());
|
||||
let pipe_resp_type = CommunicationType::PipeResponse.try_to_id(&mtp_codec::TypeMap::latest());
|
||||
|
||||
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;
|
||||
};
|
||||
if Some(msg.get_type()) == pipe_req_type {
|
||||
let pipe_id = msg.get_id();
|
||||
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;
|
||||
};
|
||||
if Some(msg.get_type()) == pipe_resp_type {
|
||||
let pipe_id = msg.get_id();
|
||||
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);
|
||||
let mut pending = dispatcher.pending_creations.lock().await;
|
||||
if let Some(tx) = pending.remove(&pipe_id) {
|
||||
let _ = tx.send(Ok(accepted));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -496,10 +199,6 @@ pub(crate) async fn run_dispatcher(
|
|||
}
|
||||
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;
|
||||
}
|
||||
|
|
@ -522,10 +221,6 @@ pub(crate) async fn run_dispatcher(
|
|||
}
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
1987
codec/Cargo.lock
generated
1987
codec/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,16 +1,15 @@
|
|||
[package]
|
||||
name = "mtp-codec"
|
||||
version = "0.3.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
mtp-type-map = { version = "0.3.0", path = "../type-map" }
|
||||
mtp-common = { version = "0.3.0", path = "../common" }
|
||||
mtp-crypto = { version = "0.3.0", path = "../crypto", optional = true }
|
||||
base64 = "0.23"
|
||||
mtp-type-map = { version = "0.2.0", path = "../type-map" }
|
||||
mtp-common = { version = "0.2.0", path = "../common" }
|
||||
mtp-crypto = { version = "0.2.0", path = "../crypto", optional = true }
|
||||
base64 = "0.22"
|
||||
byteorder = "1.5"
|
||||
rand = { version = "0.10.1", features = ["std", "std_rng"] }
|
||||
thiserror = "2.0.18"
|
||||
|
||||
[features]
|
||||
registry = ["mtp-type-map/registry"]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,44 +1,11 @@
|
|||
pub mod communication_value;
|
||||
pub mod data_value;
|
||||
#[cfg(feature = "crypto")]
|
||||
pub mod protected;
|
||||
#[cfg(feature = "crypto")]
|
||||
pub mod relay;
|
||||
|
||||
pub use communication_value::CommunicationValue;
|
||||
#[cfg(feature = "crypto")]
|
||||
pub use data_value::{
|
||||
ApplicationProtectionPurpose, EncryptedValue, MtpProtectionPurpose, ProtectionError,
|
||||
ProtectionPolicy, ProtectionPurpose, ProtectionPurposeError, SignaturePolicy, SignedValue,
|
||||
};
|
||||
pub use data_value::{
|
||||
DEFAULT_TRANSPORT_ALLOCATION_FACTOR, DataKind, DataValue, DecodeError, DecodeLimits,
|
||||
EncodeLimits,
|
||||
};
|
||||
pub use mtp_common::{CodecError, TimeError, unix_time_millis};
|
||||
#[cfg(feature = "crypto")]
|
||||
#[allow(deprecated)]
|
||||
pub use protected::{
|
||||
CURRENT_PROTECTED_VERSION, InMemoryReplayGuard, ProtectedError, ProtectedLimits,
|
||||
ProtectedMessageBuilder, ProtectedOpenOptions, ReplayError, ReplayGuard,
|
||||
VerifiedProtectedMessage, open_protected_checked, open_protected_with_checked,
|
||||
open_protected_with_keys_checked, open_protected_with_keys_without_replay,
|
||||
open_protected_with_without_replay, open_protected_without_replay, protected_claimed_signer_id,
|
||||
protected_claimed_signer_id_with_limits, protected_claimed_signer_id_with_options,
|
||||
};
|
||||
#[cfg(feature = "crypto")]
|
||||
#[allow(deprecated)]
|
||||
pub use relay::{
|
||||
CURRENT_RELAY_VERSION, RelayError, RelayOpenOptions, SealedRelayBuilder, VerifiedRelayContent,
|
||||
VerifiedRelayMetadata, forward_relay_frame, open_relay_content,
|
||||
open_relay_content_with_keyrings, open_relay_content_with_keyrings_and_limits,
|
||||
open_relay_content_with_keys, open_relay_content_with_limits,
|
||||
open_relay_content_with_limits_without_replay, open_relay_metadata_checked,
|
||||
open_relay_metadata_with_checked, open_relay_metadata_with_limits_checked,
|
||||
open_relay_metadata_with_limits_without_replay, open_relay_metadata_with_without_replay,
|
||||
open_relay_metadata_without_replay, relay_metadata_claimed_signer_id,
|
||||
relay_metadata_claimed_signer_id_with_limits, relay_metadata_claimed_signer_id_with_options,
|
||||
};
|
||||
pub use communication_value::EncryptedPayload;
|
||||
pub use communication_value::{CommunicationValue, MAX_WIRE_ID};
|
||||
pub use data_value::{DataKind, DataValue};
|
||||
pub use mtp_common::CodecError;
|
||||
|
||||
pub use mtp_type_map::{
|
||||
CommunicationType, CommunicationTypeId, DataType, DataTypeId, PROTOCOL_VERSION, TypeMap,
|
||||
|
|
@ -46,12 +13,7 @@ pub use mtp_type_map::{
|
|||
};
|
||||
|
||||
pub(crate) fn rand_u32() -> u32 {
|
||||
loop {
|
||||
let value = rand::random();
|
||||
if value != 0 {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
rand::random()
|
||||
}
|
||||
|
||||
#[cfg(feature = "registry")]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,6 @@ use mtp_common::CodecError;
|
|||
use mtp_type_map::{PROTOCOL_VERSION, TypeMap, Version};
|
||||
|
||||
use crate::CommunicationValue;
|
||||
use crate::EncodeLimits;
|
||||
|
||||
pub use mtp_type_map::Registry;
|
||||
|
||||
|
|
@ -43,41 +42,7 @@ impl VersionedCodec {
|
|||
|
||||
/// 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)
|
||||
value.to_bytes()
|
||||
}
|
||||
|
||||
/// Decode a frame and retain the negotiated type map for typed access.
|
||||
|
|
@ -93,34 +58,3 @@ impl VersionedCodec {
|
|||
&self.registry
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::DataValue;
|
||||
use mtp_type_map::{CommunicationType, Version};
|
||||
|
||||
#[test]
|
||||
fn encode_rejects_a_value_from_another_negotiated_map() {
|
||||
let mut registry = Registry::new();
|
||||
let version_a = Version::new(3, 0);
|
||||
let version_b = Version::new(4, 0);
|
||||
registry.register(TypeMap::new(version_a.clone()));
|
||||
registry.register(TypeMap::new(version_b.clone()));
|
||||
|
||||
let codec = VersionedCodec::for_version(registry, version_b).expect("codec version");
|
||||
let value = CommunicationValue::new_with_type_map(
|
||||
CommunicationType::Ping,
|
||||
&TypeMap::new(version_a.clone()),
|
||||
)
|
||||
.with_payload(DataValue::Null);
|
||||
|
||||
assert_eq!(
|
||||
codec.encode(&value),
|
||||
Err(CodecError::TypeMapMismatch {
|
||||
expected: "4.0".into(),
|
||||
actual: "3.0".into(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1753
codec/src/relay.rs
1753
codec/src/relay.rs
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,6 +1,6 @@
|
|||
[package]
|
||||
name = "mtp-common"
|
||||
version = "0.3.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
|
@ -15,6 +15,7 @@ wtransport = { version = "0.7.1", default-features = false, features = [
|
|||
"quinn",
|
||||
"self-signed",
|
||||
] }
|
||||
rustls = { version = "0.23.41" }
|
||||
quinn = { version = "0.11.11", default-features = false, features = [
|
||||
"rustls-aws-lc-rs",
|
||||
"rustls",
|
||||
|
|
|
|||
|
|
@ -1,32 +1,5 @@
|
|||
use thiserror::Error;
|
||||
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Errors returned when the system clock cannot be represented as MTP time.
|
||||
#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
|
||||
pub enum TimeError {
|
||||
#[error("system clock is before the Unix epoch")]
|
||||
BeforeUnixEpoch,
|
||||
#[error("Unix epoch milliseconds exceed the u64 range")]
|
||||
OutOfRange,
|
||||
}
|
||||
|
||||
fn duration_to_unix_time_millis(duration: Duration) -> Result<u64, 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)]
|
||||
pub enum CodecError {
|
||||
#[error("Unknown version")]
|
||||
|
|
@ -41,10 +14,6 @@ pub enum CodecError {
|
|||
InvalidEncoding,
|
||||
#[error("Too many entries to encode")]
|
||||
TooManyEntries,
|
||||
#[error("Missing negotiated type map")]
|
||||
MissingTypeMap,
|
||||
#[error("Type-map mismatch: expected {expected}, actual {actual}")]
|
||||
TypeMapMismatch { expected: String, actual: String },
|
||||
#[error("Crypto failed: {0}")]
|
||||
CryptoFailed(String),
|
||||
#[error("Missing required field: {0}")]
|
||||
|
|
@ -56,24 +25,6 @@ pub enum CodecError {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unix_time_millis_preserves_subsecond_precision() {
|
||||
let duration = Duration::new(1_786_449_600, 123_000_000);
|
||||
assert_eq!(
|
||||
duration_to_unix_time_millis(duration),
|
||||
Ok(1_786_449_600_123)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unix_time_millis_rejects_values_outside_u64() {
|
||||
let duration = Duration::new(u64::MAX, 0);
|
||||
assert_eq!(
|
||||
duration_to_unix_time_millis(duration),
|
||||
Err(TimeError::OutOfRange)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codec_error_display() {
|
||||
let e = CodecError::InvalidEncoding;
|
||||
|
|
@ -164,9 +115,6 @@ pub enum CommunicationError {
|
|||
#[error("Stream Error")]
|
||||
StreamError,
|
||||
|
||||
#[error("Stream failed after delivery may have started")]
|
||||
DeliveryUnknown,
|
||||
|
||||
#[error("Stream Error: {0}")]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
StreamWriteError(#[from] wtransport::error::StreamWriteError),
|
||||
|
|
@ -185,38 +133,6 @@ pub enum CommunicationError {
|
|||
Other(String),
|
||||
}
|
||||
|
||||
/// How the protocol layer should handle the first frame on a receive stream.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FirstFrameDisposition {
|
||||
Message,
|
||||
Pipe(u32),
|
||||
}
|
||||
|
||||
/// Classify a first frame without tying the decision to a WebTransport backend.
|
||||
///
|
||||
/// `PipeRequest` is used both as a control message and as the header of the raw
|
||||
/// stream opened after that request is accepted. Only the protocol layer knows
|
||||
/// which raw stream IDs are currently expected.
|
||||
pub fn classify_first_frame(
|
||||
is_pipe_request: bool,
|
||||
pipe_id: Option<u32>,
|
||||
pipe_is_expected: bool,
|
||||
) -> Result<FirstFrameDisposition, CommunicationError> {
|
||||
if !is_pipe_request {
|
||||
return Ok(FirstFrameDisposition::Message);
|
||||
}
|
||||
|
||||
let pipe_id = pipe_id.filter(|id| *id != 0).ok_or_else(|| {
|
||||
CommunicationError::Other("PipeRequest frame must contain a non-zero id".into())
|
||||
})?;
|
||||
|
||||
if pipe_is_expected {
|
||||
Ok(FirstFrameDisposition::Pipe(pipe_id))
|
||||
} else {
|
||||
Ok(FirstFrameDisposition::Message)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- manual PartialEq (quinn / wtransport types don't impl PartialEq) ----
|
||||
|
||||
impl PartialEq for CommunicationError {
|
||||
|
|
@ -247,7 +163,6 @@ impl PartialEq for CommunicationError {
|
|||
(Self::ReadExactError(_), Self::ReadExactError(_)) => true,
|
||||
(Self::StreamClosed, Self::StreamClosed) => true,
|
||||
(Self::StreamError, Self::StreamError) => true,
|
||||
(Self::DeliveryUnknown, Self::DeliveryUnknown) => true,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
(Self::StreamWriteError(_), Self::StreamWriteError(_)) => true,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
|
|
|||
|
|
@ -1,243 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { access, cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".");
|
||||
const packageJsonPath = path.join(repositoryRoot, "package.json");
|
||||
|
||||
function usage() {
|
||||
return `Usage: node create-web-release.mjs [options]
|
||||
|
||||
Build and pack the browser package using the version of the root Cargo package.
|
||||
|
||||
Options:
|
||||
--skip-build Pack the existing dist/ and wasm/pkg/ artifacts
|
||||
--output-dir <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,6 +1,6 @@
|
|||
[package]
|
||||
name = "mtp-crypto"
|
||||
version = "0.3.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[package.metadata.cargo-machete]
|
||||
|
|
@ -9,7 +9,7 @@ ignored = ["rand_core"]
|
|||
[dependencies]
|
||||
chacha20poly1305 = { version = "0.10", optional = true }
|
||||
aes-gcm = { version = "0.10", optional = true }
|
||||
ed25519-dalek = { version = "3.0", optional = true, features = [
|
||||
ed25519-dalek = { version = "2.2", optional = true, features = [
|
||||
"pkcs8",
|
||||
"pem",
|
||||
] }
|
||||
|
|
@ -18,12 +18,11 @@ sha2 = { version = "0.11", optional = true }
|
|||
zeroize = { version = "1.9", features = ["derive"] }
|
||||
thiserror = "1"
|
||||
base64 = "0.22"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
rand_core = { version = "0.10.1" }
|
||||
rand = "0.10.2"
|
||||
getrandom = "0.4.3"
|
||||
mlkem-tls = { version = "0.2", optional = true }
|
||||
ml-dsa = { version = "0.1.1", optional = true }
|
||||
argon2 = { version = "0.5", optional = true }
|
||||
serde = { version = "1", optional = true, features = ["derive"] }
|
||||
rcgen = { version = "0.14", optional = true }
|
||||
time = { version = "0.3", optional = true }
|
||||
|
|
@ -44,4 +43,3 @@ hkdf = ["dep:hkdf", "dep:sha2"]
|
|||
sha2 = ["dep:sha2"]
|
||||
tls = ["dep:rcgen", "dep:time"]
|
||||
parallel = ["dep:tokio"]
|
||||
password-kdf = ["dep:argon2"]
|
||||
|
|
|
|||
|
|
@ -6,15 +6,6 @@ use zeroize::Zeroizing;
|
|||
#[cfg(any(feature = "chacha20poly1305", feature = "aes-gcm"))]
|
||||
use getrandom::fill;
|
||||
|
||||
/// Authentication-tag length shared by the supported AEAD constructions.
|
||||
pub const AUTH_TAG_LEN: usize = 16;
|
||||
|
||||
/// Nonce length stored at the front of an XChaCha20-Poly1305 output.
|
||||
pub const XCHACHA20POLY1305_NONCE_LEN: usize = 24;
|
||||
|
||||
/// Nonce length stored at the front of an AES-256-GCM output.
|
||||
pub const AES256GCM_NONCE_LEN: usize = 12;
|
||||
|
||||
pub trait AeadEncrypt {
|
||||
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError>;
|
||||
}
|
||||
|
|
@ -36,12 +27,12 @@ fn prepend_nonce(nonce: &[u8], ciphertext: &mut Vec<u8>) -> Vec<u8> {
|
|||
}
|
||||
|
||||
#[cfg(feature = "chacha20poly1305")]
|
||||
pub struct XChaCha20Poly1305 {
|
||||
pub struct ChaCha20Poly1305 {
|
||||
key: Zeroizing<[u8; 32]>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "chacha20poly1305")]
|
||||
impl XChaCha20Poly1305 {
|
||||
impl ChaCha20Poly1305 {
|
||||
pub fn new(key: [u8; 32]) -> Self {
|
||||
Self {
|
||||
key: Zeroizing::new(key),
|
||||
|
|
@ -50,7 +41,7 @@ impl XChaCha20Poly1305 {
|
|||
}
|
||||
|
||||
#[cfg(feature = "chacha20poly1305")]
|
||||
impl AeadEncrypt for XChaCha20Poly1305 {
|
||||
impl AeadEncrypt for ChaCha20Poly1305 {
|
||||
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||
use chacha20poly1305::XChaCha20Poly1305;
|
||||
use chacha20poly1305::XNonce;
|
||||
|
|
@ -59,7 +50,7 @@ impl AeadEncrypt for XChaCha20Poly1305 {
|
|||
let key = chacha20poly1305::Key::from_slice(self.key.as_ref());
|
||||
let cipher = XChaCha20Poly1305::new(key);
|
||||
|
||||
let mut nonce = [0u8; XCHACHA20POLY1305_NONCE_LEN];
|
||||
let mut nonce = [0u8; 24];
|
||||
fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?;
|
||||
let nonce_ref = XNonce::from_slice(&nonce);
|
||||
|
||||
|
|
@ -77,17 +68,17 @@ impl AeadEncrypt for XChaCha20Poly1305 {
|
|||
}
|
||||
|
||||
#[cfg(feature = "chacha20poly1305")]
|
||||
impl AeadDecrypt for XChaCha20Poly1305 {
|
||||
impl AeadDecrypt for ChaCha20Poly1305 {
|
||||
fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||
use chacha20poly1305::XChaCha20Poly1305;
|
||||
use chacha20poly1305::XNonce;
|
||||
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
||||
|
||||
if ciphertext.len() < XCHACHA20POLY1305_NONCE_LEN + AUTH_TAG_LEN {
|
||||
if ciphertext.len() < 24 {
|
||||
return Err(CryptoError::InvalidNonceLength);
|
||||
}
|
||||
|
||||
let (nonce, ct) = ciphertext.split_at(XCHACHA20POLY1305_NONCE_LEN);
|
||||
let (nonce, ct) = ciphertext.split_at(24);
|
||||
let key = chacha20poly1305::Key::from_slice(self.key.as_ref());
|
||||
let cipher = XChaCha20Poly1305::new(key);
|
||||
let nonce_ref = XNonce::from_slice(nonce);
|
||||
|
|
@ -101,17 +92,12 @@ impl AeadDecrypt for XChaCha20Poly1305 {
|
|||
}
|
||||
|
||||
#[cfg(feature = "chacha20poly1305")]
|
||||
impl AeadCipher for XChaCha20Poly1305 {
|
||||
impl AeadCipher for ChaCha20Poly1305 {
|
||||
fn key_size() -> usize {
|
||||
32
|
||||
}
|
||||
}
|
||||
|
||||
/// Compatibility alias for the original public name. The implementation is
|
||||
/// XChaCha20-Poly1305, including its 24-byte nonce format.
|
||||
#[cfg(feature = "chacha20poly1305")]
|
||||
pub type ChaCha20Poly1305 = XChaCha20Poly1305;
|
||||
|
||||
#[cfg(feature = "aes-gcm")]
|
||||
pub struct Aes256Gcm {
|
||||
key: Zeroizing<[u8; 32]>,
|
||||
|
|
@ -136,7 +122,7 @@ impl AeadEncrypt for Aes256Gcm {
|
|||
let key = aes_gcm::Key::<AesGcmInner>::from_slice(self.key.as_ref());
|
||||
let cipher = AesGcmInner::new(key);
|
||||
|
||||
let mut nonce = [0u8; AES256GCM_NONCE_LEN];
|
||||
let mut nonce = [0u8; 12];
|
||||
fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?;
|
||||
let nonce_ref = Nonce::from_slice(&nonce);
|
||||
|
||||
|
|
@ -160,11 +146,11 @@ impl AeadDecrypt for Aes256Gcm {
|
|||
use aes_gcm::Nonce;
|
||||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||||
|
||||
if ciphertext.len() < AES256GCM_NONCE_LEN + AUTH_TAG_LEN {
|
||||
if ciphertext.len() < 12 {
|
||||
return Err(CryptoError::InvalidNonceLength);
|
||||
}
|
||||
|
||||
let (nonce, ct) = ciphertext.split_at(AES256GCM_NONCE_LEN);
|
||||
let (nonce, ct) = ciphertext.split_at(12);
|
||||
let key = aes_gcm::Key::<AesGcmInner>::from_slice(self.key.as_ref());
|
||||
let cipher = AesGcmInner::new(key);
|
||||
let nonce_ref = Nonce::from_slice(nonce);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||
use crate::error::CryptoError;
|
||||
|
||||
#[cfg(feature = "mlkem-tls")]
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||
use crate::kdf::derive_encryption_key;
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||
use crate::kem::HybridKem;
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||
use crate::keypair::{Keyring, PublicKeyBundle};
|
||||
|
||||
/*
|
||||
* Algorithm selector for encrypted values.
|
||||
* Algorithm selector for encrypted containers.
|
||||
*
|
||||
* Mirrors `SigAlgorithm` for signatures: a single marking byte identifies the
|
||||
* key-encapsulation mechanism and the AEAD used to seal a container. The byte
|
||||
* is stored as the first byte of every encrypted envelope so the decryptor can pick
|
||||
* is stored as the first byte of every encrypted blob so the decryptor can pick
|
||||
* the matching algorithm (and the matching keypair from a `Keyring`) without
|
||||
* any out-of-band agreement.
|
||||
*
|
||||
|
|
@ -30,7 +34,7 @@ impl EncryptionType {
|
|||
pub const ML_KEM_CHACHA20POLY1305: u8 = 0x01;
|
||||
pub const ML_KEM_AES256_GCM: u8 = 0x02;
|
||||
|
||||
/// The marking byte written at the front of an encrypted envelope.
|
||||
/// The marking byte written at the front of an encrypted blob.
|
||||
pub const fn to_byte(self) -> u8 {
|
||||
match self {
|
||||
Self::MlKemChaCha20Poly1305 => Self::ML_KEM_CHACHA20POLY1305,
|
||||
|
|
@ -46,50 +50,6 @@ impl EncryptionType {
|
|||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Size of the content-encryption key wrapped for each recipient.
|
||||
pub const CONTENT_ENCRYPTION_KEY_LEN: usize = 32;
|
||||
|
||||
/// The fixed-size ciphertext emitted by the KEM selected by this suite.
|
||||
pub const fn kem_ciphertext_len(self) -> usize {
|
||||
match self {
|
||||
Self::MlKemChaCha20Poly1305 | Self::MlKemAes256Gcm => {
|
||||
#[cfg(feature = "mlkem-tls")]
|
||||
{
|
||||
HybridKem::ciphertext_len()
|
||||
}
|
||||
#[cfg(not(feature = "mlkem-tls"))]
|
||||
{
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes the selected AEAD prepends/appends to an encrypted payload.
|
||||
pub const fn aead_overhead(self) -> usize {
|
||||
match self {
|
||||
Self::MlKemChaCha20Poly1305 => {
|
||||
crate::aead::XCHACHA20POLY1305_NONCE_LEN + crate::aead::AUTH_TAG_LEN
|
||||
}
|
||||
Self::MlKemAes256Gcm => crate::aead::AES256GCM_NONCE_LEN + crate::aead::AUTH_TAG_LEN,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total output length for an encrypted plaintext of `plaintext_len` bytes.
|
||||
pub const fn encrypted_len(self, plaintext_len: usize) -> usize {
|
||||
plaintext_len.saturating_add(self.aead_overhead())
|
||||
}
|
||||
|
||||
/// Minimum valid AEAD output length for this suite.
|
||||
pub const fn minimum_ciphertext_len(self) -> usize {
|
||||
self.encrypted_len(0)
|
||||
}
|
||||
|
||||
/// The size of a wrapped 32-byte content key for this suite.
|
||||
pub const fn wrapped_key_len(self) -> usize {
|
||||
self.encrypted_len(Self::CONTENT_ENCRYPTION_KEY_LEN)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
@ -99,7 +59,7 @@ impl EncryptionType {
|
|||
*/
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||
#[allow(unused_variables)]
|
||||
pub fn seal_with_key(
|
||||
fn aead_seal(
|
||||
enc_type: EncryptionType,
|
||||
key: [u8; 32],
|
||||
plaintext: &[u8],
|
||||
|
|
@ -110,7 +70,7 @@ pub fn seal_with_key(
|
|||
match enc_type {
|
||||
#[cfg(feature = "chacha20poly1305")]
|
||||
EncryptionType::MlKemChaCha20Poly1305 => {
|
||||
crate::aead::XChaCha20Poly1305::new(key).encrypt(plaintext, aad)
|
||||
crate::aead::ChaCha20Poly1305::new(key).encrypt(plaintext, aad)
|
||||
}
|
||||
#[cfg(feature = "aes-gcm")]
|
||||
EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).encrypt(plaintext, aad),
|
||||
|
|
@ -126,7 +86,7 @@ pub fn seal_with_key(
|
|||
*/
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||
#[allow(unused_variables)]
|
||||
pub fn open_with_key(
|
||||
fn aead_open(
|
||||
enc_type: EncryptionType,
|
||||
key: [u8; 32],
|
||||
ciphertext: &[u8],
|
||||
|
|
@ -137,7 +97,7 @@ pub fn open_with_key(
|
|||
match enc_type {
|
||||
#[cfg(feature = "chacha20poly1305")]
|
||||
EncryptionType::MlKemChaCha20Poly1305 => {
|
||||
crate::aead::XChaCha20Poly1305::new(key).decrypt(ciphertext, aad)
|
||||
crate::aead::ChaCha20Poly1305::new(key).decrypt(ciphertext, aad)
|
||||
}
|
||||
#[cfg(feature = "aes-gcm")]
|
||||
EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).decrypt(ciphertext, aad),
|
||||
|
|
@ -146,51 +106,76 @@ pub fn open_with_key(
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||
const ENC_KDF_SALT: &[u8] = b"mtp-container-enc";
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||
const ENC_KDF_CONTEXT: &[u8] = b"single-recipient";
|
||||
|
||||
/*
|
||||
* Encrypt `plaintext` for a single recipient, selecting the algorithm with
|
||||
* `enc_type` and the recipient's KEM public key from `recipient`.
|
||||
*
|
||||
* The returned, self-describing blob is laid out as:
|
||||
* [1 byte EncryptionType] [2 bytes u16 kem_ct_len] [kem_ciphertext] [aead_payload]
|
||||
* where `aead_payload` is the AEAD output (nonce + ciphertext + tag). The AEAD
|
||||
* key is derived from the KEM shared secret via HKDF, so no separate content key
|
||||
* is transmitted.
|
||||
*
|
||||
* Requires the `mlkem-tls` and `hkdf` features, plus the AEAD feature backing
|
||||
* `enc_type`.
|
||||
*/
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||
pub fn encrypt_for(
|
||||
enc_type: EncryptionType,
|
||||
recipient: &PublicKeyBundle,
|
||||
plaintext: &[u8],
|
||||
aad: &[u8],
|
||||
) -> Result<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(feature = "mlkem-tls")]
|
||||
#[test]
|
||||
fn envelope_parser_uses_suite_dependent_fixed_widths() {
|
||||
use crate::helper::{MultiEncryptedMessage, RecipientEntry};
|
||||
|
||||
let suites = [
|
||||
EncryptionType::MlKemChaCha20Poly1305,
|
||||
EncryptionType::MlKemAes256Gcm,
|
||||
];
|
||||
assert_ne!(suites[0].wrapped_key_len(), suites[1].wrapped_key_len());
|
||||
|
||||
for (index, suite) in suites.into_iter().enumerate() {
|
||||
let marker = u8::try_from(index).unwrap();
|
||||
let message = MultiEncryptedMessage {
|
||||
encryption_type: suite,
|
||||
purpose: 0xA5,
|
||||
recipients: vec![RecipientEntry {
|
||||
kem_ciphertext: vec![0x10 + marker; suite.kem_ciphertext_len()],
|
||||
encrypted_key: vec![0x20 + marker; suite.wrapped_key_len()],
|
||||
}],
|
||||
ciphertext: vec![0x30 + marker; suite.minimum_ciphertext_len() + 3],
|
||||
};
|
||||
let encoded = message.to_bytes().expect("synthetic envelope is valid");
|
||||
let kem_end = 4 + suite.kem_ciphertext_len();
|
||||
let wrapped_end = kem_end + suite.wrapped_key_len();
|
||||
|
||||
assert_eq!(&encoded[..4], &[suite.to_byte(), 0xA5, 0, 1]);
|
||||
assert_eq!(&encoded[4..kem_end], message.recipients[0].kem_ciphertext);
|
||||
assert_eq!(
|
||||
&encoded[kem_end..wrapped_end],
|
||||
message.recipients[0].encrypted_key
|
||||
);
|
||||
assert_eq!(&encoded[wrapped_end..], message.ciphertext);
|
||||
assert_eq!(
|
||||
MultiEncryptedMessage::from_bytes(&encoded)
|
||||
.expect("suite-specific envelope should parse"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encryption_type_byte_roundtrip() {
|
||||
for t in [
|
||||
|
|
@ -203,19 +188,59 @@ mod tests {
|
|||
assert_eq!(EncryptionType::from_byte(0xFF), None);
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
|
||||
#[test]
|
||||
fn suite_lengths_are_derived_from_the_selected_primitives() {
|
||||
assert_eq!(
|
||||
EncryptionType::MlKemChaCha20Poly1305.wrapped_key_len(),
|
||||
EncryptionType::CONTENT_ENCRYPTION_KEY_LEN
|
||||
+ crate::aead::XCHACHA20POLY1305_NONCE_LEN
|
||||
+ crate::aead::AUTH_TAG_LEN
|
||||
);
|
||||
assert_eq!(
|
||||
EncryptionType::MlKemAes256Gcm.wrapped_key_len(),
|
||||
EncryptionType::CONTENT_ENCRYPTION_KEY_LEN
|
||||
+ crate::aead::AES256GCM_NONCE_LEN
|
||||
+ crate::aead::AUTH_TAG_LEN
|
||||
);
|
||||
fn encrypt_for_roundtrip() -> Result<(), CryptoError> {
|
||||
let kr = Keyring::generate();
|
||||
let blob = encrypt_for(
|
||||
EncryptionType::MlKemChaCha20Poly1305,
|
||||
&kr.public_key_bundle(),
|
||||
b"secret payload",
|
||||
b"aad",
|
||||
)?;
|
||||
assert_eq!(blob[0], EncryptionType::ML_KEM_CHACHA20POLY1305);
|
||||
|
||||
let pt = decrypt_with(&blob, &kr, b"aad")?;
|
||||
assert_eq!(pt, b"secret payload");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
|
||||
#[test]
|
||||
fn decrypt_with_wrong_keyring_fails() -> Result<(), CryptoError> {
|
||||
let kr = Keyring::generate();
|
||||
let other = Keyring::generate();
|
||||
let blob = encrypt_for(
|
||||
EncryptionType::MlKemChaCha20Poly1305,
|
||||
&kr.public_key_bundle(),
|
||||
b"secret",
|
||||
b"aad",
|
||||
)?;
|
||||
assert!(decrypt_with(&blob, &other, b"aad").is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
|
||||
#[test]
|
||||
fn decrypt_with_wrong_aad_fails() -> Result<(), CryptoError> {
|
||||
let kr = Keyring::generate();
|
||||
let blob = encrypt_for(
|
||||
EncryptionType::MlKemChaCha20Poly1305,
|
||||
&kr.public_key_bundle(),
|
||||
b"secret",
|
||||
b"right",
|
||||
)?;
|
||||
assert!(decrypt_with(&blob, &kr, b"wrong").is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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,18 +6,8 @@ pub enum CryptoError {
|
|||
EncryptionFailed,
|
||||
#[error("decryption failed")]
|
||||
DecryptionFailed,
|
||||
#[error("decryption output exceeds the caller's allocation limit")]
|
||||
AllocationLimit,
|
||||
#[error("malformed encryption envelope")]
|
||||
MalformedEnvelope,
|
||||
#[error("no encryption recipients")]
|
||||
NoRecipients,
|
||||
#[error("no matching encryption recipient")]
|
||||
NoMatchingRecipient,
|
||||
#[error("invalid key length")]
|
||||
InvalidKeyLength,
|
||||
#[error("public and private key material do not match")]
|
||||
InvalidKeyMaterial,
|
||||
#[error("invalid nonce length")]
|
||||
InvalidNonceLength,
|
||||
#[error("invalid signature")]
|
||||
|
|
|
|||
|
|
@ -1,471 +1,208 @@
|
|||
// Canonical multi-recipient encryption envelopes.
|
||||
|
||||
use crate::enc::EncryptionType;
|
||||
use crate::error::CryptoError;
|
||||
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||
use crate::enc::{open_with_key, seal_with_key};
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
||||
use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305};
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
||||
use crate::kdf::derive_encryption_key;
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
||||
use crate::kem::HybridKem;
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
||||
use crate::keypair::{Keyring, PublicKeyBundle};
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
||||
use rand::Rng;
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", 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 kem_ciphertext: Vec<u8>,
|
||||
pub encrypted_key: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The envelope body used by `DataValue::Encrypted`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
/*
|
||||
* A payload encrypted for multiple recipients.
|
||||
*
|
||||
* Any recipient who possesses the corresponding `KemPrivateKey` can decrypt the message.
|
||||
*/
|
||||
pub struct MultiEncryptedMessage {
|
||||
pub encryption_type: EncryptionType,
|
||||
pub purpose: u8,
|
||||
pub recipients: Vec<RecipientEntry>,
|
||||
/// The AEAD output, including its nonce as defined by the selected suite.
|
||||
pub nonce: [u8; 24],
|
||||
pub ciphertext: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Borrowed view of a canonical encrypted envelope.
|
||||
///
|
||||
/// The codec uses this view while validating an attacker-controlled envelope
|
||||
/// so parsing it does not first create a complete temporary copy of every
|
||||
/// recipient entry and the ciphertext.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MultiEncryptedMessageRef<'a> {
|
||||
encryption_type: EncryptionType,
|
||||
purpose: u8,
|
||||
bytes: &'a [u8],
|
||||
entries_start: usize,
|
||||
entry_len: usize,
|
||||
count: usize,
|
||||
ciphertext_start: usize,
|
||||
}
|
||||
impl MultiEncryptedMessage {
|
||||
/*
|
||||
* Serialize into a compact byte vector.
|
||||
*
|
||||
* Format:
|
||||
* - `num_recipients: u16`
|
||||
* - for each recipient:
|
||||
* - `kem_ct_len: u16` | `kem_ciphertext`
|
||||
* - `ek_len: u16` | `encrypted_key`
|
||||
* - `nonce: 24 bytes`
|
||||
* - `ciphertext` (remaining)
|
||||
*/
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&(self.recipients.len() as u16).to_be_bytes());
|
||||
for r in &self.recipients {
|
||||
out.extend_from_slice(&(r.kem_ciphertext.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(&r.kem_ciphertext);
|
||||
out.extend_from_slice(&(r.encrypted_key.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(&r.encrypted_key);
|
||||
}
|
||||
out.extend_from_slice(&self.nonce);
|
||||
out.extend_from_slice(&self.ciphertext);
|
||||
out
|
||||
}
|
||||
|
||||
impl<'a> MultiEncryptedMessageRef<'a> {
|
||||
pub fn from_bytes(bytes: &'a [u8]) -> Result<Self, CryptoError> {
|
||||
if bytes.len() < 4 {
|
||||
return Err(CryptoError::MalformedEnvelope);
|
||||
}
|
||||
let encryption_type =
|
||||
EncryptionType::from_byte(bytes[0]).ok_or(CryptoError::UnknownAlgorithm)?;
|
||||
let purpose = bytes[1];
|
||||
let count = u16::from_be_bytes([bytes[2], bytes[3]]) as usize;
|
||||
if count == 0 || count > MAX_RECIPIENTS {
|
||||
return Err(CryptoError::MalformedEnvelope);
|
||||
}
|
||||
let entry_len = encryption_type
|
||||
.kem_ciphertext_len()
|
||||
.checked_add(encryption_type.wrapped_key_len())
|
||||
.ok_or(CryptoError::MalformedEnvelope)?;
|
||||
let entries_len = count
|
||||
.checked_mul(entry_len)
|
||||
.ok_or(CryptoError::MalformedEnvelope)?;
|
||||
let entries_start = 4usize;
|
||||
let ciphertext_start = entries_start
|
||||
.checked_add(entries_len)
|
||||
.ok_or(CryptoError::MalformedEnvelope)?;
|
||||
let ciphertext_len = bytes
|
||||
.len()
|
||||
.checked_sub(ciphertext_start)
|
||||
.ok_or(CryptoError::MalformedEnvelope)?;
|
||||
if ciphertext_len < encryption_type.minimum_ciphertext_len() {
|
||||
return Err(CryptoError::MalformedEnvelope);
|
||||
/// Deserialize from bytes produced by `to_bytes`.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, CryptoError> {
|
||||
let mut offset = 0;
|
||||
let read_u16 = |off: &mut usize| -> Result<u16, CryptoError> {
|
||||
let slice = bytes
|
||||
.get(*off..*off + 2)
|
||||
.ok_or(CryptoError::DecryptionFailed)?;
|
||||
let arr: [u8; 2] = slice
|
||||
.try_into()
|
||||
.map_err(|_| CryptoError::DecryptionFailed)?;
|
||||
*off += 2;
|
||||
Ok(u16::from_be_bytes(arr))
|
||||
};
|
||||
|
||||
let num = read_u16(&mut offset)? as usize;
|
||||
let mut recipients = Vec::with_capacity(num);
|
||||
for _ in 0..num {
|
||||
let klen = read_u16(&mut offset)? as usize;
|
||||
let kem_ct = bytes
|
||||
.get(offset..offset + klen)
|
||||
.ok_or(CryptoError::DecryptionFailed)?
|
||||
.to_vec();
|
||||
offset += klen;
|
||||
|
||||
let elen = read_u16(&mut offset)? as usize;
|
||||
let enc_key = bytes
|
||||
.get(offset..offset + elen)
|
||||
.ok_or(CryptoError::DecryptionFailed)?
|
||||
.to_vec();
|
||||
offset += elen;
|
||||
|
||||
recipients.push(RecipientEntry {
|
||||
kem_ciphertext: kem_ct,
|
||||
encrypted_key: enc_key,
|
||||
});
|
||||
}
|
||||
|
||||
let nonce: [u8; 24] = bytes
|
||||
.get(offset..offset + 24)
|
||||
.ok_or(CryptoError::DecryptionFailed)?
|
||||
.try_into()
|
||||
.map_err(|_| CryptoError::DecryptionFailed)?;
|
||||
offset += 24;
|
||||
|
||||
let ciphertext = bytes
|
||||
.get(offset..)
|
||||
.ok_or(CryptoError::DecryptionFailed)?
|
||||
.to_vec();
|
||||
|
||||
Ok(Self {
|
||||
encryption_type,
|
||||
purpose,
|
||||
bytes,
|
||||
entries_start,
|
||||
entry_len,
|
||||
count,
|
||||
ciphertext_start,
|
||||
recipients,
|
||||
nonce,
|
||||
ciphertext,
|
||||
})
|
||||
}
|
||||
|
||||
pub const fn encryption_type(&self) -> EncryptionType {
|
||||
self.encryption_type
|
||||
}
|
||||
|
||||
pub const fn purpose(&self) -> u8 {
|
||||
self.purpose
|
||||
}
|
||||
|
||||
pub const fn recipient_count(&self) -> usize {
|
||||
self.count
|
||||
}
|
||||
|
||||
pub fn recipient(&self, index: usize) -> Option<(&'a [u8], &'a [u8])> {
|
||||
if index >= self.count {
|
||||
return None;
|
||||
}
|
||||
let offset = self
|
||||
.entries_start
|
||||
.checked_add(index.checked_mul(self.entry_len)?)?;
|
||||
let kem_len = self.encryption_type.kem_ciphertext_len();
|
||||
let kem_end = offset.checked_add(kem_len)?;
|
||||
let end = offset.checked_add(self.entry_len)?;
|
||||
Some((
|
||||
self.bytes.get(offset..kem_end)?,
|
||||
self.bytes.get(kem_end..end)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn ciphertext(&self) -> &'a [u8] {
|
||||
&self.bytes[self.ciphertext_start..]
|
||||
}
|
||||
|
||||
pub fn to_owned(self) -> MultiEncryptedMessage {
|
||||
let recipients = (0..self.count)
|
||||
.filter_map(|index| {
|
||||
let (kem_ciphertext, encrypted_key) = self.recipient(index)?;
|
||||
Some(RecipientEntry {
|
||||
kem_ciphertext: kem_ciphertext.to_vec(),
|
||||
encrypted_key: encrypted_key.to_vec(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
MultiEncryptedMessage {
|
||||
encryption_type: self.encryption_type,
|
||||
purpose: self.purpose,
|
||||
recipients,
|
||||
ciphertext: self.ciphertext().to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MultiEncryptedMessage {
|
||||
/// Serialize the envelope body without redundant per-recipient lengths.
|
||||
pub fn to_bytes(&self) -> Result<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,
|
||||
/*
|
||||
* Encrypt `plaintext` for every recipient in `entities`.
|
||||
*
|
||||
* Internally generates a fresh content-encryption key, encrypts the payload
|
||||
* with ChaCha20-Poly1305, then KEM-encapsulates and wraps the key for each
|
||||
* recipient. The returned `MultiEncryptedMessage` can be decrypted by any
|
||||
* entity whose keyring contains the corresponding private KEM key.
|
||||
*
|
||||
* Requires the `pqc` and `chacha20poly1305` features.
|
||||
*/
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
||||
pub fn encrypt_multi(
|
||||
plaintext: &[u8],
|
||||
aad: &[u8],
|
||||
entities: &[PublicKeyBundle],
|
||||
) -> Result<MultiEncryptedMessage, CryptoError> {
|
||||
if entities.is_empty() {
|
||||
return Err(CryptoError::NoRecipients);
|
||||
}
|
||||
if entities.len() > MAX_RECIPIENTS {
|
||||
return Err(CryptoError::EncryptionFailed);
|
||||
}
|
||||
|
||||
let mut cek = Zeroizing::new([0u8; 32]);
|
||||
rand::rng().fill_bytes(cek.as_mut());
|
||||
|
||||
let cipher = ChaCha20Poly1305::new(*cek);
|
||||
let encrypted_payload = cipher.encrypt(plaintext, aad)?;
|
||||
|
||||
let nonce: [u8; 24] = encrypted_payload[..24]
|
||||
.try_into()
|
||||
.map_err(|_| CryptoError::EncryptionFailed)?;
|
||||
let ciphertext = encrypted_payload[24..].to_vec();
|
||||
|
||||
let mut recipients = Vec::with_capacity(entities.len());
|
||||
for entity in entities {
|
||||
let enc = HybridKem::encapsulate(&entity.kem_public_key)?;
|
||||
let wrap_key = Zeroizing::new(derive_encryption_key(
|
||||
&enc.shared_secret,
|
||||
KEY_WRAP_DOMAIN,
|
||||
&[encryption_type.to_byte(), purpose],
|
||||
b"mtp-multi-key-wrap",
|
||||
b"multi-recipient",
|
||||
)?);
|
||||
let aad = wrap_aad(encryption_type, purpose, &enc.ciphertext);
|
||||
let encrypted_key = seal_with_key(encryption_type, *wrap_key, cek.as_ref(), &aad)?;
|
||||
|
||||
let wrap_cipher = ChaCha20Poly1305::new(*wrap_key);
|
||||
let encrypted_key = wrap_cipher.encrypt(cek.as_ref(), b"")?;
|
||||
|
||||
recipients.push(RecipientEntry {
|
||||
kem_ciphertext: enc.ciphertext,
|
||||
encrypted_key,
|
||||
});
|
||||
}
|
||||
|
||||
let mut message = MultiEncryptedMessage {
|
||||
encryption_type,
|
||||
purpose,
|
||||
Ok(MultiEncryptedMessage {
|
||||
recipients,
|
||||
ciphertext: Vec::new(),
|
||||
};
|
||||
let aad = payload_aad(&message)?;
|
||||
message.ciphertext = seal_with_key(encryption_type, *cek, plaintext, &aad)?;
|
||||
Ok(message)
|
||||
nonce,
|
||||
ciphertext,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decrypt a canonical envelope for a recipient in `keyring`.
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||
pub fn decrypt_multi_for(
|
||||
message: &MultiEncryptedMessage,
|
||||
purpose: u8,
|
||||
/*
|
||||
* Decrypt a `MultiEncryptedMessage` using the recipient's `Keyring`.
|
||||
*
|
||||
* Tries each `RecipientEntry` until one succeeds with the given keyring's
|
||||
* KEM secret key. Returns the original plaintext.
|
||||
*/
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
||||
pub fn decrypt_multi(
|
||||
msg: &MultiEncryptedMessage,
|
||||
aad: &[u8],
|
||||
keyring: &Keyring,
|
||||
) -> Result<Vec<u8>, CryptoError> {
|
||||
decrypt_multi_for_parts(
|
||||
message.encryption_type,
|
||||
message.purpose,
|
||||
&message.recipients,
|
||||
&message.ciphertext,
|
||||
purpose,
|
||||
keyring,
|
||||
)
|
||||
}
|
||||
|
||||
/// Decrypt an envelope represented by borrowed recipient and ciphertext
|
||||
/// slices. This keeps protected-value opening from cloning an already-owned
|
||||
/// envelope solely to call the cryptographic primitive.
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||
pub fn decrypt_multi_for_parts(
|
||||
encryption_type: EncryptionType,
|
||||
envelope_purpose: u8,
|
||||
recipients: &[RecipientEntry],
|
||||
ciphertext: &[u8],
|
||||
purpose: u8,
|
||||
keyring: &Keyring,
|
||||
) -> Result<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,
|
||||
};
|
||||
let wrap_key = Zeroizing::new(derive_encryption_key(
|
||||
&shared_secret,
|
||||
KEY_WRAP_DOMAIN,
|
||||
&[encryption_type.to_byte(), purpose],
|
||||
)?);
|
||||
let aad = wrap_aad(encryption_type, purpose, &entry.kem_ciphertext);
|
||||
let cek = match open_with_key(encryption_type, *wrap_key, &entry.encrypted_key, &aad) {
|
||||
Ok(key) => key,
|
||||
for entry in &msg.recipients {
|
||||
let ss = match HybridKem::decapsulate(&keyring.kem_secret_key, &entry.kem_ciphertext) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let cek: [u8; 32] = cek.try_into().map_err(|_| CryptoError::DecryptionFailed)?;
|
||||
return open_with_key(encryption_type, cek, ciphertext, &payload_aad);
|
||||
}
|
||||
|
||||
Err(CryptoError::NoMatchingRecipient)
|
||||
}
|
||||
|
||||
/// Decrypt a canonical envelope only when its plaintext can fit inside the
|
||||
/// caller's allocation budget.
|
||||
///
|
||||
/// The AEAD implementation allocates its output buffer internally. Checking
|
||||
/// the ciphertext upper bound before entering that implementation makes the
|
||||
/// codec's reservation meaningful instead of merely checking the result
|
||||
/// after the allocation has already happened.
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||
pub fn decrypt_multi_for_parts_with_limit(
|
||||
encryption_type: EncryptionType,
|
||||
envelope_purpose: u8,
|
||||
recipients: &[RecipientEntry],
|
||||
ciphertext: &[u8],
|
||||
purpose: u8,
|
||||
keyring: &Keyring,
|
||||
max_plaintext_len: usize,
|
||||
) -> Result<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],
|
||||
let wrap_key = Zeroizing::new(derive_encryption_key(
|
||||
&ss,
|
||||
b"mtp-multi-key-wrap",
|
||||
b"multi-recipient",
|
||||
)?);
|
||||
let wrap_cipher = ChaCha20Poly1305::new(*wrap_key);
|
||||
let cek = match wrap_cipher.decrypt(&entry.encrypted_key, b"") {
|
||||
Ok(k) => Zeroizing::new(k),
|
||||
Err(_) => continue,
|
||||
};
|
||||
assert!(matches!(
|
||||
message.to_bytes(),
|
||||
Err(CryptoError::MalformedEnvelope)
|
||||
));
|
||||
let cek_arr = Zeroizing::new(
|
||||
cek.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| CryptoError::DecryptionFailed)?,
|
||||
);
|
||||
|
||||
let mut encoded = vec![encryption_type.to_byte(), 1, 0, 1];
|
||||
encoded.extend_from_slice(&vec![0; encryption_type.kem_ciphertext_len()]);
|
||||
encoded.extend_from_slice(&vec![0; encryption_type.wrapped_key_len()]);
|
||||
encoded.extend_from_slice(&vec![0; encryption_type.minimum_ciphertext_len() - 1]);
|
||||
assert!(matches!(
|
||||
MultiEncryptedMessage::from_bytes(&encoded),
|
||||
Err(CryptoError::MalformedEnvelope)
|
||||
));
|
||||
}
|
||||
let mut full_ct = Vec::with_capacity(24 + msg.ciphertext.len());
|
||||
full_ct.extend_from_slice(&msg.nonce);
|
||||
full_ct.extend_from_slice(&msg.ciphertext);
|
||||
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
|
||||
#[test]
|
||||
fn bounded_decryption_rejects_before_plaintext_allocation() -> Result<(), CryptoError> {
|
||||
let recipient = Keyring::generate();
|
||||
let message = encrypt_multi_for(
|
||||
EncryptionType::MlKemChaCha20Poly1305,
|
||||
1,
|
||||
b"bounded plaintext",
|
||||
&[recipient.public_key_bundle()],
|
||||
)?;
|
||||
|
||||
assert!(matches!(
|
||||
decrypt_multi_for_parts_with_limit(
|
||||
message.encryption_type,
|
||||
message.purpose,
|
||||
&message.recipients,
|
||||
&message.ciphertext,
|
||||
message.purpose,
|
||||
&recipient,
|
||||
message.ciphertext.len() - 1,
|
||||
),
|
||||
Err(CryptoError::AllocationLimit)
|
||||
));
|
||||
Ok(())
|
||||
let data_cipher = ChaCha20Poly1305::new(*cek_arr);
|
||||
return data_cipher.decrypt(&full_ct, aad);
|
||||
}
|
||||
Err(CryptoError::DecryptionFailed)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,29 +36,3 @@ pub fn derive_encryption_key(
|
|||
out.copy_from_slice(&key);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(feature = "password-kdf")]
|
||||
pub fn derive_password_key(
|
||||
passphrase: &[u8],
|
||||
salt: &[u8],
|
||||
memory_kib: u32,
|
||||
iterations: u32,
|
||||
lanes: u32,
|
||||
) -> Result<[u8; 32], CryptoError> {
|
||||
if passphrase.is_empty()
|
||||
|| salt.len() < 16
|
||||
|| !(8 * 1024..=256 * 1024).contains(&memory_kib)
|
||||
|| !(1..=10).contains(&iterations)
|
||||
|| !(1..=8).contains(&lanes)
|
||||
{
|
||||
return Err(CryptoError::KdfError);
|
||||
}
|
||||
let params = argon2::Params::new(memory_kib, iterations, lanes, Some(32))
|
||||
.map_err(|_| CryptoError::KdfError)?;
|
||||
let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
|
||||
let mut key = [0u8; 32];
|
||||
argon
|
||||
.hash_password_into(passphrase, salt, &mut key)
|
||||
.map_err(|_| CryptoError::KdfError)?;
|
||||
Ok(key)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,13 +12,11 @@ pub struct HybridKem;
|
|||
|
||||
#[cfg(feature = "mlkem-tls")]
|
||||
impl HybridKem {
|
||||
/// Fixed wire size of the KEM ciphertext used by MTP envelopes.
|
||||
pub const fn ciphertext_len() -> usize {
|
||||
mlkem_tls::X25519MlKem768::CIPHERTEXT_SIZE
|
||||
}
|
||||
|
||||
pub fn generate_keypair() -> (KemPrivateKey, KemPublicKey) {
|
||||
let (ek, dk) = mlkem_tls::X25519MlKem768::keygen(&mut rand_core::OsRng);
|
||||
/* Obviously: cannot find module or crate rand_core06 in this scope
|
||||
use of unresolved module or unlinked crate rand_core06 (rustc E0433) */
|
||||
let (ek, dk) =
|
||||
mlkem_tls::X25519MlKem768::keygen(&mut chacha20poly1305::aead::rand_core::OsRng);
|
||||
(
|
||||
KemPrivateKey::new(dk.as_bytes().to_vec()),
|
||||
KemPublicKey::new(ek.as_bytes().to_vec()),
|
||||
|
|
@ -28,7 +26,10 @@ impl HybridKem {
|
|||
pub fn encapsulate(recipient_pk: &KemPublicKey) -> Result<Encapsulated, CryptoError> {
|
||||
let ek = mlkem_tls::EncapsKey768::try_from(recipient_pk.as_bytes())
|
||||
.map_err(|_| CryptoError::KemEncapsulationFailed)?;
|
||||
let (ct, ss) = mlkem_tls::X25519MlKem768::encapsulate(&ek, &mut rand_core::OsRng);
|
||||
let (ct, ss) = mlkem_tls::X25519MlKem768::encapsulate(
|
||||
&ek,
|
||||
&mut chacha20poly1305::aead::rand_core::OsRng,
|
||||
);
|
||||
Ok(Encapsulated {
|
||||
ciphertext: ct.as_bytes().to_vec(),
|
||||
shared_secret: Zeroizing::new(ss.as_bytes().to_vec()),
|
||||
|
|
|
|||
|
|
@ -237,91 +237,7 @@ impl Keyring {
|
|||
}
|
||||
}
|
||||
|
||||
/// Validate the material required to produce classical signatures. This
|
||||
/// intentionally permits a browser role-specific keyring without KEM or
|
||||
/// PQ fields.
|
||||
pub fn validate_ed25519_signing(&self) -> Result<(), crate::error::CryptoError> {
|
||||
use crate::error::CryptoError;
|
||||
if self.sig_cl_secret_key.as_bytes().len() != 32
|
||||
|| self.sig_cl_public_key.as_bytes().len() != SIG_CL_PUBLIC_KEY_LEN
|
||||
{
|
||||
return Err(CryptoError::InvalidKeyLength);
|
||||
}
|
||||
#[cfg(feature = "ed25519-dalek")]
|
||||
{
|
||||
let signer = crate::sign::Ed25519Signer::new(&self.sig_cl_secret_key)?;
|
||||
if signer.public_key().as_bytes() != self.sig_cl_public_key.as_bytes() {
|
||||
return Err(CryptoError::InvalidKeyMaterial);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate material required for a hybrid Ed25519 + ML-DSA signature.
|
||||
pub fn validate_dual_signing(&self) -> Result<(), crate::error::CryptoError> {
|
||||
use crate::error::CryptoError;
|
||||
self.validate_ed25519_signing()?;
|
||||
if self.sig_pq_secret_key.as_bytes().len() != 32
|
||||
|| self.sig_pq_public_key.as_bytes().len() != SIG_PQ_PUBLIC_KEY_LEN
|
||||
{
|
||||
return Err(CryptoError::InvalidKeyLength);
|
||||
}
|
||||
#[cfg(feature = "ml-dsa")]
|
||||
{
|
||||
let signer =
|
||||
crate::sign::MlDsaSigner::new(&self.sig_pq_secret_key, &self.sig_pq_public_key)?;
|
||||
if signer.public_key().as_bytes() != self.sig_pq_public_key.as_bytes() {
|
||||
return Err(CryptoError::InvalidKeyMaterial);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate the KEM material required to decrypt envelopes addressed to
|
||||
/// this keyring. This is intentionally separate from full identity
|
||||
/// validation because browser and relay roles may use Ed25519-only
|
||||
/// signing material while still needing a complete encryption key pair.
|
||||
pub fn validate_encryption(&self) -> Result<(), crate::error::CryptoError> {
|
||||
use crate::error::CryptoError;
|
||||
if self.kem_public_key.as_bytes().is_empty() || self.kem_secret_key.as_bytes().is_empty() {
|
||||
return Err(CryptoError::InvalidKeyLength);
|
||||
}
|
||||
#[cfg(feature = "mlkem-tls")]
|
||||
{
|
||||
let encapsulated = crate::kem::HybridKem::encapsulate(&self.kem_public_key)?;
|
||||
let recovered =
|
||||
crate::kem::HybridKem::decapsulate(&self.kem_secret_key, &encapsulated.ciphertext)?;
|
||||
if recovered.as_slice() != encapsulated.shared_secret.as_slice() {
|
||||
return Err(CryptoError::InvalidKeyMaterial);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a complete identity before using it at a protocol boundary.
|
||||
///
|
||||
/// `Keyring` remains permissive because browser callers may intentionally
|
||||
/// hold role-specific material. Protocol paths that need encryption and
|
||||
/// both signing suites should call this method explicitly.
|
||||
pub fn validate_full(&self) -> Result<(), crate::error::CryptoError> {
|
||||
use crate::error::CryptoError;
|
||||
|
||||
self.public_key_bundle().validate()?;
|
||||
self.validate_encryption()?;
|
||||
if self.sig_pq_secret_key.as_bytes().len() != 32
|
||||
|| self.sig_cl_secret_key.as_bytes().len() != 32
|
||||
{
|
||||
return Err(CryptoError::InvalidKeyLength);
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "ml-dsa", feature = "ed25519-dalek"))]
|
||||
{
|
||||
self.validate_dual_signing()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn try_to_bytes(&self) -> Result<Zeroizing<Vec<u8>>, crate::error::CryptoError> {
|
||||
pub fn to_bytes(&self) -> Zeroizing<Vec<u8>> {
|
||||
let fields: &[&[u8]] = &[
|
||||
self.kem_public_key.as_bytes(),
|
||||
self.kem_secret_key.as_bytes(),
|
||||
|
|
@ -332,17 +248,10 @@ impl Keyring {
|
|||
];
|
||||
let mut out = Zeroizing::new(Vec::new());
|
||||
for f in fields {
|
||||
let length =
|
||||
u16::try_from(f.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?;
|
||||
out.extend_from_slice(&length.to_be_bytes());
|
||||
out.extend_from_slice(&(f.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(f);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[deprecated(note = "use try_to_bytes for the primary fallible serializer")]
|
||||
pub fn to_bytes(&self) -> Result<Zeroizing<Vec<u8>>, crate::error::CryptoError> {
|
||||
self.try_to_bytes()
|
||||
out
|
||||
}
|
||||
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, crate::error::CryptoError> {
|
||||
|
|
@ -374,35 +283,18 @@ impl Keyring {
|
|||
sig_cl_public_key: SignaturePublicKey::new(read_key(&mut offset)?),
|
||||
sig_cl_secret_key: SignaturePrivateKey::new(read_key(&mut offset)?),
|
||||
})
|
||||
.and_then(|keyring| {
|
||||
if offset == bytes.len() {
|
||||
Ok(keyring)
|
||||
} else {
|
||||
Err(CryptoError::InvalidKeyLength)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[deprecated(note = "use try_to_hex for the primary fallible serializer")]
|
||||
pub fn to_hex(&self) -> Result<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 to_hex(&self) -> String {
|
||||
bytes_to_hex(&self.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 to_base64(&self) -> String {
|
||||
bytes_to_base64(&self.to_bytes())
|
||||
}
|
||||
|
||||
pub fn from_base64(s: &str) -> Result<Self, crate::error::CryptoError> {
|
||||
|
|
@ -460,6 +352,7 @@ impl PublicKeyBundle {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa", feature = "mlkem-tls"))]
|
||||
pub fn validate(&self) -> Result<(), crate::error::CryptoError> {
|
||||
use crate::error::CryptoError;
|
||||
if self.sig_cl_public_key.as_bytes().len() != SIG_CL_PUBLIC_KEY_LEN {
|
||||
|
|
@ -471,70 +364,25 @@ impl PublicKeyBundle {
|
|||
if self.kem_public_key.as_bytes().len() != KEM_PUBLIC_KEY_LEN {
|
||||
return Err(CryptoError::InvalidKeyLength);
|
||||
}
|
||||
#[cfg(feature = "ed25519-dalek")]
|
||||
{
|
||||
let bytes: [u8; SIG_CL_PUBLIC_KEY_LEN] = self
|
||||
.sig_cl_public_key
|
||||
.as_bytes()
|
||||
.try_into()
|
||||
.map_err(|_| CryptoError::InvalidKeyLength)?;
|
||||
ed25519_dalek::VerifyingKey::from_bytes(&bytes)
|
||||
.map_err(|_| CryptoError::InvalidKeyMaterial)?;
|
||||
}
|
||||
#[cfg(feature = "ml-dsa")]
|
||||
{
|
||||
let encoded = ml_dsa::EncodedVerifyingKey::<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(())
|
||||
}
|
||||
|
||||
pub fn try_as_bytes(&self) -> Result<Vec<u8>, crate::error::CryptoError> {
|
||||
pub fn as_bytes(&self) -> Vec<u8> {
|
||||
let kem = self.kem_public_key.as_bytes();
|
||||
let pq = self.sig_pq_public_key.as_bytes();
|
||||
let cl = self.sig_cl_public_key.as_bytes();
|
||||
let kem_len =
|
||||
u16::try_from(kem.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?;
|
||||
let pq_len =
|
||||
u16::try_from(pq.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?;
|
||||
let cl_len =
|
||||
u16::try_from(cl.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?;
|
||||
|
||||
let mut out = Vec::with_capacity(kem.len() + pq.len() + cl.len() + 6);
|
||||
out.extend_from_slice(&kem_len.to_be_bytes());
|
||||
out.extend_from_slice(&(kem.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(kem);
|
||||
out.extend_from_slice(&pq_len.to_be_bytes());
|
||||
out.extend_from_slice(&(pq.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(pq);
|
||||
out.extend_from_slice(&cl_len.to_be_bytes());
|
||||
out.extend_from_slice(&(cl.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(cl);
|
||||
Ok(out)
|
||||
out
|
||||
}
|
||||
|
||||
#[deprecated(note = "use try_as_bytes for the primary fallible serializer")]
|
||||
pub fn as_bytes(&self) -> Result<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> {
|
||||
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;
|
||||
let mut offset = 0;
|
||||
|
||||
|
|
@ -574,11 +422,6 @@ impl PublicKeyBundle {
|
|||
.ok_or(CryptoError::InvalidKeyLength)?
|
||||
.to_vec(),
|
||||
);
|
||||
offset += cl_len;
|
||||
|
||||
if offset != bytes.len() {
|
||||
return Err(CryptoError::InvalidKeyLength);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
kem_public_key: kem,
|
||||
|
|
@ -587,27 +430,13 @@ impl PublicKeyBundle {
|
|||
})
|
||||
}
|
||||
|
||||
/// 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 to_base64(&self) -> String {
|
||||
bytes_to_base64(&self.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 {
|
||||
|
|
@ -617,6 +446,12 @@ 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 {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("PublicKeyBundle")
|
||||
|
|
@ -638,8 +473,8 @@ mod tests {
|
|||
let cl = SignaturePublicKey::new(vec![3u8; 32]);
|
||||
|
||||
let bundle = PublicKeyBundle::new(kem, pq, cl);
|
||||
let bytes = bundle.try_as_bytes()?;
|
||||
let recovered = PublicKeyBundle::from_bytes_unvalidated(&bytes)?;
|
||||
let bytes = bundle.as_bytes();
|
||||
let recovered = PublicKeyBundle::from_bytes(&bytes)?;
|
||||
|
||||
assert_eq!(
|
||||
bundle.kem_public_key.as_bytes(),
|
||||
|
|
@ -656,24 +491,6 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "ml-dsa", feature = "ed25519-dalek"))]
|
||||
#[test]
|
||||
fn full_keyring_validation_checks_key_correspondence() {
|
||||
let keyring = Keyring::generate();
|
||||
assert!(keyring.validate_full().is_ok());
|
||||
assert!(keyring.validate_encryption().is_ok());
|
||||
|
||||
let mut invalid = Keyring::generate();
|
||||
invalid.sig_cl_public_key = SignaturePublicKey::new(vec![0; SIG_CL_PUBLIC_KEY_LEN]);
|
||||
assert!(matches!(
|
||||
invalid.validate_full(),
|
||||
Err(crate::error::CryptoError::InvalidKeyMaterial)
|
||||
));
|
||||
|
||||
invalid.kem_secret_key = KemPrivateKey::new(vec![0]);
|
||||
assert!(invalid.validate_encryption().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_key_bundle_try_from_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let bundle = PublicKeyBundle::new(
|
||||
|
|
@ -681,9 +498,9 @@ mod tests {
|
|||
SignaturePqPublicKey::new(vec![0xCDu8; 96]),
|
||||
SignaturePublicKey::new(vec![0xEFu8; 32]),
|
||||
);
|
||||
let bytes = bundle.try_as_bytes()?;
|
||||
let recovered = PublicKeyBundle::from_bytes_unvalidated(bytes.as_slice())?;
|
||||
assert_eq!(bundle.try_as_bytes()?, recovered.try_as_bytes()?);
|
||||
let bytes: Vec<u8> = Vec::from(&bundle);
|
||||
let recovered = PublicKeyBundle::try_from(bytes.as_slice())?;
|
||||
assert_eq!(bundle.as_bytes(), recovered.as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -697,8 +514,8 @@ mod tests {
|
|||
SignaturePublicKey::new(vec![5u8; 32]),
|
||||
SignaturePrivateKey::new(vec![6u8; 32]),
|
||||
);
|
||||
let bytes = keyring.try_to_bytes()?;
|
||||
let recovered = Keyring::from_bytes(bytes.as_slice())?;
|
||||
let bytes = keyring.to_bytes();
|
||||
let recovered = Keyring::from_bytes(&bytes)?;
|
||||
assert_eq!(
|
||||
keyring.kem_public_key.as_bytes(),
|
||||
recovered.kem_public_key.as_bytes()
|
||||
|
|
@ -714,68 +531,6 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyring_try_to_bytes_rejects_fields_larger_than_wire_length() {
|
||||
let keyring = Keyring::new(
|
||||
KemPublicKey::new(vec![0u8; 65_536]),
|
||||
KemPrivateKey::new(Vec::new()),
|
||||
SignaturePqPublicKey::new(Vec::new()),
|
||||
SignaturePqPrivateKey::new(Vec::new()),
|
||||
SignaturePublicKey::new(Vec::new()),
|
||||
SignaturePrivateKey::new(Vec::new()),
|
||||
);
|
||||
assert!(matches!(
|
||||
keyring.try_to_bytes(),
|
||||
Err(crate::error::CryptoError::InvalidKeyLength)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_key_parsers_reject_trailing_bytes() -> Result<(), Box<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(
|
||||
|
|
@ -786,9 +541,9 @@ mod tests {
|
|||
SignaturePublicKey::new(vec![4u8; 16]),
|
||||
SignaturePrivateKey::new(vec![5u8; 16]),
|
||||
);
|
||||
let bytes = keyring.try_to_bytes()?;
|
||||
let bytes = keyring.to_bytes();
|
||||
let recovered = Keyring::try_from(bytes.as_slice())?;
|
||||
assert_eq!(keyring.try_to_bytes()?, recovered.try_to_bytes()?);
|
||||
assert_eq!(keyring.to_bytes(), recovered.to_bytes());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -812,9 +567,9 @@ mod tests {
|
|||
SignaturePublicKey::new(vec![5u8; 16]),
|
||||
SignaturePrivateKey::new(vec![6u8; 16]),
|
||||
);
|
||||
let hex = keyring.try_to_hex()?;
|
||||
let hex = keyring.to_hex();
|
||||
let recovered = Keyring::from_hex(&hex)?;
|
||||
assert_eq!(keyring.try_to_bytes()?, recovered.try_to_bytes()?);
|
||||
assert_eq!(keyring.to_bytes(), recovered.to_bytes());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -828,9 +583,9 @@ mod tests {
|
|||
SignaturePublicKey::new(vec![5u8; 16]),
|
||||
SignaturePrivateKey::new(vec![6u8; 16]),
|
||||
);
|
||||
let b64 = keyring.try_to_base64()?;
|
||||
let b64 = keyring.to_base64();
|
||||
let recovered = Keyring::from_base64(&b64)?;
|
||||
assert_eq!(keyring.try_to_bytes()?, recovered.try_to_bytes()?);
|
||||
assert_eq!(keyring.to_bytes(), recovered.to_bytes());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -841,9 +596,9 @@ mod tests {
|
|||
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()?);
|
||||
let b64 = bundle.to_base64();
|
||||
let recovered = PublicKeyBundle::from_base64(&b64)?;
|
||||
assert_eq!(bundle.as_bytes(), recovered.as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ pub use keypair::{
|
|||
};
|
||||
|
||||
#[cfg(feature = "chacha20poly1305")]
|
||||
pub use aead::{ChaCha20Poly1305, XChaCha20Poly1305};
|
||||
pub use aead::ChaCha20Poly1305;
|
||||
|
||||
#[cfg(feature = "aes-gcm")]
|
||||
pub use aead::Aes256Gcm;
|
||||
|
|
@ -55,13 +55,11 @@ pub use sign::{Ed25519Signer, SignatureScheme, verify_ed25519};
|
|||
pub use sign::{MlDsaSigner, verify_ml_dsa};
|
||||
|
||||
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||
pub use sign::{DualSignature, DualSigner, sign_dual};
|
||||
pub use sign::{DualSignature, sign_dual};
|
||||
|
||||
#[cfg(feature = "sha2")]
|
||||
pub use hash::{Sha256Hasher, sha256, sha256_double};
|
||||
|
||||
#[cfg(feature = "password-kdf")]
|
||||
pub use kdf::derive_password_key;
|
||||
#[cfg(feature = "hkdf")]
|
||||
pub use kdf::{derive_encryption_key, hkdf_expand, hkdf_extract};
|
||||
|
||||
|
|
@ -81,14 +79,11 @@ pub fn ensure_crypto_provider() {
|
|||
});
|
||||
}
|
||||
|
||||
pub use helper::{ENCRYPT_DOMAIN, KEY_WRAP_DOMAIN};
|
||||
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||
pub use helper::{
|
||||
MAX_RECIPIENTS, MultiEncryptedMessage, MultiEncryptedMessageRef, RecipientEntry,
|
||||
decrypt_multi_for, decrypt_multi_for_parts, decrypt_multi_for_parts_with_limit,
|
||||
encrypt_multi_for,
|
||||
};
|
||||
pub use enc::{decrypt_with, encrypt_for};
|
||||
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
||||
pub use helper::{MultiEncryptedMessage, RecipientEntry, decrypt_multi, encrypt_multi};
|
||||
|
||||
/* ================================ TESTS ================================ */
|
||||
#[cfg(test)]
|
||||
|
|
@ -214,20 +209,6 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||
#[test]
|
||||
fn dual_scheme_implements_signature_trait() {
|
||||
use crate::sign::SignatureScheme;
|
||||
|
||||
let (signer, _, _, _, _) = DualSigner::generate();
|
||||
let signature = signer.sign(b"msg").expect("dual signing should succeed");
|
||||
assert_eq!(signer.algorithm(), SigAlgorithm::DUAL);
|
||||
signer
|
||||
.verify(b"msg", &signature)
|
||||
.expect("dual verification should succeed");
|
||||
assert!(signer.verify(b"wrong", &signature).is_err());
|
||||
}
|
||||
|
||||
#[cfg(feature = "hkdf")]
|
||||
#[test]
|
||||
fn hkdf_expand_produces_key() {
|
||||
|
|
@ -315,9 +296,7 @@ mod tests {
|
|||
#[test]
|
||||
fn keyring_serialize_roundtrip() {
|
||||
let kr = Keyring::generate();
|
||||
let bytes = kr
|
||||
.try_to_bytes()
|
||||
.expect("keyring serialization should succeed");
|
||||
let bytes = kr.to_bytes();
|
||||
let loaded = Keyring::from_bytes(&bytes).expect("keyring roundtrip should succeed");
|
||||
assert_eq!(
|
||||
kr.kem_public_key.as_bytes(),
|
||||
|
|
@ -338,9 +317,7 @@ mod tests {
|
|||
fn public_key_bundle_serialize_roundtrip() {
|
||||
let kr = Keyring::generate();
|
||||
let bundle = kr.public_key_bundle();
|
||||
let bytes = bundle
|
||||
.try_as_bytes()
|
||||
.expect("bundle serialization should succeed");
|
||||
let bytes = bundle.as_bytes();
|
||||
let loaded = PublicKeyBundle::from_bytes(&bytes).expect("bundle roundtrip should succeed");
|
||||
assert_eq!(
|
||||
bundle.kem_public_key.as_bytes(),
|
||||
|
|
@ -366,46 +343,17 @@ mod tests {
|
|||
assert_eq!(enc.shared_secret, ss);
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
feature = "mlkem-tls",
|
||||
feature = "hkdf",
|
||||
feature = "ml-dsa",
|
||||
feature = "ed25519-dalek"
|
||||
))]
|
||||
fn multi_envelope_roundtrip(encryption_type: EncryptionType) {
|
||||
use crate::helper::{decrypt_multi_for, encrypt_multi_for};
|
||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
||||
#[test]
|
||||
fn encrypt_multi_roundtrip() {
|
||||
use crate::helper::{decrypt_multi, encrypt_multi};
|
||||
use crate::keypair::Keyring;
|
||||
|
||||
let kr = Keyring::generate();
|
||||
let entities = vec![kr.public_key_bundle()];
|
||||
let msg = b"secret data";
|
||||
let ct = encrypt_multi_for(encryption_type, 7, msg, &entities)
|
||||
.expect("multi encrypt should succeed");
|
||||
let pt = decrypt_multi_for(&ct, 7, &kr).expect("multi decrypt should succeed");
|
||||
let ct = encrypt_multi(msg, b"aad", &entities).expect("multi encrypt should succeed");
|
||||
let pt = decrypt_multi(&ct, b"aad", &kr).expect("multi decrypt should succeed");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@ impl SigAlgorithm {
|
|||
use crate::keypair::{SignaturePqPrivateKey, SignaturePqPublicKey};
|
||||
|
||||
pub trait SignatureScheme {
|
||||
/// The wire algorithm identifier produced by this signer.
|
||||
fn algorithm(&self) -> u8;
|
||||
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError>;
|
||||
fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError>;
|
||||
}
|
||||
|
|
@ -78,10 +76,6 @@ impl Ed25519Signer {
|
|||
|
||||
#[cfg(feature = "ed25519-dalek")]
|
||||
impl SignatureScheme for Ed25519Signer {
|
||||
fn algorithm(&self) -> u8 {
|
||||
SigAlgorithm::ED25519
|
||||
}
|
||||
|
||||
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||
use ed25519_dalek::Signer;
|
||||
let signature = self.secret.sign(msg).to_bytes().to_vec();
|
||||
|
|
@ -176,10 +170,6 @@ impl MlDsaSigner {
|
|||
|
||||
#[cfg(feature = "ml-dsa")]
|
||||
impl SignatureScheme for MlDsaSigner {
|
||||
fn algorithm(&self) -> u8 {
|
||||
SigAlgorithm::ML_DSA_65
|
||||
}
|
||||
|
||||
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||
use ml_dsa::Signer;
|
||||
let signature = self
|
||||
|
|
@ -247,75 +237,6 @@ pub fn sign_dual(
|
|||
Ok(DualSignature { ed25519, mldsa })
|
||||
}
|
||||
|
||||
/// A signer that produces the canonical concatenated Ed25519 + ML-DSA-65
|
||||
/// signature represented by [`SigAlgorithm::DUAL`].
|
||||
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||
pub struct DualSigner {
|
||||
ed25519: Ed25519Signer,
|
||||
mldsa: MlDsaSigner,
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||
impl DualSigner {
|
||||
pub fn new(
|
||||
ed25519_secret: &SignaturePrivateKey,
|
||||
mldsa_secret: &SignaturePqPrivateKey,
|
||||
mldsa_public: &SignaturePqPublicKey,
|
||||
) -> Result<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 {
|
||||
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||
pub fn verify(
|
||||
|
|
|
|||
16
deny.toml
16
deny.toml
|
|
@ -8,23 +8,9 @@ ignore = []
|
|||
|
||||
[bans]
|
||||
# Flag multiple versions of the same crate so duplicate trees are visible.
|
||||
multiple-versions = "deny"
|
||||
multiple-versions = "warn"
|
||||
wildcards = "deny"
|
||||
|
||||
# These versions are required by incompatible upstream dependency lines:
|
||||
# - pem/rcgen/wtransport still use base64 0.22.
|
||||
# - ring and wasm-bindgen still use getrandom 0.2.
|
||||
# - current displaydoc/serde/thiserror/tokio and wasm-bindgen trees span syn 2
|
||||
# and syn 3.
|
||||
# - ring still uses windows-sys 0.52 while the Tokio/QUIC tree uses 0.61.
|
||||
# Keep the duplicate-version policy strict for every other crate/version.
|
||||
skip = [
|
||||
{ name = "base64", version = "0.22.1" },
|
||||
{ name = "getrandom", version = "0.2.17" },
|
||||
{ name = "syn", version = "2.0.119" },
|
||||
{ name = "windows-sys", version = "0.52.0" },
|
||||
]
|
||||
|
||||
[licenses]
|
||||
# Allowlist of licenses acceptable for this project's dependencies.
|
||||
allow = [
|
||||
|
|
|
|||
|
|
@ -38,33 +38,12 @@ MTP separates wire encoding, QUIC transport, connection policy, protocol negotia
|
|||
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 middle row is shared protocol machinery. The type map determines numeric IDs, the codec serializes values, and transport framing places each serialized frame on a QUIC stream. This is why a type-map 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.
|
||||
|
|
|
|||
|
|
@ -1,23 +1,19 @@
|
|||
# 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()`.
|
||||
Native clients and hosts share the same connection shape after the opening handshake. The client creates the connection; the host receives it 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 |
|
||||
| `receiver` | Receives application frames | Receives application frames | Receives 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`) |
|
||||
| `request_path` | — | — | 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.
|
||||
`WebMTPConnection`, returned by `MTPWebServer::accept()`, exposes the same members as the native host connection plus `request_path`, which 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`.
|
||||
|
|
|
|||
|
|
@ -4,28 +4,23 @@ This file documents the connection and version negotiation logic.
|
|||
|
||||
## Registry
|
||||
|
||||
The `registry` module provides a multi-version `Registry` used by the host for
|
||||
version negotiation. Accessed through the `mtp` facade (requires the `host`
|
||||
feature). In this repository, `Registry::builtin()` is generated from
|
||||
[`example/type-maps.yaml`](../example/type-maps.yaml), which currently contains
|
||||
protocol version 3.0 only. Downstream projects can register additional versions
|
||||
in their own YAML configuration.
|
||||
The `registry` module provides a multi-version `Registry` used by the host for version negotiation. Accessed through the `mtp` facade (requires the `host` feature):
|
||||
|
||||
```rust
|
||||
use mtp::codec::{Version, registry::Registry};
|
||||
use mtp::codec::registry::Registry;
|
||||
|
||||
let registry = Registry::builtin(); // loads all TypeMaps from the build config
|
||||
let registry = Registry::builtin(); // loads all TypeMaps from config
|
||||
|
||||
// Check if a version is supported
|
||||
assert!(registry.supports(&Version(3, 0)));
|
||||
assert!(registry.supports(&Version(1, 0)));
|
||||
|
||||
// Find highest mutual version for a client
|
||||
let client_versions = &[Version(2, 0), Version(3, 0)];
|
||||
let client_versions = &[Version(0, 0), Version(1, 0)];
|
||||
let negotiated = registry.negotiate(client_versions);
|
||||
assert_eq!(negotiated, Some(Version(3, 0)));
|
||||
assert_eq!(negotiated, Some(Version(1, 0)));
|
||||
|
||||
// Look up a version's TypeMap
|
||||
let tm = registry.get(&Version(3, 0)).unwrap();
|
||||
let tm = registry.get(&Version(2, 0)).unwrap();
|
||||
```
|
||||
|
||||
The `Registry::builtin()` constructor uses the `TypeMap::vX_Y()` methods generated from the config.
|
||||
|
|
@ -59,9 +54,9 @@ let mut host = MTPHost::new(config).await?;
|
|||
while let Some(conn) = host.accept().await? {
|
||||
// conn.version is the negotiated version
|
||||
// conn.codec is a VersionedCodec scoped to that version
|
||||
// conn.sender / conn.receive() for application CommunicationValue I/O
|
||||
// conn.sender / conn.receiver for raw CommunicationValue I/O
|
||||
|
||||
let msg = conn.receive().await?;
|
||||
let msg = conn.receiver.receive().await?;
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -99,31 +94,29 @@ The client's `PROTOCOL_VERSION` constant is set by `protocol_version` in `type-m
|
|||
## Version Negotiation Flow
|
||||
|
||||
```
|
||||
Client (v3.0) Host (v3.0)
|
||||
Client (v2.0) Host (v0.0, v1.0, v2.0)
|
||||
| |
|
||||
| QUIC connect |
|
||||
|----------------------->|
|
||||
| |
|
||||
| CommValue{ Ident. } |
|
||||
| Version -> "3.0" |
|
||||
| Version -> "2.0" |
|
||||
| Id -> 8765 |
|
||||
| (unsigned hello; auth |
|
||||
| challenge follows) |
|
||||
|----------------------->|
|
||||
| | registry.negotiate(&[Version(3,0)])
|
||||
| | -> Some(Version(3,0))
|
||||
| | registry.negotiate(&[Version(2,0)])
|
||||
| | -> Some(Version(2,0))
|
||||
| |
|
||||
| Response | selected v3.0 TypeMap
|
||||
| Response | selected v2.0 TypeMap
|
||||
|<-----------------------|
|
||||
| Status, version |
|
||||
| |
|
||||
| subsequent messages |
|
||||
| use v3.0 TypeMap |
|
||||
| use v2.0 TypeMap |
|
||||
```
|
||||
|
||||
If the client sends an unsupported version (for example, v2.0 to the current
|
||||
repository builtin host), `negotiate` returns `None` and the connection is
|
||||
closed.
|
||||
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.
|
||||
|
||||
## Protocol Ping and Pong
|
||||
|
||||
|
|
@ -131,6 +124,6 @@ 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.
|
||||
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` and keeps the enum as the union of all configured type 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.
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ MTP reports codec failures separately from connection and transport failures.
|
|||
| `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. |
|
||||
|
||||
|
|
@ -45,4 +43,4 @@ Native builds may expose additional variants wrapping QUIC and WebTransport erro
|
|||
|
||||
## 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).
|
||||
The host reports unsupported or missing protocol versions through `AcceptError`. Authentication failures return `AcceptError::AuthenticationFailed` after the host sends a rejected handshake response. The authentication flow and its signed fields are defined in [Security](SECURITY.md).
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ let conn = MTPClient::connect(
|
|||
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;
|
||||
println!("received {}", response.get_id());
|
||||
conn.sender.close();
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
|
@ -142,7 +142,7 @@ let conn = MTPClient::auth_register(config, &keyring, &host_pk).await?;
|
|||
|
||||
// Save for next session
|
||||
let id = conn.client_id;
|
||||
let keyring_bytes = keyring.try_to_bytes()?;
|
||||
let keyring_bytes = keyring.to_bytes();
|
||||
```
|
||||
|
||||
When callers already know whether a saved client ID exists, the convenience helper uses `Some(id)` for login and `None` for registration:
|
||||
|
|
@ -175,7 +175,7 @@ pub struct Keyring {
|
|||
}
|
||||
```
|
||||
|
||||
- Serialise: `keyring.try_to_bytes()` -> `Result<Zeroizing<Vec<u8>>, CryptoError>`
|
||||
- Serialise: `keyring.to_bytes()` -> `Vec<u8>`
|
||||
- Deserialise: `Keyring::from_bytes(&bytes)` -> `Result<Keyring, CryptoError>`
|
||||
- Get public half: `keyring.public_key_bundle()` -> `PublicKeyBundle`
|
||||
|
||||
|
|
@ -230,7 +230,7 @@ let response = conn
|
|||
|
||||
Requests are routed by id through the connection's receive dispatcher. Frames with other ids remain available through `conn.receive()`.
|
||||
|
||||
Two send modes (configured via `mtp::client::Policy`):
|
||||
Two send modes (configured via `mtp::transport::Policy`):
|
||||
- `PersistentStream` (default): reuses one QUIC unidirectional stream
|
||||
- `SingleStreamPerMessage`: opens a new stream per message
|
||||
|
||||
|
|
@ -248,72 +248,65 @@ Inbound frames are queued internally. The `receive()` method returns the next av
|
|||
### Close
|
||||
|
||||
```rust
|
||||
conn.sender.close().await;
|
||||
conn.sender.close();
|
||||
// or
|
||||
conn.receiver.close();
|
||||
```
|
||||
|
||||
`Sender::close().await` gracefully finishes the active send stream, sends the
|
||||
MTP close frame, and waits for `force_close_delay` (default 300ms) before
|
||||
force-closing the QUIC connection if necessary. `Sender::close_immediate()` is
|
||||
the fire-and-forget variant. `Receiver::close()` closes the local receive
|
||||
handle without performing the sender's graceful close sequence.
|
||||
Sends a close frame and signals the peer. The `Sender::close()` spawns an async task that sends the frame, waits for `force_close_delay` (default 300ms), then force-closes the QUIC connection if the peer has not already done so.
|
||||
|
||||
### Pipes
|
||||
|
||||
The complete pipe protocol, native API, browser API, lifecycle, and errors are documented in [Pipes](PIPES.md). Use the connection facade described there when the `pipes` feature is enabled.
|
||||
|
||||
## Appendix: Composable Data Protection
|
||||
## Appendix: Crypto Containers
|
||||
|
||||
With the `crypto` feature, any `DataValue` can be signed or encrypted. The operations return typed errors and compose by operation order. `Encrypted(Signed(Value))` keeps the signer identity inside the encrypted plaintext; `Signed(Encrypted(Value))` leaves it visible. The example uses different keyrings for the signer and recipient to make the ownership explicit.
|
||||
With the `crypto` feature, `DataValue` supports encrypted, signed, and signed+encrypted containers. Encryption uses ML-KEM to encapsulate to a recipient's KEM public key (from their `PublicKeyBundle`); only the holder of the matching `Keyring` can decrypt. Signing uses the sender's Ed25519 key.
|
||||
|
||||
```rust
|
||||
use mtp::codec::{ProtectionPurpose, DataTypeId, DataValue};
|
||||
use mtp::crypto::{Ed25519Signer, Keyring};
|
||||
use mtp::crypto::{EncryptionType, Ed25519Signer, SigAlgorithm};
|
||||
|
||||
let sender_keyring = Keyring::generate();
|
||||
let recipient_keyring = Keyring::generate();
|
||||
let signer = Ed25519Signer::new(&sender_keyring.sig_cl_secret_key)?;
|
||||
let recipient = recipient_keyring.public_key_bundle();
|
||||
let sender_public_keys = sender_keyring.public_key_bundle();
|
||||
let value = DataValue::Container(vec![
|
||||
(DataTypeId(32), DataValue::Str("secret".into())),
|
||||
let enc_type = EncryptionType::MlKemChaCha20Poly1305;
|
||||
let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)?;
|
||||
|
||||
// `recipient` is the PublicKeyBundle of whoever should be able to decrypt
|
||||
// (e.g. the host's bundle, obtained out of band).
|
||||
|
||||
// Encrypted container
|
||||
let mut enc = DataValue::Container(vec![
|
||||
(DataTypeId(1), DataValue::Str("secret".into())),
|
||||
]);
|
||||
enc.encrypt_container(enc_type, &recipient, b"aad");
|
||||
|
||||
// The outer encrypted wrapper hides the signer metadata.
|
||||
let private_signer = value.clone().sign(7, ProtectionPurpose::from(1), &signer)?;
|
||||
let sealed = private_signer.encrypt_for(
|
||||
std::slice::from_ref(&recipient),
|
||||
ProtectionPurpose::from(2),
|
||||
)?;
|
||||
// Signed container
|
||||
let mut sig = DataValue::Container(vec![
|
||||
(DataTypeId(1), DataValue::Str("signed".into())),
|
||||
]);
|
||||
sig.sign_container(SigAlgorithm::ED25519, &signer);
|
||||
|
||||
// Signed + encrypted
|
||||
let mut sec = DataValue::Container(vec![
|
||||
(DataTypeId(1), DataValue::Str("both".into())),
|
||||
]);
|
||||
sec.sign_and_encrypt_container(SigAlgorithm::ED25519, &signer, enc_type, &recipient, b"aad");
|
||||
```
|
||||
> Note: DataTypeId(1) maps intenally to the reserved DataType::Id, uncareful work with reserved DataTypes & CommunicationTypes (0 - 31) may lead to unexpected behaviour.
|
||||
> Prefer registring your own.
|
||||
|
||||
Reverse the calls when the signer identity should remain visible to the recipient before opening the encrypted value:
|
||||
On the receiving side, the recipient decrypts with its own `Keyring` (each blob is self-describing: its leading byte selects the algorithm and the matching KEM key from the keyring):
|
||||
|
||||
```rust
|
||||
let encrypted = value.encrypt_for(
|
||||
std::slice::from_ref(&recipient),
|
||||
ProtectionPurpose::from(2),
|
||||
)?;
|
||||
let public_signer = encrypted.sign(7, ProtectionPurpose::from(1), &signer)?;
|
||||
enc.decrypt_into_container(&keyring, b"aad"); // -> Container
|
||||
sig.verify_into_container(&verifier); // verifier: impl SignatureScheme
|
||||
sec.decrypt_signed_encrypted_container(&keyring, b"aad"); // -> SignedContainer, then verify_into_container
|
||||
```
|
||||
|
||||
Opening and verification are explicit and return the inner value without mutating the wrapper:
|
||||
|
||||
```rust
|
||||
let signed = sealed.decrypt(&recipient_keyring, ProtectionPurpose::from(2))?;
|
||||
signed.verify(7, &sender_public_keys, ProtectionPurpose::from(1))?;
|
||||
let plain = signed.into_verified(7, &sender_public_keys, ProtectionPurpose::from(1))?;
|
||||
```
|
||||
|
||||
For `public_signer`, call `verify` and `into_verified` before calling `decrypt`; its outer signature is available before the encrypted value is opened.
|
||||
|
||||
### Policy Configuration
|
||||
|
||||
The `Policy` struct controls transport behaviour:
|
||||
|
||||
```rust
|
||||
use mtp::client::{Policy, SendMode};
|
||||
use mtp::transport::{Policy, SendMode};
|
||||
|
||||
let policy = Policy {
|
||||
send_mode: SendMode::PersistentStream,
|
||||
|
|
|
|||
|
|
@ -124,13 +124,13 @@ 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());
|
||||
println!("received MTP message {}", message.get_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.
|
||||
`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, request path, remote address, description, sender, and receiver used by native MTP connections.
|
||||
|
||||
## Deployment
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ For direct browser access, leave `serve_tcp_https(true)` enabled. The server adv
|
|||
|
||||
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.
|
||||
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()` stops both accept loops, gracefully finishes active HTTP requests until `drain_timeout`, closes Quinn, and then aborts remaining work. `close()` and dropping the server stop both listeners immediately.
|
||||
|
||||
### Authentication
|
||||
|
||||
|
|
@ -146,7 +146,7 @@ With port `0` and TCP enabled, construction binds TCP first and binds UDP to the
|
|||
|
||||
| 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. |
|
||||
| `Unauthenticated` (default) | No authentication handshake is performed. The connection has `AuthState::Unauthenticated` and a random 48-bit 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. |
|
||||
|
||||
|
|
@ -164,18 +164,12 @@ On success, the connection has `AuthState::Authenticated`, the assigned `client_
|
|||
|
||||
## 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.
|
||||
`MTPWebServer::new` returns `CommunicationError` for certificate parsing, certificate loading, bind failures, and rejected authentication policy.
|
||||
`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)
|
||||
|
|
|
|||
|
|
@ -83,18 +83,18 @@ network metadata, not an authenticated client identity.
|
|||
|
||||
## Version Negotiation
|
||||
|
||||
`accept()` uses the version-bearing opening frame and registry flow in [Connector](CONNECTOR.md). The host registry is built from the type maps in [`example/type-maps.yaml`](../example/type-maps.yaml) by `Registry::builtin()` in this repository; downstream builds can provide their own `MTP_TYPE_MAPS` configuration.
|
||||
`accept()` uses the version-bearing opening frame and registry flow in [Connector](CONNECTOR.md). The host registry is built from the type maps in `type-maps.yaml` by `Registry::builtin()`.
|
||||
|
||||
### Registry
|
||||
|
||||
```rust
|
||||
use mtp::codec::Version;
|
||||
use mtp::codec::registry::Registry;
|
||||
|
||||
let registry = host.registry();
|
||||
assert!(registry.supports(&Version(3, 0)));
|
||||
assert!(registry.supports(&Version(2, 0)));
|
||||
|
||||
let negotiated = registry.negotiate(&[Version(2, 0), Version(3, 0)]);
|
||||
// -> Some(Version(3, 0)) for this repository's builtin map
|
||||
let negotiated = registry.negotiate(&[Version(1, 0), Version(2, 0)]);
|
||||
// -> Some(Version(2, 0)) if both versions are registered
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
|
@ -105,15 +105,13 @@ After a successful handshake, `MTPConnection` exposes `AuthState::Authenticated`
|
|||
|
||||
## Handling Messages
|
||||
|
||||
Use `conn.sender` and `conn.receive()` for bidirectional message exchange. The
|
||||
connection dispatcher owns the underlying receiver, especially when `pipes` is
|
||||
enabled:
|
||||
Use `conn.sender` and `conn.receiver` for bidirectional message exchange:
|
||||
|
||||
```rust
|
||||
while let Some(conn) = host.accept().await? {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match conn.receive().await {
|
||||
match conn.receiver.receive().await {
|
||||
Ok(msg) => {
|
||||
let response = process_message(&msg, &conn);
|
||||
conn.sender.send(&response).await.ok();
|
||||
|
|
@ -159,9 +157,10 @@ let get_existing_client = |id: u64, _description: Option<String>| {
|
|||
|
||||
### 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.
|
||||
Optional callback that controls how unauthenticated connections receive their client ID. When `None` (the default), the host generates a random 48-bit 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.
|
||||
Return `Some(id)` to accept the guest with that ID, or `None` to reject the connection. The ID must fit in 48 bits (`id <= mtp_codec::MAX_WIRE_ID`);
|
||||
values outside that range are rejected automatically and fall back to the built-in generator.
|
||||
|
||||
```rust
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
|
@ -214,7 +213,7 @@ let (kem_sk, kem_pk) = HybridKem::generate_keypair();
|
|||
let host_keyring = Keyring::new(kem_pk, kem_sk, sig_pq_pk, sig_pq_sk, sig_pk, sig_sk);
|
||||
|
||||
// Save to disk
|
||||
let bytes = host_keyring.try_to_bytes()?;
|
||||
let bytes = host_keyring.to_bytes();
|
||||
std::fs::write("host_keys.bin", bytes)?;
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -39,4 +39,4 @@ Back up host keyrings and client keyrings as protected secrets. Test restoring a
|
|||
|
||||
### 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.
|
||||
Stop accepting new connections, reject new work at the application layer, and allow active requests and pipe writers to finish. For `MTPWebServer`, call `shutdown()`; its `drain_timeout` controls graceful TCP HTTP completion and the QUIC drain period before remaining connection tasks are terminated.
|
||||
|
|
|
|||
151
docs/PIPES.md
151
docs/PIPES.md
|
|
@ -1,14 +1,6 @@
|
|||
# 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.
|
||||
Pipes are unidirectional QUIC streams for raw bytes. The creator sends a `PipeRequest` communication value, the peer accepts or rejects it, and the stream then carries bytes without an MTP frame around every write.
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -19,88 +11,6 @@ The creator calls `create_pipe` or the corresponding SDK `createPipe` method wit
|
|||
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.
|
||||
|
|
@ -110,12 +20,7 @@ Normal messages and pipe requests share the transport and must pass through the
|
|||
|
||||
## 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 creator closes a successful pipe with `PipeWriter::finish` or the browser writer's `close`; this sends a QUIC FIN and lets the reader observe EOF. 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.
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -133,43 +38,23 @@ Native applications use the pipe APIs on `MTPConnection`; browser applications u
|
|||
|
||||
## 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:
|
||||
The creator streams a file in chunks. The accepting side processes each chunk without buffering the complete file:
|
||||
|
||||
```rust
|
||||
// Client
|
||||
use mtp_transport::{PipeSessionParameters, initiate_pipe_session};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
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?;
|
||||
if let Some(mut writer) = handle.wait().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?;
|
||||
}
|
||||
tokio::io::copy(&mut file, &mut writer).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.
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
while let Ok(request) = conn.receive_pipe().await {
|
||||
if request.description() != "file-upload" {
|
||||
|
|
@ -177,18 +62,16 @@ while let Ok(request) = conn.receive_pipe().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 mut reader = request.accept().await?;
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let count = reader.read(&mut buffer).await?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..count]);
|
||||
process_chunk(&buffer[..count]).await?;
|
||||
}
|
||||
let digest = hasher.finalize();
|
||||
println!("processed upload with digest {digest:x}");
|
||||
|
|
|
|||
|
|
@ -17,67 +17,6 @@ The client sends an MTP `Ping` communication value with a frame ID. The host ret
|
|||
|
||||
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
|
||||
|
|
@ -97,24 +36,8 @@ Client Host
|
|||
|
||||
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.
|
||||
|
|
|
|||
175
docs/SECURITY.md
175
docs/SECURITY.md
|
|
@ -27,7 +27,7 @@ For rotation, publish the replacement certificate or key before changing the ser
|
|||
|
||||
### 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.
|
||||
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. `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.
|
||||
|
||||
|
|
@ -95,110 +95,9 @@ The tags prevent a valid signature for one handshake step from being accepted as
|
|||
| 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.
|
||||
AEAD output stores the nonce before the authenticated ciphertext. Encrypted containers select their algorithm with a leading marking byte, derive an AEAD key from the KEM shared secret with HKDF, and authenticate caller-supplied AAD. Multi-recipient encryption wraps one content-encryption key separately for each recipient.
|
||||
|
||||
[mtp-crypto API](../crypto/), [native client](NATIVE-CLIENT.md), and [native host](NATIVE-HOST.md).
|
||||
|
||||
|
|
@ -212,9 +111,8 @@ The crate's feature groups are:
|
|||
| `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`.
|
||||
The main types are `Keyring`, `PublicKeyBundle`, `EncryptionType`, `HybridKem`, `ChaCha20Poly1305`, `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
|
||||
|
||||
|
|
@ -241,74 +139,21 @@ The browser SDK's optional E2EE session uses XChaCha20-Poly1305 with message key
|
|||
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.
|
||||
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. `encryptedDeviceSecretProvider` supplies encrypted device secret storage when sessions must survive page reloads. The provider must protect its wrapping secret outside the SDK; the SDK does not recover a lost device secret or skipped message keys.
|
||||
|
||||
## 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.
|
||||
Applications remain responsible for storage at rest. The `files` feature writes passphrase-protected keyrings to `.mk` files and public bundles to `.mpkb` files. 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.
|
||||
The host does not provide a general authentication-attempt rate limiter.
|
||||
Deploy authentication endpoints behind a rate-limiting proxy or add admission control through the host callbacks, including `GuestIdGenerator` where guest connections are permitted.
|
||||
|
||||
## Security Limitations
|
||||
|
||||
|
|
@ -316,9 +161,3 @@ receiver capacity.
|
|||
- `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.
|
||||
|
|
|
|||
|
|
@ -59,11 +59,11 @@ When `require_pq` is true, both Ed25519 and ML-DSA-65 keys and signatures must b
|
|||
|
||||
**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.
|
||||
`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. 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).
|
||||
`InvalidEncoding` indicates truncated, malformed, or structurally invalid bytes. `TooManyEntries` indicates that an array, container, or frame exceeds the codec's representable count or length. `CryptoFailed` indicates that signature verification or encrypted-container processing failed. The complete variant table is in [Errors](ERRORS.md).
|
||||
|
||||
## Frames and Message Limits
|
||||
|
||||
|
|
|
|||
173
docs/TYPE-MAP.md
173
docs/TYPE-MAP.md
|
|
@ -1,106 +1,26 @@
|
|||
# Type Map
|
||||
|
||||
This file documents the type-map and registry configuration used by MTP. The
|
||||
repository workspace uses [`example/type-maps.yaml`](../example/type-maps.yaml)
|
||||
through [`.cargo/config.toml`](../.cargo/config.toml); that map currently
|
||||
selects protocol version 3.0. The root [`example-type-maps.yaml`](../example-type-maps.yaml)
|
||||
is a separate illustrative multi-version configuration used by the manual WASM
|
||||
build script. Downstream applications should provide their own map.
|
||||
|
||||
The protocol version selects the generated codec/type-map build, while the
|
||||
type-map entries define the available application types and their IDs.
|
||||
This file documents the Type Map & Registry configuration used by the MTP protocol. It will assume you are working with the [example-type-maps.yaml](./../example-type-maps.yaml).
|
||||
|
||||
## 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]
|
||||
u32 length
|
||||
u16 communication_type
|
||||
u8 flags
|
||||
u32 id if flag 0x04 is set
|
||||
u48 sender if flag 0x01 is set
|
||||
u48 receiver if flag 0x02 is set
|
||||
u8 signature_type if flag 0x10 is set
|
||||
... signature if flag 0x10 is set, length depends on signature_type
|
||||
... data container or encrypted 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.
|
||||
The flag values are `0x01` for sender, `0x02` for receiver, `0x04` for frame ID, `0x08` for encrypted data, `0x10` for a frame signature, and `0x20` for a signed encrypted container. Sender and receiver IDs are six-byte unsigned big-endian values. The `communication_type` and every container field use IDs from the negotiated `TypeMap`.
|
||||
|
||||
`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.
|
||||
Data values begin with a one-byte kind marker. MTP assigns `0x01` and `0x02` to boolean true and false, `0x03` to signed integers, `0x04` to unsigned integers, `0x05` to floats, `0x06` to UTF-8 strings, `0x07` to bytes, `0x08` to arrays, `0x09` to containers, `0x0A` through `0x0C` to crypto containers, and `0xFF` to null. Length-prefixed values use a four-byte big-endian payload length; container and array counts use two-byte big-endian counts.
|
||||
|
||||
## TypeMap & Compile-Time Type Safety
|
||||
|
||||
|
|
@ -123,12 +43,6 @@ export default defineConfig({
|
|||
|
||||
Rust and manual WASM builds can set `MTP_TYPE_MAPS` directly (see [Customizing Type Maps in Downstream Projects](#customizing-type-maps-in-downstream-projects)).
|
||||
|
||||
For browser builds, `protocol_version` selects the one application map compiled
|
||||
into that WASM client. The Vite-generated `mtp/type-map` module contains the
|
||||
reserved MTP names and the application names from that selected version only;
|
||||
the selected version must be present in `type_maps`. This keeps its TypeScript
|
||||
unions aligned with the client runtime.
|
||||
|
||||
### Using Generated Enums
|
||||
|
||||
After editing the config and rebuilding, `CommunicationType` and `DataType` enums are generated automatically. Use them in code:
|
||||
|
|
@ -136,46 +50,43 @@ After editing the config and rebuilding, `CommunicationType` and `DataType` enum
|
|||
```rust
|
||||
use mtp::type_map::{CommunicationType, DataType, TypeMap};
|
||||
|
||||
let tm = TypeMap::v3_0();
|
||||
let id = tm.data_id_enum(DataType::ExampleText).unwrap();
|
||||
let tm = TypeMap::v2_0();
|
||||
let id = tm.data_id_enum(DataType::SomeType).unwrap();
|
||||
```
|
||||
|
||||
For native builds with the `registry` feature, the enums are a **union across
|
||||
all versions**; every type name from every version is a variant. The
|
||||
version-specific `TypeMap` maps each variant to the correct wire ID for that
|
||||
version. For a type absent from a selected version, the lookup returns `None`.
|
||||
Browser-generated TypeScript unions intentionally differ: they contain only
|
||||
the selected `protocol_version` plus reserved names, matching the WASM client
|
||||
compiled by the Vite plugin.
|
||||
The enums are a **union across all versions**; every type name from every version is a variant. The version-specific `TypeMap` maps each variant to the correct wire ID for that version. For a type absent from a selected version, the lookup returns `None`.
|
||||
|
||||
Encoding/decoding uses a `TypeMap` to resolve type names to wire IDs:
|
||||
|
||||
```rust
|
||||
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||
use mtp::codec::{encode, decode, DataValue};
|
||||
use mtp::type_map::TypeMap;
|
||||
|
||||
let tm = TypeMap::v3_0();
|
||||
let value = CommunicationValue::new_with_type_map(CommunicationType::Ping, &tm)
|
||||
.add_typed(DataType::Description, &tm, DataValue::Str("hello".into()));
|
||||
let tm = TypeMap::v2_0();
|
||||
let value = DataValue::Str("hello".into());
|
||||
|
||||
let bytes = value.to_bytes().unwrap();
|
||||
let decoded = CommunicationValue::from_bytes_with(&bytes, &tm).unwrap();
|
||||
let bytes = encode(&value, &tm).unwrap();
|
||||
let decoded = decode(&bytes, &tm).unwrap();
|
||||
```
|
||||
|
||||
```rust
|
||||
let tm_v3 = TypeMap::v3_0();
|
||||
assert!(tm_v3.data_id_enum(DataType::ExampleText).is_some());
|
||||
let tm_v2 = TypeMap::v2_0();
|
||||
assert!(tm_v2.data_id_enum(DataType::SomeType).is_some()); // defined in v2.0
|
||||
assert!(tm_v2.data_id_enum(DataType::ExampleType).is_none()); // NOT in v2.0
|
||||
|
||||
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.
|
||||
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. Keep old IDs stable, register both versions during migration, and remove a version only after its clients have moved.
|
||||
|
||||
### Forward/Backward Compatibility Between Versions
|
||||
|
||||
Because enums are a union of all types across versions, a variant might exist that has no wire mapping in the *negotiated* version:
|
||||
|
||||
```
|
||||
v3.0 client sends DataType::ExampleText → host encodes with v3.0 TypeMap → wire ID 43
|
||||
v3.0 host receives a version absent from the registry → version negotiation error
|
||||
v2.0 client sends DataType::SomeType → host encodes with v2.0 TypeMap → wire ID 32
|
||||
v2.0 host receives DataType::ExampleType (from v1.0 client) → not in v2.0 TypeMap → None → Error
|
||||
```
|
||||
|
||||
Encoding a frame with an unmapped communication or data type returns `CodecError::UnknownCommunicationType` or `CodecError::UnknownDataType`. Select a mapped variant from the compiled-in version before sending it.
|
||||
|
|
@ -193,33 +104,17 @@ mtp = { path = "..", features = ["host"] }
|
|||
|
||||
```rust
|
||||
use mtp::codec::registry::{Registry, VersionedCodec};
|
||||
use mtp::codec::{CommunicationType, CommunicationValue, DataValue};
|
||||
use mtp_type_map::Version;
|
||||
|
||||
let registry = Registry::builtin();
|
||||
let codec = VersionedCodec::for_version(registry, Version(3, 0)).unwrap();
|
||||
let value = CommunicationValue::new_with_type_map(
|
||||
CommunicationType::Ping,
|
||||
codec.type_map(),
|
||||
).with_payload(DataValue::Null);
|
||||
let codec = VersionedCodec::new(registry);
|
||||
|
||||
// The value must retain the negotiated map used to construct it.
|
||||
let bytes = codec.encode(&value).unwrap();
|
||||
// Encode with a specific version
|
||||
let bytes = codec.encode(&value, Version(2, 0)).unwrap();
|
||||
|
||||
let decoded = codec.decode(&bytes).unwrap();
|
||||
|
||||
// A clear value can be migrated explicitly when the application has chosen
|
||||
// that behavior. Protected values are not silently remapped.
|
||||
let migrated = codec.encode_migrating(&value).unwrap();
|
||||
// Decode with a specific version
|
||||
let decoded = codec.decode(&bytes, Version(2, 0)).unwrap();
|
||||
```
|
||||
|
||||
`VersionedCodec::encode` compares the retained map identity (its protocol
|
||||
version) and returns `CodecError::MissingTypeMap` or
|
||||
`CodecError::TypeMapMismatch` on failure. `reply_to` retains the request's
|
||||
map, while `try_merge` rejects frames from different maps before copying any
|
||||
fields. The deprecated `merge` method records the error for compatibility; new
|
||||
code should migrate to `try_merge` and handle the result.
|
||||
|
||||
## Customizing Type Maps in Downstream Projects
|
||||
|
||||
External projects must provide their own type map configuration. Browser projects use the Vite plugin from [Defining Type Maps](#defining-type-maps) and do not need to publish, fork, or copy a generated WASM package.
|
||||
|
|
|
|||
|
|
@ -30,11 +30,6 @@ import { mtp } from "mtp/vite";
|
|||
|
||||
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).
|
||||
|
||||
The browser build uses the map named by `protocol_version` and includes the
|
||||
reserved MTP names. It does not advertise application names from other map
|
||||
versions, because the generated WASM client is compiled for that one protocol
|
||||
version. The selected version must exist in `type_maps`.
|
||||
|
||||
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.
|
||||
|
|
@ -104,12 +99,8 @@ if (!MTPClient.isSupported()) {
|
|||
| `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. |
|
||||
| `encryptedDeviceSecretProvider` | In-memory | Device-secret storage for E2EE. |
|
||||
|
||||
`wasm` selects a custom generated WASM module. `MTPClient.create` validates positive safe-integer values for the numeric limits and timeout options.
|
||||
|
||||
|
|
@ -121,223 +112,7 @@ The browser SDK uses WebTransport and JavaScript promises. The native client use
|
|||
|
||||
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.
|
||||
`sessionStorage` and `encryptedDeviceSecretProvider` are separate E2EE session stores. The latter exchanges `EncryptedDeviceSecretRecord` values through `setEncryptedDeviceSecret` and `getEncryptedDeviceSecret`; the application chooses the backing store and protects its wrapping key.
|
||||
|
||||
### Native and Browser Certificate Checks
|
||||
|
||||
|
|
@ -474,60 +249,6 @@ const unsubscribe = client.subscribe("SomeType", (message) => {
|
|||
unsubscribe();
|
||||
```
|
||||
|
||||
### Zod request and response schemas
|
||||
|
||||
Applications can provide their request and response schemas once when creating
|
||||
the client. MTP uses `parseAsync`, so synchronous schemas, async refinements,
|
||||
defaults, coercions, and transforms all work. MTP has no runtime dependency on
|
||||
Zod; the application supplies its preferred Zod version.
|
||||
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
import { MTPClient, MTPValidationError } from "mtp";
|
||||
|
||||
const schemas = {
|
||||
GetUser: {
|
||||
request: z.object({ UserId: z.number().int().positive() }),
|
||||
response: z.object({
|
||||
UserId: z.number().int().positive(),
|
||||
Display: z.string(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const client = await MTPClient.create({
|
||||
url,
|
||||
schemas,
|
||||
throwProtocolErrors: true,
|
||||
onValidationError(error) {
|
||||
console.error(error.messageType, error.cause);
|
||||
},
|
||||
});
|
||||
|
||||
const response = await client.request("GetUser", { UserId: 42 });
|
||||
console.log(response.data.Display);
|
||||
```
|
||||
|
||||
Request schemas run before frame encoding and transmission. Their transformed
|
||||
output is sent. Response schemas run after request correlation, and their
|
||||
transformed output replaces `frame.data`; `frame.raw`, when present, remains the
|
||||
original wire frame. Invalid requests and responses reject with
|
||||
`MTPValidationError`. Invalid subscription messages do not reach the handler
|
||||
and are reported through `onValidationError`.
|
||||
|
||||
`throwProtocolErrors: true` converts correlated `Error*` frames into
|
||||
`MTPProtocolError`. It defaults to `false` for compatibility.
|
||||
|
||||
`MTPProxyConnection` applies the same schema registry to another TypeScript
|
||||
request/subscription transport, such as a Tauri command and event proxy:
|
||||
|
||||
```typescript
|
||||
const connection = new MTPProxyConnection(adapter, {
|
||||
schemas,
|
||||
throwProtocolErrors: true,
|
||||
});
|
||||
```
|
||||
|
||||
Protocol ping behavior is defined in [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive). The SDK configuration is:
|
||||
|
||||
```typescript
|
||||
|
|
@ -541,10 +262,7 @@ 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`.
|
||||
Pipes are raw binary streams over QUIC. A pipe starts with a lightweight `PipeRequest` handshake frame, then the stream carries raw bytes with zero per-frame overhead. Pipes are unidirectional; the peer that initiates the pipe writes, and the peer that accepts it reads.
|
||||
|
||||
### Outgoing Pipes
|
||||
|
||||
|
|
@ -566,41 +284,6 @@ 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
|
||||
|
|
@ -647,8 +330,8 @@ console.log(reader.pipeId, reader.description);
|
|||
|
||||
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.
|
||||
3. The receiver calls `acceptPipe(pipeId)`; the SDK sends a `PipeResponse` with `Accepted = true` and opens a new unidirectional stream for raw data.
|
||||
4. The initiator's `handle.wait()` resolves with a `PipeWriter` bound to that stream.
|
||||
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.
|
||||
|
|
@ -659,19 +342,8 @@ The SDK logger receives parsed events:
|
|||
|
||||
```typescript
|
||||
type MTPLogEvent =
|
||||
| {
|
||||
hint: "info" | "warning";
|
||||
type: string;
|
||||
data: unknown;
|
||||
direction?: "send" | "recv";
|
||||
}
|
||||
| {
|
||||
hint: "error";
|
||||
type: string | "error";
|
||||
error: string;
|
||||
data?: unknown;
|
||||
direction?: "send" | "recv";
|
||||
};
|
||||
| { hint: "info" | "warning"; type: string; data: unknown }
|
||||
| { hint: "error"; type: string | "error"; error: string };
|
||||
```
|
||||
|
||||
Incoming non-error frames and sent frames are logged as `info`. Error frames and transport errors are logged as `error`.
|
||||
|
|
@ -706,40 +378,12 @@ config.free();
|
|||
Raw callbacks receive parsed frames, not application-specific SDK objects:
|
||||
|
||||
```typescript
|
||||
interface ParsedEncryptedValue {
|
||||
kind: "encrypted";
|
||||
encryptionType: number;
|
||||
purpose: number;
|
||||
recipientCount: number;
|
||||
encoded: Uint8Array;
|
||||
}
|
||||
|
||||
interface ParsedSignedValue {
|
||||
kind: "signed";
|
||||
signatureType: number;
|
||||
purpose: number;
|
||||
signerId: bigint;
|
||||
value: ParsedDataValue;
|
||||
}
|
||||
|
||||
type ParsedDataValue =
|
||||
| boolean
|
||||
| number
|
||||
| bigint
|
||||
| string
|
||||
| Uint8Array
|
||||
| ParsedDataValue[]
|
||||
| { [key: string]: ParsedDataValue }
|
||||
| ParsedEncryptedValue
|
||||
| ParsedSignedValue
|
||||
| null;
|
||||
|
||||
interface ParsedFrame {
|
||||
id?: number;
|
||||
type: string;
|
||||
sender?: bigint;
|
||||
receiver?: bigint;
|
||||
data: ParsedDataValue;
|
||||
data: Record<string, unknown>;
|
||||
raw: Uint8Array;
|
||||
}
|
||||
```
|
||||
|
|
@ -773,13 +417,9 @@ Raw crypto and key helpers include:
|
|||
- `keyring_generate()`
|
||||
- `keyring_from_ed25519(secretKey, publicKey)`
|
||||
- `WasmKeyring.from_bytes(bytes)` and `keyring.to_bytes()`
|
||||
- `keyring.validate_encryption()` for envelope decryption roles
|
||||
- `keyring.validate_full()` for complete hybrid identities
|
||||
- `WasmPublicKeyBundle.from_bytes(bytes)` and `bundle.to_bytes()`
|
||||
- `WasmEd25519Signer`
|
||||
- `WasmChaCha20Poly1305`
|
||||
- `sign_data_value_with_keyring` and `verify_data_value_with_policy` (both require an explicit signature suite), plus `encrypt_data_value`, `encrypt_data_value_for_recipients`, and `decrypt_data_value`
|
||||
- `parse_data_value` and `encode_data_value`
|
||||
- `wasm_sha256`, `wasm_sha256_double`, `wasm_hkdf_expand`, and `wasm_derive_encryption_key`
|
||||
|
||||
Raw authenticated login and registration map directly to the Rust WASM layer:
|
||||
|
|
@ -823,4 +463,4 @@ A `WasmClient` manages one active WebTransport session. Create a new instance fo
|
|||
|
||||
### 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).
|
||||
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 device secret persistence are documented in [Security](SECURITY.md#browser-end-to-end-encryption).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,3 @@
|
|||
#################################################################################
|
||||
# This is an example, overwrite it for your project to register your own types. #
|
||||
#################################################################################
|
||||
|
||||
# The version a Client should use
|
||||
protocol_version: "0.0"
|
||||
|
||||
|
|
@ -30,7 +26,6 @@ protocol_version: "0.0"
|
|||
# BadGateway: 20
|
||||
# ServiceUnavailable: 21
|
||||
# GatewayTimeout: 22
|
||||
# Relay: 26
|
||||
# PipeRequest: 23
|
||||
# PipeResponse: 24
|
||||
# PipeAbort: 25
|
||||
|
|
@ -50,29 +45,16 @@ protocol_version: "0.0"
|
|||
# ErrorParsing: 11
|
||||
# ErrorMessage: 12
|
||||
# Accepted: 13,
|
||||
# RequirePq: 14
|
||||
# MessageId: 15
|
||||
# FinalRecipientId: 18
|
||||
# CreatedAt: 21
|
||||
# MessageType: 22
|
||||
# Content: 23
|
||||
# Metadata: 24
|
||||
# RelayVersion: 25
|
||||
# ProtectedVersion: 26
|
||||
#
|
||||
# Types absent from a protocol version cannot be encoded for that version.
|
||||
|
||||
type_maps:
|
||||
"0.0": # Protocol version 0.0
|
||||
CommunicationTypes:
|
||||
ProtectedMessage: 32
|
||||
AlternateMessage: 33
|
||||
DataTypes:
|
||||
ExampleType: 32
|
||||
"1.0":
|
||||
CommunicationTypes:
|
||||
ProtectedMessage: 32
|
||||
AlternateMessage: 33
|
||||
DataTypes:
|
||||
# If a v0.0 client connects
|
||||
# - the server can't use "AnotherType"
|
||||
|
|
@ -82,8 +64,6 @@ type_maps:
|
|||
SomeType: 34
|
||||
"2.0":
|
||||
CommunicationTypes:
|
||||
ProtectedMessage: 32
|
||||
AlternateMessage: 33
|
||||
DataTypes:
|
||||
# If a v0.0 client connects
|
||||
# - the server can't use "AnotherType"
|
||||
|
|
|
|||
1
example/.gitignore
vendored
1
example/.gitignore
vendored
|
|
@ -14,4 +14,3 @@ web-client/dist/
|
|||
client.id
|
||||
*.mk
|
||||
*.mpkb
|
||||
metrics/
|
||||
|
|
|
|||
560
example/Cargo.lock
generated
560
example/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "client"
|
||||
version = "0.3.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
|
|
@ -8,9 +8,7 @@ name = "client"
|
|||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
mtp = { version = "0.3.0", path = "../../", features = ["client", "crypto", "files", "pipes", "raw"] }
|
||||
mtp = { version = "0.2.0", path = "../../", features = ["client", "crypto", "files", "pipes"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
rand = "0.10.1"
|
||||
tracing-subscriber = "0.3.23"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
use std::time::{Duration, Instant};
|
||||
use std::time::Instant;
|
||||
|
||||
use tokio::fs;
|
||||
|
||||
use mtp::client::{ClientConfig, MTPClient, MTPConnection};
|
||||
use mtp::crypto::{Keyring, PublicKeyBundle};
|
||||
use mtp::crypto::{
|
||||
Ed25519Signer, KemPrivateKey, KemPublicKey, Keyring, MlDsaSigner, PublicKeyBundle,
|
||||
};
|
||||
use mtp::files::{load_keyring_raw, save_keyring_raw};
|
||||
|
||||
pub async fn connect_or_register(
|
||||
mut config: ClientConfig,
|
||||
host_public_key: PublicKeyBundle,
|
||||
key_prefix: &str,
|
||||
) -> Result<(MTPConnection, Keyring, String, Duration), Box<dyn std::error::Error>> {
|
||||
) -> Result<(MTPConnection, Keyring), Box<dyn std::error::Error>> {
|
||||
let keyring_path = format!("{key_prefix}.mk");
|
||||
let id_path = format!("{key_prefix}.id");
|
||||
|
||||
|
|
@ -28,43 +30,34 @@ pub async fn connect_or_register(
|
|||
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
|
||||
conn.version,
|
||||
auth_started.elapsed()
|
||||
);
|
||||
return Ok((conn, keyring, "connect".into(), auth_duration));
|
||||
return Ok((conn, keyring));
|
||||
}
|
||||
|
||||
println!("No existing keys found: registering new client");
|
||||
|
||||
/* Registration publishes a complete MTP identity for later protection. */
|
||||
let keyring = Keyring::generate();
|
||||
/* The client authenticates with signatures only, so the KEM slot is empty. */
|
||||
let (_ed_signer, sig_sk, sig_pk) = Ed25519Signer::generate();
|
||||
let (_pq_signer, sig_pq_sk, sig_pq_pk) = MlDsaSigner::generate();
|
||||
let keyring = Keyring::new(
|
||||
KemPublicKey::new(vec![]),
|
||||
KemPrivateKey::new(vec![]),
|
||||
sig_pq_pk,
|
||||
sig_pq_sk,
|
||||
sig_pk,
|
||||
sig_sk,
|
||||
);
|
||||
|
||||
let reg_started = Instant::now();
|
||||
let conn = MTPClient::auth_register(config, &keyring, &host_public_key).await?;
|
||||
let reg_duration = reg_started.elapsed();
|
||||
println!("Registered with ID: {} in {:?}", conn.client_id, reg_duration);
|
||||
println!("Registered with ID: {}", conn.client_id);
|
||||
|
||||
save_keyring_raw(&keyring, &keyring_path)?;
|
||||
fs::write(&id_path, conn.client_id.to_string()).await?;
|
||||
println!("Saved client keys -> {keyring_path}");
|
||||
|
||||
Ok((conn, keyring, "register".into(), reg_duration))
|
||||
}
|
||||
|
||||
/// Open a guest transport even when the caller already owns registered
|
||||
/// credentials. The credentials stay with the caller for protected signing.
|
||||
pub async fn connect_unauthenticated(
|
||||
config: ClientConfig,
|
||||
) -> Result<MTPConnection, Box<dyn std::error::Error>> {
|
||||
let conn = MTPClient::connect(config).await?;
|
||||
if conn.auth_state != mtp::client::AuthState::Unauthenticated {
|
||||
return Err("guest connection did not report Unauthenticated state".into());
|
||||
}
|
||||
println!(
|
||||
"Opened unauthenticated transport with host-assigned guest ID {}",
|
||||
conn.client_id
|
||||
);
|
||||
Ok(conn)
|
||||
Ok((conn, keyring))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
mod auth;
|
||||
mod metrics;
|
||||
mod messages;
|
||||
mod pipes;
|
||||
mod protected;
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use mtp::client::{AuthState, ClientConfig};
|
||||
use mtp::client::ClientConfig;
|
||||
use mtp::files::load_public_key_bundle;
|
||||
|
||||
fn dev_cert_path() -> String {
|
||||
|
|
@ -39,100 +36,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
}
|
||||
};
|
||||
|
||||
let mut client_metrics = metrics::ClientMetrics::load("metrics/client_sessions.json");
|
||||
|
||||
println!("Connecting to 127.0.0.1:8080 ...");
|
||||
|
||||
let config = ClientConfig::new("https://127.0.0.1:8080")
|
||||
.with_pinned_pem(cert_pem.clone())
|
||||
.with_pinned_pem(cert_pem)
|
||||
.with_description("MTP example client");
|
||||
|
||||
let server_bundle = host_public_key.clone();
|
||||
let (conn, keyring, auth_method, auth_duration) =
|
||||
match auth::connect_or_register(config, host_public_key, "client").await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
let mut builder = metrics::SessionBuilder::new("failed", Duration::from_secs(0));
|
||||
builder.set_error(e.to_string());
|
||||
client_metrics.record_session(builder.build());
|
||||
client_metrics.save("metrics/client_sessions.json");
|
||||
client_metrics.build_overview("metrics/client_overview.json");
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let 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
|
||||
);
|
||||
let (conn, keyring) = auth::connect_or_register(config, host_public_key, "client").await?;
|
||||
messages::send_and_receive(&conn, &keyring, &server_bundle).await?;
|
||||
|
||||
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");
|
||||
pipes::run_pipe_demo(&conn, 1).await?;
|
||||
|
||||
conn.sender.close().await;
|
||||
println!("\nDone");
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use mtp::client::MTPConnection;
|
||||
use mtp::codec::ProtectionPurpose;
|
||||
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||
use mtp::crypto::{Ed25519Signer, Keyring, PublicKeyBundle};
|
||||
use mtp::crypto::{Ed25519Signer, EncryptionType, Keyring, PublicKeyBundle, SigAlgorithm};
|
||||
use mtp::type_map::TypeMap;
|
||||
|
||||
pub fn build_demo_message(
|
||||
|
|
@ -12,6 +9,7 @@ pub fn build_demo_message(
|
|||
server_bundle: &PublicKeyBundle,
|
||||
) -> Result<CommunicationValue, Box<dyn std::error::Error>> {
|
||||
// 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 tm = TypeMap::latest();
|
||||
|
|
@ -26,16 +24,15 @@ pub fn build_demo_message(
|
|||
(version_id, DataValue::Str("secret inner data".into())),
|
||||
(id_id, DataValue::UnsignedNumber(42)),
|
||||
]);
|
||||
let dv_enc = inner_enc.encrypt_for(
|
||||
std::slice::from_ref(server_bundle),
|
||||
ProtectionPurpose::from(1),
|
||||
)?;
|
||||
let mut dv_enc = inner_enc;
|
||||
dv_enc.encrypt_container(enc_type, server_bundle, b"demo-aad");
|
||||
|
||||
let inner_sig = DataValue::Container(vec![
|
||||
(version_id, DataValue::Str("signed by client".into())),
|
||||
(id_id, DataValue::UnsignedNumber(99)),
|
||||
]);
|
||||
let dv_sig = inner_sig.sign(client_id, ProtectionPurpose::from(2), &signer)?;
|
||||
let mut dv_sig = inner_sig;
|
||||
dv_sig.sign_container(SigAlgorithm::ED25519, &signer);
|
||||
|
||||
let inner_sec = DataValue::Container(vec![
|
||||
(
|
||||
|
|
@ -44,16 +41,18 @@ pub fn build_demo_message(
|
|||
),
|
||||
(id_id, DataValue::UnsignedNumber(7)),
|
||||
]);
|
||||
let dv_sec = inner_sec
|
||||
.sign(client_id, ProtectionPurpose::from(3), &signer)?
|
||||
.encrypt_for(
|
||||
std::slice::from_ref(server_bundle),
|
||||
ProtectionPurpose::from(4),
|
||||
)?;
|
||||
let mut dv_sec = inner_sec;
|
||||
dv_sec.sign_and_encrypt_container(
|
||||
SigAlgorithm::ED25519,
|
||||
&signer,
|
||||
enc_type,
|
||||
server_bundle,
|
||||
b"demo-aad",
|
||||
);
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_millis();
|
||||
.as_secs();
|
||||
|
||||
let msg = CommunicationValue::new(CommunicationType::Ping)
|
||||
.add_typed_default(
|
||||
|
|
@ -62,7 +61,7 @@ pub fn build_demo_message(
|
|||
)
|
||||
.add_typed_default(
|
||||
DataType::Timestamp,
|
||||
DataValue::UnsignedNumber(timestamp),
|
||||
DataValue::UnsignedNumber(timestamp as u128),
|
||||
)
|
||||
.add_typed_default(DataType::Data, DataValue::Str("Hello, MTP!".into()))
|
||||
.add_typed_default(DataType::Flags, DataValue::BoolTrue)
|
||||
|
|
@ -90,25 +89,17 @@ pub async fn send_and_receive(
|
|||
conn: &MTPConnection,
|
||||
keyring: &Keyring,
|
||||
server_bundle: &PublicKeyBundle,
|
||||
) -> Result<Duration, Box<dyn std::error::Error>> {
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let msg = build_demo_message(conn.client_id, keyring, server_bundle)?;
|
||||
println!("Sending: {msg}");
|
||||
let start = Instant::now();
|
||||
conn.sender.send(&msg).await?;
|
||||
|
||||
match conn.receive().await {
|
||||
Ok(resp) => {
|
||||
let roundtrip = start.elapsed();
|
||||
println!("Received: {resp}");
|
||||
println!(
|
||||
"Message round-trip: {:.3}ms",
|
||||
roundtrip.as_secs_f64() * 1000.0
|
||||
);
|
||||
Ok(roundtrip)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Receive error: {e}");
|
||||
Err(e.into())
|
||||
}
|
||||
Err(e) => eprintln!("Receive error: {e}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,558 +0,0 @@
|
|||
use mtp::common::unix_time_millis;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn now_epoch_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
fn now_epoch_millis() -> u64 {
|
||||
unix_time_millis().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn generate_session_id() -> String {
|
||||
let ts = now_epoch_secs();
|
||||
let rand_part: u32 = rand::random();
|
||||
format!("{ts}-{rand_part:08x}")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persisted data types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PipeResult {
|
||||
pub size: usize,
|
||||
pub iteration: usize,
|
||||
pub total_ms: f64,
|
||||
pub data_only_ms: f64,
|
||||
pub bytes_matched: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ClientSessionRecord {
|
||||
pub session_id: String,
|
||||
pub timestamp: u64,
|
||||
pub auth_method: String,
|
||||
pub auth_duration_ms: f64,
|
||||
pub error: Option<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,16 +3,13 @@ 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>> {
|
||||
) -> Result<(), 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);
|
||||
|
|
@ -100,14 +97,6 @@ pub async fn run_pipe_demo(
|
|||
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);
|
||||
|
|
@ -134,7 +123,7 @@ pub async fn run_pipe_demo(
|
|||
all_elapsed.len()
|
||||
);
|
||||
|
||||
Ok(pipe_results)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper: average a slice of Durations without overflowing.
|
||||
|
|
|
|||
|
|
@ -1,199 +0,0 @@
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use mtp::client::MTPConnection;
|
||||
use mtp::codec::{
|
||||
CommunicationType, DataType, DataValue, ProtectionPolicy, ProtectedMessageBuilder,
|
||||
ProtectionPurpose, RelayOpenOptions, SealedRelayBuilder, SignaturePolicy, TypeMap,
|
||||
open_relay_content_with_limits_without_replay,
|
||||
open_relay_metadata_without_replay,
|
||||
};
|
||||
use mtp::common::unix_time_millis;
|
||||
use mtp::crypto::{Ed25519Signer, Keyring, PublicKeyBundle};
|
||||
|
||||
/// The direct protected example sends to the host as the destination MTP ID.
|
||||
pub const DIRECT_DESTINATION_ID: u64 = 1;
|
||||
|
||||
/// The example host acts as the metadata relay and uses this stable MTP ID.
|
||||
pub const METADATA_RELAY_ID: u64 = 1;
|
||||
|
||||
/// This keyring represents a final recipient independently of the transport
|
||||
/// identity used by the example client.
|
||||
pub const FINAL_RECIPIENT_ID: u64 = 7_002;
|
||||
|
||||
const DIRECT_SIGNATURE_PURPOSE: u8 = 0x40;
|
||||
const DIRECT_ENCRYPTION_PURPOSE: u8 = 0x41;
|
||||
const RELAY_SIGNATURE_POLICY: ProtectionPolicy = ProtectionPolicy {
|
||||
signature: SignaturePolicy::Ed25519,
|
||||
};
|
||||
|
||||
fn type_id(
|
||||
data_type: DataType,
|
||||
type_map: &TypeMap,
|
||||
) -> Result<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)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
[package]
|
||||
name = "keygen"
|
||||
version = "0.3.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
mtp = { version = "0.3.0", path = "../../", features = ["files", "raw"] }
|
||||
mtp = { version = "0.2.0", path = "../../", features = ["files"] }
|
||||
|
|
|
|||
|
|
@ -17,22 +17,14 @@ fn main() -> Result<(), files::FileError> {
|
|||
/* 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!(keyring.to_bytes(), loaded_keyring.to_bytes());
|
||||
assert_eq!(
|
||||
bundle_bytes,
|
||||
loaded_bundle_bytes
|
||||
);
|
||||
println!(
|
||||
"\nPrivateKeyRing (base64):\n{}",
|
||||
keyring.try_to_base64()?
|
||||
keyring.public_key_bundle().as_bytes(),
|
||||
loaded_bundle.as_bytes()
|
||||
);
|
||||
println!("\nPrivateKeyRing (base64):\n{}", keyring.to_base64());
|
||||
|
||||
println!(
|
||||
"\nPublicKeyBundle (base64):\n{}",
|
||||
loaded_bundle.try_to_base64()?
|
||||
);
|
||||
println!("\nPublicKeyBundle (base64):\n{}", loaded_bundle.to_base64());
|
||||
|
||||
println!("Wrote keyring -> {}", keyring_path.display());
|
||||
println!("Wrote bundle -> {}", bundle_path.display());
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "server"
|
||||
version = "0.3.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
|
|
@ -8,12 +8,10 @@ name = "server"
|
|||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
mtp = { version = "0.3.0", path = "../../", features = ["crypto", "tls", "web-server", "files", "pipes", "raw"] }
|
||||
mtp = { version = "0.2.0", path = "../../", features = ["crypto", "tls", "web-server", "files", "pipes"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
http = "1"
|
||||
serde_json = { version = "1" }
|
||||
hex = "0.4"
|
||||
base64 = "0.23"
|
||||
base64 = "0.22"
|
||||
tracing-subscriber = "0.3.23"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
rand = "0.10.1"
|
||||
|
|
|
|||
|
|
@ -1,213 +1,23 @@
|
|||
use std::collections::HashMap;
|
||||
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, TypeMap};
|
||||
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};
|
||||
struct Ed25519Verifier(SignaturePublicKey);
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
fn resolve_signer_key(
|
||||
signer_id: u64,
|
||||
registered_clients: &HashMap<u64, PublicKeyBundle>,
|
||||
) -> Option<PublicKeyBundle> {
|
||||
registered_clients.get(&signer_id).cloned()
|
||||
}
|
||||
|
||||
fn pong(tm: &TypeMap, data: impl Into<String>) -> Result<CommunicationValue, String> {
|
||||
let desc_id = DataTypeId(
|
||||
tm.data_id_enum(DataType::Description)
|
||||
.ok_or("missing Description type mapping")?,
|
||||
);
|
||||
let ts_id = DataTypeId(
|
||||
tm.data_id_enum(DataType::Timestamp)
|
||||
.ok_or("missing Timestamp type mapping")?,
|
||||
);
|
||||
let data_id = DataTypeId(
|
||||
tm.data_id_enum(DataType::Data)
|
||||
.ok_or("missing Data type mapping")?,
|
||||
);
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|e| e.to_string())?
|
||||
.as_millis();
|
||||
|
||||
CommunicationValue::from_comm(CommunicationType::Pong, tm)
|
||||
.add_data(desc_id, DataValue::Str("MTP example response".into()))
|
||||
.map_err(|e| e.to_string())?
|
||||
.add_data(ts_id, DataValue::UnsignedNumber(now))
|
||||
.map_err(|e| e.to_string())?
|
||||
.add_data(data_id, DataValue::Str(data.into()))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn process_direct_protected(
|
||||
msg: &CommunicationValue,
|
||||
tm: &TypeMap,
|
||||
client_pk: Option<&PublicKeyBundle>,
|
||||
registered_clients: &HashMap<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()
|
||||
));
|
||||
impl SignatureScheme for Ed25519Verifier {
|
||||
fn sign(&self, _msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||
Err(CryptoError::SigningFailed)
|
||||
}
|
||||
|
||||
let opened = open_protected_with_checked(
|
||||
msg,
|
||||
std::slice::from_ref(&host_keyring),
|
||||
None,
|
||||
|signer_id| resolve_signer_key(signer_id, registered_clients).map(|key| vec![key]),
|
||||
ProtectedOpenOptions::new(
|
||||
Some(DIRECT_DESTINATION_ID),
|
||||
ProtectionPurpose::from(DIRECT_SIGNATURE_PURPOSE),
|
||||
ProtectionPurpose::from(DIRECT_ENCRYPTION_PURPOSE),
|
||||
SIGNATURE_POLICY,
|
||||
),
|
||||
accepted_messages,
|
||||
)
|
||||
.map_err(|e| format!("direct protected message could not be authenticated: {e}"))?;
|
||||
let signer_id = opened.signer_id;
|
||||
let message_id = opened.message_id;
|
||||
let value = opened
|
||||
.content
|
||||
.as_container()
|
||||
.ok_or("direct protected application value is not a container")?;
|
||||
|
||||
let text_id = DataTypeId(
|
||||
tm.data_id_enum(DataType::ExampleText)
|
||||
.ok_or("missing ExampleText type mapping")?,
|
||||
);
|
||||
let number_id = DataTypeId(
|
||||
tm.data_id_enum(DataType::ExampleNumber)
|
||||
.ok_or("missing ExampleNumber type mapping")?,
|
||||
);
|
||||
let text = value
|
||||
.iter()
|
||||
.find(|(id, _)| *id == text_id)
|
||||
.and_then(|(_, value)| value.as_str())
|
||||
.ok_or("direct protected value is missing ExampleText")?;
|
||||
let number = value
|
||||
.iter()
|
||||
.find(|(id, _)| *id == number_id)
|
||||
.and_then(|(_, value)| value.as_unsigned_number())
|
||||
.ok_or("direct protected value is missing ExampleNumber")?;
|
||||
|
||||
println!(
|
||||
" Direct protected message: signer={signer_id}, message_id={message_id}, transport_key_available={}, ExampleText={text:?}, ExampleNumber={number}",
|
||||
client_pk.is_some()
|
||||
);
|
||||
if client_pk.is_none() {
|
||||
println!(
|
||||
" Protected signer was verified from the registered key map; transport is unauthenticated"
|
||||
);
|
||||
fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError> {
|
||||
verify_ed25519(&self.0, msg, signature)
|
||||
}
|
||||
pong(
|
||||
tm,
|
||||
format!("direct protected value verified for signer {signer_id}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn process_sealed_relay(
|
||||
msg: &CommunicationValue,
|
||||
registered_clients: &HashMap<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(
|
||||
msg: &CommunicationValue,
|
||||
tm: &TypeMap,
|
||||
client_pk: Option<&mtp::crypto::PublicKeyBundle>,
|
||||
registered_clients: &HashMap<u64, PublicKeyBundle>,
|
||||
host_keyring: &Keyring,
|
||||
accepted_direct_messages: &mut InMemoryReplayGuard,
|
||||
accepted_relay_messages: &mut InMemoryReplayGuard,
|
||||
) -> Result<CommunicationValue, String> {
|
||||
if msg.is_type(CommunicationType::ProtectedMessage) {
|
||||
return process_direct_protected(
|
||||
msg,
|
||||
tm,
|
||||
client_pk,
|
||||
registered_clients,
|
||||
host_keyring,
|
||||
accepted_direct_messages,
|
||||
);
|
||||
}
|
||||
if msg.is_type(CommunicationType::Relay) {
|
||||
return process_sealed_relay(
|
||||
msg,
|
||||
registered_clients,
|
||||
host_keyring,
|
||||
accepted_relay_messages,
|
||||
);
|
||||
}
|
||||
|
||||
let desc_id = DataTypeId(
|
||||
tm.data_id_enum(DataType::Description)
|
||||
.ok_or("missing Description type mapping")?,
|
||||
|
|
@ -249,34 +59,13 @@ pub fn process_and_respond(
|
|||
.ok_or("missing SecurePayload type mapping")?,
|
||||
);
|
||||
|
||||
let description = msg
|
||||
.get_data(DataType::Description)
|
||||
.cloned()
|
||||
.unwrap_or(DataValue::Null);
|
||||
let timestamp = msg
|
||||
.get_data(DataType::Timestamp)
|
||||
.cloned()
|
||||
.unwrap_or(DataValue::Null);
|
||||
let data = msg
|
||||
.get_data(DataType::Data)
|
||||
.cloned()
|
||||
.unwrap_or(DataValue::Null);
|
||||
let flags = msg
|
||||
.get_data(DataType::Flags)
|
||||
.cloned()
|
||||
.unwrap_or(DataValue::Null);
|
||||
let value = msg
|
||||
.get_data(DataType::Value)
|
||||
.cloned()
|
||||
.unwrap_or(DataValue::Null);
|
||||
let binary = msg
|
||||
.get_data(DataType::BinaryData)
|
||||
.cloned()
|
||||
.unwrap_or(DataValue::Null);
|
||||
let items = msg
|
||||
.get_data(DataType::Items)
|
||||
.cloned()
|
||||
.unwrap_or(DataValue::Null);
|
||||
let description = msg.get_data(DataType::Description);
|
||||
let timestamp = msg.get_data(DataType::Timestamp);
|
||||
let data = msg.get_data(DataType::Data);
|
||||
let flags = msg.get_data(DataType::Flags);
|
||||
let value = msg.get_data(DataType::Value);
|
||||
let binary = msg.get_data(DataType::BinaryData);
|
||||
let items = msg.get_data(DataType::Items);
|
||||
|
||||
println!(
|
||||
" Description: {}",
|
||||
|
|
@ -293,8 +82,13 @@ pub fn process_and_respond(
|
|||
let mut sig_status = String::from("SignedPayload: not present");
|
||||
let mut secure_status = String::from("SecurePayload: not present");
|
||||
|
||||
if let Some(enc @ DataValue::Encrypted(_)) = msg.get_data(DataType::EncryptedPayload) {
|
||||
if let Ok(dv) = enc.decrypt(host_keyring, mtp::codec::ProtectionPurpose::from(1)) {
|
||||
let enc = msg.get_data(DataType::EncryptedPayload);
|
||||
if matches!(enc, DataValue::EncryptedContainer(_)) {
|
||||
let mut dv = enc.clone();
|
||||
if dv
|
||||
.decrypt_into_container(host_keyring, b"demo-aad")
|
||||
.is_some()
|
||||
{
|
||||
if let Some(entries) = dv.as_container() {
|
||||
println!(" Decrypted EncryptedPayload: {:?}", entries);
|
||||
enc_status = format!("EncryptedPayload decrypted OK ({} entries)", entries.len());
|
||||
|
|
@ -305,29 +99,13 @@ pub fn process_and_respond(
|
|||
}
|
||||
}
|
||||
|
||||
if let Some(sig @ DataValue::Signed(_)) = msg.get_data(DataType::SignedPayload) {
|
||||
let sig = msg.get_data(DataType::SignedPayload);
|
||||
if matches!(sig, DataValue::SignedContainer(_)) {
|
||||
if let Some(pk_bundle) = client_pk {
|
||||
let signer_id = sig.as_signed().map(|signed| signed.signer_id);
|
||||
if let Some(signer_id) = signer_id
|
||||
&& sig
|
||||
.verify_with_policy(
|
||||
signer_id,
|
||||
pk_bundle,
|
||||
mtp::codec::ProtectionPurpose::from(2),
|
||||
SIGNATURE_POLICY,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
let dv = sig
|
||||
.clone()
|
||||
.into_verified_with_policy(
|
||||
signer_id,
|
||||
pk_bundle,
|
||||
mtp::codec::ProtectionPurpose::from(2),
|
||||
SIGNATURE_POLICY,
|
||||
)
|
||||
.ok();
|
||||
if let Some(entries) = dv.and_then(|value| value.as_container()) {
|
||||
let verifier = Ed25519Verifier(pk_bundle.sig_cl_public_key.clone());
|
||||
let mut dv = sig.clone();
|
||||
if dv.verify_into_container(&verifier).is_some() {
|
||||
if let Some(entries) = dv.as_container() {
|
||||
println!(" Verified SignedPayload: {:?}", entries);
|
||||
sig_status = format!("SignedPayload verified OK ({} entries)", entries.len());
|
||||
}
|
||||
|
|
@ -341,29 +119,17 @@ pub fn process_and_respond(
|
|||
}
|
||||
}
|
||||
|
||||
if let Some(secure @ DataValue::Encrypted(_)) = msg.get_data(DataType::SecurePayload) {
|
||||
let secure = msg.get_data(DataType::SecurePayload);
|
||||
if matches!(secure, DataValue::SignedEncryptedContainer(_)) {
|
||||
if let Some(pk_bundle) = client_pk {
|
||||
if let Ok(opened) = secure.decrypt(host_keyring, mtp::codec::ProtectionPurpose::from(4))
|
||||
&& let Some(signed) = opened.as_signed()
|
||||
&& opened
|
||||
.verify_with_policy(
|
||||
signed.signer_id,
|
||||
pk_bundle,
|
||||
mtp::codec::ProtectionPurpose::from(3),
|
||||
SIGNATURE_POLICY,
|
||||
)
|
||||
.is_ok()
|
||||
let verifier = Ed25519Verifier(pk_bundle.sig_cl_public_key.clone());
|
||||
let mut dv = secure.clone();
|
||||
if dv
|
||||
.decrypt_signed_encrypted_container(host_keyring, b"demo-aad")
|
||||
.is_some()
|
||||
&& dv.verify_into_container(&verifier).is_some()
|
||||
{
|
||||
let signer_id = signed.signer_id;
|
||||
let dv = opened
|
||||
.into_verified_with_policy(
|
||||
signer_id,
|
||||
pk_bundle,
|
||||
mtp::codec::ProtectionPurpose::from(3),
|
||||
SIGNATURE_POLICY,
|
||||
)
|
||||
.ok();
|
||||
if let Some(entries) = dv.and_then(|value| value.as_container()) {
|
||||
if let Some(entries) = dv.as_container() {
|
||||
println!(" Verified SecurePayload: {:?}", entries);
|
||||
secure_status = format!(
|
||||
"SecurePayload decrypted+verified OK ({} entries)",
|
||||
|
|
@ -383,13 +149,11 @@ pub fn process_and_respond(
|
|||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_err(|e| e.to_string())?
|
||||
.as_millis();
|
||||
.as_secs();
|
||||
|
||||
let response = CommunicationValue::from_comm(CommunicationType::Pong, tm)
|
||||
.add_data(desc_id, description)
|
||||
.map_err(|e| e.to_string())?
|
||||
.add_data(ts_id, DataValue::UnsignedNumber(now))
|
||||
.map_err(|e| e.to_string())?
|
||||
Ok(CommunicationValue::from_comm(CommunicationType::Pong, tm)
|
||||
.add_data(desc_id, description.clone())
|
||||
.add_data(ts_id, DataValue::UnsignedNumber(now as u128))
|
||||
.add_data(
|
||||
data_id,
|
||||
DataValue::Str(format!(
|
||||
|
|
@ -397,14 +161,8 @@ pub fn process_and_respond(
|
|||
enc_status, sig_status, secure_status
|
||||
)),
|
||||
)
|
||||
.map_err(|e| e.to_string())?
|
||||
.add_data(flags_id, flags)
|
||||
.map_err(|e| e.to_string())?
|
||||
.add_data(value_id, value)
|
||||
.map_err(|e| e.to_string())?
|
||||
.add_data(bin_id, binary)
|
||||
.map_err(|e| e.to_string())?
|
||||
.add_data(items_id, items)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(response)
|
||||
.add_data(flags_id, flags.clone())
|
||||
.add_data(value_id, value.clone())
|
||||
.add_data(bin_id, binary.clone())
|
||||
.add_data(items_id, items.clone()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ pub async fn export_host_public_keys(
|
|||
save_public_key_bundle(&bundle, "host.mpkb")?;
|
||||
|
||||
/* The web client fetches the bundle as hex over HTTP. */
|
||||
let bundle_hex = hex::encode(bundle.try_as_bytes()?);
|
||||
let bundle_hex = hex::encode(bundle.as_bytes());
|
||||
fs::write("host_public_key_bundle.hex", &bundle_hex).await?;
|
||||
fs::create_dir_all("web-client/public").await?;
|
||||
fs::write("web-client/public/host_public_key_bundle.hex", &bundle_hex).await?;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
mod clients;
|
||||
mod handlers;
|
||||
mod keys;
|
||||
mod metrics;
|
||||
mod tls;
|
||||
#[path = "web-server.rs"]
|
||||
mod web_server;
|
||||
|
||||
use mtp::host::{AuthenticationPolicy, AuthState, HostConfig};
|
||||
use mtp::host::HostConfig;
|
||||
use mtp::type_map::TypeMap;
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
|
|
@ -38,10 +37,9 @@ async fn handle_pipe_loopback(
|
|||
conn: &mtp::webserver::WebMTPConnection,
|
||||
request: mtp::host::PipeRequest<
|
||||
mtp::webserver::WebMtpSender,
|
||||
mtp::webserver::WebMtpReceiver,
|
||||
mtp::webserver::H3TransportReceiver,
|
||||
>,
|
||||
) -> Result<u64, Box<dyn std::error::Error>> {
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let pipe_id = request.id();
|
||||
println!(" [loopback] Accepting pipe {pipe_id} ...");
|
||||
let mut reader = request.accept().await?;
|
||||
|
|
@ -58,7 +56,7 @@ async fn handle_pipe_loopback(
|
|||
let copied = tokio::io::copy(&mut reader, &mut writer).await?;
|
||||
writer.finish_async().await?;
|
||||
println!(" [loopback] Pipe {pipe_id} complete ({copied} bytes)");
|
||||
Ok(copied)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -101,10 +99,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
serde_json::to_string_pretty(&*db).ok()
|
||||
};
|
||||
|
||||
if let Some(json) = json
|
||||
&& let Err(error) = tokio::fs::write("clients.json", json).await
|
||||
{
|
||||
eprintln!("Failed to persist clients.json: {error}");
|
||||
if let Some(json) = json {
|
||||
if let Err(error) = tokio::fs::write("clients.json", json).await {
|
||||
eprintln!("Failed to persist clients.json: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
println!("Registered new client with ID: {id}");
|
||||
|
|
@ -112,9 +110,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
}) as Pin<Box<dyn Future<Output = u64> + Send>>
|
||||
};
|
||||
|
||||
let decrypt_keyring_bytes = host_keyring.try_to_bytes()?;
|
||||
let decrypt_keyring = Arc::new(
|
||||
match mtp::crypto::Keyring::from_bytes(&decrypt_keyring_bytes) {
|
||||
match mtp::crypto::Keyring::from_bytes(&host_keyring.to_bytes()) {
|
||||
Ok(keyring) => keyring,
|
||||
Err(e) => {
|
||||
return Err(format!("failed to re-load host keyring for decryption: {e}").into());
|
||||
|
|
@ -122,10 +119,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
},
|
||||
);
|
||||
|
||||
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(
|
||||
|
|
@ -138,39 +131,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
host_keyring,
|
||||
Box::new(get_existing_client),
|
||||
Box::new(complete_register),
|
||||
)
|
||||
.with_authentication_policy(AuthenticationPolicy::AllowAuthentication);
|
||||
);
|
||||
|
||||
let mut host = mtp::webserver::MTPWebServer::new(config, web_server::config()?).await?;
|
||||
println!("Server listening on https://{}", host.local_addr());
|
||||
println!("TCP: HTTP/1.1 and HTTP/2");
|
||||
println!("UDP: HTTP/3 and WebTransport");
|
||||
|
||||
loop {
|
||||
let conn = match host.accept().await {
|
||||
Ok(Some(conn)) => conn,
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
eprintln!("Accept error: {msg}");
|
||||
metrics.record_accept_error();
|
||||
metrics.save("metrics/server_sessions.json");
|
||||
metrics.build_overview("metrics/server_overview.json");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
while let Some(conn) = host.accept().await? {
|
||||
let decrypt_keyring = Arc::clone(&decrypt_keyring);
|
||||
let metrics = Arc::clone(&metrics);
|
||||
let registered_clients = Arc::clone(&clients);
|
||||
metrics.record_connection_version(&conn.version.to_string());
|
||||
tokio::spawn(async move {
|
||||
let desc = conn.description.as_deref().unwrap_or("(no description)");
|
||||
let connection_state = match &conn.auth_state {
|
||||
AuthState::Authenticated => "authenticated client",
|
||||
AuthState::Unauthenticated => "unauthenticated client",
|
||||
AuthState::Pending => "pending client",
|
||||
AuthState::Failed => "failed client",
|
||||
};
|
||||
println!(
|
||||
"\n--- New connection (version {}, remote: {}, description: {desc}) ---",
|
||||
conn.version,
|
||||
|
|
@ -178,18 +149,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.map(|addr| addr.to_string())
|
||||
.unwrap_or_else(|| "unknown".into())
|
||||
);
|
||||
println!("Connection state: {connection_state}; MTP ID: {}", conn.client_id);
|
||||
|
||||
let mut session = metrics.start_session(conn.client_id, desc.to_string());
|
||||
println!("Client ID: {}", conn.client_id);
|
||||
|
||||
let tm: &TypeMap = conn.codec.registry().get(&conn.version).unwrap();
|
||||
|
||||
println!("Waiting for messages / pipe requests ...");
|
||||
let mut pipe_open = true;
|
||||
let mut message_open = true;
|
||||
let mut accepted_direct_messages = mtp::codec::InMemoryReplayGuard::default();
|
||||
let mut accepted_relay_messages = mtp::codec::InMemoryReplayGuard::default();
|
||||
let mut exit_reason = "normal".to_string();
|
||||
let mut messages_received = 0_u64;
|
||||
|
||||
while pipe_open || message_open {
|
||||
let activity = tokio::time::timeout(CONNECTION_IDLE_TIMEOUT, async {
|
||||
|
|
@ -198,17 +165,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
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}");
|
||||
}
|
||||
if let Err(error) = handle_pipe_loopback(&conn, request).await {
|
||||
eprintln!(" [loopback] Pipe error: {error}");
|
||||
}
|
||||
}
|
||||
Err(mtp::common::CommunicationError::StreamClosed)
|
||||
|
|
@ -225,31 +183,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
message = conn.receive(), if message_open => {
|
||||
match message {
|
||||
Ok(message) => {
|
||||
messages_received += 1;
|
||||
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(
|
||||
match handlers::process_and_respond(
|
||||
&message,
|
||||
tm,
|
||||
conn.client_public_key.as_ref(),
|
||||
®istered_clients,
|
||||
&decrypt_keyring,
|
||||
&mut accepted_direct_messages,
|
||||
&mut accepted_relay_messages,
|
||||
);
|
||||
let latency = msg_start.elapsed();
|
||||
let ok = result.is_ok();
|
||||
session.record_message(latency, ok);
|
||||
|
||||
match result {
|
||||
) {
|
||||
Ok(response) => {
|
||||
println!("Sending: {response}");
|
||||
if let Err(error) = conn.sender.send(&response).await {
|
||||
eprintln!("Send error: {error}");
|
||||
session.record_send_error();
|
||||
pipe_open = false;
|
||||
message_open = false;
|
||||
}
|
||||
|
|
@ -275,27 +220,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||
.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();
|
||||
if messages_received >= MAX_MESSAGES_PER_CONNECTION {
|
||||
println!("Connection message limit reached");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let record = session.finish(exit_reason);
|
||||
println!(
|
||||
"Connection closed (messages: {}, pipes: {}, duration: {:.1}s)\n",
|
||||
record.messages_received,
|
||||
record.pipes_handled,
|
||||
record.duration_secs
|
||||
);
|
||||
|
||||
metrics.save("metrics/server_sessions.json");
|
||||
metrics.build_overview("metrics/server_overview.json");
|
||||
println!("Connection closed\n");
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,885 +0,0 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn now_epoch_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn generate_session_id() -> String {
|
||||
let ts = now_epoch_secs();
|
||||
let rand_part: u32 = rand::random();
|
||||
format!("{ts}-{rand_part:08x}")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persisted data types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SessionRecord {
|
||||
pub session_id: String,
|
||||
pub client_id: u64,
|
||||
pub description: String,
|
||||
pub start_time: u64,
|
||||
pub end_time: u64,
|
||||
pub duration_secs: f64,
|
||||
pub messages_received: u64,
|
||||
pub messages_ok: u64,
|
||||
pub messages_failed: u64,
|
||||
pub pipes_handled: u64,
|
||||
pub pipe_bytes_copied: u64,
|
||||
pub pipe_denials: u64,
|
||||
pub send_errors: u64,
|
||||
pub avg_message_latency_ms: f64,
|
||||
pub max_message_latency_ms: f64,
|
||||
pub exit_reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct AggregateStats {
|
||||
pub total_connections: u64,
|
||||
pub total_messages: u64,
|
||||
pub total_messages_ok: u64,
|
||||
pub total_messages_failed: u64,
|
||||
pub total_pipes: u64,
|
||||
pub total_pipe_bytes: u64,
|
||||
pub total_pipe_denials: u64,
|
||||
pub total_send_errors: u64,
|
||||
pub total_accept_errors: u64,
|
||||
pub avg_session_duration_secs: f64,
|
||||
pub avg_messages_per_session: f64,
|
||||
pub avg_pipes_per_session: f64,
|
||||
pub avg_message_latency_ms: f64,
|
||||
pub max_message_latency_ms: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Overview {
|
||||
pub total_sessions: u64,
|
||||
pub first_session_timestamp: u64,
|
||||
pub last_session_timestamp: u64,
|
||||
pub aggregate: AggregateStats,
|
||||
pub connection_versions: HashMap<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,13 +1,29 @@
|
|||
protocol_version: "3.0"
|
||||
protocol_version: "1.0"
|
||||
|
||||
type_maps:
|
||||
"3.0":
|
||||
"0.0":
|
||||
CommunicationTypes:
|
||||
ProtectedMessage: 32
|
||||
AlternateMessage: 33
|
||||
DataTypes:
|
||||
"1.0":
|
||||
CommunicationTypes:
|
||||
CommunicationType: 32
|
||||
DataTypes:
|
||||
Data: 32
|
||||
Flags: 33
|
||||
Value: 34
|
||||
BinaryData: 35
|
||||
Items: 36
|
||||
EncryptedPayload: 37
|
||||
SignedPayload: 38
|
||||
SecurePayload: 39
|
||||
CommunicationType: 40
|
||||
DataType: 41
|
||||
"2.0":
|
||||
CommunicationTypes:
|
||||
CommunicationType: 32
|
||||
DataTypes:
|
||||
Data: 34
|
||||
Flags: 33
|
||||
Value: 35
|
||||
BinaryData: 36
|
||||
Items: 37
|
||||
|
|
@ -16,7 +32,3 @@ type_maps:
|
|||
SecurePayload: 40
|
||||
CommunicationType: 41
|
||||
DataType: 42
|
||||
ExampleText: 43
|
||||
ExampleNumber: 44
|
||||
ExampleRole: 45
|
||||
ExampleMetadata: 46
|
||||
|
|
|
|||
|
|
@ -67,9 +67,6 @@
|
|||
Use new credentials
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "mtp-web-client",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
"scripts": {
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"mtp": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^7.0.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,6 @@ const GENERATE_KEYPAIR = document.getElementById(
|
|||
"generate-keypair",
|
||||
) as HTMLButtonElement;
|
||||
const CONNECT = document.getElementById("connect") as HTMLButtonElement;
|
||||
const CONNECT_UNAUTHENTICATED = document.getElementById(
|
||||
"connect-unauthenticated",
|
||||
) as HTMLButtonElement;
|
||||
const CLEAR_KEYS = document.getElementById("clear-keys") as HTMLButtonElement;
|
||||
const STREAM_MIC = document.getElementById("stream-mic") as HTMLButtonElement;
|
||||
const STOP_MIC = document.getElementById("stop-mic") as HTMLButtonElement;
|
||||
|
|
@ -35,6 +32,7 @@ const HOST_PUBLIC_KEY_KEY = "mtp-web-client-host-public-key";
|
|||
type SavedKeys = {
|
||||
clientId: string | null;
|
||||
keyring?: number[];
|
||||
keyringBytes?: number[];
|
||||
hostPublicKey?: number[];
|
||||
};
|
||||
|
||||
|
|
@ -292,7 +290,7 @@ function loadKeys() {
|
|||
|
||||
const data = JSON.parse(raw) as SavedKeys;
|
||||
clientId = data.clientId ? BigInt(data.clientId) : null;
|
||||
const keyringLength = (data.keyring ?? []).length;
|
||||
const keyringLength = (data.keyring ?? data.keyringBytes ?? []).length;
|
||||
CLIENT_CREDENTIALS.value = renderStructured({
|
||||
clientId: data.clientId,
|
||||
keyringBytes: keyringLength,
|
||||
|
|
@ -354,7 +352,6 @@ async function initWasm() {
|
|||
const supported = MTPClient.isSupported();
|
||||
log(`WASM loaded. WebTransport supported: ${supported}`);
|
||||
CONNECT.disabled = !supported;
|
||||
CONNECT_UNAUTHENTICATED.disabled = !supported;
|
||||
}
|
||||
|
||||
async function createClient() {
|
||||
|
|
@ -451,39 +448,6 @@ async function connect() {
|
|||
}
|
||||
}
|
||||
|
||||
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");
|
||||
|
|
@ -755,13 +719,6 @@ CONNECT.addEventListener("click", () => {
|
|||
});
|
||||
});
|
||||
|
||||
CONNECT_UNAUTHENTICATED.addEventListener("click", () => {
|
||||
connectUnauthenticated().catch((e) => {
|
||||
log(`Unauthenticated connection failed: ${e}`, "error");
|
||||
console.error(e);
|
||||
});
|
||||
});
|
||||
|
||||
CLEAR_KEYS.addEventListener("click", () => {
|
||||
clientId = null;
|
||||
CLIENT_CREDENTIALS.value = "";
|
||||
|
|
|
|||
|
|
@ -1,22 +1,13 @@
|
|||
[package]
|
||||
name = "mtp-files"
|
||||
version = "0.3.0"
|
||||
version = "0.2.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"] }
|
||||
mtp-crypto = { version = "0.2.0", path = "../crypto", default-features = false, features = ["chacha20poly1305", "hkdf"] }
|
||||
rand = "0.10.2"
|
||||
|
||||
thiserror = "2"
|
||||
thiserror = "1"
|
||||
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"] }
|
||||
|
|
|
|||
154
files/src/lib.rs
154
files/src/lib.rs
|
|
@ -13,7 +13,7 @@ use std::io;
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use mtp_crypto::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305};
|
||||
use mtp_crypto::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305, derive_encryption_key};
|
||||
use rand::RngExt;
|
||||
use thiserror::Error;
|
||||
use zeroize::Zeroizing;
|
||||
|
|
@ -29,15 +29,11 @@ pub const BUNDLE_EXTENSION: &str = "mpkb";
|
|||
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 PROTECTED_FORMAT_VERSION: u8 = 2;
|
||||
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;
|
||||
const KEYRING_KDF_CONTEXT: &[u8] = b"mtp-keyring-at-rest-v2";
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum FileError {
|
||||
|
|
@ -119,20 +115,6 @@ fn temporary_path(path: &Path, attempt: u64) -> io::Result<PathBuf> {
|
|||
|
||||
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;
|
||||
|
||||
|
|
@ -160,33 +142,10 @@ fn write_secret_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
|||
let _ = fs::remove_file(&temporary);
|
||||
return Err(error);
|
||||
}
|
||||
sync_parent_directory(path)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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.
|
||||
/// Save a keyring encrypted with XChaCha20-Poly1305 under an HKDF-derived key.
|
||||
pub fn save_keyring(
|
||||
keyring: &Keyring,
|
||||
path: impl AsRef<Path>,
|
||||
|
|
@ -197,24 +156,16 @@ pub fn save_keyring(
|
|||
}
|
||||
let mut salt = [0u8; SALT_LEN];
|
||||
rand::rng().fill(&mut salt);
|
||||
let key = derive_key(
|
||||
let key = Zeroizing::new(derive_encryption_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);
|
||||
KEYRING_KDF_CONTEXT,
|
||||
)?);
|
||||
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);
|
||||
let plaintext = keyring.to_bytes();
|
||||
let encrypted = cipher.encrypt(&plaintext, &KEYRING_MAGIC)?;
|
||||
let mut payload = Vec::with_capacity(SALT_LEN + encrypted.len());
|
||||
payload.extend_from_slice(&salt);
|
||||
payload.extend_from_slice(&encrypted);
|
||||
let bytes = encode(KEYRING_MAGIC, PROTECTED_FORMAT_VERSION, &payload);
|
||||
write_secret_atomic(path.as_ref(), &bytes)?;
|
||||
|
|
@ -236,42 +187,31 @@ pub fn load_keyring(path: impl AsRef<Path>, passphrase: &[u8]) -> Result<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..)
|
||||
let salt = payload
|
||||
.get(..SALT_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]),
|
||||
let encrypted = payload
|
||||
.get(SALT_LEN..)
|
||||
.ok_or(FileError::Truncated(bytes.len()))?;
|
||||
let key = Zeroizing::new(derive_encryption_key(
|
||||
passphrase,
|
||||
salt,
|
||||
KEYRING_KDF_CONTEXT,
|
||||
)?);
|
||||
let cipher = ChaCha20Poly1305::new(*key);
|
||||
let plaintext = Zeroizing::new(cipher.decrypt(encrypted, &KEYRING_MAGIC)?);
|
||||
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 payload = keyring.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")?;
|
||||
|
|
@ -291,8 +231,7 @@ 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);
|
||||
let bytes = encode(BUNDLE_MAGIC, BUNDLE_FORMAT_VERSION, &bundle.as_bytes());
|
||||
fs::write(path, bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -306,16 +245,15 @@ pub fn load_public_key_bundle(path: impl AsRef<Path>) -> Result<PublicKeyBundle,
|
|||
found: version,
|
||||
});
|
||||
}
|
||||
Ok(PublicKeyBundle::from_bytes_validated(payload)?)
|
||||
Ok(PublicKeyBundle::from_bytes(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,
|
||||
KemPrivateKey, KemPublicKey, SignaturePqPrivateKey, SignaturePqPublicKey,
|
||||
SignaturePrivateKey, SignaturePublicKey,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
|
@ -330,11 +268,11 @@ mod tests {
|
|||
|
||||
fn sample_keyring() -> Keyring {
|
||||
Keyring::new(
|
||||
KemPublicKey::new(vec![1u8; KEM_PUBLIC_KEY_LEN]),
|
||||
KemPublicKey::new(vec![1u8; 32]),
|
||||
KemPrivateKey::new(vec![2u8; 32]),
|
||||
SignaturePqPublicKey::new(vec![3u8; SIG_PQ_PUBLIC_KEY_LEN]),
|
||||
SignaturePqPublicKey::new(vec![3u8; 64]),
|
||||
SignaturePqPrivateKey::new(vec![4u8; 64]),
|
||||
SignaturePublicKey::new(vec![5u8; SIG_CL_PUBLIC_KEY_LEN]),
|
||||
SignaturePublicKey::new(vec![5u8; 32]),
|
||||
SignaturePrivateKey::new(vec![6u8; 32]),
|
||||
)
|
||||
}
|
||||
|
|
@ -345,7 +283,7 @@ mod tests {
|
|||
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()?);
|
||||
assert_eq!(keyring.to_bytes(), loaded.to_bytes());
|
||||
let _ = fs::remove_file(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -353,10 +291,10 @@ mod tests {
|
|||
#[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();
|
||||
let bundle = sample_keyring().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()?);
|
||||
assert_eq!(bundle.as_bytes(), loaded.as_bytes());
|
||||
let _ = fs::remove_file(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -364,8 +302,7 @@ mod tests {
|
|||
#[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)?;
|
||||
save_public_key_bundle(&sample_keyring().public_key_bundle(), &path)?;
|
||||
assert!(matches!(
|
||||
load_keyring(&path, b"passphrase"),
|
||||
Err(FileError::BadMagic { .. })
|
||||
|
|
@ -420,7 +357,7 @@ mod tests {
|
|||
Err(FileError::UnprotectedKeyring)
|
||||
));
|
||||
let loaded = load_keyring_raw(&path)?;
|
||||
assert_eq!(keyring.try_to_bytes()?, loaded.try_to_bytes()?);
|
||||
assert_eq!(keyring.to_bytes(), loaded.to_bytes());
|
||||
let _ = fs::remove_file(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -429,7 +366,7 @@ mod tests {
|
|||
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()?;
|
||||
let serialized = keyring.to_bytes();
|
||||
save_keyring(&keyring, &path, b"passphrase")?;
|
||||
let stored = fs::read(&path)?;
|
||||
assert!(
|
||||
|
|
@ -440,21 +377,4 @@ mod tests {
|
|||
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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
79
flake.nix
79
flake.nix
|
|
@ -1,34 +1,31 @@
|
|||
{
|
||||
description = "MTP - Methanium Transport Protocol";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
rust-overlay,
|
||||
}:
|
||||
let
|
||||
systems = [
|
||||
"aarch64-darwin"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"x86_64-linux"
|
||||
];
|
||||
eachSystem =
|
||||
f:
|
||||
nixpkgs.lib.foldl' nixpkgs.lib.recursiveUpdate { } (
|
||||
map (system: nixpkgs.lib.mapAttrs (_: value: { ${system} = value; }) (f system)) systems
|
||||
);
|
||||
in
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
rust-overlay,
|
||||
}: let
|
||||
systems = [
|
||||
"aarch64-darwin"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"x86_64-linux"
|
||||
];
|
||||
eachSystem = f:
|
||||
nixpkgs.lib.foldl' nixpkgs.lib.recursiveUpdate {} (
|
||||
map (system: nixpkgs.lib.mapAttrs (_: value: {${system} = value;}) (f system)) systems
|
||||
);
|
||||
in
|
||||
eachSystem (
|
||||
system:
|
||||
let
|
||||
overlays = [ rust-overlay.overlays.default ];
|
||||
pkgs = import nixpkgs { inherit system overlays; };
|
||||
system: let
|
||||
overlays = [rust-overlay.overlays.default];
|
||||
pkgs = import nixpkgs {inherit system overlays;};
|
||||
|
||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = [
|
||||
|
|
@ -36,21 +33,21 @@
|
|||
"clippy"
|
||||
"rustfmt"
|
||||
];
|
||||
targets = [ "wasm32-unknown-unknown" ];
|
||||
targets = ["wasm32-unknown-unknown"];
|
||||
};
|
||||
|
||||
clippyCheck = pkgs.writeShellApplication {
|
||||
name = "mtp-clippy";
|
||||
runtimeInputs = [ rustToolchain ];
|
||||
runtimeInputs = [rustToolchain];
|
||||
text = ''
|
||||
export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}"
|
||||
export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example-type-maps.yaml}"
|
||||
cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings -W unreachable-pub
|
||||
'';
|
||||
};
|
||||
|
||||
macheteCheck = pkgs.writeShellApplication {
|
||||
name = "mtp-machete";
|
||||
runtimeInputs = [ pkgs.cargo-machete ];
|
||||
runtimeInputs = [pkgs.cargo-machete];
|
||||
text = ''
|
||||
cargo machete "$@"
|
||||
'';
|
||||
|
|
@ -58,17 +55,9 @@
|
|||
|
||||
buildAll = pkgs.writeShellApplication {
|
||||
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 = ''
|
||||
export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}"
|
||||
export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example-type-maps.yaml}"
|
||||
|
||||
timeout 60s pnpm install --frozen-lockfile
|
||||
cargo fmt --all --check
|
||||
|
|
@ -78,29 +67,21 @@
|
|||
cargo check --manifest-path example/Cargo.toml --workspace --all-targets --all-features
|
||||
mtp-clippy
|
||||
mtp-machete
|
||||
pnpm run dup
|
||||
pnpm run build
|
||||
RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack test --node wasm
|
||||
pnpm run test:e2e
|
||||
pnpm run test:secrets
|
||||
pnpm run test:types
|
||||
pnpm run test:boundary
|
||||
pnpm --filter mtp-web-client run build
|
||||
'';
|
||||
};
|
||||
|
||||
healthCheck = pkgs.writeShellApplication {
|
||||
name = "mtp-health";
|
||||
runtimeInputs = [
|
||||
clippyCheck
|
||||
macheteCheck
|
||||
];
|
||||
runtimeInputs = [clippyCheck macheteCheck];
|
||||
text = ''
|
||||
mtp-clippy
|
||||
mtp-machete
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
in {
|
||||
devShells = {
|
||||
default = pkgs.mkShell {
|
||||
name = "mtp-dev";
|
||||
|
|
@ -115,7 +96,7 @@
|
|||
openssl
|
||||
];
|
||||
|
||||
MTP_TYPE_MAPS = "${toString ./example/type-maps.yaml}";
|
||||
MTP_TYPE_MAPS = "${toString ./example-type-maps.yaml}";
|
||||
|
||||
shellHook = ''
|
||||
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
[package]
|
||||
name = "mtp-host"
|
||||
version = "0.3.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
mtp-common = { version = "0.3.0", path = "../common" }
|
||||
mtp-codec = { version = "0.3.0", path = "../codec", features = ["registry"] }
|
||||
mtp-transport = { version = "0.3.0", path = "../transport", features = ["host"] }
|
||||
mtp-crypto = { version = "0.3.0", path = "../crypto", optional = true }
|
||||
rand = "0.10"
|
||||
thiserror = "2"
|
||||
mtp-common = { version = "0.2.0", path = "../common" }
|
||||
mtp-codec = { version = "0.2.0", path = "../codec", features = ["registry"] }
|
||||
mtp-transport = { version = "0.2.0", path = "../transport", features = ["host"] }
|
||||
mtp-crypto = { version = "0.2.0", path = "../crypto", optional = true }
|
||||
rand = "0.8"
|
||||
tokio = { version = "1", features = ["macros", "rt", "time", "sync"] }
|
||||
tracing = "0.1"
|
||||
wtransport = "0.7"
|
||||
|
|
|
|||
|
|
@ -1,18 +1,8 @@
|
|||
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;
|
||||
|
|
@ -36,23 +26,18 @@ pub type GetExistingClient = Box<
|
|||
|
||||
/// 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.
|
||||
/// Return `Some(id)` to accept the guest with the given ID, or `None` to reject
|
||||
/// the connection. The returned ID must fit in 48 bits
|
||||
/// (`id <= mtp_codec::MAX_WIRE_ID`); values outside that range are rejected
|
||||
/// automatically.
|
||||
///
|
||||
/// 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.
|
||||
/// 48-bit ID that avoids collisions with registered clients.
|
||||
#[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,
|
||||
|
|
@ -62,22 +47,6 @@ pub type CompleteRegister = Box<
|
|||
+ 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 {
|
||||
|
|
@ -86,159 +55,6 @@ pub enum AuthenticationPolicy {
|
|||
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,
|
||||
|
|
@ -251,8 +67,6 @@ pub struct HostConfig {
|
|||
#[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,
|
||||
|
|
@ -261,21 +75,9 @@ pub struct HostConfig {
|
|||
#[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 {
|
||||
|
|
@ -290,8 +92,6 @@ impl HostConfig {
|
|||
#[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,
|
||||
|
|
@ -307,24 +107,9 @@ impl HostConfig {
|
|||
#[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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -345,9 +130,7 @@ impl HostConfig {
|
|||
get_existing_client: GetExistingClient,
|
||||
complete_register: CompleteRegister,
|
||||
) -> Self {
|
||||
if !self.authentication_policy_explicit {
|
||||
self.authentication_policy = AuthenticationPolicy::ForceAuthentication;
|
||||
}
|
||||
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);
|
||||
|
|
@ -357,7 +140,6 @@ impl HostConfig {
|
|||
#[cfg(feature = "crypto")]
|
||||
pub fn with_authentication_policy(mut self, policy: AuthenticationPolicy) -> Self {
|
||||
self.authentication_policy = policy;
|
||||
self.authentication_policy_explicit = true;
|
||||
self
|
||||
}
|
||||
|
||||
|
|
@ -378,148 +160,4 @@ impl HostConfig {
|
|||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,7 @@ 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,
|
||||
};
|
||||
use crate::pipe::{PipeDispatcher, PipeReceiver, PipeRequest, PipeSender, run_dispatcher};
|
||||
#[cfg(feature = "pipes")]
|
||||
use mtp_transport::Policy;
|
||||
|
||||
|
|
@ -73,37 +70,19 @@ pub struct MTPConnection<
|
|||
#[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>>>,
|
||||
pub(crate) pipe_req_rx: Mutex<mpsc::Receiver<PipeRequest<S, 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")]
|
||||
|
|
@ -148,15 +127,12 @@ where
|
|||
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 (app_tx, app_rx) = mpsc::channel(policy.receiver_queue_capacity);
|
||||
let (pipe_req_tx, pipe_req_rx) = mpsc::channel(policy.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_creations: 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(),
|
||||
|
|
@ -177,70 +153,12 @@ where
|
|||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -285,15 +203,12 @@ impl<S, R, P> MTPConnection<S, R, P> {
|
|||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -328,60 +243,32 @@ where
|
|||
pub async fn create_pipe(
|
||||
&self,
|
||||
description: &str,
|
||||
) -> Result<crate::pipe::PipeHandle<S, P>, mtp_common::PipeError> {
|
||||
) -> Result<crate::pipe::PipeHandle<S>, mtp_common::PipeError> {
|
||||
let pipe_id = rand::random::<u32>();
|
||||
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());
|
||||
self.pipe_dispatcher
|
||||
.pending_creations
|
||||
.lock()
|
||||
.await
|
||||
.insert(pipe_id, response_tx);
|
||||
|
||||
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));
|
||||
}
|
||||
let request = CommunicationValue::new(CommunicationType::PipeRequest)
|
||||
.with_id(pipe_id)
|
||||
.add_typed_default(DataType::Description, DataValue::Str(description.into()));
|
||||
self.sender
|
||||
.send_pipe_message(&request)
|
||||
.await
|
||||
.map_err(mtp_common::PipeError::from)?;
|
||||
|
||||
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> {
|
||||
pub async fn receive_pipe(&self) -> Result<PipeRequest<S, P>, CommunicationError> {
|
||||
self.pipe_req_rx
|
||||
.lock()
|
||||
.await
|
||||
|
|
|
|||
1306
host/src/engine.rs
1306
host/src/engine.rs
File diff suppressed because it is too large
Load diff
|
|
@ -1,19 +1,49 @@
|
|||
use mtp_codec::Version;
|
||||
use mtp_common::CommunicationError;
|
||||
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, Version};
|
||||
use mtp_common::{CommunicationError, RejectionReason};
|
||||
use mtp_transport::Sender;
|
||||
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>()
|
||||
rand::random::<u64>() & mtp_codec::MAX_WIRE_ID
|
||||
}
|
||||
|
||||
pub(crate) async fn send_rejection(sender: &Sender, reason: RejectionReason) {
|
||||
let response = match &reason {
|
||||
RejectionReason::BadVersion { supported_versions } => {
|
||||
CommunicationValue::new(CommunicationType::ErrorBadVersion)
|
||||
.add_typed_default(
|
||||
DataType::Version,
|
||||
DataValue::Str(supported_versions.join(",")),
|
||||
)
|
||||
.add_typed_default(DataType::ErrorMessage, DataValue::Str(reason.to_string()))
|
||||
}
|
||||
_ => CommunicationValue::new(CommunicationType::IdentificationResponse)
|
||||
.add_typed_default(DataType::Connected, DataValue::BoolFalse)
|
||||
.add_typed_default(DataType::ErrorMessage, DataValue::Str(reason.to_string())),
|
||||
};
|
||||
let _ = sender.send(&response).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn send_accepted(
|
||||
sender: &Sender,
|
||||
version: &Version,
|
||||
assigned_id: Option<u64>,
|
||||
) -> Result<(), CommunicationError> {
|
||||
let mut response = CommunicationValue::new(CommunicationType::IdentificationResponse)
|
||||
.add_typed_default(DataType::Connected, DataValue::BoolTrue)
|
||||
.add_typed_default(DataType::Version, DataValue::Str(version.to_string()));
|
||||
if let Some(id) = assigned_id {
|
||||
response = response.add_typed_default(DataType::Id, DataValue::UnsignedNumber(id as u128));
|
||||
}
|
||||
sender.send(&response).await?;
|
||||
sender.finish_stream().await
|
||||
}
|
||||
|
||||
#[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()),
|
||||
let value = msg.get_data(DataType::Version);
|
||||
match value {
|
||||
DataValue::Str(s) => Version::parse(s.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
#[cfg(feature = "crypto")]
|
||||
use mtp_codec::registry::Registry;
|
||||
#[cfg(not(feature = "crypto"))]
|
||||
use mtp_codec::{CommunicationType, CommunicationValue};
|
||||
use mtp_codec::{
|
||||
Version,
|
||||
DataType, DataValue, Version,
|
||||
registry::{Registry, VersionedCodec},
|
||||
};
|
||||
use mtp_common::RejectionReason;
|
||||
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};
|
||||
#[cfg(feature = "crypto")]
|
||||
use crate::config::AuthenticationPolicy;
|
||||
use crate::config::HostConfig;
|
||||
use crate::connection::MTPConnection;
|
||||
use crate::engine::HandshakeEngine;
|
||||
use crate::error::AcceptError;
|
||||
#[cfg(feature = "crypto")]
|
||||
use crate::error::AuthState;
|
||||
use crate::error::{AcceptError, extract_version, send_accepted, send_rejection};
|
||||
#[cfg(feature = "pipes")]
|
||||
use crate::pipe::PipeDispatcher;
|
||||
#[cfg(feature = "pipes")]
|
||||
|
|
@ -134,119 +137,179 @@ impl HandshakeContext {
|
|||
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,
|
||||
)))
|
||||
return tokio::time::timeout(
|
||||
self.config.auth_timeout,
|
||||
self.accept_pair(sender, receiver),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(Err(AcceptError::AuthenticationTimedOut));
|
||||
}
|
||||
#[cfg(not(feature = "crypto"))]
|
||||
{
|
||||
Ok(Some(self.connection_from_parts(
|
||||
sender,
|
||||
receiver,
|
||||
result.negotiated_version,
|
||||
result.codec,
|
||||
result.description,
|
||||
)))
|
||||
}
|
||||
self.accept_pair(sender, receiver).await
|
||||
}
|
||||
|
||||
#[cfg(feature = "crypto")]
|
||||
pub(crate) fn connection_from_handshake_result(
|
||||
async fn accept_pair(
|
||||
&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());
|
||||
) -> Result<Option<MTPConnection>, AcceptError> {
|
||||
#[cfg(feature = "crypto")]
|
||||
match self.config.authentication_policy {
|
||||
AuthenticationPolicy::ForceAuthentication => {
|
||||
let timeout = self.config.auth_timeout;
|
||||
match tokio::time::timeout(timeout, self.accept_authenticated(sender, receiver))
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(AcceptError::AuthenticationTimedOut),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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 Some(first_msg.get_type())
|
||||
== CommunicationType::Register.try_to_id(&mtp_codec::TypeMap::latest())
|
||||
{
|
||||
send_rejection(
|
||||
&sender,
|
||||
RejectionReason::AuthenticationFailed {
|
||||
detail: "authentication not allowed on this host".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
sender.close().await;
|
||||
return Err(AcceptError::AuthenticationFailed(
|
||||
"authentication not allowed on this host".into(),
|
||||
));
|
||||
}
|
||||
let client_version = match extract_version(&first_msg) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
send_rejection(
|
||||
&sender,
|
||||
RejectionReason::AuthenticationFailed {
|
||||
detail: "opening message omitted a valid protocol version".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
sender.close().await;
|
||||
return Err(AcceptError::MissingVersion);
|
||||
}
|
||||
};
|
||||
let negotiated = match self
|
||||
.registry
|
||||
.negotiate(std::slice::from_ref(&client_version))
|
||||
{
|
||||
Some(v) => v,
|
||||
None => {
|
||||
send_rejection(
|
||||
&sender,
|
||||
RejectionReason::BadVersion {
|
||||
supported_versions: self
|
||||
.registry
|
||||
.versions()
|
||||
.map(|v| v.to_string())
|
||||
.collect(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
sender.close().await;
|
||||
return Err(AcceptError::UnsupportedVersion(client_version));
|
||||
}
|
||||
};
|
||||
let codec =
|
||||
match VersionedCodec::for_version(self.registry.clone(), negotiated.clone()) {
|
||||
Some(codec) => codec,
|
||||
None => {
|
||||
return Err(AcceptError::UnsupportedVersion(negotiated));
|
||||
}
|
||||
};
|
||||
let description = match first_msg.get_data(DataType::Description) {
|
||||
DataValue::Str(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let guest_id = self.assign_guest_id().await;
|
||||
send_accepted(&sender, &negotiated, Some(guest_id))
|
||||
.await
|
||||
.map_err(AcceptError::Send)?;
|
||||
Ok(Some(self.connection_from_parts(
|
||||
sender,
|
||||
receiver,
|
||||
negotiated,
|
||||
codec,
|
||||
description,
|
||||
AuthState::Unauthenticated,
|
||||
guest_id,
|
||||
None,
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "pipes"))]
|
||||
#[cfg(not(feature = "crypto"))]
|
||||
{
|
||||
if self.config.send_pongs {
|
||||
receiver.respond_to_pings(sender.clone());
|
||||
}
|
||||
|
||||
let task = tokio::spawn(async {});
|
||||
|
||||
MTPConnection {
|
||||
version: result.negotiated_version,
|
||||
codec: result.codec,
|
||||
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 => {
|
||||
send_rejection(
|
||||
&sender,
|
||||
RejectionReason::AuthenticationFailed {
|
||||
detail: "opening message omitted a valid protocol version".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
sender.close().await;
|
||||
return Err(AcceptError::MissingVersion);
|
||||
}
|
||||
};
|
||||
let negotiated = match self
|
||||
.registry
|
||||
.negotiate(std::slice::from_ref(&client_version))
|
||||
{
|
||||
Some(v) => v,
|
||||
None => {
|
||||
send_rejection(
|
||||
&sender,
|
||||
RejectionReason::BadVersion {
|
||||
supported_versions: vec![mtp_codec::PROTOCOL_VERSION.to_string()],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
sender.close().await;
|
||||
return Err(AcceptError::UnsupportedVersion(client_version));
|
||||
}
|
||||
};
|
||||
let codec = match VersionedCodec::for_version(self.registry.clone(), negotiated.clone())
|
||||
{
|
||||
Some(codec) => codec,
|
||||
None => {
|
||||
return Err(AcceptError::UnsupportedVersion(negotiated));
|
||||
}
|
||||
};
|
||||
let description = match first_msg.get_data(DataType::Description) {
|
||||
DataValue::Str(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
};
|
||||
send_accepted(&sender, &negotiated, None)
|
||||
.await
|
||||
.map_err(AcceptError::Send)?;
|
||||
Ok(Some(self.connection_from_parts(
|
||||
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,
|
||||
}
|
||||
negotiated,
|
||||
codec,
|
||||
description,
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -263,23 +326,19 @@ impl HandshakeContext {
|
|||
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 (app_tx, app_rx) = mpsc::channel(self.config.policy.receiver_queue_capacity);
|
||||
let (pipe_req_tx, pipe_req_rx) =
|
||||
mpsc::channel(self.config.policy.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_creations: tokio::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();
|
||||
|
|
@ -305,7 +364,6 @@ impl HandshakeContext {
|
|||
pipe_dispatcher: dispatcher,
|
||||
description,
|
||||
_dispatcher_task: task,
|
||||
_connection_guard: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -327,8 +385,648 @@ impl HandshakeContext {
|
|||
_pipe_stream: std::marker::PhantomData,
|
||||
description,
|
||||
_dispatcher_task: task,
|
||||
_connection_guard: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(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>,
|
||||
auth_state: AuthState,
|
||||
client_id: u64,
|
||||
client_public_key: Option<mtp_crypto::PublicKeyBundle>,
|
||||
) -> MTPConnection {
|
||||
let remote_addr = sender.handle().remote_addr();
|
||||
receiver.set_max_message_size(self.config.policy.max_message_size);
|
||||
#[cfg(feature = "pipes")]
|
||||
{
|
||||
if self.config.send_pongs {
|
||||
receiver.respond_to_pings(sender.clone());
|
||||
}
|
||||
|
||||
let (app_tx, app_rx) = mpsc::channel(self.config.policy.receiver_queue_capacity);
|
||||
let (pipe_req_tx, pipe_req_rx) =
|
||||
mpsc::channel(self.config.policy.receiver_queue_capacity);
|
||||
|
||||
let dispatcher = Arc::new(PipeDispatcher {
|
||||
pending_creations: tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
||||
pending_pipes: tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
||||
policy: Arc::new(self.config.policy),
|
||||
});
|
||||
|
||||
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,
|
||||
auth_state,
|
||||
client_id,
|
||||
client_public_key,
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
auth_state,
|
||||
client_id,
|
||||
client_public_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 HandshakeContext {
|
||||
const GUEST_ID_MAX_RETRIES: u32 = 100;
|
||||
|
||||
async fn assign_guest_id(&self) -> u64 {
|
||||
if let Some(ref generator) = self.config.guest_id_generator {
|
||||
if let Some(id) = generator().await
|
||||
&& id <= mtp_codec::MAX_WIRE_ID
|
||||
{
|
||||
return id;
|
||||
}
|
||||
return self.random_guest_id().await;
|
||||
}
|
||||
self.random_guest_id().await
|
||||
}
|
||||
|
||||
async fn random_guest_id(&self) -> u64 {
|
||||
for _ in 0..Self::GUEST_ID_MAX_RETRIES {
|
||||
let id = rand::random::<u64>() & mtp_codec::MAX_WIRE_ID;
|
||||
if (self.config.get_existing_client)(id, None).await.is_none() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
rand::random::<u64>() & mtp_codec::MAX_WIRE_ID
|
||||
}
|
||||
|
||||
async fn accept_authenticated(
|
||||
&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) => {
|
||||
sender.close().await;
|
||||
return Err(AcceptError::Receive(e));
|
||||
}
|
||||
};
|
||||
let version_str = match hello.get_data(DataType::Version) {
|
||||
DataValue::Str(s) => s.clone(),
|
||||
_ => {
|
||||
sender.close().await;
|
||||
return Err(AcceptError::MissingVersion);
|
||||
}
|
||||
};
|
||||
let client_version = match Version::parse(&version_str) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
sender.close().await;
|
||||
return Err(AcceptError::MissingVersion);
|
||||
}
|
||||
};
|
||||
|
||||
let description = match hello.get_data(DataType::Description) {
|
||||
DataValue::Str(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let (flow, response_type) = if Some(hello.get_type())
|
||||
== CommunicationType::Identification.try_to_id(&tm)
|
||||
{
|
||||
let cid = match hello.get_data(DataType::Id) {
|
||||
DataValue::UnsignedNumber(n) => *n as u64,
|
||||
_ => {
|
||||
sender.close().await;
|
||||
return Err(AcceptError::AuthenticationFailed(
|
||||
"missing client id".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let bundle = match (self.config.get_existing_client)(cid, description.clone()).await {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
let rejection =
|
||||
CommunicationValue::new(CommunicationType::IdentificationResponse)
|
||||
.add_typed_default(DataType::Connected, DataValue::BoolFalse)
|
||||
.add_typed_default(
|
||||
DataType::ErrorMessage,
|
||||
DataValue::Str("unknown client id".into()),
|
||||
);
|
||||
let _ = sender.send(&rejection).await;
|
||||
sender.close().await;
|
||||
return Err(AcceptError::AuthenticationFailed(
|
||||
"unknown client id".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
(
|
||||
Flow::Login { id: cid, bundle },
|
||||
CommunicationType::IdentificationResponse,
|
||||
)
|
||||
} else if Some(hello.get_type()) == CommunicationType::Register.try_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().await;
|
||||
return Err(AcceptError::AuthenticationFailed(
|
||||
"missing public keys".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let pk_bytes = bundle.as_bytes();
|
||||
(
|
||||
Flow::Register { bundle, pk_bytes },
|
||||
CommunicationType::RegisterResponse,
|
||||
)
|
||||
} else {
|
||||
sender.close().await;
|
||||
return Err(AcceptError::AuthenticationFailed(
|
||||
"unexpected authentication message".into(),
|
||||
));
|
||||
};
|
||||
|
||||
self.complete_auth_handshake(
|
||||
sender,
|
||||
receiver,
|
||||
flow,
|
||||
response_type,
|
||||
&version_str,
|
||||
client_version,
|
||||
description,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn complete_auth_handshake(
|
||||
&self,
|
||||
sender: Sender,
|
||||
receiver: Receiver,
|
||||
flow: Flow,
|
||||
response_type: CommunicationType,
|
||||
version_str: &str,
|
||||
client_version: Version,
|
||||
description: Option<String>,
|
||||
) -> Result<Option<MTPConnection>, AcceptError> {
|
||||
use mtp_crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme, auth, verify_ed25519};
|
||||
|
||||
let handshake_started = Instant::now();
|
||||
let tm = mtp_codec::TypeMap::latest();
|
||||
let negotiate_started = Instant::now();
|
||||
let negotiated = match self
|
||||
.registry
|
||||
.negotiate(std::slice::from_ref(&client_version))
|
||||
{
|
||||
Some(version) => version,
|
||||
None => {
|
||||
send_rejection(
|
||||
&sender,
|
||||
RejectionReason::BadVersion {
|
||||
supported_versions: vec![mtp_codec::PROTOCOL_VERSION.to_string()],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
sender.close().await;
|
||||
return Err(AcceptError::UnsupportedVersion(client_version));
|
||||
}
|
||||
};
|
||||
tracing::debug!(elapsed = ?negotiate_started.elapsed(), "authentication handshake: version negotiation");
|
||||
let pq_enabled = !self
|
||||
.config
|
||||
.host_keyring
|
||||
.sig_pq_secret_key
|
||||
.as_bytes()
|
||||
.is_empty();
|
||||
if self.config.require_pq
|
||||
&& (!pq_enabled
|
||||
|| self
|
||||
.config
|
||||
.host_keyring
|
||||
.sig_pq_public_key
|
||||
.as_bytes()
|
||||
.is_empty())
|
||||
{
|
||||
send_rejection(
|
||||
&sender,
|
||||
RejectionReason::AuthenticationFailed {
|
||||
detail: "host requires PQ authentication but has no PQ signing key".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
sender.close().await;
|
||||
return Err(AcceptError::AuthenticationFailed(
|
||||
"PQ authentication is required but the host PQ key is absent".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let signer_init_started = Instant::now();
|
||||
let host_pq_signer = if pq_enabled {
|
||||
Some(Arc::new(
|
||||
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()))?,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
tracing::debug!(elapsed = ?signer_init_started.elapsed(), "authentication handshake: signer initialization");
|
||||
|
||||
let host_sign = |payload: Vec<u8>| async {
|
||||
let signer = Ed25519Signer::new(&self.config.host_keyring.sig_cl_secret_key)
|
||||
.map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?;
|
||||
if let Some(pq_signer) = host_pq_signer.as_ref() {
|
||||
mtp_crypto::sign_parallel::sign_dual_parallel_shared_pq(
|
||||
signer,
|
||||
Arc::clone(pq_signer),
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))
|
||||
} else {
|
||||
let sig = signer
|
||||
.sign(&payload)
|
||||
.map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?;
|
||||
Ok((sig, Vec::new()))
|
||||
}
|
||||
};
|
||||
|
||||
let challenge_id = match &flow {
|
||||
Flow::Login { id, .. } => *id,
|
||||
Flow::Register { .. } => 0,
|
||||
};
|
||||
|
||||
let server_challenge: u128 = rand::random();
|
||||
let sign_challenge_started = Instant::now();
|
||||
let (chal_sig, chal_pq_sig) =
|
||||
host_sign(auth::challenge_payload(challenge_id, server_challenge)).await?;
|
||||
tracing::debug!(elapsed = ?sign_challenge_started.elapsed(), "authentication handshake: sign challenge");
|
||||
|
||||
let mut challenge_msg = CommunicationValue::new(CommunicationType::Challenge)
|
||||
.add_typed_default(
|
||||
DataType::ServerNonce,
|
||||
DataValue::UnsignedNumber(server_challenge),
|
||||
)
|
||||
.add_typed_default(DataType::Signature, DataValue::Bytes(chal_sig));
|
||||
challenge_msg = challenge_msg.add_typed_default(
|
||||
DataType::RequirePq,
|
||||
if self.config.require_pq {
|
||||
DataValue::BoolTrue
|
||||
} else {
|
||||
DataValue::BoolFalse
|
||||
},
|
||||
);
|
||||
if pq_enabled {
|
||||
challenge_msg = challenge_msg
|
||||
.add_typed_default(DataType::PqSignature, DataValue::Bytes(chal_pq_sig));
|
||||
}
|
||||
let send_challenge_started = Instant::now();
|
||||
if let Err(e) = sender.send(&challenge_msg).await {
|
||||
sender.close().await;
|
||||
return Err(AcceptError::Send(e));
|
||||
}
|
||||
tracing::debug!(elapsed = ?send_challenge_started.elapsed(), "authentication handshake: send challenge");
|
||||
|
||||
let receive_proof_started = Instant::now();
|
||||
let proof = match receiver.receive().await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
sender.close().await;
|
||||
return Err(AcceptError::Receive(e));
|
||||
}
|
||||
};
|
||||
tracing::debug!(elapsed = ?receive_proof_started.elapsed(), "authentication handshake: receive client proof");
|
||||
if Some(proof.get_type()) != CommunicationType::ChallengeResponse.try_to_id(&tm) {
|
||||
sender.close().await;
|
||||
return Err(AcceptError::AuthenticationFailed(
|
||||
"missing challenge response".into(),
|
||||
));
|
||||
}
|
||||
let client_nonce = match proof.get_data(DataType::ClientNonce) {
|
||||
DataValue::UnsignedNumber(n) => *n,
|
||||
_ => {
|
||||
sender.close().await;
|
||||
return Err(AcceptError::AuthenticationFailed(
|
||||
"missing client nonce".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let sig_bytes = match proof.get_data(DataType::Signature) {
|
||||
DataValue::Bytes(b) => b.clone(),
|
||||
_ => {
|
||||
sender.close().await;
|
||||
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 has_client_pq_key = !bundle.sig_pq_public_key.as_bytes().is_empty();
|
||||
let verify_proof_started = Instant::now();
|
||||
let proof_ok = if pq_sig_bytes.is_empty() {
|
||||
!self.config.require_pq
|
||||
&& verify_ed25519(&bundle.sig_cl_public_key, &proof_payload, &sig_bytes).is_ok()
|
||||
} else if has_client_pq_key {
|
||||
mtp_crypto::sign_parallel::verify_dual_parallel(
|
||||
bundle.sig_cl_public_key.clone(),
|
||||
bundle.sig_pq_public_key.clone(),
|
||||
proof_payload,
|
||||
sig_bytes,
|
||||
pq_sig_bytes,
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
tracing::debug!(elapsed = ?verify_proof_started.elapsed(), "authentication handshake: verify client proof");
|
||||
|
||||
if !proof_ok {
|
||||
send_rejection(
|
||||
&sender,
|
||||
RejectionReason::AuthenticationFailed {
|
||||
detail: "client proof signature invalid".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
sender.close().await;
|
||||
return Err(AcceptError::AuthenticationFailed(
|
||||
"client proof signature invalid".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let register_started = Instant::now();
|
||||
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(), description.clone()).await;
|
||||
(new_id, bundle)
|
||||
}
|
||||
};
|
||||
tracing::debug!(elapsed = ?register_started.elapsed(), "authentication handshake: registration callback");
|
||||
|
||||
let sign_final_started = Instant::now();
|
||||
let (host_sig, host_pq_sig) = host_sign(auth::host_final_payload(
|
||||
assigned_id,
|
||||
client_nonce,
|
||||
server_challenge,
|
||||
))
|
||||
.await?;
|
||||
tracing::debug!(elapsed = ?sign_final_started.elapsed(), "authentication handshake: sign final response");
|
||||
|
||||
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));
|
||||
response =
|
||||
response.add_typed_default(DataType::Version, DataValue::Str(negotiated.to_string()));
|
||||
if pq_enabled {
|
||||
response =
|
||||
response.add_typed_default(DataType::PqSignature, DataValue::Bytes(host_pq_sig));
|
||||
}
|
||||
|
||||
let send_final_started = Instant::now();
|
||||
if let Err(e) = sender.send(&response).await {
|
||||
sender.close().await;
|
||||
return Err(AcceptError::Send(e));
|
||||
}
|
||||
if let Err(e) = sender.finish_stream().await {
|
||||
sender.close().await;
|
||||
return Err(AcceptError::Send(e));
|
||||
}
|
||||
tracing::debug!(elapsed = ?send_final_started.elapsed(), "authentication handshake: send final response");
|
||||
tracing::debug!(elapsed = ?handshake_started.elapsed(), "authentication handshake: complete");
|
||||
|
||||
let codec = match VersionedCodec::for_version(self.registry.clone(), negotiated.clone()) {
|
||||
Some(codec) => codec,
|
||||
None => {
|
||||
return Err(AcceptError::UnsupportedVersion(negotiated));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(self.connection_from_parts(
|
||||
sender,
|
||||
receiver,
|
||||
negotiated,
|
||||
codec,
|
||||
description,
|
||||
AuthState::Authenticated,
|
||||
assigned_id,
|
||||
Some(client_bundle),
|
||||
)))
|
||||
}
|
||||
|
||||
async fn accept_allow_auth(
|
||||
&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().await;
|
||||
return Err(AcceptError::MissingVersion);
|
||||
}
|
||||
};
|
||||
let client_version = match Version::parse(&version_str) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
sender.close().await;
|
||||
return Err(AcceptError::MissingVersion);
|
||||
}
|
||||
};
|
||||
|
||||
let description = match hello.get_data(DataType::Description) {
|
||||
DataValue::Str(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if Some(hello.get_type()) == CommunicationType::Register.try_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().await;
|
||||
return Err(AcceptError::AuthenticationFailed(
|
||||
"missing public keys".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
sender.close().await;
|
||||
let pk_bytes = bundle.as_bytes();
|
||||
return self
|
||||
.complete_auth_handshake(
|
||||
sender,
|
||||
receiver,
|
||||
Flow::Register { bundle, pk_bytes },
|
||||
CommunicationType::RegisterResponse,
|
||||
&version_str,
|
||||
client_version,
|
||||
description,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if Some(hello.get_type()) == CommunicationType::Identification.try_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_client)(cid, description.clone()).await
|
||||
{
|
||||
return self
|
||||
.complete_auth_handshake(
|
||||
sender,
|
||||
receiver,
|
||||
Flow::Login { id: cid, bundle },
|
||||
CommunicationType::IdentificationResponse,
|
||||
&version_str,
|
||||
client_version,
|
||||
description,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let negotiated = match self
|
||||
.registry
|
||||
.negotiate(std::slice::from_ref(&client_version))
|
||||
{
|
||||
Some(v) => v,
|
||||
None => {
|
||||
send_rejection(
|
||||
&sender,
|
||||
RejectionReason::BadVersion {
|
||||
supported_versions: vec![mtp_codec::PROTOCOL_VERSION.to_string()],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
sender.close().await;
|
||||
return Err(AcceptError::UnsupportedVersion(client_version));
|
||||
}
|
||||
};
|
||||
let codec = match VersionedCodec::for_version(self.registry.clone(), negotiated.clone())
|
||||
{
|
||||
Some(codec) => codec,
|
||||
None => {
|
||||
return Err(AcceptError::UnsupportedVersion(negotiated));
|
||||
}
|
||||
};
|
||||
let guest_id = self.assign_guest_id().await;
|
||||
send_accepted(&sender, &negotiated, Some(guest_id))
|
||||
.await
|
||||
.map_err(AcceptError::Send)?;
|
||||
return Ok(Some(self.connection_from_parts(
|
||||
sender,
|
||||
receiver,
|
||||
negotiated,
|
||||
codec,
|
||||
description,
|
||||
AuthState::Unauthenticated,
|
||||
guest_id,
|
||||
None,
|
||||
)));
|
||||
}
|
||||
|
||||
sender.close().await;
|
||||
Err(AcceptError::AuthenticationFailed(
|
||||
"unexpected message type".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
pub mod config;
|
||||
pub mod connection;
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
pub mod handshake;
|
||||
#[cfg(feature = "pipes")]
|
||||
|
|
@ -11,7 +10,6 @@ pub use MTPHost as Host;
|
|||
pub use config::HostConfig;
|
||||
pub use config::Policy;
|
||||
pub use connection::{MTPConnection, MtpReceiverLike, MtpSenderLike};
|
||||
pub use engine::{HandshakeEngine, HandshakeReceiver, HandshakeResult, HandshakeSender};
|
||||
pub use error::AcceptError;
|
||||
pub use handshake::MTPHost;
|
||||
pub use mtp_transport::Receiver;
|
||||
|
|
@ -28,11 +26,7 @@ pub use pipe::PipeRequest;
|
|||
pub use mtp_codec::registry::Registry;
|
||||
|
||||
#[cfg(feature = "crypto")]
|
||||
pub use config::{
|
||||
AuthenticationAttempt, AuthenticationAttemptLimiter, AuthenticationContext,
|
||||
AuthenticationLimitError, AuthenticationPolicy, CompleteRegister, FindRegisteredClient,
|
||||
GetExistingClient, GuestIdGenerator, InMemoryAuthenticationAttemptLimiter,
|
||||
};
|
||||
pub use config::{AuthenticationPolicy, CompleteRegister, GetExistingClient, GuestIdGenerator};
|
||||
#[cfg(feature = "crypto")]
|
||||
pub use error::AuthState;
|
||||
|
||||
|
|
@ -55,9 +49,9 @@ mod tests {
|
|||
fn version_extraction() {
|
||||
let tm = mtp_codec::TypeMap::latest();
|
||||
let msg = mtp_codec::CommunicationValue::from_comm(CommunicationType::Identification, &tm)
|
||||
.add_typed(DataType::Version, &tm, DataValue::Str("3.0".to_string()));
|
||||
.add_typed(DataType::Version, &tm, DataValue::Str("2.0".to_string()));
|
||||
let version = error::extract_version(&msg);
|
||||
assert_eq!(version, Some(mtp_codec::Version(3, 0)));
|
||||
assert_eq!(version, Some(mtp_codec::Version(2, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -96,7 +90,7 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn alternative_transports_use_the_shared_connection_type() {
|
||||
let registry = Registry::builtin();
|
||||
let version = mtp_codec::Version(3, 0);
|
||||
let version = mtp_codec::Version(1, 0);
|
||||
let codec = VersionedCodec::for_version(registry, version.clone()).unwrap();
|
||||
let connection: MTPConnection<AlternateSender, AlternateReceiver> =
|
||||
MTPConnection::from_transport_parts(
|
||||
|
|
|
|||
360
host/src/pipe.rs
360
host/src/pipe.rs
|
|
@ -1,9 +1,8 @@
|
|||
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap};
|
||||
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||
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.
|
||||
|
|
@ -27,10 +26,6 @@ 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;
|
||||
|
|
@ -56,14 +51,6 @@ impl PipeSender for mtp_transport::Sender {
|
|||
}
|
||||
|
||||
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> {
|
||||
|
|
@ -99,14 +86,6 @@ 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> {
|
||||
|
|
@ -114,20 +93,14 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
pub struct PipeHandle<S: PipeSender, P = wtransport::RecvStream> {
|
||||
pub struct PipeHandle<S: PipeSender> {
|
||||
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,
|
||||
{
|
||||
impl<S: PipeSender> PipeHandle<S> {
|
||||
pub fn pipe_id(&self) -> u32 {
|
||||
self.pipe_id
|
||||
}
|
||||
|
|
@ -136,96 +109,31 @@ where
|
|||
&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
|
||||
pub async fn wait(self) -> Result<Option<PipeWriter<S::Writer>>, PipeError> {
|
||||
match self.response_rx.await {
|
||||
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)
|
||||
}
|
||||
Ok(Ok(false)) => Ok(None),
|
||||
Ok(Err(error)) => Err(error),
|
||||
Err(_) => Err(PipeError::StreamClosed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 struct PipeRequest<S, 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>
|
||||
impl<S, P> PipeRequest<S, P>
|
||||
where
|
||||
S: PipeSender,
|
||||
R: PipeReceiver<P>,
|
||||
P: tokio::io::AsyncRead + Send + Unpin + 'static,
|
||||
{
|
||||
pub fn id(&self) -> u32 {
|
||||
|
|
@ -237,10 +145,6 @@ where
|
|||
}
|
||||
|
||||
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
|
||||
|
|
@ -248,52 +152,24 @@ where
|
|||
.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));
|
||||
}
|
||||
let response = CommunicationValue::new(CommunicationType::PipeResponse)
|
||||
.with_id(self.pipe_id)
|
||||
.add_typed_default(DataType::Accepted, DataValue::BoolTrue);
|
||||
self.sender
|
||||
.send_pipe_message(&response)
|
||||
.await
|
||||
.map_err(PipeError::from)?;
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
tokio::time::timeout(self.dispatcher.policy.read_timeout, pipe_rx)
|
||||
.await
|
||||
.map_err(|_| PipeError::HandshakeTimeout)?
|
||||
.map_err(|_| PipeError::StreamClosed)
|
||||
}
|
||||
|
||||
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);
|
||||
let response = CommunicationValue::new(CommunicationType::PipeResponse)
|
||||
.with_id(self.pipe_id)
|
||||
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
|
||||
self.sender
|
||||
.send_pipe_message(&response)
|
||||
.await
|
||||
|
|
@ -302,203 +178,47 @@ where
|
|||
}
|
||||
|
||||
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_creations:
|
||||
Mutex<HashMap<u32, tokio::sync::oneshot::Sender<Result<bool, PipeError>>>>,
|
||||
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>>,
|
||||
pipe_req_tx: mpsc::Sender<PipeRequest<S, P>>,
|
||||
dispatcher: Arc<PipeDispatcher<P>>,
|
||||
) where
|
||||
S: PipeSender,
|
||||
R: PipeReceiver<P>,
|
||||
P: tokio::io::AsyncRead + Send + Unpin + 'static,
|
||||
{
|
||||
let pipe_req_type = CommunicationType::PipeRequest.try_to_id(&mtp_codec::TypeMap::latest());
|
||||
let pipe_resp_type = CommunicationType::PipeResponse.try_to_id(&mtp_codec::TypeMap::latest());
|
||||
|
||||
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;
|
||||
};
|
||||
if Some(message.get_type()) == pipe_req_type {
|
||||
let request = PipeRequest {
|
||||
pipe_id,
|
||||
pipe_id: message.get_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;
|
||||
if Some(message.get_type()) == pipe_resp_type {
|
||||
let mut pending = dispatcher.pending_creations.lock().await;
|
||||
if let Some(reply) = pending.remove(&message.get_id()) {
|
||||
let _ =
|
||||
reply.send(Ok(message.get_bool(DataType::Accepted).unwrap_or(false)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -518,18 +238,14 @@ pub(crate) async fn run_dispatcher<S, R, P>(
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
[package]
|
||||
name = "mtp-webserver"
|
||||
version = "0.3.0"
|
||||
version = "0.2.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" }
|
||||
mtp-common = { version = "0.2.0", path = "../common" }
|
||||
mtp-codec = { version = "0.2.0", path = "../codec", features = ["registry"] }
|
||||
mtp-host = { version = "0.2.0", path = "../host" }
|
||||
mtp-transport = { version = "0.2.0", path = "../transport" }
|
||||
mtp-crypto = { version = "0.2.0", path = "../crypto" }
|
||||
bytes = "1"
|
||||
http = "1"
|
||||
tokio = { version = "1", features = ["io-util", "macros", "net", "rt", "sync", "time"] }
|
||||
|
|
@ -25,6 +25,7 @@ rustls = "0.23"
|
|||
tracing = "0.1"
|
||||
thiserror = "2"
|
||||
async-trait = "0.1"
|
||||
rand = { version = "0.10.1", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
rcgen = "0.14"
|
||||
|
|
@ -32,5 +33,5 @@ hyper = { version = "1", features = ["client", "http2"] }
|
|||
|
||||
[features]
|
||||
default = []
|
||||
crypto = ["mtp-host/crypto"]
|
||||
crypto = ["mtp-host/crypto", "dep:rand"]
|
||||
pipes = ["mtp-host/pipes", "mtp-transport/pipes"]
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ pub(crate) struct DriverConfig {
|
|||
pub(crate) policy: mtp_transport::Policy,
|
||||
pub(crate) host_config: Arc<HostConfig>,
|
||||
pub(crate) metrics: Option<Arc<dyn WebServerMetrics>>,
|
||||
pub(crate) auth_semaphore: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
pub(crate) async fn run_driver(
|
||||
|
|
@ -38,7 +37,6 @@ pub(crate) async fn run_driver(
|
|||
policy,
|
||||
host_config,
|
||||
metrics,
|
||||
auth_semaphore,
|
||||
} = config;
|
||||
let mut connection_tasks = tokio::task::JoinSet::new();
|
||||
loop {
|
||||
|
|
@ -65,12 +63,8 @@ pub(crate) async fn run_driver(
|
|||
let mtp_tx = mtp_tx.clone();
|
||||
let metrics = metrics.clone();
|
||||
let host_config = host_config.clone();
|
||||
let auth_semaphore = auth_semaphore.clone();
|
||||
connection_tasks.spawn(async move {
|
||||
// The permit normally lives for this HTTP/3 connection.
|
||||
// For an MTP session it is moved into the resulting
|
||||
// connection so the limit covers the session lifetime.
|
||||
let mut connection_permit = Some(permit);
|
||||
let _permit = permit;
|
||||
let connect_start = std::time::Instant::now();
|
||||
let connection = match incoming.await {
|
||||
Ok(connection) => connection,
|
||||
|
|
@ -143,11 +137,10 @@ pub(crate) async fn run_driver(
|
|||
return;
|
||||
}
|
||||
};
|
||||
tracing::debug!(
|
||||
remote = %remote_addr,
|
||||
session_id = ?session.session_id(),
|
||||
"accepted WebTransport MTP session"
|
||||
);
|
||||
// The WebTransport session request driver must outlive this
|
||||
// endpoint request task. Keep it detached so handing the MTP
|
||||
// connection to the application does not wait for the session
|
||||
// (which is intentionally an open-ended accept loop).
|
||||
tokio::spawn(run_session_requests(
|
||||
session.clone(),
|
||||
router.clone(),
|
||||
|
|
@ -156,43 +149,26 @@ pub(crate) async fn run_driver(
|
|||
metrics.clone(),
|
||||
remote_addr,
|
||||
));
|
||||
let mtp_tx = mtp_tx.clone();
|
||||
let mtp_queue_permit = match mtp_tx.clone().try_reserve_owned() {
|
||||
Ok(permit) => permit,
|
||||
Err(_) => {
|
||||
tracing::debug!(
|
||||
"rejecting MTP session because the application queue is full"
|
||||
);
|
||||
connection.close(
|
||||
quinn::VarInt::from_u32(0),
|
||||
b"mtp application queue is full",
|
||||
);
|
||||
return;
|
||||
let result =
|
||||
accept_web_connection(session, mtp_path, connection, send_pongs, policy, host_config.clone())
|
||||
.await;
|
||||
match mtp_tx.try_send(result) {
|
||||
Ok(()) => {
|
||||
// The detached session driver remains active while the
|
||||
// delivered MTP connection keeps the session alive.
|
||||
}
|
||||
};
|
||||
let auth_semaphore = auth_semaphore.clone();
|
||||
let host_config = host_config.clone();
|
||||
let connection_guard = connection_permit.take();
|
||||
let connection = connection.clone();
|
||||
let close_connection = connection.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = accept_web_connection(
|
||||
session,
|
||||
mtp_path,
|
||||
connection,
|
||||
send_pongs,
|
||||
policy,
|
||||
host_config,
|
||||
auth_semaphore,
|
||||
connection_guard,
|
||||
)
|
||||
.await;
|
||||
if result.is_err() {
|
||||
close_connection
|
||||
.close(quinn::VarInt::from_u32(0), b"mtp handshake failed");
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Full(result)) => {
|
||||
tracing::warn!("MTP connection backlog is full; dropping connection");
|
||||
if let Ok(connection) = result {
|
||||
connection.sender.close();
|
||||
}
|
||||
}
|
||||
mtp_queue_permit.send(result);
|
||||
});
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Closed(result)) => {
|
||||
if let Ok(connection) = result {
|
||||
connection.sender.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let router = router.clone();
|
||||
|
|
|
|||
|
|
@ -228,7 +228,6 @@ impl MTPWebServer {
|
|||
let (mtp_tx, mtp_incoming) = tokio::sync::mpsc::channel(web_config.max_connections.max(1));
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(());
|
||||
let connection_semaphore = Arc::new(Semaphore::new(web_config.max_connections));
|
||||
let auth_semaphore = Arc::new(Semaphore::new(web_config.max_connections));
|
||||
let router = web_config.router.clone();
|
||||
let metrics = web_config.metrics.clone();
|
||||
let driver_config = DriverConfig {
|
||||
|
|
@ -241,7 +240,6 @@ impl MTPWebServer {
|
|||
policy: host_config.policy,
|
||||
host_config,
|
||||
metrics: web_config.metrics.clone(),
|
||||
auth_semaphore,
|
||||
};
|
||||
let quic_driver = tokio::spawn(run_driver(
|
||||
driver_endpoint,
|
||||
|
|
@ -341,29 +339,10 @@ fn build_endpoint(
|
|||
.map_err(|_| CommunicationError::CertificateLoadFailed)?;
|
||||
tls.alpn_protocols = vec![b"h3".to_vec()];
|
||||
|
||||
let mut server = quinn::ServerConfig::with_crypto(Arc::new(
|
||||
let server = quinn::ServerConfig::with_crypto(Arc::new(
|
||||
quinn::crypto::rustls::QuicServerConfig::try_from(tls)
|
||||
.map_err(|error| CommunicationError::Other(error.to_string()))?,
|
||||
));
|
||||
// Apply policy keepalive and idle timeout settings to Quinn
|
||||
server.transport_config({
|
||||
let mut transport = quinn::TransportConfig::default();
|
||||
if let Some(keep_alive) = config.policy.keep_alive_interval {
|
||||
transport.keep_alive_interval(Some(keep_alive));
|
||||
}
|
||||
transport.max_idle_timeout(
|
||||
config
|
||||
.policy
|
||||
.max_idle_timeout
|
||||
.map(|idle_timeout| {
|
||||
idle_timeout
|
||||
.try_into()
|
||||
.map_err(|error| CommunicationError::Other(format!("{error}")))
|
||||
})
|
||||
.transpose()?,
|
||||
);
|
||||
Arc::new(transport)
|
||||
});
|
||||
quinn::Endpoint::server(server, SocketAddr::new(config.ip, config.port))
|
||||
.map_err(|error| CommunicationError::Other(error.to_string()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
use bytes::Bytes;
|
||||
use mtp_codec::registry::Registry;
|
||||
use mtp_codec::{
|
||||
DataType, DataValue, Version,
|
||||
registry::{Registry, VersionedCodec},
|
||||
};
|
||||
use mtp_common::CommunicationError;
|
||||
use mtp_host::AcceptError;
|
||||
use mtp_host::HostConfig;
|
||||
|
|
@ -8,6 +11,8 @@ use mtp_transport::{
|
|||
TransportSendStream,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
#[cfg(feature = "crypto")]
|
||||
use std::time::Instant;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tracing::error;
|
||||
|
||||
|
|
@ -32,8 +37,6 @@ pub struct H3TransportSender {
|
|||
|
||||
pub struct H3TransportReceiver {
|
||||
stream: H3RecvStream,
|
||||
quinn: quinn::Connection,
|
||||
read_exact_calls: u64,
|
||||
}
|
||||
|
||||
impl H3TransportConnection {
|
||||
|
|
@ -44,11 +47,6 @@ impl H3TransportConnection {
|
|||
pub(crate) fn remote_addr(&self) -> std::net::SocketAddr {
|
||||
self.quinn.remote_address()
|
||||
}
|
||||
|
||||
#[cfg(feature = "crypto")]
|
||||
pub(crate) fn connection_id(&self) -> u64 {
|
||||
self.quinn.stable_id() as u64
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
|
@ -57,14 +55,14 @@ impl TransportSendStream for H3TransportSender {
|
|||
self.stream
|
||||
.write_all(buf)
|
||||
.await
|
||||
.map_err(|_| CommunicationError::DeliveryUnknown)?;
|
||||
.map_err(|_| CommunicationError::StreamError)?;
|
||||
// Control/authentication frames use a persistent stream. h3 keeps
|
||||
// those writes buffered until flushed; without this the peer can wait
|
||||
// for the challenge while the server waits for its proof.
|
||||
self.stream
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|_| CommunicationError::DeliveryUnknown)
|
||||
.map_err(|_| CommunicationError::StreamError)
|
||||
}
|
||||
|
||||
async fn finish(&mut self) -> Result<(), CommunicationError> {
|
||||
|
|
@ -73,53 +71,23 @@ impl TransportSendStream for H3TransportSender {
|
|||
.await
|
||||
.map_err(|_| CommunicationError::StreamError)
|
||||
}
|
||||
|
||||
fn reset(&mut self, code: u32) -> Result<(), CommunicationError> {
|
||||
h3::quic::SendStream::reset(&mut self.stream, code as u64);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TransportRecvStream for H3TransportReceiver {
|
||||
async fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), CommunicationError> {
|
||||
let first_read = self.read_exact_calls == 0;
|
||||
self.read_exact_calls += 1;
|
||||
self.stream
|
||||
.read_exact(buf)
|
||||
.await
|
||||
.map(|_| {
|
||||
if first_read {
|
||||
tracing::debug!(
|
||||
remote = %self.quinn.remote_address(),
|
||||
bytes = buf.len(),
|
||||
header = ?buf,
|
||||
"received first bytes from WebTransport MTP stream"
|
||||
);
|
||||
}
|
||||
})
|
||||
.map(|_| ())
|
||||
.map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::UnexpectedEof
|
||||
|| self.quinn.close_reason().is_some()
|
||||
{
|
||||
/*
|
||||
* Reaching FIN, or losing the enclosing QUIC connection,
|
||||
* is a normal stream-closure path. Do not turn it into a
|
||||
* frame-header failure and close the connection again.
|
||||
*/
|
||||
if error.kind() == std::io::ErrorKind::UnexpectedEof {
|
||||
// Browser control frames are sent on one-frame uni streams.
|
||||
// Reaching FIN while looking for another frame is normal.
|
||||
return CommunicationError::StreamClosed;
|
||||
}
|
||||
error!(
|
||||
"[mtp-webserver] receive stream read_exact failed ({} bytes): {error}",
|
||||
buf.len()
|
||||
);
|
||||
tracing::warn!(
|
||||
remote = %self.quinn.remote_address(),
|
||||
first_read,
|
||||
len = buf.len(),
|
||||
%error,
|
||||
"WebTransport receive stream read_exact failed"
|
||||
);
|
||||
error!("[mtp-webserver] receive stream read_exact failed ({} bytes): {error}", buf.len());
|
||||
tracing::warn!(len = buf.len(), %error, "WebTransport receive stream read_exact failed");
|
||||
CommunicationError::StreamError
|
||||
})
|
||||
}
|
||||
|
|
@ -133,9 +101,6 @@ impl TransportRecvStream for H3TransportReceiver {
|
|||
Ok(Some(buf))
|
||||
}
|
||||
Err(error) => {
|
||||
if self.quinn.close_reason().is_some() {
|
||||
return Err(CommunicationError::StreamClosed);
|
||||
}
|
||||
error!(
|
||||
"[mtp-webserver] receive stream read failed (max {} bytes): {error}",
|
||||
max
|
||||
|
|
@ -145,11 +110,6 @@ impl TransportRecvStream for H3TransportReceiver {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stop(mut self, code: u32) -> Result<(), CommunicationError> {
|
||||
h3::quic::RecvStream::stop_sending(&mut self.stream, code as u64);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncWrite for H3TransportSender {
|
||||
|
|
@ -207,27 +167,10 @@ impl TransportConnection for H3TransportConnection {
|
|||
loop {
|
||||
match self.session.accept_uni().await {
|
||||
Ok(Some((id, stream))) if id == self.session.session_id() => {
|
||||
let stream_id = h3::quic::RecvStream::recv_id(&stream);
|
||||
tracing::debug!(
|
||||
remote = %self.quinn.remote_address(),
|
||||
session_id = ?self.session.session_id(),
|
||||
stream_id = ?stream_id,
|
||||
"accepted WebTransport MTP receive stream"
|
||||
);
|
||||
return Ok(H3TransportReceiver {
|
||||
stream,
|
||||
quinn: self.quinn.clone(),
|
||||
read_exact_calls: 0,
|
||||
});
|
||||
return Ok(H3TransportReceiver { stream });
|
||||
}
|
||||
Ok(Some((stream_session_id, _stream))) => {
|
||||
Ok(Some(_)) => {
|
||||
consecutive_errors = 0;
|
||||
tracing::debug!(
|
||||
remote = %self.quinn.remote_address(),
|
||||
session_id = ?self.session.session_id(),
|
||||
stream_session_id = ?stream_session_id,
|
||||
"ignored WebTransport receive stream belonging to another session"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Ok(None) => return Err(CommunicationError::StreamClosed),
|
||||
|
|
@ -282,7 +225,6 @@ pub type WebMtpReceiver = GenericReceiver<H3TransportConnection>;
|
|||
pub type WebMTPConnection =
|
||||
mtp_host::MTPConnection<WebMtpSender, WebMtpReceiver, H3TransportReceiver>;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn accept_web_connection(
|
||||
session: Arc<Session>,
|
||||
path: String,
|
||||
|
|
@ -290,139 +232,328 @@ pub(crate) async fn accept_web_connection(
|
|||
send_pongs: bool,
|
||||
policy: Policy,
|
||||
host_config: Arc<HostConfig>,
|
||||
#[allow(unused_variables)] auth_semaphore: Arc<tokio::sync::Semaphore>,
|
||||
connection_guard: Option<tokio::sync::OwnedSemaphorePermit>,
|
||||
) -> Result<WebMTPConnection, AcceptError> {
|
||||
#[cfg(feature = "crypto")]
|
||||
{
|
||||
let deadline = tokio::time::Instant::now() + host_config.auth_timeout;
|
||||
let permit = tokio::time::timeout_at(deadline, auth_semaphore.clone().acquire_owned())
|
||||
.await
|
||||
.map_err(|_| AcceptError::AuthenticationTimedOut)?
|
||||
.map_err(|_| {
|
||||
AcceptError::AuthenticationFailed("authentication service stopped".into())
|
||||
})?;
|
||||
let result = accept_web_connection_inner(
|
||||
session,
|
||||
path,
|
||||
quinn,
|
||||
send_pongs,
|
||||
policy,
|
||||
host_config,
|
||||
Some(deadline),
|
||||
connection_guard,
|
||||
tokio::time::timeout(
|
||||
host_config.auth_timeout,
|
||||
accept_web_connection_inner(session, path, quinn, send_pongs, policy, host_config),
|
||||
)
|
||||
.await;
|
||||
drop(permit);
|
||||
result
|
||||
.await
|
||||
.unwrap_or(Err(AcceptError::AuthenticationTimedOut))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "crypto"))]
|
||||
accept_web_connection_inner(
|
||||
session,
|
||||
path,
|
||||
quinn,
|
||||
send_pongs,
|
||||
policy,
|
||||
host_config,
|
||||
None,
|
||||
connection_guard,
|
||||
)
|
||||
.await
|
||||
accept_web_connection_inner(session, path, quinn, send_pongs, policy, host_config).await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn accept_web_connection_inner(
|
||||
session: Arc<Session>,
|
||||
path: String,
|
||||
quinn: quinn::Connection,
|
||||
send_pongs: bool,
|
||||
policy: Policy,
|
||||
host_config: Arc<HostConfig>,
|
||||
#[allow(unused_variables)] deadline: Option<tokio::time::Instant>,
|
||||
connection_guard: Option<tokio::sync::OwnedSemaphorePermit>,
|
||||
_host_config: Arc<HostConfig>,
|
||||
) -> Result<WebMTPConnection, AcceptError> {
|
||||
#[cfg(feature = "crypto")]
|
||||
let auth_handshake_started = Instant::now();
|
||||
let max_message_size = policy.max_message_size;
|
||||
let transport = H3TransportConnection::new(session, quinn);
|
||||
let remote_addr = transport.remote_addr();
|
||||
#[cfg(feature = "crypto")]
|
||||
let connection_id = transport.connection_id();
|
||||
let policy = Arc::new(policy);
|
||||
let sender = WebMtpSender::new(transport.clone(), policy.clone());
|
||||
let receiver = WebMtpReceiver::new(transport, policy.clone());
|
||||
let receiver = WebMtpReceiver::new(transport.clone(), policy.clone());
|
||||
|
||||
let engine = mtp_host::HandshakeEngine::new(Registry::builtin(), host_config);
|
||||
#[cfg(feature = "crypto")]
|
||||
let result = engine
|
||||
.accept_until_with_context(
|
||||
&sender,
|
||||
&receiver,
|
||||
deadline.expect("crypto WebTransport handshakes have a deadline"),
|
||||
mtp_host::AuthenticationContext {
|
||||
peer_network_identity: Some(remote_addr.to_string()),
|
||||
connection_id,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
#[cfg(feature = "crypto")]
|
||||
if let Err(error) = &result {
|
||||
tracing::warn!(
|
||||
remote = %remote_addr,
|
||||
connection_id,
|
||||
%error,
|
||||
"WebTransport MTP handshake failed"
|
||||
);
|
||||
let first = receiver.receive().await.map_err(AcceptError::Receive)?;
|
||||
let version = match first.get_data(DataType::Version) {
|
||||
DataValue::Str(value) => Version::parse(value).ok_or(AcceptError::MissingVersion)?,
|
||||
_ => return Err(AcceptError::MissingVersion),
|
||||
};
|
||||
let registry = Registry::builtin();
|
||||
let negotiated = registry
|
||||
.negotiate(std::slice::from_ref(&version))
|
||||
.ok_or_else(|| AcceptError::UnsupportedVersion(version.clone()))?;
|
||||
let codec = VersionedCodec::for_version(registry, negotiated.clone())
|
||||
.ok_or_else(|| AcceptError::UnsupportedVersion(negotiated.clone()))?;
|
||||
let description = match first.get_data(DataType::Description) {
|
||||
DataValue::Str(value) => Some(value.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let sender = WebMtpSender::new(transport, policy);
|
||||
if send_pongs {
|
||||
receiver.respond_to_pings(sender.clone()).await;
|
||||
}
|
||||
#[cfg(feature = "crypto")]
|
||||
let result = result?;
|
||||
#[cfg(not(feature = "crypto"))]
|
||||
let result = engine.accept(&sender, &receiver).await?;
|
||||
|
||||
let version = result.negotiated_version.clone();
|
||||
let codec = result.codec.clone();
|
||||
let description = result.description.clone();
|
||||
#[cfg(feature = "pipes")]
|
||||
let connection: WebMTPConnection = mtp_host::MTPConnection::from_transport_parts_with_policy(
|
||||
version,
|
||||
codec,
|
||||
sender,
|
||||
receiver,
|
||||
path,
|
||||
description,
|
||||
Some(remote_addr),
|
||||
policy,
|
||||
);
|
||||
#[cfg(not(feature = "pipes"))]
|
||||
let connection: WebMTPConnection =
|
||||
mtp_host::MTPConnection::from_transport_parts_with_remote_addr(
|
||||
version,
|
||||
negotiated,
|
||||
codec,
|
||||
sender,
|
||||
receiver,
|
||||
path,
|
||||
description,
|
||||
description.clone(),
|
||||
Some(remote_addr),
|
||||
);
|
||||
|
||||
let mut connection = connection;
|
||||
|
||||
#[cfg(feature = "crypto")]
|
||||
{
|
||||
connection.auth_state = result.auth_state;
|
||||
connection.client_id = result.client_id;
|
||||
connection.client_public_key = result.client_public_key;
|
||||
connection.set_guest_id_lease(result.guest_id_lease);
|
||||
}
|
||||
if let Some(connection_guard) = connection_guard {
|
||||
connection.set_connection_guard(connection_guard);
|
||||
let mut connection = connection;
|
||||
#[cfg(feature = "crypto")]
|
||||
if !matches!(
|
||||
_host_config.authentication_policy,
|
||||
mtp_host::AuthenticationPolicy::Unauthenticated
|
||||
) {
|
||||
use mtp_crypto::{
|
||||
Ed25519Signer, MlDsaSigner, PublicKeyBundle, SignatureScheme, auth, verify_ed25519,
|
||||
verify_ml_dsa,
|
||||
};
|
||||
let tm = mtp_codec::TypeMap::latest();
|
||||
let client_lookup_started = Instant::now();
|
||||
let (client_id, client_bundle, response_type) = if Some(first.get_type())
|
||||
== mtp_codec::CommunicationType::Identification.try_to_id(&tm)
|
||||
{
|
||||
let id = match first.get_data(DataType::Id) {
|
||||
DataValue::UnsignedNumber(value) => *value as u64,
|
||||
_ => {
|
||||
return Err(AcceptError::AuthenticationFailed(
|
||||
"missing client id".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let bundle = (_host_config.get_existing_client)(id, description.clone())
|
||||
.await
|
||||
.ok_or_else(|| AcceptError::AuthenticationFailed("unknown client id".into()))?;
|
||||
(
|
||||
id,
|
||||
bundle,
|
||||
mtp_codec::CommunicationType::IdentificationResponse,
|
||||
)
|
||||
} else if Some(first.get_type()) == mtp_codec::CommunicationType::Register.try_to_id(&tm) {
|
||||
let bundle = match first.get_data(DataType::PublicKeys) {
|
||||
DataValue::Bytes(bytes) => PublicKeyBundle::from_bytes(bytes).map_err(|_| {
|
||||
AcceptError::AuthenticationFailed("invalid public key bundle".into())
|
||||
})?,
|
||||
_ => {
|
||||
return Err(AcceptError::AuthenticationFailed(
|
||||
"missing public keys".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
(0, bundle, mtp_codec::CommunicationType::RegisterResponse)
|
||||
} else {
|
||||
return Err(AcceptError::AuthenticationFailed(
|
||||
"unexpected authentication message".into(),
|
||||
));
|
||||
};
|
||||
tracing::debug!(elapsed = ?client_lookup_started.elapsed(), "web authentication handshake: identify client");
|
||||
|
||||
let signer_init_started = Instant::now();
|
||||
let host_pq_signer = if !_host_config
|
||||
.host_keyring
|
||||
.sig_pq_secret_key
|
||||
.as_bytes()
|
||||
.is_empty()
|
||||
{
|
||||
Some(
|
||||
MlDsaSigner::new(
|
||||
&_host_config.host_keyring.sig_pq_secret_key,
|
||||
&_host_config.host_keyring.sig_pq_public_key,
|
||||
)
|
||||
.map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
tracing::debug!(elapsed = ?signer_init_started.elapsed(), "web authentication handshake: signer initialization");
|
||||
|
||||
let server_challenge: u128 = rand::random();
|
||||
let host_sign = |payload: &[u8]| -> Result<(Vec<u8>, Vec<u8>), AcceptError> {
|
||||
let signer = Ed25519Signer::new(&_host_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 = if let Some(pq_signer) = host_pq_signer.as_ref() {
|
||||
pq_signer
|
||||
.sign(payload)
|
||||
.map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Ok((sig, pq))
|
||||
};
|
||||
let sign_challenge_started = Instant::now();
|
||||
let (sig, pq_sig) = host_sign(&auth::challenge_payload(client_id, server_challenge))?;
|
||||
tracing::debug!(elapsed = ?sign_challenge_started.elapsed(), "web authentication handshake: sign challenge");
|
||||
let mut challenge =
|
||||
mtp_codec::CommunicationValue::new(mtp_codec::CommunicationType::Challenge)
|
||||
.add_typed_default(
|
||||
DataType::ServerNonce,
|
||||
DataValue::UnsignedNumber(server_challenge),
|
||||
)
|
||||
.add_typed_default(DataType::Signature, DataValue::Bytes(sig))
|
||||
.add_typed_default(
|
||||
DataType::RequirePq,
|
||||
if _host_config.require_pq {
|
||||
DataValue::BoolTrue
|
||||
} else {
|
||||
DataValue::BoolFalse
|
||||
},
|
||||
);
|
||||
if !pq_sig.is_empty() {
|
||||
challenge =
|
||||
challenge.add_typed_default(DataType::PqSignature, DataValue::Bytes(pq_sig));
|
||||
}
|
||||
let send_challenge_started = Instant::now();
|
||||
connection
|
||||
.sender
|
||||
.send(&challenge)
|
||||
.await
|
||||
.map_err(AcceptError::Send)?;
|
||||
tracing::debug!(elapsed = ?send_challenge_started.elapsed(), "web authentication handshake: send challenge");
|
||||
let receive_proof_started = Instant::now();
|
||||
let proof = {
|
||||
#[cfg(feature = "pipes")]
|
||||
{
|
||||
connection.receive().await.map_err(AcceptError::Receive)?
|
||||
}
|
||||
#[cfg(not(feature = "pipes"))]
|
||||
{
|
||||
let mut proof = connection
|
||||
.receiver
|
||||
.receive()
|
||||
.await
|
||||
.map_err(AcceptError::Receive)?;
|
||||
proof.set_type_map(connection.codec.type_map());
|
||||
proof
|
||||
}
|
||||
};
|
||||
tracing::debug!(elapsed = ?receive_proof_started.elapsed(), "web authentication handshake: receive client proof");
|
||||
if Some(proof.get_type()) != mtp_codec::CommunicationType::ChallengeResponse.try_to_id(&tm)
|
||||
{
|
||||
return Err(AcceptError::AuthenticationFailed(
|
||||
"missing challenge response".into(),
|
||||
));
|
||||
}
|
||||
let nonce = match proof.get_data(DataType::ClientNonce) {
|
||||
DataValue::UnsignedNumber(n) => n.to_owned(),
|
||||
_ => {
|
||||
return Err(AcceptError::AuthenticationFailed(
|
||||
"missing client nonce".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let signature = match proof.get_data(DataType::Signature) {
|
||||
DataValue::Bytes(bytes) => bytes,
|
||||
_ => {
|
||||
return Err(AcceptError::AuthenticationFailed(
|
||||
"missing challenge signature".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let pq_signature = match proof.get_data(DataType::PqSignature) {
|
||||
DataValue::Bytes(bytes) => bytes.as_slice(),
|
||||
_ => &[],
|
||||
};
|
||||
let payload = if first.get_type()
|
||||
== mtp_codec::CommunicationType::Register
|
||||
.try_to_id(&tm)
|
||||
.unwrap()
|
||||
{
|
||||
auth::register_proof_payload(
|
||||
&version.to_string(),
|
||||
&client_bundle.as_bytes(),
|
||||
server_challenge,
|
||||
nonce,
|
||||
)
|
||||
} else {
|
||||
auth::login_proof_payload(&version.to_string(), client_id, server_challenge, nonce)
|
||||
};
|
||||
let verify_proof_started = Instant::now();
|
||||
if verify_ed25519(&client_bundle.sig_cl_public_key, &payload, signature).is_err() {
|
||||
return Err(AcceptError::AuthenticationFailed(
|
||||
"client proof signature invalid".into(),
|
||||
));
|
||||
}
|
||||
let client_has_pq = !client_bundle.sig_pq_public_key.as_bytes().is_empty();
|
||||
if _host_config.require_pq
|
||||
&& (!client_has_pq
|
||||
|| pq_signature.is_empty()
|
||||
|| verify_ml_dsa(&client_bundle.sig_pq_public_key, &payload, pq_signature).is_err())
|
||||
{
|
||||
return Err(AcceptError::AuthenticationFailed(
|
||||
"client PQ proof signature invalid".into(),
|
||||
));
|
||||
}
|
||||
tracing::debug!(elapsed = ?verify_proof_started.elapsed(), "web authentication handshake: verify client proof");
|
||||
let register_started = Instant::now();
|
||||
let assigned_id = if response_type == mtp_codec::CommunicationType::RegisterResponse {
|
||||
(_host_config.complete_register)(client_bundle.clone(), description.clone()).await
|
||||
} else {
|
||||
client_id
|
||||
};
|
||||
tracing::debug!(elapsed = ?register_started.elapsed(), "web authentication handshake: registration callback");
|
||||
let sign_final_started = Instant::now();
|
||||
let (final_sig, final_pq) = host_sign(&auth::host_final_payload(
|
||||
assigned_id,
|
||||
nonce,
|
||||
server_challenge,
|
||||
))?;
|
||||
tracing::debug!(elapsed = ?sign_final_started.elapsed(), "web authentication handshake: sign final response");
|
||||
let mut response = mtp_codec::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(nonce))
|
||||
.add_typed_default(DataType::Signature, DataValue::Bytes(final_sig))
|
||||
.add_typed_default(DataType::Version, DataValue::Str(version.to_string()));
|
||||
if !final_pq.is_empty() {
|
||||
response =
|
||||
response.add_typed_default(DataType::PqSignature, DataValue::Bytes(final_pq));
|
||||
}
|
||||
let send_final_started = Instant::now();
|
||||
connection
|
||||
.sender
|
||||
.send(&response)
|
||||
.await
|
||||
.map_err(AcceptError::Send)?;
|
||||
connection
|
||||
.sender
|
||||
.finish_stream()
|
||||
.await
|
||||
.map_err(AcceptError::Send)?;
|
||||
tracing::debug!(elapsed = ?send_final_started.elapsed(), "web authentication handshake: send final response");
|
||||
tracing::debug!(elapsed = ?auth_handshake_started.elapsed(), "web authentication handshake: complete");
|
||||
connection.receiver.set_max_message_size(max_message_size);
|
||||
connection.auth_state = mtp_host::AuthState::Authenticated;
|
||||
connection.client_id = assigned_id;
|
||||
connection.client_public_key = Some(client_bundle);
|
||||
return Ok(connection);
|
||||
}
|
||||
|
||||
if send_pongs {
|
||||
connection
|
||||
.receiver
|
||||
.respond_to_pings(connection.sender.clone())
|
||||
.await;
|
||||
}
|
||||
// Complete the opening handshake for unauthenticated connections. Native clients
|
||||
// wait for this response before sending application messages.
|
||||
let response =
|
||||
mtp_codec::CommunicationValue::new(mtp_codec::CommunicationType::IdentificationResponse)
|
||||
.add_typed_default(
|
||||
mtp_codec::DataType::Connected,
|
||||
mtp_codec::DataValue::BoolTrue,
|
||||
)
|
||||
.add_typed_default(
|
||||
mtp_codec::DataType::Version,
|
||||
mtp_codec::DataValue::Str(connection.version.to_string()),
|
||||
)
|
||||
.add_typed_default(
|
||||
mtp_codec::DataType::Id,
|
||||
// WebTransport connections currently do not expose the host's guest
|
||||
// ID through MTPConnection; unauthenticated clients do not need it.
|
||||
mtp_codec::DataValue::UnsignedNumber(0),
|
||||
);
|
||||
connection
|
||||
.sender
|
||||
.send(&response)
|
||||
.await
|
||||
.map_err(AcceptError::Send)?;
|
||||
connection
|
||||
.sender
|
||||
.finish_stream()
|
||||
.await
|
||||
.map_err(AcceptError::Send)?;
|
||||
connection.receiver.set_max_message_size(max_message_size);
|
||||
Ok(connection)
|
||||
}
|
||||
|
|
|
|||
26
package.json
26
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "mtp",
|
||||
"version": "0.3.0",
|
||||
"version": "0.2.0",
|
||||
"description": "MTP TypeScript SDK",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
|
|
@ -32,19 +32,14 @@
|
|||
"Cargo.lock",
|
||||
"dist/",
|
||||
"README.md",
|
||||
"codec/Cargo.lock",
|
||||
"codec/Cargo.toml",
|
||||
"codec/src/",
|
||||
"common/Cargo.lock",
|
||||
"common/Cargo.toml",
|
||||
"common/src/",
|
||||
"crypto/Cargo.lock",
|
||||
"crypto/Cargo.toml",
|
||||
"crypto/src/",
|
||||
"type-map/Cargo.lock",
|
||||
"type-map/Cargo.toml",
|
||||
"type-map/build.rs",
|
||||
"type-map/reserved.json",
|
||||
"type-map/src/",
|
||||
"wasm/.cargo/",
|
||||
"wasm/Cargo.toml",
|
||||
|
|
@ -54,25 +49,14 @@
|
|||
],
|
||||
"scripts": {
|
||||
"example": "pnpm install && pnpm run build:all && nix develop .#autoStart",
|
||||
"clean": "rm -rf dist wasm/pkg mtp-*.tgz",
|
||||
"build:wasm": "MTP_TYPE_MAPS=$PWD/example-type-maps.yaml wasm-pack build wasm --target web --out-dir pkg --release && rm -f wasm/pkg/.gitignore",
|
||||
"build:ts": "rm -rf dist && tsc",
|
||||
"build": "pnpm run build:wasm && pnpm run build:ts",
|
||||
"pack": "pnpm run release:web",
|
||||
"release:web": "node create-web-release.mjs",
|
||||
"build": "MTP_TYPE_MAPS=$PWD/example-type-maps.yaml RUSTFLAGS='--cfg web_sys_unstable_apis' wasm-pack build wasm --target web --out-dir pkg --release && tsc",
|
||||
"build:all": "nix run .#build-all",
|
||||
"test:e2e": "tsc && node test/e2ee.mjs",
|
||||
"test:secrets": "tsc && node --test --test-isolation=none test/encrypted-secret.mjs",
|
||||
"test:wasm-init": "tsc && node --test test/wasm-init.mjs",
|
||||
"test:types": "tsc -p tsconfig.type-tests.json --noEmit",
|
||||
"test:vite": "tsc && node test/vite-type-map.mjs",
|
||||
"test:boundary": "node --test test/package-boundary.mjs",
|
||||
"test": "pnpm run test:e2e && pnpm run test:secrets && pnpm run test:wasm-init && pnpm run test:types && pnpm run test:vite && pnpm run test:boundary"
|
||||
"dup": "jscpd --pattern '**/*.{rs,ts}' --ignore 'target/**' --ignore 'wasm/pkg/**' --ignore '.git/**' --min-lines 8 --min-tokens 80 --threshold 4 --reporters console --noTips ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.0.1",
|
||||
"jscpd": "5.0.14",
|
||||
"typescript": "^7.0.0"
|
||||
"jscpd": "4.2.5",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"yaml": "^2.8.1"
|
||||
|
|
|
|||
1080
pnpm-lock.yaml
generated
1080
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
||||
2760
src/sdk/client.ts
2760
src/sdk/client.ts
File diff suppressed because it is too large
Load diff
959
src/sdk/codec.ts
959
src/sdk/codec.ts
|
|
@ -1,959 +0,0 @@
|
|||
import * as bindings from "mtp/raw";
|
||||
import type { MTPCommunicationType } from "../type-map/index";
|
||||
import { RESERVED_COMMUNICATION_TYPE_IDS } from "../type-map/reserved.js";
|
||||
import { utf8Encode } from "./utils.js";
|
||||
import { initWasmOnce } from "./wasm-init.js";
|
||||
import type {
|
||||
MTPBytesInput,
|
||||
MTPCodec,
|
||||
MTPCodecOptions,
|
||||
MTPDataValue,
|
||||
MTPDataValueInput,
|
||||
MTPEncodeLimits,
|
||||
MTPEncodedBytesInput,
|
||||
MTPKeyringKeys,
|
||||
MTPKeyMaterialInput,
|
||||
MTPReceiveLimits,
|
||||
MTPCrypto,
|
||||
MTPPublicKeyBundleKeys,
|
||||
MTPProtectedFrameInput,
|
||||
MTPProtectionSignatureSuite,
|
||||
ParsedFrame,
|
||||
} from "./client.js";
|
||||
|
||||
const checkedKeyringGenerator = (
|
||||
bindings as typeof bindings & {
|
||||
keyring_generate_checked?: () => Uint8Array;
|
||||
}
|
||||
).keyring_generate_checked;
|
||||
|
||||
export const crypto: MTPCrypto = {
|
||||
generateKeyring: () =>
|
||||
checkedKeyringGenerator?.() ?? bindings.keyring_generate(),
|
||||
generateEd25519: () => bindings.ed25519_generate(),
|
||||
keyringFromEd25519: (secretKey, publicKey) =>
|
||||
bindings.keyring_from_ed25519(secretKey, publicKey),
|
||||
verifyEd25519: (publicKey, message, signature) =>
|
||||
bindings.ed25519_verify(publicKey, message, signature),
|
||||
deriveEncryptionKey: (ikm, salt, context) =>
|
||||
bindings.wasm_derive_encryption_key(ikm, salt, context),
|
||||
hkdfExpand: (ikm, salt, info, len) =>
|
||||
bindings.wasm_hkdf_expand(ikm, salt, info, len),
|
||||
sha256: (data) => bindings.wasm_sha256(data),
|
||||
sha256Double: (data) => bindings.wasm_sha256_double(data),
|
||||
keyringToKeys: (keyring) => keyringToKeys(keyring),
|
||||
publicKeyBundleToKeys: (publicKeyBundle) =>
|
||||
publicKeyBundleToKeys(publicKeyBundle),
|
||||
|
||||
encrypt: async (key, input) => {
|
||||
const cipher = new bindings.WasmChaCha20Poly1305(key);
|
||||
try {
|
||||
return cipher.encrypt(input, new Uint8Array(0));
|
||||
} finally {
|
||||
cipher.free();
|
||||
}
|
||||
},
|
||||
|
||||
decrypt: async (key, input) => {
|
||||
const cipher = new bindings.WasmChaCha20Poly1305(key);
|
||||
try {
|
||||
return cipher.decrypt(input, new Uint8Array(0));
|
||||
} finally {
|
||||
cipher.free();
|
||||
}
|
||||
},
|
||||
|
||||
encryptText: async (key, plaintext) => {
|
||||
const cipher = new bindings.WasmChaCha20Poly1305(key);
|
||||
try {
|
||||
const ciphertext = cipher.encrypt(
|
||||
utf8Encode(plaintext),
|
||||
new Uint8Array(0),
|
||||
);
|
||||
return bytesToBase64(ciphertext);
|
||||
} finally {
|
||||
cipher.free();
|
||||
}
|
||||
},
|
||||
|
||||
decryptText: async (key, ciphertext) => {
|
||||
const cipher = new bindings.WasmChaCha20Poly1305(key);
|
||||
try {
|
||||
const decoded = base64ToBytes(ciphertext);
|
||||
const plaintext = cipher.decrypt(decoded, new Uint8Array(0));
|
||||
return utf8Decode(plaintext);
|
||||
} finally {
|
||||
cipher.free();
|
||||
}
|
||||
},
|
||||
|
||||
encapsulate: (otherPublicKey) =>
|
||||
bindings.wasm_kem_encapsulate(otherPublicKey),
|
||||
|
||||
decapsulate: (ownPrivateKey, ciphertext) =>
|
||||
bindings.wasm_kem_decapsulate(ownPrivateKey, ciphertext),
|
||||
};
|
||||
|
||||
export function encode(
|
||||
type: MTPCommunicationType,
|
||||
data: Record<string, unknown>,
|
||||
options?: MTPCodecOptions,
|
||||
): Uint8Array {
|
||||
const limits: MTPEncodeLimits = {
|
||||
maxDepth: MAX_DATA_VALUE_DEPTH,
|
||||
maxValues: MAX_DATA_VALUE_VALUES,
|
||||
maxOutputSize: 16 * 1024 * 1024,
|
||||
};
|
||||
const maxOutputSize = limits.maxOutputSize ?? 16 * 1024 * 1024;
|
||||
validateMTPDataValue(data as MTPDataValueInput, limits);
|
||||
const bounded = (
|
||||
bindings as typeof bindings & {
|
||||
build_frame_with_limits?: (
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
options: MTPCodecOptions,
|
||||
limits: MTPEncodeLimits,
|
||||
) => Uint8Array;
|
||||
}
|
||||
).build_frame_with_limits;
|
||||
if (!bounded) {
|
||||
throw new Error("bounded WASM frame encoding is unavailable; rebuild mtp-wasm");
|
||||
}
|
||||
const frame = bounded(type, data, options ?? {}, limits);
|
||||
if (frame.length > maxOutputSize) {
|
||||
throw new RangeError("MTP frame encoded output limit exceeded");
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
export function decode(frame: MTPBytesInput): ParsedFrame {
|
||||
return bindings.parse_frame(bytesFrom(frame, "frame"));
|
||||
}
|
||||
|
||||
export function decodeWithLimits(
|
||||
frame: MTPBytesInput,
|
||||
limits: MTPReceiveLimits,
|
||||
): ParsedFrame {
|
||||
const parse = (
|
||||
bindings as typeof bindings & {
|
||||
parse_frame_with_limits?: (
|
||||
frame: Uint8Array,
|
||||
limits: MTPReceiveLimits,
|
||||
) => ParsedFrame;
|
||||
}
|
||||
).parse_frame_with_limits;
|
||||
if (!parse) {
|
||||
throw new Error(
|
||||
"configured receive limits require a rebuilt bounded WASM package",
|
||||
);
|
||||
}
|
||||
return parse(bytesFrom(frame, "frame"), limits);
|
||||
}
|
||||
|
||||
export function decodeDataValueWithLimits(
|
||||
value: MTPBytesInput,
|
||||
limits: MTPReceiveLimits,
|
||||
): MTPDataValue {
|
||||
const parse = (
|
||||
bindings as typeof bindings & {
|
||||
parse_data_value_with_limits?: (
|
||||
value: Uint8Array,
|
||||
limits: MTPReceiveLimits,
|
||||
) => MTPDataValue;
|
||||
}
|
||||
).parse_data_value_with_limits;
|
||||
if (!parse) {
|
||||
throw new Error(
|
||||
"configured receive limits require a rebuilt bounded WASM package",
|
||||
);
|
||||
}
|
||||
return parse(bytesFrom(value, "data value"), limits);
|
||||
}
|
||||
|
||||
export function format(frame: MTPBytesInput): string {
|
||||
return bindings.format_frame(bytesFrom(frame, "frame"));
|
||||
}
|
||||
|
||||
export const codec: MTPCodec = { encode, decode, format };
|
||||
|
||||
export function isBytes(value: unknown): value is MTPBytesInput {
|
||||
return value instanceof Uint8Array || Array.isArray(value);
|
||||
}
|
||||
|
||||
export function bytesFrom(value: MTPBytesInput, name: string): Uint8Array {
|
||||
if (value instanceof Uint8Array) return value.slice();
|
||||
if (Array.isArray(value)) {
|
||||
for (const byte of value) {
|
||||
if (!Number.isInteger(byte) || byte < 0 || byte > 255) {
|
||||
throw new RangeError(`${name} contains a non-byte value`);
|
||||
}
|
||||
}
|
||||
return Uint8Array.from(value);
|
||||
}
|
||||
throw new TypeError(`${name} must be a Uint8Array or number[]`);
|
||||
}
|
||||
|
||||
export function strictHexDecode(value: string, name = "value"): Uint8Array {
|
||||
if (typeof value !== "string") throw new TypeError(`${name} must be a string`);
|
||||
const text = value.replace(/^0x/i, "");
|
||||
if (text.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(text)) {
|
||||
throw new TypeError(`${name} must be an even-length hexadecimal string`);
|
||||
}
|
||||
const bytes = new Uint8Array(text.length / 2);
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
bytes[i] = Number.parseInt(text.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function strictBase64Decode(value: string, name = "value"): Uint8Array {
|
||||
if (typeof value !== "string") throw new TypeError(`${name} must be a string`);
|
||||
if (value.length === 0) return new Uint8Array(0);
|
||||
if (
|
||||
value.length % 4 !== 0 ||
|
||||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
|
||||
value,
|
||||
)
|
||||
) {
|
||||
throw new TypeError(`${name} is not valid padded base64`);
|
||||
}
|
||||
|
||||
let bytes: Uint8Array;
|
||||
try {
|
||||
if (typeof atob === "function") {
|
||||
const binary = atob(value);
|
||||
bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
} else if (typeof Buffer !== "undefined") {
|
||||
bytes = new Uint8Array(Buffer.from(value, "base64"));
|
||||
} else {
|
||||
throw new TypeError("base64 decoding is not available in this environment");
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TypeError(`${name} is not valid base64`, { cause: error });
|
||||
}
|
||||
|
||||
if (bytesToBase64(bytes) !== value) {
|
||||
throw new TypeError(`${name} is not canonical padded base64`);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function bytesFromEncodedString(
|
||||
value: string,
|
||||
encoding: "hex" | "base64",
|
||||
name: string,
|
||||
): Uint8Array {
|
||||
return encoding === "hex"
|
||||
? strictHexDecode(value, name)
|
||||
: strictBase64Decode(value, name);
|
||||
}
|
||||
|
||||
/*
|
||||
* Compatibility parser for the historical format-detecting API. New callers
|
||||
* should select `bytesFromEncodedString` explicitly so a value cannot change
|
||||
* meaning when it happens to contain only hexadecimal characters.
|
||||
*/
|
||||
/** @deprecated Use `bytesFromEncodedString(value, encoding, name)`. */
|
||||
export function bytesFromString(value: string, name: string): Uint8Array {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) throw new TypeError(`${name} must not be empty`);
|
||||
|
||||
const hex = trimmed.replace(/^(0x)/i, "").replace(/[\s:_-]/g, "");
|
||||
if (/^[0-9a-fA-F]+$/.test(hex)) {
|
||||
return strictHexDecode(hex, name);
|
||||
}
|
||||
return strictBase64Decode(trimmed, name);
|
||||
}
|
||||
|
||||
const HEX_DIGITS = "0123456789abcdef";
|
||||
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
out += HEX_DIGITS[(bytes[i] >> 4) & 0xf] + HEX_DIGITS[bytes[i] & 0xf];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
if (typeof btoa === "function") {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return Buffer.from(bytes).toString("base64");
|
||||
}
|
||||
throw new TypeError("base64 encoding is not available in this environment");
|
||||
}
|
||||
|
||||
export function base64ToBytes(input: string): Uint8Array {
|
||||
return strictBase64Decode(input, "base64");
|
||||
}
|
||||
|
||||
function utf8Decode(bytes: Uint8Array): string {
|
||||
if (typeof TextDecoder !== "undefined") {
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch (error) {
|
||||
throw new TypeError("invalid UTF-8", { cause: error });
|
||||
}
|
||||
}
|
||||
let out = "";
|
||||
let i = 0;
|
||||
while (i < bytes.length) {
|
||||
const b = bytes[i];
|
||||
if (b < 0x80) {
|
||||
out += String.fromCharCode(b);
|
||||
i += 1;
|
||||
} else if (b >= 0xc2 && b <= 0xdf) {
|
||||
if (i + 1 >= bytes.length || (bytes[i + 1] & 0xc0) !== 0x80) {
|
||||
throw new TypeError("invalid UTF-8");
|
||||
}
|
||||
out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i + 1] & 0x3f));
|
||||
i += 2;
|
||||
} else if (b >= 0xe0 && b <= 0xef) {
|
||||
if (
|
||||
i + 2 >= bytes.length ||
|
||||
(bytes[i + 1] & 0xc0) !== 0x80 ||
|
||||
(bytes[i + 2] & 0xc0) !== 0x80 ||
|
||||
(b === 0xe0 && bytes[i + 1] < 0xa0) ||
|
||||
(b === 0xed && bytes[i + 1] >= 0xa0)
|
||||
) {
|
||||
throw new TypeError("invalid UTF-8");
|
||||
}
|
||||
out += String.fromCharCode(
|
||||
((b & 0x0f) << 12) |
|
||||
((bytes[i + 1] & 0x3f) << 6) |
|
||||
(bytes[i + 2] & 0x3f),
|
||||
);
|
||||
i += 3;
|
||||
} else if (b >= 0xf0 && b <= 0xf4) {
|
||||
if (
|
||||
i + 3 >= bytes.length ||
|
||||
(bytes[i + 1] & 0xc0) !== 0x80 ||
|
||||
(bytes[i + 2] & 0xc0) !== 0x80 ||
|
||||
(bytes[i + 3] & 0xc0) !== 0x80 ||
|
||||
(b === 0xf0 && bytes[i + 1] < 0x90) ||
|
||||
(b === 0xf4 && bytes[i + 1] >= 0x90)
|
||||
) {
|
||||
throw new TypeError("invalid UTF-8");
|
||||
}
|
||||
const cp =
|
||||
((b & 0x07) << 18) |
|
||||
((bytes[i + 1] & 0x3f) << 12) |
|
||||
((bytes[i + 2] & 0x3f) << 6) |
|
||||
(bytes[i + 3] & 0x3f);
|
||||
out += String.fromCodePoint(cp);
|
||||
i += 4;
|
||||
} else {
|
||||
throw new TypeError("invalid UTF-8");
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function requiredSecretKeyLength(): number {
|
||||
const lengthBinding = (
|
||||
bindings as typeof bindings & {
|
||||
mtp_symmetric_key_length?: () => number;
|
||||
}
|
||||
).mtp_symmetric_key_length;
|
||||
if (!lengthBinding) return 32;
|
||||
try {
|
||||
return lengthBinding();
|
||||
} catch {
|
||||
// The generated WASM wrapper is callable only after initialization. Keep
|
||||
// the historical size as a pre-initialization validation fallback.
|
||||
return 32;
|
||||
}
|
||||
}
|
||||
|
||||
export function secretKeyFromBytes(value: MTPBytesInput): Uint8Array {
|
||||
const bytes = bytesFrom(value, "secret key");
|
||||
const requiredLength = requiredSecretKeyLength();
|
||||
if (bytes.length !== requiredLength) {
|
||||
throw new RangeError(`secret key must be exactly ${requiredLength} bytes`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function secretKeyFromHex(value: string): Uint8Array {
|
||||
return secretKeyFromBytes(strictHexDecode(value, "secret key"));
|
||||
}
|
||||
|
||||
export function secretKeyFromBase64(value: string): Uint8Array {
|
||||
return secretKeyFromBytes(strictBase64Decode(value, "secret key"));
|
||||
}
|
||||
|
||||
/*
|
||||
* Compatibility entry point. It now accepts only explicitly encoded key
|
||||
* material; arbitrary strings are no longer silently treated as passphrases.
|
||||
*/
|
||||
/** @deprecated Use `secretKeyFromBytes`, `secretKeyFromHex`, or `secretKeyFromBase64`. */
|
||||
export function secretKeyFromString(secret: string): Uint8Array {
|
||||
if (typeof secret !== "string" || !secret.trim()) {
|
||||
throw new TypeError("secret must be a non-empty string");
|
||||
}
|
||||
const trimmed = secret.trim();
|
||||
const hex = trimmed.replace(/^(0x)/i, "");
|
||||
if (/^[0-9a-fA-F]+$/.test(hex)) return secretKeyFromHex(hex);
|
||||
return secretKeyFromBase64(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reproduce the pre-v1 implicit-HKDF derivation for data migration only.
|
||||
*
|
||||
* @deprecated Do not use for new secrets. Replace this with explicit key
|
||||
* material or `deriveKeyFromPassphrase` and persist a password-KDF salt.
|
||||
*/
|
||||
export function legacySecretKeyFromStringV1(secret: string): Uint8Array {
|
||||
if (typeof secret !== "string" || !secret.trim()) {
|
||||
throw new TypeError("secret must be a non-empty string");
|
||||
}
|
||||
const trimmed = secret.trim();
|
||||
const hex = trimmed.replace(/^(0x)/i, "").replace(/[\s:_-]/g, "");
|
||||
if (/^[0-9a-fA-F]+$/.test(hex) && hex.length === 64) {
|
||||
return strictHexDecode(hex, "legacy secret key");
|
||||
}
|
||||
try {
|
||||
const decoded = bytesFromString(trimmed, "legacy secret key");
|
||||
if (decoded.length === requiredSecretKeyLength()) return decoded;
|
||||
} catch {
|
||||
// Preserve the historical fallback to HKDF for non-encoded strings.
|
||||
}
|
||||
const context = utf8Encode("mtp-symmetric-key");
|
||||
return bindings.wasm_derive_encryption_key(
|
||||
utf8Encode(trimmed),
|
||||
context,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
export interface PasswordKdfParameters {
|
||||
memoryKiB: number;
|
||||
iterations: number;
|
||||
lanes: number;
|
||||
}
|
||||
|
||||
function validatePasswordKdfInput(
|
||||
passphrase: string,
|
||||
salt: MTPBytesInput,
|
||||
parameters: PasswordKdfParameters,
|
||||
): { passphrase: string; salt: Uint8Array; parameters: PasswordKdfParameters } {
|
||||
if (typeof passphrase !== "string" || passphrase.length === 0) {
|
||||
throw new TypeError("passphrase must not be empty");
|
||||
}
|
||||
const saltBytes = bytesFrom(salt, "passphrase salt");
|
||||
if (saltBytes.length < 16) {
|
||||
throw new RangeError("passphrase salt must be at least 16 bytes");
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(parameters.memoryKiB) ||
|
||||
parameters.memoryKiB < 8 * 1024 ||
|
||||
parameters.memoryKiB > 256 * 1024 ||
|
||||
!Number.isInteger(parameters.iterations) ||
|
||||
parameters.iterations < 1 ||
|
||||
parameters.iterations > 10 ||
|
||||
!Number.isInteger(parameters.lanes) ||
|
||||
parameters.lanes < 1 ||
|
||||
parameters.lanes > 8
|
||||
) {
|
||||
throw new RangeError("invalid Argon2id password-KDF parameters");
|
||||
}
|
||||
return { passphrase, salt: saltBytes, parameters };
|
||||
}
|
||||
|
||||
function deriveKeyFromPassphraseSyncImpl(
|
||||
passphrase: string,
|
||||
salt: MTPBytesInput,
|
||||
parameters: PasswordKdfParameters,
|
||||
): Uint8Array {
|
||||
const validated = validatePasswordKdfInput(passphrase, salt, parameters);
|
||||
const kdf = (bindings as unknown as {
|
||||
wasm_argon2id?: (
|
||||
passphrase: Uint8Array,
|
||||
salt: Uint8Array,
|
||||
memoryKiB: number,
|
||||
iterations: number,
|
||||
lanes: number,
|
||||
) => Uint8Array;
|
||||
}).wasm_argon2id;
|
||||
if (!kdf) {
|
||||
throw new Error("Argon2id password derivation is unavailable in this WASM build");
|
||||
}
|
||||
return kdf(
|
||||
utf8Encode(validated.passphrase),
|
||||
validated.salt,
|
||||
validated.parameters.memoryKiB,
|
||||
validated.parameters.iterations,
|
||||
validated.parameters.lanes,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a passphrase key without yielding. Prefer the asynchronous API in
|
||||
* browser applications; this form is retained for workers and synchronous
|
||||
* command-line migrations.
|
||||
*/
|
||||
/** @deprecated Use `deriveKeyFromPassphrase` in browser-facing code. */
|
||||
export function deriveKeyFromPassphraseSync(
|
||||
passphrase: string,
|
||||
salt: MTPBytesInput,
|
||||
parameters: PasswordKdfParameters,
|
||||
): Uint8Array {
|
||||
return deriveKeyFromPassphraseSyncImpl(passphrase, salt, parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a passphrase key off the browser main thread when workers are
|
||||
* available. The worker imports the same generated WASM binding, so the
|
||||
* Argon2id computation does not block UI/event-loop work.
|
||||
*/
|
||||
export function deriveKeyFromPassphrase(
|
||||
passphrase: string,
|
||||
salt: MTPBytesInput,
|
||||
parameters: PasswordKdfParameters,
|
||||
): Promise<Uint8Array> {
|
||||
const validated = validatePasswordKdfInput(passphrase, salt, parameters);
|
||||
if (typeof Worker === "undefined") {
|
||||
return initWasmOnce().then(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve(
|
||||
deriveKeyFromPassphraseSyncImpl(
|
||||
validated.passphrase,
|
||||
validated.salt,
|
||||
validated.parameters,
|
||||
),
|
||||
),
|
||||
0,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const worker = new Worker(new URL("./passphrase-worker.js", import.meta.url), {
|
||||
type: "module",
|
||||
});
|
||||
return new Promise<Uint8Array>((resolve, reject) => {
|
||||
const cleanup = () => worker.terminate();
|
||||
worker.onmessage = (event: MessageEvent<Uint8Array | { error: string }>) => {
|
||||
cleanup();
|
||||
if (event.data && "error" in event.data) {
|
||||
reject(new Error(event.data.error));
|
||||
} else {
|
||||
resolve(new Uint8Array(event.data));
|
||||
}
|
||||
};
|
||||
worker.onerror = (event) => {
|
||||
cleanup();
|
||||
reject(new Error(event.message || "Argon2id worker failed"));
|
||||
};
|
||||
const passphraseBytes = utf8Encode(validated.passphrase);
|
||||
const saltBytes = validated.salt.slice();
|
||||
worker.postMessage(
|
||||
{
|
||||
passphrase: passphraseBytes,
|
||||
salt: saltBytes,
|
||||
parameters: validated.parameters,
|
||||
},
|
||||
[passphraseBytes.buffer, saltBytes.buffer],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeBytes(
|
||||
value: string | MTPBytesInput | MTPEncodedBytesInput,
|
||||
name: string,
|
||||
encoding?: "hex" | "base64",
|
||||
): Uint8Array {
|
||||
if (typeof value === "string") {
|
||||
if (!encoding) {
|
||||
throw new TypeError(
|
||||
`${name} string input requires an explicit 'hex' or 'base64' encoding`,
|
||||
);
|
||||
}
|
||||
return bytesFromEncodedString(value, encoding, name);
|
||||
}
|
||||
if (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
!(value instanceof Uint8Array) &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
const encoded = value as Partial<MTPEncodedBytesInput>;
|
||||
if (
|
||||
typeof encoded.value !== "string" ||
|
||||
(encoded.encoding !== "hex" && encoded.encoding !== "base64")
|
||||
) {
|
||||
throw new TypeError(
|
||||
`${name} must be bytes or { value: string, encoding: 'hex' | 'base64' }`,
|
||||
);
|
||||
}
|
||||
return bytesFromEncodedString(encoded.value, encoded.encoding, name);
|
||||
}
|
||||
return bytesFrom(value, name);
|
||||
}
|
||||
|
||||
export function inputU64(value: bigint | number | string, name: string): bigint {
|
||||
if (typeof value === "number" && !Number.isSafeInteger(value)) {
|
||||
throw new RangeError(
|
||||
`${name} must be a safe integer number, bigint, or integer string`,
|
||||
);
|
||||
}
|
||||
let result: bigint;
|
||||
try {
|
||||
result = BigInt(value);
|
||||
} catch (error) {
|
||||
throw new RangeError(`${name} must be an integer`, { cause: error });
|
||||
}
|
||||
if (result < 0n || result > 0xffff_ffff_ffff_ffffn) {
|
||||
throw new RangeError(`${name} must be a u64`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function toBigInt(
|
||||
value: bigint | string | number | null | undefined,
|
||||
): bigint | null {
|
||||
if (value == null || value === "") return null;
|
||||
return inputU64(value, "clientId");
|
||||
}
|
||||
|
||||
const KEM_PUBLIC_KEY_LEN = 1216;
|
||||
const SIG_PQ_PUBLIC_KEY_LEN = 1952;
|
||||
const SIG_CL_PUBLIC_KEY_LEN = 32;
|
||||
|
||||
export function keyringToKeys(keyring: MTPKeyMaterialInput): MTPKeyringKeys {
|
||||
const bytes = normalizeBytes(keyring, "keyring");
|
||||
if (bytes.length < 12) {
|
||||
throw new TypeError("keyring data is too short to contain 6 keys");
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
const readKey = () => {
|
||||
if (offset + 2 > bytes.length) throw new TypeError("keyring is truncated");
|
||||
const len = (bytes[offset] << 8) | bytes[offset + 1];
|
||||
offset += 2;
|
||||
if (offset + len > bytes.length) throw new TypeError("keyring is truncated");
|
||||
const key = bytes.slice(offset, offset + len);
|
||||
offset += len;
|
||||
return key;
|
||||
};
|
||||
|
||||
const result = {
|
||||
kemPublicKey: readKey(),
|
||||
kemSecretKey: readKey(),
|
||||
sigPqPublicKey: readKey(),
|
||||
sigPqSecretKey: readKey(),
|
||||
sigClPublicKey: readKey(),
|
||||
sigClSecretKey: readKey(),
|
||||
};
|
||||
if (offset !== bytes.length) throw new TypeError("keyring has trailing data");
|
||||
return result;
|
||||
}
|
||||
|
||||
export function publicKeyBundleToKeys(
|
||||
publicKeyBundle: MTPKeyMaterialInput,
|
||||
): MTPPublicKeyBundleKeys {
|
||||
const bytes = normalizeBytes(publicKeyBundle, "publicKeyBundle");
|
||||
if (bytes.length < 6) {
|
||||
throw new TypeError("public key bundle data is too short to contain 3 keys");
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
const readKey = () => {
|
||||
if (offset + 2 > bytes.length) {
|
||||
throw new TypeError("public key bundle is truncated");
|
||||
}
|
||||
const len = (bytes[offset] << 8) | bytes[offset + 1];
|
||||
offset += 2;
|
||||
if (offset + len > bytes.length) {
|
||||
throw new TypeError("public key bundle is truncated");
|
||||
}
|
||||
const key = bytes.slice(offset, offset + len);
|
||||
offset += len;
|
||||
return key;
|
||||
};
|
||||
|
||||
const result = {
|
||||
kemPublicKey: readKey(),
|
||||
sigPqPublicKey: readKey(),
|
||||
sigClPublicKey: readKey(),
|
||||
};
|
||||
if (offset !== bytes.length) {
|
||||
throw new TypeError("public key bundle has trailing data");
|
||||
}
|
||||
if (
|
||||
result.kemPublicKey.length !== KEM_PUBLIC_KEY_LEN ||
|
||||
result.sigPqPublicKey.length !== SIG_PQ_PUBLIC_KEY_LEN ||
|
||||
result.sigClPublicKey.length !== SIG_CL_PUBLIC_KEY_LEN
|
||||
) {
|
||||
throw new TypeError("public key bundle contains invalid suite key lengths");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function cloneParsedValue(value: unknown): unknown {
|
||||
if (value instanceof Uint8Array) return value.slice();
|
||||
if (Array.isArray(value)) return value.map(cloneParsedValue);
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [key, cloneParsedValue(entry)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function cloneParsedFrame(frame: ParsedFrame): ParsedFrame {
|
||||
return cloneParsedValue(frame) as ParsedFrame;
|
||||
}
|
||||
|
||||
function parsedDataObject(
|
||||
data: ParsedFrame["data"] | null | undefined,
|
||||
): Record<string, unknown> {
|
||||
if (
|
||||
data === null ||
|
||||
typeof data !== "object" ||
|
||||
Array.isArray(data) ||
|
||||
data instanceof Uint8Array
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
const object = data as Record<string, unknown>;
|
||||
if (object.kind === "encrypted" || object.kind === "signed") return {};
|
||||
return object;
|
||||
}
|
||||
|
||||
export function errorMessage(
|
||||
frame: Pick<ParsedFrame, "type" | "data"> | null | undefined,
|
||||
): string {
|
||||
const data = parsedDataObject(frame?.data);
|
||||
return String(
|
||||
data.ErrorMessage ??
|
||||
data.Error ??
|
||||
data.Description ??
|
||||
`Received ${frame?.type ?? "error"} frame`,
|
||||
);
|
||||
}
|
||||
|
||||
export function parseProtectedFrame(
|
||||
frame: MTPProtectedFrameInput,
|
||||
limits?: MTPReceiveLimits,
|
||||
): ParsedFrame {
|
||||
const parse = (bytes: Uint8Array): ParsedFrame =>
|
||||
limits ? decodeWithLimits(bytes, limits) : bindings.parse_frame(bytes);
|
||||
if (isBytes(frame)) return parse(bytesFrom(frame, "frame"));
|
||||
if (
|
||||
frame === null ||
|
||||
typeof frame !== "object" ||
|
||||
typeof frame.type !== "string"
|
||||
) {
|
||||
throw new TypeError("frame must be a parsed MTP frame or serialized bytes");
|
||||
}
|
||||
if (frame.raw instanceof Uint8Array) return parse(frame.raw);
|
||||
return frame;
|
||||
}
|
||||
|
||||
export function assertKnownCommunicationType(frame: ParsedFrame): void {
|
||||
if (!frame.type || /^[0-9]+$/.test(frame.type)) {
|
||||
throw new Error(`Unknown communication type: ${frame.type || "unknown"}`);
|
||||
}
|
||||
try {
|
||||
bindings.build_frame(frame.type, null, {});
|
||||
} catch (error) {
|
||||
throw new Error(`Unknown communication type: ${frame.type}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export function protectedFrameBytes(
|
||||
frame: ParsedFrame,
|
||||
limits?: MTPReceiveLimits,
|
||||
): Uint8Array {
|
||||
if (frame.raw instanceof Uint8Array) return frame.raw.slice();
|
||||
const data =
|
||||
frame.data !== null &&
|
||||
typeof frame.data === "object" &&
|
||||
!Array.isArray(frame.data) &&
|
||||
!(frame.data instanceof Uint8Array)
|
||||
? (frame.data as Record<string, unknown>)
|
||||
: null;
|
||||
const encoded = data?.encoded;
|
||||
if (data?.kind !== "encrypted" || !(encoded instanceof Uint8Array)) {
|
||||
throw new Error("protected frame payload is not encrypted");
|
||||
}
|
||||
const options = {
|
||||
id: frame.id,
|
||||
...(frame.sender == null ? {} : { sender: frame.sender }),
|
||||
...(frame.receiver == null ? {} : { receiver: frame.receiver }),
|
||||
};
|
||||
const bounded = (
|
||||
bindings as typeof bindings & {
|
||||
build_frame_with_payload_with_limits?: (
|
||||
type: string,
|
||||
payload: Uint8Array,
|
||||
options: MTPCodecOptions,
|
||||
limits: MTPReceiveLimits,
|
||||
) => Uint8Array;
|
||||
}
|
||||
).build_frame_with_payload_with_limits;
|
||||
if (!bounded) {
|
||||
throw new Error("bounded WASM frame encoding is unavailable; rebuild mtp-wasm");
|
||||
}
|
||||
return bounded(frame.type, encoded, options, limits ?? {});
|
||||
}
|
||||
|
||||
export function assertApplicationCommunicationType(type: string): string {
|
||||
if (typeof type !== "string" || !type.trim() || /^[0-9]+$/.test(type)) {
|
||||
throw new Error(`Unknown communication type: ${type || "unknown"}`);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(RESERVED_COMMUNICATION_TYPE_IDS, type)) {
|
||||
throw new Error(
|
||||
`MTP control communication type ${type} cannot be used as application content`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
bindings.build_frame(type, null, {});
|
||||
} catch (error) {
|
||||
throw new Error(`Unknown communication type: ${type}`, { cause: error });
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
export const MAX_DATA_VALUE_DEPTH = 64;
|
||||
export const MAX_DATA_VALUE_VALUES = 65_536;
|
||||
|
||||
const DEFAULT_ENCODE_LIMITS: Required<MTPEncodeLimits> = {
|
||||
maxDepth: MAX_DATA_VALUE_DEPTH,
|
||||
maxValues: MAX_DATA_VALUE_VALUES,
|
||||
maxOutputSize: 16 * 1024 * 1024,
|
||||
};
|
||||
|
||||
function normalizedEncodeLimits(
|
||||
limits: MTPEncodeLimits | undefined,
|
||||
): Required<MTPEncodeLimits> {
|
||||
const result = { ...DEFAULT_ENCODE_LIMITS, ...(limits ?? {}) };
|
||||
for (const [key, value] of Object.entries(result)) {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new TypeError(`encode limits ${key} must be a non-negative safe integer`);
|
||||
}
|
||||
}
|
||||
return result as Required<MTPEncodeLimits>;
|
||||
}
|
||||
|
||||
/** Validate a JS DataValue before crossing into the recursive WASM parser. */
|
||||
export function validateMTPDataValue(
|
||||
value: MTPDataValueInput,
|
||||
limits?: MTPEncodeLimits,
|
||||
): void {
|
||||
const effective = normalizedEncodeLimits(limits);
|
||||
const ancestors = new WeakSet<object>();
|
||||
let values = 0;
|
||||
const validate = (candidate: unknown, depth: number): void => {
|
||||
values += 1;
|
||||
if (values > effective.maxValues) {
|
||||
throw new RangeError("MTP DataValue value-count limit exceeded");
|
||||
}
|
||||
if (depth > effective.maxDepth) {
|
||||
throw new RangeError("MTP DataValue nesting-depth limit exceeded");
|
||||
}
|
||||
if (
|
||||
candidate === null ||
|
||||
typeof candidate === "boolean" ||
|
||||
typeof candidate === "string" ||
|
||||
typeof candidate === "bigint" ||
|
||||
candidate instanceof Uint8Array
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (typeof candidate === "number") {
|
||||
if (Number.isInteger(candidate) && !Number.isSafeInteger(candidate)) {
|
||||
throw new TypeError("unsafe integral MTP DataValue inputs must use bigint");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof candidate !== "object") {
|
||||
throw new TypeError(`unsupported MTP DataValue input: ${typeof candidate}`);
|
||||
}
|
||||
const object = candidate as object;
|
||||
if (ancestors.has(object)) throw new TypeError("MTP DataValue input must not be cyclic");
|
||||
if (
|
||||
!Array.isArray(candidate) &&
|
||||
Object.getPrototypeOf(candidate) !== Object.prototype &&
|
||||
Object.getPrototypeOf(candidate) !== null
|
||||
) {
|
||||
throw new TypeError("MTP DataValue containers must be plain objects");
|
||||
}
|
||||
ancestors.add(object);
|
||||
const entries = Array.isArray(candidate)
|
||||
? candidate
|
||||
: Object.values(candidate as Record<string, unknown>);
|
||||
try {
|
||||
for (const entry of entries) validate(entry, depth + 1);
|
||||
} finally {
|
||||
ancestors.delete(object);
|
||||
}
|
||||
};
|
||||
validate(value, 0);
|
||||
}
|
||||
|
||||
export function encodeMTPDataValue(
|
||||
value: MTPDataValueInput,
|
||||
limits?: MTPEncodeLimits,
|
||||
): Uint8Array {
|
||||
const effective = normalizedEncodeLimits(limits);
|
||||
validateMTPDataValue(value, effective);
|
||||
const bounded = (
|
||||
bindings as typeof bindings & {
|
||||
encode_data_value_with_limits?: (
|
||||
value: MTPDataValueInput,
|
||||
limits: MTPEncodeLimits,
|
||||
) => Uint8Array;
|
||||
}
|
||||
).encode_data_value_with_limits;
|
||||
if (!bounded) {
|
||||
throw new Error("bounded WASM DataValue encoding is unavailable; rebuild mtp-wasm");
|
||||
}
|
||||
const encoded = bounded(value, effective);
|
||||
if (encoded.length > effective.maxOutputSize) {
|
||||
throw new RangeError("MTP DataValue encoded output limit exceeded");
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
export function inputDataValueBigInt(value: unknown, name: string): bigint {
|
||||
try {
|
||||
if (typeof value === "bigint") return value;
|
||||
if (typeof value === "number" && Number.isSafeInteger(value)) return BigInt(value);
|
||||
if (typeof value === "string" && value.length > 0) return BigInt(value);
|
||||
} catch {
|
||||
// Normalize malformed protected metadata below.
|
||||
}
|
||||
throw new Error(`protected metadata field ${name} is not an integer`);
|
||||
}
|
||||
|
||||
export function inputDataValueString(value: unknown, name: string): string {
|
||||
if (typeof value === "string" && value.length > 0) return value;
|
||||
throw new Error(`protected metadata field ${name} is not a non-empty string`);
|
||||
}
|
||||
|
||||
export function signatureSuiteValue(
|
||||
suite: MTPProtectionSignatureSuite,
|
||||
): number {
|
||||
return suite === "dual"
|
||||
? bindings.mtp_protection_signature_suite_dual()
|
||||
: bindings.mtp_protection_signature_suite_ed25519();
|
||||
}
|
||||
|
||||
export function formatDataValue(value: MTPDataValue): MTPDataValue {
|
||||
return cloneParsedValue(value) as MTPDataValue;
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import type { MTPClientCredentials } from "./index.js";
|
||||
|
||||
export type InternalCredentials = {
|
||||
clientId: bigint | null;
|
||||
keyring: Uint8Array;
|
||||
hostPublicKey?: Uint8Array;
|
||||
};
|
||||
|
||||
export function publicCredentials(
|
||||
credentials: InternalCredentials | null,
|
||||
): MTPClientCredentials | null {
|
||||
if (!credentials) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
clientId: credentials.clientId,
|
||||
keyring: credentials.keyring.slice(),
|
||||
hostPublicKey: credentials.hostPublicKey?.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
export function zeroCredentials(credentials: InternalCredentials | null): void {
|
||||
// The host public key is intentionally not wiped: it is public configuration
|
||||
// and may also be retained by the connection options.
|
||||
credentials?.keyring.fill(0);
|
||||
}
|
||||
74
src/sdk/encrypted-device-secret.ts
Normal file
74
src/sdk/encrypted-device-secret.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
export interface EncryptedDeviceSecretRecord {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
secretId: string;
|
||||
version: number;
|
||||
encryptedSecret: Uint8Array;
|
||||
wrappingPublicKeyId?: string;
|
||||
wrappingScheme: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface MTPEncryptedDeviceSecretProvider {
|
||||
setEncryptedDeviceSecret(record: EncryptedDeviceSecretRecord): Promise<void>;
|
||||
getEncryptedDeviceSecret(query: {
|
||||
userId: string;
|
||||
deviceId?: string;
|
||||
secretId?: string;
|
||||
}): Promise<EncryptedDeviceSecretRecord | null>;
|
||||
}
|
||||
|
||||
function keyFor(record: Pick<EncryptedDeviceSecretRecord, "userId" | "deviceId" | "secretId">): string {
|
||||
return `${record.userId}\0${record.deviceId}\0${record.secretId}`;
|
||||
}
|
||||
|
||||
function cloneRecord(record: EncryptedDeviceSecretRecord): EncryptedDeviceSecretRecord {
|
||||
return {
|
||||
...record,
|
||||
encryptedSecret: new Uint8Array(record.encryptedSecret),
|
||||
};
|
||||
}
|
||||
|
||||
function validateEncryptedRecord(record: EncryptedDeviceSecretRecord): void {
|
||||
if (!record.userId || !record.deviceId || !record.secretId) {
|
||||
throw new Error("encrypted device secret requires userId, deviceId, and secretId");
|
||||
}
|
||||
if (!(record.encryptedSecret instanceof Uint8Array) || record.encryptedSecret.length === 0) {
|
||||
throw new Error("encrypted device secret requires non-empty encryptedSecret bytes");
|
||||
}
|
||||
if (!record.wrappingScheme) {
|
||||
throw new Error("encrypted device secret requires wrappingScheme");
|
||||
}
|
||||
}
|
||||
|
||||
export class InMemoryEncryptedDeviceSecretProvider implements MTPEncryptedDeviceSecretProvider {
|
||||
private store = new Map<string, EncryptedDeviceSecretRecord>();
|
||||
|
||||
async setEncryptedDeviceSecret(record: EncryptedDeviceSecretRecord): Promise<void> {
|
||||
validateEncryptedRecord(record);
|
||||
const now = Date.now();
|
||||
this.store.set(keyFor(record), cloneRecord({ ...record, updatedAt: record.updatedAt || now }));
|
||||
}
|
||||
|
||||
async getEncryptedDeviceSecret(query: {
|
||||
userId: string;
|
||||
deviceId?: string;
|
||||
secretId?: string;
|
||||
}): Promise<EncryptedDeviceSecretRecord | null> {
|
||||
if (!query.userId) {
|
||||
throw new Error("userId is required");
|
||||
}
|
||||
if (query.deviceId && query.secretId) {
|
||||
const found = this.store.get(`${query.userId}\0${query.deviceId}\0${query.secretId}`);
|
||||
return found ? cloneRecord(found) : null;
|
||||
}
|
||||
for (const record of this.store.values()) {
|
||||
if (record.userId !== query.userId) continue;
|
||||
if (query.deviceId && record.deviceId !== query.deviceId) continue;
|
||||
if (query.secretId && record.secretId !== query.secretId) continue;
|
||||
return cloneRecord(record);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -14,8 +14,8 @@ const HEADER_FIXED_LEN = 1 + 1 + 8 + 8 + 4 + 2 + 4;
|
|||
export interface ParsedEncryptedMessage {
|
||||
version: 1;
|
||||
flags: number;
|
||||
senderId: bigint;
|
||||
recipientId: bigint;
|
||||
senderClientId: bigint;
|
||||
recipientClientId: bigint;
|
||||
messageNumber: number;
|
||||
kemCiphertext?: Uint8Array;
|
||||
ciphertext: Uint8Array;
|
||||
|
|
@ -28,8 +28,8 @@ export interface ParsedEncryptedMessage {
|
|||
export interface EncryptedMessageHeader {
|
||||
version: 1;
|
||||
flags: number;
|
||||
senderId: bigint;
|
||||
recipientId: bigint;
|
||||
senderClientId: bigint;
|
||||
recipientClientId: bigint;
|
||||
messageNumber: number;
|
||||
kemCiphertext?: Uint8Array;
|
||||
}
|
||||
|
|
@ -105,8 +105,8 @@ export function serializeEncryptedMessage(
|
|||
return concatBytes([
|
||||
new Uint8Array([normalized.version]),
|
||||
new Uint8Array([normalized.flags]),
|
||||
writeU64BE(normalized.senderId),
|
||||
writeU64BE(normalized.recipientId),
|
||||
writeU64BE(normalized.senderClientId),
|
||||
writeU64BE(normalized.recipientClientId),
|
||||
writeU32BE(normalized.messageNumber),
|
||||
new Uint8Array([
|
||||
(kemCiphertext.length >>> 8) & 0xff,
|
||||
|
|
@ -131,9 +131,9 @@ export function parseEncryptedMessage(
|
|||
|
||||
const version = bytes[offset++];
|
||||
const flags = bytes[offset++];
|
||||
const senderId = readU64BE(bytes, offset);
|
||||
const senderClientId = readU64BE(bytes, offset);
|
||||
offset += 8;
|
||||
const recipientId = readU64BE(bytes, offset);
|
||||
const recipientClientId = readU64BE(bytes, offset);
|
||||
offset += 8;
|
||||
const messageNumber =
|
||||
((bytes[offset] << 24) |
|
||||
|
|
@ -176,8 +176,8 @@ export function parseEncryptedMessage(
|
|||
const parsed: ParsedEncryptedMessage = {
|
||||
version: version as 1,
|
||||
flags,
|
||||
senderId,
|
||||
recipientId,
|
||||
senderClientId,
|
||||
recipientClientId,
|
||||
messageNumber,
|
||||
kemCiphertext,
|
||||
ciphertext,
|
||||
|
|
@ -185,8 +185,8 @@ export function parseEncryptedMessage(
|
|||
parsed.header = {
|
||||
version: parsed.version,
|
||||
flags: parsed.flags,
|
||||
senderId: parsed.senderId,
|
||||
recipientId: parsed.recipientId,
|
||||
senderClientId: parsed.senderClientId,
|
||||
recipientClientId: parsed.recipientClientId,
|
||||
messageNumber: parsed.messageNumber,
|
||||
kemCiphertext: parsed.kemCiphertext,
|
||||
};
|
||||
|
|
@ -199,8 +199,8 @@ function buildAAD(header: EncryptedMessageHeader): Uint8Array {
|
|||
return concatBytes([
|
||||
new Uint8Array([header.version]),
|
||||
new Uint8Array([header.flags]),
|
||||
writeU64BE(header.senderId),
|
||||
writeU64BE(header.recipientId),
|
||||
writeU64BE(header.senderClientId),
|
||||
writeU64BE(header.recipientClientId),
|
||||
writeU32BE(header.messageNumber),
|
||||
]);
|
||||
}
|
||||
|
|
@ -227,8 +227,8 @@ export async function encryptPayload(args: {
|
|||
const header: EncryptedMessageHeader = {
|
||||
version: 1,
|
||||
flags: args.kemCiphertext ? FLAG_INIT : 0,
|
||||
senderId: args.session.localId,
|
||||
recipientId: args.session.remoteId,
|
||||
senderClientId: args.session.ownClientId,
|
||||
recipientClientId: args.session.peerClientId,
|
||||
messageNumber: args.session.sendCount,
|
||||
kemCiphertext: args.kemCiphertext,
|
||||
};
|
||||
|
|
@ -257,18 +257,19 @@ export async function encryptPayload(args: {
|
|||
export async function decryptPayload(args: {
|
||||
payload: Uint8Array;
|
||||
session: MTPSessionState;
|
||||
expectedRecipientId?: bigint;
|
||||
expectedRecipientClientId?: bigint;
|
||||
aad?: Uint8Array;
|
||||
}): Promise<{
|
||||
plaintext: Uint8Array;
|
||||
session: MTPSessionState;
|
||||
}> {
|
||||
const parsed = parseEncryptedMessage(args.payload);
|
||||
const expectedRecipientId = args.expectedRecipientId ?? args.session.localId;
|
||||
if (parsed.recipientId !== expectedRecipientId) {
|
||||
const expectedRecipientClientId =
|
||||
args.expectedRecipientClientId ?? args.session.ownClientId;
|
||||
if (parsed.recipientClientId !== expectedRecipientClientId) {
|
||||
throw new Error("Encrypted message recipient mismatch");
|
||||
}
|
||||
if (parsed.senderId !== args.session.remoteId) {
|
||||
if (parsed.senderClientId !== args.session.peerClientId) {
|
||||
throw new Error("Encrypted message sender mismatch");
|
||||
}
|
||||
|
||||
|
|
@ -321,8 +322,8 @@ export async function decryptPayload(args: {
|
|||
const header: EncryptedMessageHeader = {
|
||||
version: parsed.version,
|
||||
flags: parsed.flags,
|
||||
senderId: parsed.senderId,
|
||||
recipientId: parsed.recipientId,
|
||||
senderClientId: parsed.senderClientId,
|
||||
recipientClientId: parsed.recipientClientId,
|
||||
messageNumber: parsed.messageNumber,
|
||||
kemCiphertext: parsed.kemCiphertext,
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,90 +0,0 @@
|
|||
/**
|
||||
* Encrypted secret material persisted for MTP cryptographic facilities.
|
||||
*
|
||||
* `id` is an opaque, MTP-owned or caller-derived identifier. The provider
|
||||
* does not interpret it or infer an identity hierarchy from it.
|
||||
*/
|
||||
export interface MTPEncryptedSecretRecord {
|
||||
id: string;
|
||||
encryptedSecret: Uint8Array;
|
||||
formatVersion: number;
|
||||
wrappingScheme: string;
|
||||
wrappingKeyId?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface MTPEncryptedSecretProvider {
|
||||
get(id: string): Promise<MTPEncryptedSecretRecord | null>;
|
||||
set(record: MTPEncryptedSecretRecord): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
function requireId(id: string): string {
|
||||
if (typeof id !== "string" || id.length === 0) {
|
||||
throw new TypeError("encrypted secret id must be a non-empty string");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function validateTimestamp(value: number, name: string): void {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new TypeError(`${name} must be a non-negative safe integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function cloneRecord(record: MTPEncryptedSecretRecord): MTPEncryptedSecretRecord {
|
||||
return {
|
||||
...record,
|
||||
encryptedSecret: new Uint8Array(record.encryptedSecret),
|
||||
};
|
||||
}
|
||||
|
||||
function validateRecord(record: MTPEncryptedSecretRecord): void {
|
||||
requireId(record.id);
|
||||
if (
|
||||
!(record.encryptedSecret instanceof Uint8Array) ||
|
||||
record.encryptedSecret.length === 0
|
||||
) {
|
||||
throw new TypeError(
|
||||
"encrypted secret requires non-empty encryptedSecret bytes",
|
||||
);
|
||||
}
|
||||
if (!Number.isSafeInteger(record.formatVersion) || record.formatVersion < 0) {
|
||||
throw new TypeError(
|
||||
"encrypted secret formatVersion must be a non-negative safe integer",
|
||||
);
|
||||
}
|
||||
if (typeof record.wrappingScheme !== "string" || !record.wrappingScheme) {
|
||||
throw new TypeError("encrypted secret requires wrappingScheme");
|
||||
}
|
||||
validateTimestamp(record.createdAt, "encrypted secret createdAt");
|
||||
validateTimestamp(record.updatedAt, "encrypted secret updatedAt");
|
||||
if (
|
||||
record.wrappingKeyId !== undefined &&
|
||||
(typeof record.wrappingKeyId !== "string" || !record.wrappingKeyId)
|
||||
) {
|
||||
throw new TypeError("encrypted secret wrappingKeyId must be non-empty");
|
||||
}
|
||||
}
|
||||
|
||||
/** A small reference implementation for callers that need local persistence. */
|
||||
export class InMemoryEncryptedSecretProvider
|
||||
implements MTPEncryptedSecretProvider
|
||||
{
|
||||
private store = new Map<string, MTPEncryptedSecretRecord>();
|
||||
|
||||
async get(id: string): Promise<MTPEncryptedSecretRecord | null> {
|
||||
const record = this.store.get(requireId(id));
|
||||
return record ? cloneRecord(record) : null;
|
||||
}
|
||||
|
||||
async set(record: MTPEncryptedSecretRecord): Promise<void> {
|
||||
validateRecord(record);
|
||||
this.store.set(record.id, cloneRecord(record));
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
this.store.delete(requireId(id));
|
||||
}
|
||||
}
|
||||
1685
src/sdk/index.ts
1685
src/sdk/index.ts
File diff suppressed because it is too large
Load diff
|
|
@ -1,33 +0,0 @@
|
|||
import initWasm, * as bindings from "mtp/raw";
|
||||
|
||||
interface PasswordKdfWorkerRequest {
|
||||
passphrase: Uint8Array;
|
||||
salt: Uint8Array;
|
||||
parameters: {
|
||||
memoryKiB: number;
|
||||
iterations: number;
|
||||
lanes: number;
|
||||
};
|
||||
}
|
||||
|
||||
const scope = globalThis as unknown as {
|
||||
onmessage: ((event: MessageEvent<PasswordKdfWorkerRequest>) => void) | null;
|
||||
postMessage(message: Uint8Array | { error: string }, transfer?: Transferable[]): void;
|
||||
};
|
||||
|
||||
scope.onmessage = async (event) => {
|
||||
try {
|
||||
await initWasm();
|
||||
const { passphrase, salt, parameters } = event.data;
|
||||
const key = bindings.wasm_argon2id(
|
||||
passphrase,
|
||||
salt,
|
||||
parameters.memoryKiB,
|
||||
parameters.iterations,
|
||||
parameters.lanes,
|
||||
);
|
||||
scope.postMessage(key, [key.buffer]);
|
||||
} catch (error) {
|
||||
scope.postMessage({ error: String(error) });
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue