Compare commits

...
Author SHA1 Message Date
d1b8a23535 Update Rust crate aes-gcm to 0.11
Some checks failed
renovate/artifacts Artifact file update failure
renovate/stability-days Updates have met minimum release age requirement
CI / checks (pull_request) Failing after 5m39s
2026-08-12 23:00:58 +02:00
3c09493b00
Merge branch 'master' of ssh://git.methanium.net/methanium/mtp
Some checks failed
CI / checks (push) Failing after 2m18s
2026-08-12 22:47:48 +02:00
7f0231e3f1
[WIP] Security work While on holiday 2026-08-12 22:45:28 +02:00
109 changed files with 19697 additions and 5213 deletions

View file

@ -41,6 +41,9 @@ jobs:
pnpm --filter mtp-web-client run build
node test/e2ee.mjs
pnpm run test:secrets
pnpm run test:types
pnpm run check:boundary
(
cd example

1
.gitignore vendored
View file

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

39
Cargo.lock generated
View file

@ -37,6 +37,18 @@ dependencies = [
"subtle",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"password-hash",
]
[[package]]
name = "asn1-rs"
version = "0.7.2"
@ -150,6 +162,15 @@ version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest 0.10.7",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -465,6 +486,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
@ -1320,6 +1342,7 @@ dependencies = [
"mtp-crypto",
"mtp-type-map",
"rand 0.10.2",
"thiserror 2.0.19",
]
[[package]]
@ -1345,7 +1368,7 @@ dependencies = [
"ml-dsa",
"mlkem-tls",
"rand 0.10.2",
"rand_core 0.10.1",
"rand_core 0.6.4",
"rcgen",
"rustls",
"serde",
@ -1360,6 +1383,7 @@ dependencies = [
name = "mtp-files"
version = "0.2.0"
dependencies = [
"argon2",
"mtp-crypto",
"rand 0.10.2",
"thiserror 1.0.69",
@ -1388,6 +1412,7 @@ dependencies = [
"mtp-codec",
"mtp-common",
"mtp-crypto",
"rand 0.10.2",
"rcgen",
"rustls",
"rustls-native-certs",
@ -1395,6 +1420,7 @@ dependencies = [
"tokio",
"tracing",
"wtransport",
"zeroize",
]
[[package]]
@ -1573,6 +1599,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "pem"
version = "3.0.6"

View file

@ -96,6 +96,10 @@ 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"]

View file

@ -62,7 +62,7 @@ The `mtp` facade re-exports the following modules:
### Codec
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.
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.
### Transport
@ -90,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, encrypted containers, 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, composable protection envelopes, and certificate generation for development. Feature flags and security boundaries: [Security](./docs/SECURITY.md).
## Examples

View file

@ -5,7 +5,7 @@ edition = "2024"
[dependencies]
mtp-common = { version = "0.2.0", path = "../common" }
mtp-codec = { version = "0.2.0", path = "../codec" }
mtp-codec = { version = "0.2.0", path = "../codec", features = ["registry"] }
mtp-transport = { version = "0.2.0", path = "../transport" }
mtp-crypto = { version = "0.2.0", path = "../crypto", optional = true }
rand = "0.10.1"

View file

@ -1,4 +1,4 @@
use mtp_codec::{CommunicationValue, Version};
use mtp_codec::{CommunicationValue, Version, registry::VersionedCodec};
#[cfg(feature = "pipes")]
use mtp_codec::{DataType, DataValue};
use mtp_common::CommunicationError;
@ -17,6 +17,7 @@ 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>,
@ -45,12 +46,19 @@ impl MTPConnection {
request: &CommunicationValue,
expected_response: Option<mtp_codec::CommunicationType>,
) -> Result<CommunicationValue, CommunicationError> {
let request_id = request.get_id();
let request_id = request
.id()
.ok_or_else(|| CommunicationError::Other("request frame must contain an id".into()))?;
if request_id == 0 {
return Err(CommunicationError::Other(
"request frame must have a non-zero id".into(),
));
}
if crate::pipe::is_expired_request(&self.pipe_dispatcher, request_id).await {
return Err(CommunicationError::Other(format!(
"request id {request_id} recently timed out; use a new request id"
)));
}
let (sender, receiver) = tokio::sync::oneshot::channel();
let token = Arc::new(());
@ -86,7 +94,7 @@ impl MTPConnection {
result?
}
Err(_) => {
crate::pipe::remove_pending_request(&self.pipe_dispatcher, request_id, &token)
crate::pipe::expire_pending_request(&self.pipe_dispatcher, request_id, &token)
.await;
return Err(CommunicationError::Other(format!(
"request {request_id} timed out after {:?}",
@ -96,7 +104,7 @@ impl MTPConnection {
};
if let Some(expected) = expected_response {
let expected_type = expected.try_to_id(&mtp_codec::TypeMap::latest());
let expected_type = expected.try_to_id(self.codec.type_map());
if Some(response.get_type()) != expected_type {
return Err(CommunicationError::Other(format!(
"unexpected response type: expected {:?}, got {:?}; parsed {}",
@ -125,23 +133,35 @@ 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 pipe_id = {
let mut pending = self.pipe_dispatcher.pending_creations.lock().await;
let pipe_id = loop {
let candidate = rand::random::<u32>();
if candidate != 0 && !pending.contains_key(&candidate) {
break candidate;
}
};
pending.insert(pipe_id, tx);
pipe_id
};
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 {
self.pipe_dispatcher
.pending_creations
.lock()
.await
.remove(&pipe_id);
return Err(mtp_common::PipeError::from(error));
}
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(),
@ -164,11 +184,26 @@ pub(crate) async fn connection_from_parts(
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();
let ping = start_ping_session(&config, sender.clone(), &receiver).await;
#[cfg(feature = "crypto")]
let ping_client_id = client_id;
#[cfg(not(feature = "crypto"))]
let ping_client_id = config.client_id;
let ping = start_ping_session(
&config,
sender.clone(),
&receiver,
codec.type_map(),
ping_client_id,
)
.await;
#[cfg(feature = "pipes")]
{
@ -180,6 +215,9 @@ pub(crate) async fn connection_from_parts(
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: Mutex::new(std::collections::HashMap::new()),
pending_pipes: Mutex::new(std::collections::HashMap::new()),
policy: Arc::new(config.policy),
@ -197,6 +235,7 @@ pub(crate) async fn connection_from_parts(
MTPConnection {
version,
codec,
sender,
receiver,
app_rx: Mutex::new(app_rx),
@ -221,11 +260,15 @@ pub(crate) async fn connection_from_parts(
);
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),

View file

@ -1,4 +1,4 @@
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, Version};
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, 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) {
DataValue::Bytes(b) => b.clone(),
Some(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) {
DataValue::Bytes(b) => b.clone(),
Some(DataValue::Bytes(b)) => b.clone(),
_ => vec![],
};
let host_requires_pq = challenge.get_data(DataType::RequirePq) == &DataValue::BoolTrue;
let host_requires_pq = challenge.get_data(DataType::RequirePq) == Some(&DataValue::BoolTrue);
if host_requires_pq && host_pk.sig_pq_public_key.as_bytes().is_empty() {
return Err(CommunicationError::AuthenticationFailed(
"Host requires post-quantum authentication but its PQ public key is absent".into(),
@ -80,7 +80,7 @@ pub(crate) async fn verify_host_final(
use mtp_crypto::{auth, verify_ed25519};
match response.get_data(DataType::ClientNonce) {
DataValue::UnsignedNumber(n) if *n == client_nonce => {}
Some(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) {
DataValue::Bytes(b) => b.clone(),
Some(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) {
DataValue::Bytes(b) => b.clone(),
Some(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) {
DataValue::BoolTrue => Ok(()),
DataValue::BoolFalse => Err(CommunicationError::AuthenticationFailed(
Some(DataValue::BoolTrue) => Ok(()),
Some(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) {
DataValue::Str(version) => Version::parse(version).ok_or_else(|| {
Some(DataValue::Str(version)) => Version::parse(version).ok_or_else(|| {
CommunicationError::AuthenticationFailed(
"Host returned an invalid negotiated protocol version".into(),
)
@ -162,16 +162,18 @@ 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(CommunicationType::ChallengeResponse)
.add_typed_default(
DataType::ClientNonce,
DataValue::UnsignedNumber(client_nonce),
);
let mut proof =
CommunicationValue::new_with_type_map(CommunicationType::ChallengeResponse, type_map)
.add_typed_default(
DataType::ClientNonce,
DataValue::UnsignedNumber(client_nonce),
);
if keys.sig_pq_secret_key.as_bytes().is_empty() {
let signature = signer
@ -213,14 +215,14 @@ pub(crate) async fn receive_verified_challenge(
}
let server_challenge = match challenge.get_data(DataType::ServerNonce) {
DataValue::UnsignedNumber(n) => *n,
Some(DataValue::UnsignedNumber(n)) => *n,
_ => {
return Err(CommunicationError::AuthenticationFailed(
"Missing server challenge".into(),
));
}
};
if challenge.get_data(DataType::RequirePq) == &DataValue::BoolTrue && !client_has_pq_key {
if challenge.get_data(DataType::RequirePq) == Some(&DataValue::BoolTrue) && !client_has_pq_key {
return Err(CommunicationError::AuthenticationFailed(
"Host requires post-quantum authentication but the client PQ key is absent".into(),
));

View file

@ -31,20 +31,22 @@ mod error {
}
}
use mtp_codec::{CommunicationValue, DataType, DataValue, PROTOCOL_VERSION, Version};
use mtp_codec::{
CommunicationValue, DataType, DataValue, PROTOCOL_VERSION, Version,
registry::{Registry, VersionedCodec},
};
use mtp_common::{CommunicationError, HandshakeOutcome, RejectionReason};
use connection::connection_from_parts;
fn parse_handshake_response(
response: &CommunicationValue,
type_map: &mtp_codec::TypeMap,
) -> Result<HandshakeOutcome, CommunicationError> {
let tm = mtp_codec::TypeMap::latest();
let bad_version = mtp_codec::CommunicationType::ErrorBadVersion.try_to_id(&tm);
let bad_version = mtp_codec::CommunicationType::ErrorBadVersion.try_to_id(type_map);
if Some(response.get_type()) == bad_version {
let supported_versions = match response.get_data(DataType::Version) {
DataValue::Str(v) if !v.is_empty() => v.split(',').map(String::from).collect(),
Some(DataValue::Str(v)) if !v.is_empty() => v.split(',').map(String::from).collect(),
_ => vec![],
};
return Ok(HandshakeOutcome::Rejected {
@ -53,7 +55,7 @@ fn parse_handshake_response(
}
let expected = mtp_codec::CommunicationType::IdentificationResponse
.try_to_id(&tm)
.try_to_id(type_map)
.ok_or_else(|| {
CommunicationError::Other("IdentificationResponse is absent from the type map".into())
})?;
@ -68,9 +70,9 @@ fn parse_handshake_response(
}
match response.get_data(DataType::Connected) {
DataValue::BoolTrue => {
Some(DataValue::BoolTrue) => {
let version = match response.get_data(DataType::Version) {
DataValue::Str(v) => v.clone(),
Some(DataValue::Str(v)) => v.clone(),
_ => {
return Err(CommunicationError::Other(
"host omitted the negotiated version".into(),
@ -78,15 +80,21 @@ fn parse_handshake_response(
}
};
let assigned_id = match response.get_data(DataType::Id) {
DataValue::UnsignedNumber(n) => *n as u64,
_ => 0,
Some(DataValue::UnsignedNumber(n)) => u64::try_from(*n).map_err(|_| {
CommunicationError::Other("host returned an out-of-range client id".into())
})?,
_ => {
return Err(CommunicationError::Other(
"host omitted the assigned client id".into(),
));
}
};
Ok(HandshakeOutcome::Accepted {
version,
assigned_id,
})
}
DataValue::BoolFalse => {
Some(DataValue::BoolFalse) => {
let detail = response
.get_str(DataType::ErrorMessage)
.unwrap_or("host rejected the connection")
@ -99,20 +107,34 @@ 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(mtp_codec::CommunicationType::Identification)
.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_with_type_map(
mtp_codec::CommunicationType::Identification,
opening_codec.type_map(),
)
.add_typed_default(DataType::Version, DataValue::Str(version_str))
.add_typed_default(
DataType::Id,
DataValue::UnsignedNumber(config.client_id.into()),
);
if let Some(desc) = &config.description {
ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone()));
}
@ -120,32 +142,47 @@ impl MTPClient {
sender.send(&ident).await?;
let response = receiver.receive().await?;
let outcome = parse_handshake_response(&response)?;
let negotiated = match outcome {
mtp_common::HandshakeOutcome::Accepted { version, .. } => Version::parse(&version)
.ok_or_else(|| {
let outcome = parse_handshake_response(&response, opening_codec.type_map())?;
let (negotiated, assigned_id) = match outcome {
mtp_common::HandshakeOutcome::Accepted {
version,
assigned_id,
} => (
Version::parse(&version).ok_or_else(|| {
CommunicationError::Other("host returned an invalid negotiated version".into())
})?,
assigned_id,
),
mtp_common::HandshakeOutcome::Rejected { reason } => {
sender.close().await;
return Err(CommunicationError::Other(reason.to_string()));
}
};
#[cfg(not(feature = "crypto"))]
let _ = assigned_id;
if negotiated != PROTOCOL_VERSION {
sender.close().await;
return Err(CommunicationError::Other(
"host selected a protocol version the client did not offer".into(),
));
}
let codec = codec_for_version(&negotiated)?;
#[cfg(feature = "crypto")]
let client_id = config.client_id;
let client_id = assigned_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).await)
Ok(connection_from_parts(config, sender, receiver, negotiated, codec).await)
}
}
@ -180,15 +217,26 @@ impl MTPClient {
let (sender, receiver) =
mtp_transport::connect(&config.url, config.server_cert(), config.policy).await?;
let tm = mtp_codec::TypeMap::latest();
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 version_str = format!("{}", PROTOCOL_VERSION);
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),
);
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(keys.public_key_bundle().as_bytes()),
);
if let Some(desc) = &config.description {
ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone()));
}
@ -223,14 +271,14 @@ impl MTPClient {
client_nonce,
);
let proof = match crypto::signed_challenge_response(keys, proof_payload, client_nonce).await
{
Ok(p) => p,
Err(e) => {
sender.close().await;
return Err(e);
}
};
let proof =
match crypto::signed_challenge_response(keys, proof_payload, client_nonce, &tm).await {
Ok(p) => p,
Err(e) => {
sender.close().await;
return Err(e);
}
};
if let Err(e) = sender.send(&proof).await {
sender.close().await;
return Err(e);
@ -276,12 +324,41 @@ 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,
crypto::negotiated_version(&response)?,
negotiated,
codec,
error::AuthState::Authenticated,
client_id,
)
@ -332,12 +409,15 @@ impl MTPClient {
let (sender, receiver) =
mtp_transport::connect(&config.url, config.server_cert(), config.policy).await?;
let tm = mtp_codec::TypeMap::latest();
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 version_str = format!("{}", PROTOCOL_VERSION);
let pk_bundle = keys.public_key_bundle();
let pk_bytes = pk_bundle.as_bytes();
let mut register = CommunicationValue::new(CommunicationType::Register)
let mut register = CommunicationValue::new_with_type_map(CommunicationType::Register, &tm)
.add_typed_default(DataType::Version, DataValue::Str(version_str.clone()))
.add_typed_default(DataType::PublicKeys, DataValue::Bytes(pk_bytes.clone()));
if let Some(desc) = &config.description {
@ -375,14 +455,14 @@ impl MTPClient {
client_nonce,
);
let proof = match crypto::signed_challenge_response(keys, proof_payload, client_nonce).await
{
Ok(p) => p,
Err(e) => {
sender.close().await;
return Err(e);
}
};
let proof =
match crypto::signed_challenge_response(keys, proof_payload, client_nonce, &tm).await {
Ok(p) => p,
Err(e) => {
sender.close().await;
return Err(e);
}
};
if let Err(e) = sender.send(&proof).await {
sender.close().await;
return Err(e);
@ -413,7 +493,11 @@ impl MTPClient {
return Err(e);
}
let assigned_id = match response.get_data(DataType::Id) {
DataValue::UnsignedNumber(n) => *n as u64,
Some(DataValue::UnsignedNumber(n)) => u64::try_from(*n).map_err(|_| {
CommunicationError::AuthenticationFailed(
"host returned an out-of-range client id".into(),
)
})?,
_ => {
sender.close().await;
return Err(CommunicationError::AuthenticationFailed(
@ -435,11 +519,20 @@ 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,
crypto::negotiated_version(&response)?,
negotiated,
codec,
error::AuthState::Authenticated,
assigned_id,
)
@ -504,6 +597,9 @@ 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: Mutex::new(HashMap::new()),
#[cfg(feature = "pipes")]
@ -523,11 +619,47 @@ 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().get_id(), 8);
assert_eq!(app_rx.recv().await.unwrap().unwrap().id(), Some(8));
let response = CommunicationValue::new(mtp_codec::CommunicationType::Pong).with_id(7);
assert!(pipe::route_message(response, &app_tx, &dispatcher).await);
assert_eq!(response_rx.await.unwrap().unwrap().get_id(), 7);
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: Mutex::new(HashMap::new()),
#[cfg(feature = "pipes")]
pending_pipes: Mutex::new(HashMap::new()),
#[cfg(feature = "pipes")]
policy: Arc::new(Policy::default()),
};
let token = Arc::new(());
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
dispatcher.pending_requests.lock().await.insert(
9,
pipe::PendingRequest {
token: token.clone(),
sender: response_tx,
},
);
pipe::expire_pending_request(&dispatcher, 9, &token).await;
let (app_tx, mut app_rx) = tokio::sync::mpsc::channel(1);
let response = CommunicationValue::new(mtp_codec::CommunicationType::Pong).with_id(9);
assert!(pipe::route_message(response, &app_tx, &dispatcher).await);
assert!(response_rx.await.is_err());
assert!(app_rx.try_recv().is_err());
}

View file

@ -3,7 +3,7 @@ use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
use tokio::time::{Duration, Instant};
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap};
use mtp_transport::{Receiver, Sender};
pub(crate) struct PingSession {
@ -59,6 +59,8 @@ pub(crate) async fn start_ping_session(
config: &crate::config::ClientConfig,
sender: Sender,
receiver: &Receiver,
type_map: &TypeMap,
client_id: u64,
) -> Option<PingSession> {
if config.ping_interval.is_zero() {
return None;
@ -72,6 +74,7 @@ pub(crate) async fn start_ping_session(
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 mut close_rx = receiver.handle().subscribe_close();
let task = tokio::spawn(async move {
@ -99,7 +102,11 @@ pub(crate) async fn start_ping_session(
tokio::time::sleep(Duration::from_millis(extra)).await;
}
let mut ping = CommunicationValue::new(CommunicationType::Ping);
let mut ping = CommunicationValue::new_with_type_map(
CommunicationType::Ping,
&type_map,
)
.with_sender(client_id);
if ping_timestamp {
let sent_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -110,7 +117,10 @@ pub(crate) async fn start_ping_session(
DataValue::UnsignedNumber(sent_at),
);
}
let id = ping.get_id();
let Some(id) = ping.id() else {
sender.close().await;
break;
};
if sender.send(&ping).await.is_err() {
sender.close().await;
break;
@ -119,7 +129,9 @@ pub(crate) async fn start_ping_session(
}
pong = pong_rx.recv() => match pong {
Some(pong) => {
if let Some(ping) = tracker.received(pong.get_id()) {
if let Some(id) = pong.id()
&& let Some(ping) = tracker.received(id)
{
let mut last_ping = ping_state.lock().await;
*last_ping = Some(ping);
}

View file

@ -1,9 +1,12 @@
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;
use tokio::sync::{Mutex, mpsc};
use tokio::time::{Duration, Instant};
#[cfg(feature = "pipes")]
use mtp_codec::{CommunicationType, DataType, DataValue};
@ -72,22 +75,50 @@ impl PipeRequest {
pending.insert(self.pipe_id, pipe_tx);
}
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 resp = CommunicationValue::new_with_type_map(
CommunicationType::PipeResponse,
&self.dispatcher.type_map,
)
.with_id(self.pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolTrue);
if let Err(error) = self.sender.send(&resp).await {
self.dispatcher
.pending_pipes
.lock()
.await
.remove(&self.pipe_id);
return Err(PipeError::from(error));
}
let timeout = self.dispatcher.policy.read_timeout;
tokio::time::timeout(timeout, pipe_rx)
.await
.map_err(|_| PipeError::HandshakeTimeout)?
.map_err(|_| PipeError::StreamClosed)
match tokio::time::timeout(timeout, pipe_rx).await {
Ok(Ok(reader)) => Ok(reader),
Ok(Err(_)) => {
self.dispatcher
.pending_pipes
.lock()
.await
.remove(&self.pipe_id);
Err(PipeError::StreamClosed)
}
Err(_) => {
self.dispatcher
.pending_pipes
.lock()
.await
.remove(&self.pipe_id);
Err(PipeError::HandshakeTimeout)
}
}
}
pub async fn deny(self) -> Result<(), PipeError> {
let resp = CommunicationValue::new(CommunicationType::PipeResponse)
.with_id(self.pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
let resp = CommunicationValue::new_with_type_map(
CommunicationType::PipeResponse,
&self.dispatcher.type_map,
)
.with_id(self.pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
self.sender.send(&resp).await.map_err(PipeError::from)?;
Ok(())
}
@ -100,6 +131,9 @@ pub(crate) struct PendingRequest {
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:
Mutex<HashMap<u32, tokio::sync::oneshot::Sender<Result<bool, PipeError>>>>,
@ -115,14 +149,27 @@ pub(crate) async fn route_message(
app_tx: &mpsc::Sender<Result<CommunicationValue, CommunicationError>>,
dispatcher: &PipeDispatcher,
) -> bool {
let pending = dispatcher
.pending_requests
.lock()
.await
.remove(&msg.get_id());
if let Some(tx) = pending {
let _ = tx.sender.send(Ok(msg));
return true;
if !matches!(msg.id(), Some(id) if id != 0)
&& msg
.get_type_name()
.is_some_and(|name| name.ends_with("Response"))
{
return app_tx
.send(Err(CommunicationError::Other(
"response frame must contain a non-zero id".into(),
)))
.await
.is_ok();
}
if let Some(id) = msg.id() {
let pending = dispatcher.pending_requests.lock().await.remove(&id);
if let Some(tx) = pending {
let _ = tx.sender.send(Ok(msg));
return true;
}
if consume_expired_request(dispatcher, id).await {
return true;
}
}
app_tx.send(Ok(msg)).await.is_ok()
@ -135,6 +182,44 @@ 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 {
if 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,
@ -149,6 +234,13 @@ 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,
@ -157,14 +249,19 @@ 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 Some(msg.get_type()) == pipe_req_type {
let pipe_id = msg.get_id();
if msg.is_type(CommunicationType::PipeRequest) {
let Some(pipe_id) = msg.id().filter(|id| *id != 0) else {
let error = CommunicationError::Other(
"PipeRequest frame must contain a non-zero id".into(),
);
if app_tx.send(Err(error)).await.is_err() {
break;
}
continue;
};
let description = msg.get_str(DataType::Description).unwrap_or("").to_string();
let req = PipeRequest {
pipe_id,
@ -176,8 +273,16 @@ pub(crate) async fn run_dispatcher(
continue;
}
if Some(msg.get_type()) == pipe_resp_type {
let pipe_id = msg.get_id();
if msg.is_type(CommunicationType::PipeResponse) {
let Some(pipe_id) = msg.id().filter(|id| *id != 0) else {
let error = CommunicationError::Other(
"PipeResponse frame must contain a non-zero id".into(),
);
if app_tx.send(Err(error)).await.is_err() {
break;
}
continue;
};
let accepted = msg.get_bool(DataType::Accepted).unwrap_or(false);
let mut pending = dispatcher.pending_creations.lock().await;
if let Some(tx) = pending.remove(&pipe_id) {

View file

@ -10,6 +10,7 @@ mtp-crypto = { version = "0.2.0", path = "../crypto", optional = true }
base64 = "0.23"
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

View file

@ -1,11 +1,31 @@
pub mod communication_value;
pub mod data_value;
#[cfg(feature = "crypto")]
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 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::{DataKind, DataValue, DecodeLimits};
pub use mtp_common::{CodecError, TimeError, unix_time_millis};
#[cfg(feature = "crypto")]
pub use protected::{
CURRENT_PROTECTED_VERSION, InMemoryReplayGuard, ProtectedError, ProtectedMessageBuilder,
ReplayError, ReplayGuard, VerifiedProtectedMessage, open_protected, open_protected_with,
open_protected_with_keys, protected_claimed_signer_id,
};
#[cfg(feature = "crypto")]
pub use relay::{
CURRENT_RELAY_VERSION, RelayError, SealedRelayBuilder, VerifiedRelayContent,
VerifiedRelayMetadata, forward_relay_frame, open_relay_content,
open_relay_content_with_keyrings, open_relay_content_with_keys, open_relay_metadata,
open_relay_metadata_with, open_relay_metadata_with_keys, relay_metadata_claimed_signer_id,
};
pub use mtp_type_map::{
CommunicationType, CommunicationTypeId, DataType, DataTypeId, PROTOCOL_VERSION, TypeMap,
@ -13,7 +33,12 @@ pub use mtp_type_map::{
};
pub(crate) fn rand_u32() -> u32 {
rand::random()
loop {
let value = rand::random();
if value != 0 {
return value;
}
}
}
#[cfg(feature = "registry")]

1186
codec/src/protected.rs Normal file

File diff suppressed because it is too large Load diff

1280
codec/src/relay.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,32 @@
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")]
@ -25,6 +52,24 @@ 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;

View file

@ -8,7 +8,7 @@ ignored = ["rand_core"]
[dependencies]
chacha20poly1305 = { version = "0.10", optional = true }
aes-gcm = { version = "0.10", optional = true }
aes-gcm = { version = "0.11", optional = true }
ed25519-dalek = { version = "3.0", optional = true, features = [
"pkcs8",
"pem",
@ -16,9 +16,9 @@ ed25519-dalek = { version = "3.0", optional = true, features = [
hkdf = { version = "0.13", optional = true }
sha2 = { version = "0.11", optional = true }
zeroize = { version = "1.9", features = ["derive"] }
thiserror = "2"
base64 = "0.23"
rand_core = { version = "0.10.1" }
thiserror = "1"
base64 = "0.22"
rand_core = { version = "0.6", features = ["getrandom"] }
rand = "0.10.2"
getrandom = "0.4.3"
mlkem-tls = { version = "0.2", optional = true }

View file

@ -6,6 +6,15 @@ 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>;
}
@ -27,12 +36,12 @@ fn prepend_nonce(nonce: &[u8], ciphertext: &mut Vec<u8>) -> Vec<u8> {
}
#[cfg(feature = "chacha20poly1305")]
pub struct ChaCha20Poly1305 {
pub struct XChaCha20Poly1305 {
key: Zeroizing<[u8; 32]>,
}
#[cfg(feature = "chacha20poly1305")]
impl ChaCha20Poly1305 {
impl XChaCha20Poly1305 {
pub fn new(key: [u8; 32]) -> Self {
Self {
key: Zeroizing::new(key),
@ -41,7 +50,7 @@ impl ChaCha20Poly1305 {
}
#[cfg(feature = "chacha20poly1305")]
impl AeadEncrypt for ChaCha20Poly1305 {
impl AeadEncrypt for XChaCha20Poly1305 {
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
use chacha20poly1305::XChaCha20Poly1305;
use chacha20poly1305::XNonce;
@ -50,7 +59,7 @@ impl AeadEncrypt for ChaCha20Poly1305 {
let key = chacha20poly1305::Key::from_slice(self.key.as_ref());
let cipher = XChaCha20Poly1305::new(key);
let mut nonce = [0u8; 24];
let mut nonce = [0u8; XCHACHA20POLY1305_NONCE_LEN];
fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?;
let nonce_ref = XNonce::from_slice(&nonce);
@ -68,17 +77,17 @@ impl AeadEncrypt for ChaCha20Poly1305 {
}
#[cfg(feature = "chacha20poly1305")]
impl AeadDecrypt for ChaCha20Poly1305 {
impl AeadDecrypt for XChaCha20Poly1305 {
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() < 24 {
if ciphertext.len() < XCHACHA20POLY1305_NONCE_LEN + AUTH_TAG_LEN {
return Err(CryptoError::InvalidNonceLength);
}
let (nonce, ct) = ciphertext.split_at(24);
let (nonce, ct) = ciphertext.split_at(XCHACHA20POLY1305_NONCE_LEN);
let key = chacha20poly1305::Key::from_slice(self.key.as_ref());
let cipher = XChaCha20Poly1305::new(key);
let nonce_ref = XNonce::from_slice(nonce);
@ -92,12 +101,17 @@ impl AeadDecrypt for ChaCha20Poly1305 {
}
#[cfg(feature = "chacha20poly1305")]
impl AeadCipher for ChaCha20Poly1305 {
impl AeadCipher for XChaCha20Poly1305 {
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]>,
@ -122,7 +136,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; 12];
let mut nonce = [0u8; AES256GCM_NONCE_LEN];
fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?;
let nonce_ref = Nonce::from_slice(&nonce);
@ -146,11 +160,11 @@ impl AeadDecrypt for Aes256Gcm {
use aes_gcm::Nonce;
use aes_gcm::aead::{Aead, KeyInit, Payload};
if ciphertext.len() < 12 {
if ciphertext.len() < AES256GCM_NONCE_LEN + AUTH_TAG_LEN {
return Err(CryptoError::InvalidNonceLength);
}
let (nonce, ct) = ciphertext.split_at(12);
let (nonce, ct) = ciphertext.split_at(AES256GCM_NONCE_LEN);
let key = aes_gcm::Key::<AesGcmInner>::from_slice(self.key.as_ref());
let cipher = AesGcmInner::new(key);
let nonce_ref = Nonce::from_slice(nonce);

View file

@ -1,19 +1,15 @@
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::error::CryptoError;
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::kdf::derive_encryption_key;
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
#[cfg(feature = "mlkem-tls")]
use crate::kem::HybridKem;
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::keypair::{Keyring, PublicKeyBundle};
/*
* Algorithm selector for encrypted containers.
* Algorithm selector for encrypted values.
*
* Mirrors `SigAlgorithm` for signatures: a single marking byte identifies the
* key-encapsulation mechanism and the AEAD used to seal a container. The byte
* is stored as the first byte of every encrypted blob so the decryptor can pick
* is stored as the first byte of every encrypted envelope so the decryptor can pick
* the matching algorithm (and the matching keypair from a `Keyring`) without
* any out-of-band agreement.
*
@ -34,7 +30,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 blob.
/// The marking byte written at the front of an encrypted envelope.
pub const fn to_byte(self) -> u8 {
match self {
Self::MlKemChaCha20Poly1305 => Self::ML_KEM_CHACHA20POLY1305,
@ -50,6 +46,50 @@ 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)
}
}
/*
@ -59,7 +99,7 @@ impl EncryptionType {
*/
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
#[allow(unused_variables)]
fn aead_seal(
pub fn seal_with_key(
enc_type: EncryptionType,
key: [u8; 32],
plaintext: &[u8],
@ -70,7 +110,7 @@ fn aead_seal(
match enc_type {
#[cfg(feature = "chacha20poly1305")]
EncryptionType::MlKemChaCha20Poly1305 => {
crate::aead::ChaCha20Poly1305::new(key).encrypt(plaintext, aad)
crate::aead::XChaCha20Poly1305::new(key).encrypt(plaintext, aad)
}
#[cfg(feature = "aes-gcm")]
EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).encrypt(plaintext, aad),
@ -86,7 +126,7 @@ fn aead_seal(
*/
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
#[allow(unused_variables)]
fn aead_open(
pub fn open_with_key(
enc_type: EncryptionType,
key: [u8; 32],
ciphertext: &[u8],
@ -97,7 +137,7 @@ fn aead_open(
match enc_type {
#[cfg(feature = "chacha20poly1305")]
EncryptionType::MlKemChaCha20Poly1305 => {
crate::aead::ChaCha20Poly1305::new(key).decrypt(ciphertext, aad)
crate::aead::XChaCha20Poly1305::new(key).decrypt(ciphertext, aad)
}
#[cfg(feature = "aes-gcm")]
EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).decrypt(ciphertext, aad),
@ -106,76 +146,51 @@ fn aead_open(
}
}
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
const ENC_KDF_SALT: &[u8] = b"mtp-container-enc";
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
const ENC_KDF_CONTEXT: &[u8] = b"single-recipient";
/*
* Encrypt `plaintext` for a single recipient, selecting the algorithm with
* `enc_type` and the recipient's KEM public key from `recipient`.
*
* The returned, self-describing blob is laid out as:
* [1 byte EncryptionType] [2 bytes u16 kem_ct_len] [kem_ciphertext] [aead_payload]
* where `aead_payload` is the AEAD output (nonce + ciphertext + tag). The AEAD
* key is derived from the KEM shared secret via HKDF, so no separate content key
* is transmitted.
*
* Requires the `mlkem-tls` and `hkdf` features, plus the AEAD feature backing
* `enc_type`.
*/
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub fn encrypt_for(
enc_type: EncryptionType,
recipient: &PublicKeyBundle,
plaintext: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, CryptoError> {
let enc = HybridKem::encapsulate(&recipient.kem_public_key)?;
let key = derive_encryption_key(&enc.shared_secret, ENC_KDF_SALT, ENC_KDF_CONTEXT)?;
let aead_payload = aead_seal(enc_type, key, plaintext, aad)?;
let kem_ct = enc.ciphertext;
let mut out = Vec::with_capacity(1 + 2 + kem_ct.len() + aead_payload.len());
out.push(enc_type.to_byte());
out.extend_from_slice(&(kem_ct.len() as u16).to_be_bytes());
out.extend_from_slice(&kem_ct);
out.extend_from_slice(&aead_payload);
Ok(out)
}
/*
* Decrypt a blob produced by [`encrypt_for`] using `keyring`.
*
* The leading byte selects the `EncryptionType` (and thus which keypair to use
* from the keyring); for the current ML-KEM variants that is `kem_secret_key`.
* Returns `DecryptionFailed` on any malformed input or authentication failure.
*
* Requires the `mlkem-tls` and `hkdf` features, plus the AEAD feature backing
* the blob's algorithm.
*/
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub fn decrypt_with(blob: &[u8], keyring: &Keyring, aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
if blob.len() < 3 {
return Err(CryptoError::DecryptionFailed);
}
let enc_type = EncryptionType::from_byte(blob[0]).ok_or(CryptoError::DecryptionFailed)?;
let kem_ct_len = u16::from_be_bytes([blob[1], blob[2]]) as usize;
let kem_end = 3usize
.checked_add(kem_ct_len)
.ok_or(CryptoError::DecryptionFailed)?;
let kem_ct = blob.get(3..kem_end).ok_or(CryptoError::DecryptionFailed)?;
let aead_payload = blob.get(kem_end..).ok_or(CryptoError::DecryptionFailed)?;
let shared_secret = HybridKem::decapsulate(&keyring.kem_secret_key, kem_ct)?;
let key = derive_encryption_key(&shared_secret, ENC_KDF_SALT, ENC_KDF_CONTEXT)?;
aead_open(enc_type, key, aead_payload, aad)
}
#[cfg(test)]
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 [
@ -188,59 +203,19 @@ mod tests {
assert_eq!(EncryptionType::from_byte(0xFF), None);
}
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
#[test]
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());
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
);
}
}

View file

@ -6,8 +6,16 @@ pub enum CryptoError {
EncryptionFailed,
#[error("decryption failed")]
DecryptionFailed,
#[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")]

View file

@ -1,208 +1,328 @@
// Canonical multi-recipient encryption envelopes.
use crate::enc::EncryptionType;
use crate::error::CryptoError;
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305};
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::enc::{open_with_key, seal_with_key};
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::kdf::derive_encryption_key;
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::kem::HybridKem;
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use crate::keypair::{Keyring, PublicKeyBundle};
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use rand::Rng;
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
use zeroize::Zeroizing;
pub const ENCRYPT_DOMAIN: &[u8] = b"MTP-DATA-ENC-1";
pub const KEY_WRAP_DOMAIN: &[u8] = b"MTP-DATA-WRAP-1";
/// Operational cap for recipient entries accepted in one envelope.
///
/// The wire count remains a `u16` for format stability, but decapsulation is
/// intentionally bounded because each entry can require a KEM operation.
pub const MAX_RECIPIENTS: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecipientEntry {
pub kem_ciphertext: Vec<u8>,
pub encrypted_key: Vec<u8>,
}
/*
* A payload encrypted for multiple recipients.
*
* Any recipient who possesses the corresponding `KemPrivateKey` can decrypt the message.
*/
/// The envelope body used by `DataValue::Encrypted`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MultiEncryptedMessage {
pub encryption_type: EncryptionType,
pub purpose: u8,
pub recipients: Vec<RecipientEntry>,
pub nonce: [u8; 24],
/// The AEAD output, including its nonce as defined by the selected suite.
pub ciphertext: Vec<u8>,
}
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> {
/// 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.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.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.nonce);
out.extend_from_slice(&self.ciphertext);
out
Ok(out)
}
/// Deserialize from bytes produced by `to_bytes`.
/// Parse the canonical envelope body.
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,
});
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() + encryption_type.wrapped_key_len();
let entries_len = count
.checked_mul(entry_len)
.ok_or(CryptoError::MalformedEnvelope)?;
let start = 4usize;
let end = start
.checked_add(entries_len)
.ok_or(CryptoError::MalformedEnvelope)?;
let ciphertext_len = bytes
.len()
.checked_sub(end)
.ok_or(CryptoError::MalformedEnvelope)?;
if ciphertext_len < encryption_type.minimum_ciphertext_len() {
return Err(CryptoError::MalformedEnvelope);
}
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();
let mut offset = start;
let mut recipients = Vec::with_capacity(count);
for _ in 0..count {
let kem_end = offset + encryption_type.kem_ciphertext_len();
let wrapped_end = kem_end + encryption_type.wrapped_key_len();
recipients.push(RecipientEntry {
kem_ciphertext: bytes[offset..kem_end].to_vec(),
encrypted_key: bytes[kem_end..wrapped_end].to_vec(),
});
offset = wrapped_end;
}
Ok(Self {
encryption_type,
purpose,
recipients,
nonce,
ciphertext,
ciphertext: bytes[offset..].to_vec(),
})
}
}
/*
* 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(
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
fn wrap_aad(encryption_type: EncryptionType, purpose: u8, kem_ciphertext: &[u8]) -> Vec<u8> {
let mut aad = Vec::with_capacity(KEY_WRAP_DOMAIN.len() + 2 + kem_ciphertext.len());
aad.extend_from_slice(KEY_WRAP_DOMAIN);
aad.push(encryption_type.to_byte());
aad.push(purpose);
aad.extend_from_slice(kem_ciphertext);
aad
}
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
fn payload_aad(message: &MultiEncryptedMessage) -> Result<Vec<u8>, CryptoError> {
let count =
u16::try_from(message.recipients.len()).map_err(|_| CryptoError::MalformedEnvelope)?;
let mut aad = Vec::new();
aad.extend_from_slice(ENCRYPT_DOMAIN);
aad.push(message.encryption_type.to_byte());
aad.push(message.purpose);
aad.extend_from_slice(&count.to_be_bytes());
for recipient in &message.recipients {
aad.extend_from_slice(&recipient.kem_ciphertext);
aad.extend_from_slice(&recipient.encrypted_key);
}
Ok(aad)
}
/// Encrypt a value for one or more recipients using the canonical envelope.
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub fn encrypt_multi_for(
encryption_type: EncryptionType,
purpose: u8,
plaintext: &[u8],
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,
b"mtp-multi-key-wrap",
b"multi-recipient",
KEY_WRAP_DOMAIN,
&[encryption_type.to_byte(), purpose],
)?);
let wrap_cipher = ChaCha20Poly1305::new(*wrap_key);
let encrypted_key = wrap_cipher.encrypt(cek.as_ref(), b"")?;
let aad = wrap_aad(encryption_type, purpose, &enc.ciphertext);
let encrypted_key = seal_with_key(encryption_type, *wrap_key, cek.as_ref(), &aad)?;
recipients.push(RecipientEntry {
kem_ciphertext: enc.ciphertext,
encrypted_key,
});
}
Ok(MultiEncryptedMessage {
let mut message = MultiEncryptedMessage {
encryption_type,
purpose,
recipients,
nonce,
ciphertext,
})
ciphertext: Vec::new(),
};
let aad = payload_aad(&message)?;
message.ciphertext = seal_with_key(encryption_type, *cek, plaintext, &aad)?;
Ok(message)
}
/*
* 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],
/// 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,
keyring: &Keyring,
) -> Result<Vec<u8>, CryptoError> {
for entry in &msg.recipients {
let ss = match HybridKem::decapsulate(&keyring.kem_secret_key, &entry.kem_ciphertext) {
Ok(s) => s,
Err(_) => continue,
};
if message.recipients.is_empty()
|| message.recipients.len() > MAX_RECIPIENTS
|| message.purpose != purpose
|| message.ciphertext.len() < message.encryption_type.minimum_ciphertext_len()
|| message.recipients.iter().any(|recipient| {
recipient.kem_ciphertext.len() != message.encryption_type.kem_ciphertext_len()
|| recipient.encrypted_key.len() != message.encryption_type.wrapped_key_len()
})
{
return Err(CryptoError::MalformedEnvelope);
}
let payload_aad = payload_aad(message)?;
for entry in &message.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(
&ss,
b"mtp-multi-key-wrap",
b"multi-recipient",
&shared_secret,
KEY_WRAP_DOMAIN,
&[message.encryption_type.to_byte(), purpose],
)?);
let wrap_cipher = ChaCha20Poly1305::new(*wrap_key);
let cek = match wrap_cipher.decrypt(&entry.encrypted_key, b"") {
Ok(k) => Zeroizing::new(k),
let aad = wrap_aad(message.encryption_type, purpose, &entry.kem_ciphertext);
let cek = match open_with_key(
message.encryption_type,
*wrap_key,
&entry.encrypted_key,
&aad,
) {
Ok(key) => key,
Err(_) => continue,
};
let cek_arr = Zeroizing::new(
cek.as_slice()
.try_into()
.map_err(|_| CryptoError::DecryptionFailed)?,
let cek: [u8; 32] = cek.try_into().map_err(|_| CryptoError::DecryptionFailed)?;
return open_with_key(
message.encryption_type,
cek,
&message.ciphertext,
&payload_aad,
);
}
Err(CryptoError::NoMatchingRecipient)
}
#[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 full_ct = Vec::with_capacity(24 + msg.ciphertext.len());
full_ct.extend_from_slice(&msg.nonce);
full_ct.extend_from_slice(&msg.ciphertext);
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 data_cipher = ChaCha20Poly1305::new(*cek_arr);
return data_cipher.decrypt(&full_ct, aad);
let mut wrong_recipient_table = message.clone();
wrong_recipient_table.recipients[1].encrypted_key[0] ^= 1;
assert!(
decrypt_multi_for(&wrong_recipient_table, message.purpose, &recipient_a).is_err(),
"mutating another recipient's table entry must invalidate the payload"
);
let mut wrong_ciphertext = message;
let last = wrong_ciphertext.ciphertext.len() - 1;
wrong_ciphertext.ciphertext[last] ^= 1;
assert!(
decrypt_multi_for(&wrong_ciphertext, wrong_ciphertext.purpose, &recipient_a).is_err(),
"mutating the ciphertext must invalidate the envelope"
);
Ok(())
}
#[cfg(feature = "mlkem-tls")]
#[test]
fn rejects_envelopes_without_a_complete_aead_payload() {
let encryption_type = EncryptionType::MlKemChaCha20Poly1305;
let message = MultiEncryptedMessage {
encryption_type,
purpose: 1,
recipients: vec![RecipientEntry {
kem_ciphertext: vec![0; encryption_type.kem_ciphertext_len()],
encrypted_key: vec![0; encryption_type.wrapped_key_len()],
}],
ciphertext: vec![0; encryption_type.minimum_ciphertext_len() - 1],
};
assert!(matches!(
message.to_bytes(),
Err(CryptoError::MalformedEnvelope)
));
let mut encoded = vec![encryption_type.to_byte(), 1, 0, 1];
encoded.extend_from_slice(&vec![0; encryption_type.kem_ciphertext_len()]);
encoded.extend_from_slice(&vec![0; encryption_type.wrapped_key_len()]);
encoded.extend_from_slice(&vec![0; encryption_type.minimum_ciphertext_len() - 1]);
assert!(matches!(
MultiEncryptedMessage::from_bytes(&encoded),
Err(CryptoError::MalformedEnvelope)
));
}
Err(CryptoError::DecryptionFailed)
}

View file

@ -12,11 +12,13 @@ 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) {
/* 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);
let (ek, dk) = mlkem_tls::X25519MlKem768::keygen(&mut rand_core::OsRng);
(
KemPrivateKey::new(dk.as_bytes().to_vec()),
KemPublicKey::new(ek.as_bytes().to_vec()),
@ -26,10 +28,7 @@ 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 chacha20poly1305::aead::rand_core::OsRng,
);
let (ct, ss) = mlkem_tls::X25519MlKem768::encapsulate(&ek, &mut rand_core::OsRng);
Ok(Encapsulated {
ciphertext: ct.as_bytes().to_vec(),
shared_secret: Zeroizing::new(ss.as_bytes().to_vec()),

View file

@ -237,7 +237,91 @@ impl Keyring {
}
}
pub fn to_bytes(&self) -> Zeroizing<Vec<u8>> {
/// Validate the material required to produce classical signatures. This
/// intentionally permits a browser role-specific keyring without KEM or
/// PQ fields.
pub fn validate_ed25519_signing(&self) -> Result<(), crate::error::CryptoError> {
use crate::error::CryptoError;
if self.sig_cl_secret_key.as_bytes().len() != 32
|| self.sig_cl_public_key.as_bytes().len() != SIG_CL_PUBLIC_KEY_LEN
{
return Err(CryptoError::InvalidKeyLength);
}
#[cfg(feature = "ed25519-dalek")]
{
let signer = crate::sign::Ed25519Signer::new(&self.sig_cl_secret_key)?;
if signer.public_key().as_bytes() != self.sig_cl_public_key.as_bytes() {
return Err(CryptoError::InvalidKeyMaterial);
}
}
Ok(())
}
/// Validate material required for a hybrid Ed25519 + ML-DSA signature.
pub fn validate_dual_signing(&self) -> Result<(), crate::error::CryptoError> {
use crate::error::CryptoError;
self.validate_ed25519_signing()?;
if self.sig_pq_secret_key.as_bytes().len() != 32
|| self.sig_pq_public_key.as_bytes().len() != SIG_PQ_PUBLIC_KEY_LEN
{
return Err(CryptoError::InvalidKeyLength);
}
#[cfg(feature = "ml-dsa")]
{
let signer =
crate::sign::MlDsaSigner::new(&self.sig_pq_secret_key, &self.sig_pq_public_key)?;
if signer.public_key().as_bytes() != self.sig_pq_public_key.as_bytes() {
return Err(CryptoError::InvalidKeyMaterial);
}
}
Ok(())
}
/// Validate the KEM material required to decrypt envelopes addressed to
/// this keyring. This is intentionally separate from full identity
/// validation because browser and relay roles may use Ed25519-only
/// signing material while still needing a complete encryption key pair.
pub fn validate_encryption(&self) -> Result<(), crate::error::CryptoError> {
use crate::error::CryptoError;
if self.kem_public_key.as_bytes().is_empty() || self.kem_secret_key.as_bytes().is_empty() {
return Err(CryptoError::InvalidKeyLength);
}
#[cfg(feature = "mlkem-tls")]
{
let encapsulated = crate::kem::HybridKem::encapsulate(&self.kem_public_key)?;
let recovered =
crate::kem::HybridKem::decapsulate(&self.kem_secret_key, &encapsulated.ciphertext)?;
if recovered.as_slice() != encapsulated.shared_secret.as_slice() {
return Err(CryptoError::InvalidKeyMaterial);
}
}
Ok(())
}
/// Validate a complete identity before using it at a protocol boundary.
///
/// `Keyring` remains permissive because browser callers may intentionally
/// hold role-specific material. Protocol paths that need encryption and
/// both signing suites should call this method explicitly.
pub fn validate_full(&self) -> Result<(), crate::error::CryptoError> {
use crate::error::CryptoError;
self.public_key_bundle().validate()?;
self.validate_encryption()?;
if self.sig_pq_secret_key.as_bytes().len() != 32
|| self.sig_cl_secret_key.as_bytes().len() != 32
{
return Err(CryptoError::InvalidKeyLength);
}
#[cfg(all(feature = "mlkem-tls", feature = "ml-dsa", feature = "ed25519-dalek"))]
{
self.validate_dual_signing()?;
}
Ok(())
}
pub fn try_to_bytes(&self) -> Result<Zeroizing<Vec<u8>>, crate::error::CryptoError> {
let fields: &[&[u8]] = &[
self.kem_public_key.as_bytes(),
self.kem_secret_key.as_bytes(),
@ -248,10 +332,17 @@ impl Keyring {
];
let mut out = Zeroizing::new(Vec::new());
for f in fields {
out.extend_from_slice(&(f.len() as u16).to_be_bytes());
let length =
u16::try_from(f.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?;
out.extend_from_slice(&length.to_be_bytes());
out.extend_from_slice(f);
}
out
Ok(out)
}
pub fn to_bytes(&self) -> Zeroizing<Vec<u8>> {
self.try_to_bytes()
.expect("key material length exceeds wire limit")
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, crate::error::CryptoError> {
@ -283,6 +374,13 @@ 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)
}
})
}
pub fn to_hex(&self) -> String {
@ -352,7 +450,6 @@ 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 {
@ -364,25 +461,70 @@ 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 as_bytes(&self) -> Vec<u8> {
pub fn try_as_bytes(&self) -> Result<Vec<u8>, crate::error::CryptoError> {
let kem = self.kem_public_key.as_bytes();
let 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() as u16).to_be_bytes());
out.extend_from_slice(&kem_len.to_be_bytes());
out.extend_from_slice(kem);
out.extend_from_slice(&(pq.len() as u16).to_be_bytes());
out.extend_from_slice(&pq_len.to_be_bytes());
out.extend_from_slice(pq);
out.extend_from_slice(&(cl.len() as u16).to_be_bytes());
out.extend_from_slice(&cl_len.to_be_bytes());
out.extend_from_slice(cl);
out
Ok(out)
}
pub fn as_bytes(&self) -> Vec<u8> {
self.try_as_bytes()
.expect("public key bundle field exceeds wire limit")
}
/// 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;
@ -422,6 +564,11 @@ 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,
@ -430,6 +577,11 @@ 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)
}
pub fn to_base64(&self) -> String {
bytes_to_base64(&self.as_bytes())
}
@ -437,6 +589,10 @@ impl PublicKeyBundle {
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 {
@ -474,7 +630,7 @@ mod tests {
let bundle = PublicKeyBundle::new(kem, pq, cl);
let bytes = bundle.as_bytes();
let recovered = PublicKeyBundle::from_bytes(&bytes)?;
let recovered = PublicKeyBundle::from_bytes_unvalidated(&bytes)?;
assert_eq!(
bundle.kem_public_key.as_bytes(),
@ -491,6 +647,24 @@ 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(
@ -499,7 +673,7 @@ mod tests {
SignaturePublicKey::new(vec![0xEFu8; 32]),
);
let bytes: Vec<u8> = Vec::from(&bundle);
let recovered = PublicKeyBundle::try_from(bytes.as_slice())?;
let recovered = PublicKeyBundle::from_bytes_unvalidated(bytes.as_slice())?;
assert_eq!(bundle.as_bytes(), recovered.as_bytes());
Ok(())
}
@ -531,6 +705,53 @@ 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() {
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.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.as_bytes();
bundle_bytes.push(0xBB);
assert!(PublicKeyBundle::from_bytes(&bundle_bytes).is_err());
}
#[test]
fn validated_bundle_rejects_partial_suite_keys() {
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.as_bytes()).is_err());
}
#[test]
fn keyring_try_from_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
let keyring = Keyring::new(
@ -597,7 +818,7 @@ mod tests {
SignaturePublicKey::new(vec![3u8; 32]),
);
let b64 = bundle.to_base64();
let recovered = PublicKeyBundle::from_base64(&b64)?;
let recovered = PublicKeyBundle::from_base64_unvalidated(&b64)?;
assert_eq!(bundle.as_bytes(), recovered.as_bytes());
Ok(())
}

View file

@ -43,7 +43,7 @@ pub use keypair::{
};
#[cfg(feature = "chacha20poly1305")]
pub use aead::ChaCha20Poly1305;
pub use aead::{ChaCha20Poly1305, XChaCha20Poly1305};
#[cfg(feature = "aes-gcm")]
pub use aead::Aes256Gcm;
@ -55,7 +55,7 @@ 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, sign_dual};
pub use sign::{DualSignature, DualSigner, sign_dual};
#[cfg(feature = "sha2")]
pub use hash::{Sha256Hasher, sha256, sha256_double};
@ -79,11 +79,12 @@ pub fn ensure_crypto_provider() {
});
}
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub use enc::{decrypt_with, encrypt_for};
pub use helper::{ENCRYPT_DOMAIN, KEY_WRAP_DOMAIN};
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
pub use helper::{MultiEncryptedMessage, RecipientEntry, decrypt_multi, encrypt_multi};
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub use helper::{
MAX_RECIPIENTS, MultiEncryptedMessage, RecipientEntry, decrypt_multi_for, encrypt_multi_for,
};
/* ================================ TESTS ================================ */
#[cfg(test)]
@ -209,6 +210,20 @@ 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() {
@ -343,17 +358,46 @@ mod tests {
assert_eq!(enc.shared_secret, ss);
}
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
#[test]
fn encrypt_multi_roundtrip() {
use crate::helper::{decrypt_multi, encrypt_multi};
#[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};
use crate::keypair::Keyring;
let kr = Keyring::generate();
let entities = vec![kr.public_key_bundle()];
let msg = b"secret data";
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");
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");
assert_eq!(pt, msg);
}
#[cfg(all(
feature = "mlkem-tls",
feature = "hkdf",
feature = "ml-dsa",
feature = "ed25519-dalek",
feature = "chacha20poly1305"
))]
#[test]
fn chacha20_multi_envelope_roundtrip() {
multi_envelope_roundtrip(EncryptionType::MlKemChaCha20Poly1305);
}
#[cfg(all(
feature = "mlkem-tls",
feature = "hkdf",
feature = "ml-dsa",
feature = "ed25519-dalek",
feature = "aes-gcm"
))]
#[test]
fn aes_gcm_multi_envelope_roundtrip() {
multi_envelope_roundtrip(EncryptionType::MlKemAes256Gcm);
}
}

View file

@ -25,6 +25,8 @@ 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>;
}
@ -76,6 +78,10 @@ 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();
@ -170,6 +176,10 @@ 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
@ -237,6 +247,75 @@ 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(

View file

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

View file

@ -10,7 +10,7 @@ Native clients and hosts share the same connection shape after the opening hands
| `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` |
| `request_path` | — | — | 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 members as the native host connection plus `request_path`, which contains the HTTP/3 path used for the WebTransport extended CONNECT request.

View file

@ -124,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` and keeps the enum as the union of all configured type 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`. Native hosts built with the registry feature keep an enum union across configured versions; a browser client and its generated `mtp/type-map` declarations use only the map selected by that client's `protocol_version`, plus reserved names.
For a backward-compatible change, keep existing communication and data IDs stable and add new types with the new version. For a breaking change, add a new version and register both versions on the host while clients migrate. A client compiles one protocol version; it can connect only when that version is present in the host registry. Remove an old version only after its clients no longer connect, because the host closes connections whose version is unsupported.

View file

@ -18,7 +18,7 @@ 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.get_id());
println!("received {:?}", response.id());
conn.sender.close();
```
@ -259,48 +259,51 @@ Sends a close frame and signals the peer. The `Sender::close()` spawns an async
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: Crypto Containers
## Appendix: Composable Data Protection
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.
With the `crypto` feature, any `DataValue` can be signed or encrypted. The operations return typed errors and compose by operation order. `Encrypted(Signed(Value))` keeps the signer identity inside the encrypted plaintext; `Signed(Encrypted(Value))` leaves it visible. The example uses different keyrings for the signer and recipient to make the ownership explicit.
```rust
use mtp::crypto::{EncryptionType, Ed25519Signer, SigAlgorithm};
use mtp::codec::{ProtectionPurpose, DataTypeId, DataValue};
use mtp::crypto::{Ed25519Signer, Keyring};
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())),
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())),
]);
enc.encrypt_container(enc_type, &recipient, b"aad");
// 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");
// 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),
)?;
```
> 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.
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):
Reverse the calls when the signer identity should remain visible to the recipient before opening the encrypted value:
```rust
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
let encrypted = value.encrypt_for(
std::slice::from_ref(&recipient),
ProtectionPurpose::from(2),
)?;
let public_signer = encrypted.sign(7, ProtectionPurpose::from(1), &signer)?;
```
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:

View file

@ -124,7 +124,7 @@ 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.get_id());
println!("received MTP message {:?}", message.id());
}
}
```
@ -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 48-bit 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 full-width `u64` client ID. `guest_id_generator` is not used by this adapter. |
| `AllowAuthentication` | The server accepts the first message. If it is an `Identification` or `Register` message, a full challenge-response handshake is performed. If it is an ordinary opening message, the connection remains unauthenticated. |
| `ForceAuthentication` | The server requires a valid `Identification` or `Register` message as the first frame and performs the challenge-response handshake. Any other opening message is rejected. |

View file

@ -157,10 +157,9 @@ 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 48-bit 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 full-width `u64` ID and checks it against `get_existing_client` to avoid collisions.
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.
Return `Some(id)` to accept the guest with that full-width `u64` ID, or `None` to reject the connection.
```rust
use std::sync::atomic::{AtomicU64, Ordering};

View file

@ -1,6 +1,14 @@
# MTP Pipes
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.
Pipes are unidirectional QUIC/WebTransport streams. The transport primitive is
byte-oriented, but raw pipe bytes are not confidential or authenticated by
MTP. The creator sends a `PipeRequest` communication value, the peer accepts
or rejects it, and an application that carries sensitive data must place the
encrypted record layer described below on top of the accepted stream.
The request's `Description` and `PipeRequest` type remain clear transport
metadata. Do not put identities, call details, file names, or other sensitive
protocol information in them.
The creator owns the writer. The accepting peer owns the reader. A writer finishes with a stream FIN or aborts with a stream reset. A reader returns EOF after FIN and reports a connection or stream error when the peer closes unexpectedly.
@ -11,6 +19,88 @@ 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.
@ -20,7 +110,12 @@ Normal messages and pipe requests share the transport and must pass through the
## Closing a Pipe
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 creator closes a successful encrypted pipe with `EncryptedPipeWriter::finish`
or the browser writer's `close`; this authenticates `FINAL` and then sends a
QUIC FIN. Use `abort` when the peer should discard the stream immediately; this
resets the stream and the reader receives an error instead of a clean EOF.
Dropping the connection closes all active pipes. Raw pipe FIN is not an
authenticated application completion signal.
The accepting side closes its reader by consuming it or dropping it. A reader does not send an application-level acknowledgement for EOF. If the application needs completion metadata, send an ordinary MTP message before finishing the pipe.
@ -38,23 +133,40 @@ Native applications use the pipe APIs on `MTPConnection`; browser applications u
## Native File Upload and Processing
The creator streams a file in chunks. The accepting side processes each chunk without buffering the complete file:
The creator streams a file in encrypted records. The accepting side processes
each decrypted chunk without buffering the complete file. The `session_key`
below is obtained from the authenticated pipe-establishment protocol:
```rust
// Client
use tokio::io::AsyncWriteExt;
use mtp_transport::{PipeSessionParameters, initiate_pipe_session};
use tokio::io::AsyncReadExt;
let handle = conn.create_pipe("file-upload").await?;
if let Some(mut writer) = handle.wait().await? {
let pipe_id = handle.pipe_id();
if let Some(writer) = handle.wait().await? {
let params = PipeSessionParameters::new(
format!("file-upload/{pipe_id}"), pipe_id, own_client_id, host_client_id, 0x40, 0,
)?;
let mut writer = initiate_pipe_session(
writer.into_inner(), params, &own_keyring, &host_public_bundle,
).await?;
let mut file = tokio::fs::File::open("input.bin").await?;
tokio::io::copy(&mut file, &mut writer).await?;
let mut buffer = [0u8; 64 * 1024];
loop {
let count = file.read(&mut buffer).await?;
if count == 0 {
break;
}
writer.write_record(&buffer[..count]).await?;
}
writer.finish().await?;
}
```
```rust
// Host
use tokio::io::AsyncReadExt;
use mtp_transport::{PipeSessionParameters, accept_pipe_session};
while let Ok(request) = conn.receive_pipe().await {
if request.description() != "file-upload" {
@ -62,16 +174,18 @@ while let Ok(request) = conn.receive_pipe().await {
continue;
}
let mut reader = request.accept().await?;
let pipe_id = request.id();
let reader = request.accept().await?;
let params = PipeSessionParameters::new(
format!("file-upload/{pipe_id}"), pipe_id, client_id, own_client_id, 0x40, 0,
)?;
let mut reader = accept_pipe_session(
reader.into_inner(), &params, &own_keyring, &client_public_bundle,
).await?;
let mut hasher = 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?;
while let Some(chunk) = reader.read_record().await? {
hasher.update(&chunk);
process_chunk(&chunk).await?;
}
let digest = hasher.finalize();
println!("processed upload with digest {digest:x}");

View file

@ -17,6 +17,47 @@ 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
`open_protected`; language bindings delegate envelope construction and opening
to this codec boundary.
## Authentication Flow
```text
@ -41,3 +82,5 @@ Login proof binds the protocol version, client ID, host challenge, and client no
## 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 self-delimiting `DataValue` codec and the three-bit communication header begin at protocol version `3.0`. A peer offering an older codec version is rejected during version negotiation; the new decoder does not attempt legacy flag, ID, or crypto-container fallbacks.

View file

@ -95,9 +95,100 @@ 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. 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.
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.
`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. It
never derives receive policy from the recipient keyring. The sender's
signature suite remains a separate choice. Signature policy must be applied
independently to relay metadata, relay content, and pipe session establishment.
### Key history and rotation
Recipient KEM key history is tried locally without adding a stable recipient
key identifier to the visible encrypted-recipient table. Signing-key resolvers
receive a claimed, unverified signer ID only as a trusted-key lookup key; the
relay helpers authenticate that ID when they verify against the returned
history. Deployments should retain old
verification keys for at least as long as stored signed messages remain
accepted, and should make key-history lookup an authorization decision rather
than accepting any key supplied with a message.
[mtp-crypto API](../crypto/), [native client](NATIVE-CLIENT.md), and [native host](NATIVE-HOST.md).
@ -112,7 +203,7 @@ The crate's feature groups are:
| `wasm` | `getrandom` support for WebAssembly |
| `tls` | Development certificate generation |
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`.
The main types are `Keyring`, `PublicKeyBundle`, `EncryptionType`, `HybridKem`, `XChaCha20Poly1305` (with the legacy `ChaCha20Poly1305` alias), `Aes256Gcm`, `Ed25519Signer`, and `MlDsaSigner`. Hashing and KDF helpers include `sha256`, `sha256_double`, `hkdf_extract`, `hkdf_expand`, and `derive_encryption_key`. Handshake payload builders are in `mtp_crypto::auth`.
## Cryptographic Review Status
@ -139,19 +230,45 @@ 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. `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.
Each message consumes one chain key, derives one message key with HKDF, and increments its counter. `sessionStorage` stores browser session state for the current origin. `encryptedSecretProvider` is an independent caller-managed encrypted-secret facility; it is not automatically used by `MTPSessionStorage` or `MTPSessionManager`. Applications that need encrypted session persistence must coordinate those stores explicitly. The provider must protect its wrapping secret outside the SDK; the SDK does not recover a lost secret or skipped message keys.
Relay envelopes, browser session E2EE, and encrypted pipes are separate
protocols:
| Model | State | Intended use |
| --- | --- | --- |
| `RelayEnvelope` | Stateless `Encrypted(Signed(Value))`, multi-recipient | Store-and-forward messages and routing |
| `SessionE2EE` | Stateful symmetric ratchet in `sessionStorage` | Active browser exchanges |
| `EncryptedPipeSession` | Authenticated setup plus ordered record chain | Protected streams |
Encrypted pipes bind the pipe/session transcript, direction, purpose, sequence,
record length, and record type to each record. `FINAL` is authenticated and
unexpected EOF is reported as truncation. The ordinary signed/KEM offer is not
forward-secure; the native and browser duplex helpers use an ephemeral
authenticated KEM exchange before deriving the record chain. Group membership
changes require a new session key and recipient set.
## Key Storage
`Keyring` contains three public and three private key values. Its private key fields use `ZeroizeOnDrop`, and serialized keyring output is held in a zeroizing buffer while it is constructed. Public key bundles contain only the three public values.
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.
Role-specific protocol boundaries should validate only the material they need:
`validate_encryption()` checks that a KEM public/private pair corresponds, while
`validate_full()` additionally requires a complete hybrid signing identity.
This keeps partial browser keyrings usable without allowing an envelope sender
to proceed with an invalid local decryption key.
Applications remain responsible for storage at rest. The `files` feature writes passphrase-protected keyrings to `.mk` files and public bundles to `.mpkb` files. Protected `.mk` files store the Argon2id identifier, parameters, salt, and AEAD ciphertext; they do not derive their key with HKDF. On Unix, keyring files are created with owner-only `0600` permissions.
Restrict those files to the owning account and protect backups. Browser applications should treat the configured credential storage as sensitive application data.
## 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,
and 64 encrypted recipients. Decrypted values are parsed with the same limits.
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.
@ -161,3 +278,9 @@ Deploy authentication endpoints behind a rate-limiting proxy or add admission co
- `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.

View file

@ -63,7 +63,7 @@ When `require_pq` is true, both Ed25519 and ML-DSA-65 keys and signatures must b
`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, 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).
`InvalidEncoding` indicates truncated, malformed, duplicate-field, reserved-kind, or structurally invalid bytes. `TooManyEntries` indicates that an array, container, or frame exceeds the codec's representable count or length. Protection operations return typed errors for malformed envelopes, authentication failures, invalid signatures, and missing recipients. The complete variant table is in [Errors](ERRORS.md).
## Frames and Message Limits

View file

@ -6,21 +6,84 @@ This file documents the Type Map & Registry configuration used by the MTP protoc
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
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
[4 bytes total length]
[2 bytes communication type]
[1 byte flags]
bit 0 = has ID
bit 1 = has sender ID
bit 2 = has receiver ID
bits 3-7 must be zero
[4 bytes ID] if bit 0
[8 bytes sender ID] if bit 1
[8 bytes receiver ID] if bit 2
[DataValue payload]
```
The 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`.
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.
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.
`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.
## TypeMap & Compile-Time Type Safety
@ -43,6 +106,12 @@ export default defineConfig({
Rust and manual WASM builds can set `MTP_TYPE_MAPS` directly (see [Customizing Type Maps in Downstream Projects](#customizing-type-maps-in-downstream-projects)).
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:
@ -50,11 +119,17 @@ After editing the config and rebuilding, `CommunicationType` and `DataType` enum
```rust
use mtp::type_map::{CommunicationType, DataType, TypeMap};
let tm = TypeMap::v2_0();
let tm = TypeMap::v3_0();
let id = tm.data_id_enum(DataType::SomeType).unwrap();
```
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`.
For native builds with the `registry` feature, the enums are a **union across
all versions**; every type name from every version is a variant. The
version-specific `TypeMap` maps each variant to the correct wire ID for that
version. For a type absent from a selected version, the lookup returns `None`.
Browser-generated TypeScript unions intentionally differ: they contain only
the selected `protocol_version` plus reserved names, matching the WASM client
compiled by the Vite plugin.
Encoding/decoding uses a `TypeMap` to resolve type names to wire IDs:
@ -70,23 +145,19 @@ let decoded = decode(&bytes, &tm).unwrap();
```
```rust
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
let tm_v3 = TypeMap::v3_0();
assert!(tm_v3.data_id_enum(DataType::SomeType).is_some());
```
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.
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 self-delimiting codec begins at protocol version `3.0`; older versions are not codec fallbacks.
### 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:
```
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
v3.0 client sends DataType::SomeType → host encodes with v3.0 TypeMap → wire ID 32
v3.0 host receives an unsupported pre-v3.0 peer → version negotiation 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.
@ -109,10 +180,10 @@ let registry = Registry::builtin();
let codec = VersionedCodec::new(registry);
// Encode with a specific version
let bytes = codec.encode(&value, Version(2, 0)).unwrap();
let bytes = codec.encode(&value, Version(3, 0)).unwrap();
// Decode with a specific version
let decoded = codec.decode(&bytes, Version(2, 0)).unwrap();
let decoded = codec.decode(&bytes, Version(3, 0)).unwrap();
```
## Customizing Type Maps in Downstream Projects

View file

@ -30,6 +30,11 @@ 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.
@ -100,7 +105,8 @@ if (!MTPClient.isSupported()) {
| `pings` | `false` | Protocol pings, or an object with `intervalMs`. |
| `logger` | No-op | Receives SDK state and error events. |
| `sessionStorage` | In-memory | E2EE session state storage. |
| `encryptedDeviceSecretProvider` | In-memory | Device-secret storage for E2EE. |
| `encryptedSecretProvider` | In-memory | Independent caller-managed encrypted secret storage. |
| `defaultSignatureVerificationPolicy` | `"ed25519"` | Receiver policy for protected signatures. |
`wasm` selects a custom generated WASM module. `MTPClient.create` validates positive safe-integer values for the numeric limits and timeout options.
@ -112,7 +118,222 @@ 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 `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.
`sessionStorage` and `encryptedSecretProvider` are separate caller-managed
stores. The latter exchanges `MTPEncryptedSecretRecord` values through
`setEncryptedSecret`, `getEncryptedSecret`, and `deleteEncryptedSecret`.
`MTPSessionManager` does not automatically route session state through the
provider. If session material must be encrypted at rest, the caller must make
that coordination explicit in its `MTPSessionStorage` implementation. Secret
IDs are opaque to MTP, so a caller can map its own state to the ID while
choosing the backing store and protecting its wrapping key.
### Direct Protected Messages
Use `sendProtected` when the destination is the frame receiver and no
intermediate relay needs a separately encrypted metadata layer. It keeps the
application communication type on the outer frame and encrypts an MTP-owned
signed envelope for the exact recipient bundles supplied by the caller. The
envelope authenticates `ProtectedVersion`, `MessageType`, `FinalRecipientId`,
`MessageId`, `CreatedAt`, and `Content`. The opening operation checks the
authenticated type and final recipient against the outer frame.
```typescript
await client.sendProtected("ProtectedMessage", { Content: "hello" }, {
receiverId: recipientId,
recipients: [recipientPublicKey],
signaturePurpose: 0x40,
encryptionPurpose: 0x41,
exposeSender: false,
});
```
The protection purposes are application-defined domain-separation values.
`exposeSender` controls only the outer frame sender; the protected value remains
signed in either case. If `identity` is omitted, the SDK uses stored registered
credentials and rejects the operation when no usable protection identity is
available.
An unauthenticated connection can still send a protected value when the caller
provides an explicit `identity` with the signer ID and keyring. The connection's
authentication state and the protected signer's identity are independent.
When `signatureSuite` is omitted, protected send helpers use Ed25519 even when
the signing keyring also contains post-quantum keys. This matches the default
receiver policy. Use `signatureSuite: "dual"` together with
`signaturePolicy: "dual"` when both sides explicitly require hybrid
signatures.
Open a direct protected frame with the recipient keyring and a resolver that
receives the claimed, unverified signer ID only as a trusted-key lookup key:
```typescript
const message = await client.openProtected(frame, {
recipient: {
id: recipientId,
keyring: recipientKeyring,
keyringHistory: previousRecipientKeyrings,
},
expectedReceiverId: recipientId,
expectedSignerId: signerId,
resolveSignerPublicKeys: (id) => signerDirectory.get(id) ?? [],
signaturePolicy: "dual",
signaturePurpose: 0x40,
encryptionPurpose: 0x41,
replayGuard,
});
console.log(message.type, message.signerId, message.messageId, message.data);
```
`protectedVersion`, `finalRecipientId`, `signerId`, `messageId`, and `createdAt`
are taken from the verified protected envelope. `outerSender`, when present,
must equal the authenticated signer.
Protected application data may be any supported MTP `DataValue`, including
scalar, byte, array, and container values. Direct opening uses a bounded
process-local duplicate-suppression guard by default. The bounded cache can
evict old entries, so supply a durable `replayGuard` keyed by authenticated
signer and message ID when replay protection must survive eviction, reloads, or
multiple receiver processes. The guard also receives authenticated
`createdAt` metadata, which is not part of the replay key.
`subscribeProtected` uses the same opening and verification path:
```typescript
const unsubscribe = client.subscribeProtected(
"ProtectedMessage",
(message, frame) => handleMessage(message.data, frame),
{
recipient: { id: recipientId, keyring: recipientKeyring },
resolveSignerPublicKeys: (id) => signerDirectory.get(id) ?? [],
signaturePolicy: "dual",
signaturePurpose: 0x40,
encryptionPurpose: 0x41,
},
);
```
Each `subscribeProtected` registration owns its own bounded default replay
guard, so multiple handlers receive the same raw frame through the WASM
fan-out dispatcher. Pass the same caller-owned `replayGuard` deliberately when
several subscriptions should share replay state.
### Sealed Relay Messages
`sendSealedRelay` uses the reserved opaque `Relay` communication type. Its
inner message type must be an application communication type, not an MTP
control type. The outer frame contains no sender and exposes only the next-hop
receiver. The
signed relay metadata contains the generic `signerId`, `finalRecipientId`,
`messageId`, `createdAt`, application `metadata`, and an opaque encrypted
content value. `createdAt` is generated as Unix epoch milliseconds. For
example, `2026-08-11T12:00:00.000Z` is `1786449600000`.
```typescript
const data = { Content: "hello" };
await client.sendSealedRelay("ProtectedMessage", data, {
finalRecipientId,
nextHopId,
metadataRecipients: [
relayPublicKey,
recipientPublicKey,
],
contentRecipients: [
recipientPublicKey,
],
metadata: {
ExampleMetadata: "routing context",
},
});
client.subscribeSealedRelay(
"ProtectedMessage",
(message, frame) => handleMessage(message.data, frame),
{
recipient: {
id: finalRecipientId,
keyring: recipientKeyring,
},
expectedSignerId: signerId,
resolveSignerPublicKeys: () => [senderPublicKey],
},
);
```
The caller supplies the exact metadata and content recipient sets; the SDK
does not infer application topology. Set `signaturePolicy: "dual"` to require
hybrid signatures explicitly, and install a durable `replayGuard` so a valid
`(signerId, messageId)` is dispatched only once.
Each sealed-relay or metadata subscription likewise gets an independent
bounded default guard. This preserves fan-out when multiple handlers inspect
the same outer `Relay` frame; an explicitly supplied guard is shared by the
subscriptions that receive it.
Applications choose between direct protected delivery and sealed relay based
on topology and metadata-access requirements. Prefer `sendProtected` for a
direct destination. Use `sendSealedRelay` when a next hop must route or store a
message and the application needs metadata recipients to differ from content
recipients. Neither construction requires connection authentication, although
the host can associate an authenticated connection with its registered MTP
identity.
For metadata-only access, call `openRelayMetadata` or subscribe with
`subscribeRelayMetadata`. These operations authenticate the metadata and
expose `encryptedContent` for forwarding without attempting content
decryption. A final recipient calls `openRelayContent` after metadata
verification; the returned `MTPVerifiedRelayContent` includes the application
type and data plus `signerId`, `finalRecipientId`, `messageId`, `createdAt`,
and generic metadata fields. These are authenticated protected identities, not
the clear outer sender and next-hop receiver.
Relay content inherits the authenticated metadata's `signaturePolicy` when no
content override is supplied. A different content policy is rejected so the
two relay layers cannot be verified under conflicting rules.
Metadata passed to a `subscribeRelayMetadata` handler is callback-scoped and is
disposed after the handler resolves. Do not retain it for a later
`openRelayContent` call; use `openRelayMetadata` directly when a longer-lived
verified capability is needed, and call `dispose()` when finished.
When signer key history is used, `signerPublicKeys` exposes the trusted
candidates, `matchedSignerKeyIndex` identifies the key that verified the
metadata, and `matchedSignerPublicKey` returns that exact bundle.
Protected receive operations accept an optional `recipient` decryption
identity. Its `keyring` controls decryption and its optional `id` is used only
for final-recipient validation. The identity is independent from connection
authentication. Metadata opening does not require the identity ID to match the
clear next-hop receiver, so a forwarded frame can be opened by a metadata
recipient or final recipient with the appropriate keyring. When `recipient` is
omitted, stored registered credentials remain the convenience fallback.
To open values encrypted for a rotated recipient, provide `keyringHistory` on
the decryption identity. The current `keyring` is tried first, followed by
history entries from newest to oldest. Exact duplicate byte sequences are
removed without changing the caller's input arrays. An empty current keyring
or an empty history entry is rejected.
Generic MTP `DataValue` inputs accept `bigint` for exact integer values. An
integral JavaScript `number` outside the safe-integer range is rejected, so it
cannot silently become an imprecise float. Use `bigint` for large signed or
unsigned integers.
For streams, prefer `createEncryptedPipe` and `acceptEncryptedPipe`; they bind
the actual pipe ID and local identity automatically. The lower-level
`initiateMTPPipeSession` API also accepts multiple recipient bundles for a
group bootstrap. Group membership changes require a fresh session ID and
recipient set. Live calls that need forward secrecy can use the exported
duplex `initiateMTPForwardSecurePipeSession` and
`acceptMTPForwardSecurePipeSession` helpers.
The convenience pipe methods intentionally require registered client
credentials because they use the connection's registered identity as the
endpoint identity. Use the lower-level session functions when transport
authentication and cryptographic endpoint identity must remain independent.
Receive-side signature policy is independent from the recipient keyring. Use
`signaturePolicy` on protected receive and encrypted-pipe accept operations,
or configure `defaultSignatureVerificationPolicy` on the client. The sender's
`signatureSuite` selects how local values are signed and is a separate choice.
Both sender and receiver default to Ed25519; `dual` is always an explicit
choice on each side.
### Native and Browser Certificate Checks
@ -262,7 +483,10 @@ Use `pings: true` for the default interval.
## Pipes
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.
Pipes are byte-oriented streams over WebTransport. The `PipeRequest` type and
description are clear transport metadata; raw stream bytes are not protected
by MTP. For sensitive calls, files, or application streams, wrap the accepted
pipe with `MTPEncryptedPipeWriter` or `MTPEncryptedPipeReader`.
### Outgoing Pipes
@ -284,6 +508,41 @@ 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
@ -330,8 +589,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 raw data.
4. The initiator's `handle.wait()` resolves with a `PipeWriter` bound to that stream.
3. The receiver calls `acceptPipe(pipeId)`; the SDK sends a `PipeResponse` with `Accepted = true` and opens a new unidirectional stream for byte transport.
4. The initiator's `handle.wait()` resolves with a `PipeWriter` bound to that stream. Sensitive applications then perform their signed/encrypted session-key setup and construct an encrypted record wrapper.
5. If the receiver calls `denyPipe(pipeId)`, `handle.wait()` resolves with `null`.
Pipes share the same WebTransport session as message frames; they do not need a separate connection.
@ -417,9 +676,13 @@ 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:
@ -463,4 +726,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 device 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 encrypted secret persistence are documented in [Security](SECURITY.md#browser-end-to-end-encryption).

View file

@ -1,3 +1,7 @@
#################################################################################
# This is an example, overwrite it for your project to register your own types. #
#################################################################################
# The version a Client should use
protocol_version: "0.0"
@ -26,6 +30,7 @@ protocol_version: "0.0"
# BadGateway: 20
# ServiceUnavailable: 21
# GatewayTimeout: 22
# Relay: 26
# PipeRequest: 23
# PipeResponse: 24
# PipeAbort: 25
@ -45,16 +50,29 @@ 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"
@ -64,6 +82,8 @@ type_maps:
SomeType: 34
"2.0":
CommunicationTypes:
ProtectedMessage: 32
AlternateMessage: 33
DataTypes:
# If a v0.0 client connects
# - the server can't use "AnotherType"

39
example/Cargo.lock generated
View file

@ -12,6 +12,18 @@ dependencies = [
"generic-array",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"password-hash",
]
[[package]]
name = "asn1-rs"
version = "0.7.2"
@ -131,6 +143,15 @@ version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest 0.10.7",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -433,6 +454,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
@ -1269,6 +1291,7 @@ dependencies = [
"mtp-crypto",
"mtp-type-map",
"rand 0.10.2",
"thiserror 2.0.18",
]
[[package]]
@ -1293,7 +1316,7 @@ dependencies = [
"ml-dsa",
"mlkem-tls",
"rand 0.10.2",
"rand_core 0.10.1",
"rand_core 0.6.4",
"rcgen",
"rustls",
"serde",
@ -1308,6 +1331,7 @@ dependencies = [
name = "mtp-files"
version = "0.2.0"
dependencies = [
"argon2",
"mtp-crypto",
"rand 0.10.2",
"thiserror 1.0.69",
@ -1336,6 +1360,7 @@ dependencies = [
"mtp-codec",
"mtp-common",
"mtp-crypto",
"rand 0.10.2",
"rcgen",
"rustls",
"rustls-native-certs",
@ -1343,6 +1368,7 @@ dependencies = [
"tokio",
"tracing",
"wtransport",
"zeroize",
]
[[package]]
@ -1490,6 +1516,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "pem"
version = "3.0.6"

View file

@ -8,7 +8,7 @@ name = "client"
path = "src/main.rs"
[dependencies]
mtp = { version = "0.2.0", path = "../../", features = ["client", "crypto", "files", "pipes"] }
mtp = { version = "0.2.0", path = "../../", features = ["client", "crypto", "files", "pipes", "raw"] }
tokio = { version = "1", features = ["full"] }
rand = "0.10.1"
tracing-subscriber = "0.3.23"

View file

@ -3,9 +3,7 @@ use std::time::{Duration, Instant};
use tokio::fs;
use mtp::client::{ClientConfig, MTPClient, MTPConnection};
use mtp::crypto::{
Ed25519Signer, KemPrivateKey, KemPublicKey, Keyring, MlDsaSigner, PublicKeyBundle,
};
use mtp::crypto::{Keyring, PublicKeyBundle};
use mtp::files::{load_keyring_raw, save_keyring_raw};
pub async fn connect_or_register(
@ -40,17 +38,8 @@ pub async fn connect_or_register(
println!("No existing keys found: registering new client");
/* 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,
);
/* Registration publishes a complete MTP identity for later protection. */
let keyring = Keyring::generate();
let reg_started = Instant::now();
let conn = MTPClient::auth_register(config, &keyring, &host_public_key).await?;
@ -63,3 +52,19 @@ pub async fn connect_or_register(
Ok((conn, keyring, "register".into(), reg_duration))
}
/// Open a guest transport even when the caller already owns registered
/// credentials. The credentials stay with the caller for protected signing.
pub async fn connect_unauthenticated(
config: ClientConfig,
) -> Result<MTPConnection, Box<dyn std::error::Error>> {
let conn = MTPClient::connect(config).await?;
if conn.auth_state != mtp::client::AuthState::Unauthenticated {
return Err("guest connection did not report Unauthenticated state".into());
}
println!(
"Opened unauthenticated transport with host-assigned guest ID {}",
conn.client_id
);
Ok(conn)
}

View file

@ -2,12 +2,13 @@ mod auth;
mod metrics;
mod messages;
mod pipes;
mod protected;
use std::fs;
use std::path::Path;
use std::time::Duration;
use mtp::client::ClientConfig;
use mtp::client::{AuthState, ClientConfig};
use mtp::files::load_public_key_bundle;
fn dev_cert_path() -> String {
@ -43,7 +44,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Connecting to 127.0.0.1:8080 ...");
let config = ClientConfig::new("https://127.0.0.1:8080")
.with_pinned_pem(cert_pem)
.with_pinned_pem(cert_pem.clone())
.with_description("MTP example client");
let server_bundle = host_public_key.clone();
@ -62,9 +63,57 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut builder = metrics::SessionBuilder::new(&auth_method, auth_duration);
if conn.auth_state != AuthState::Authenticated {
return Err("authenticated example connection did not report Authenticated state".into());
}
println!(
"Receive connection A: authenticated client {}",
conn.client_id
);
let unauthenticated_config = ClientConfig::new("https://127.0.0.1:8080")
.with_pinned_pem(cert_pem.clone())
.with_description("MTP example unauthenticated sender");
let unauthenticated_conn = auth::connect_unauthenticated(unauthenticated_config).await?;
println!(
"Send connection B: unauthenticated guest transport ID {}",
unauthenticated_conn.client_id
);
let direct_roundtrip = protected::send_direct_protected(
&unauthenticated_conn,
conn.client_id,
&keyring,
&server_bundle,
)
.await?;
println!(
"Protected signer {} was accepted through unauthenticated connection B",
conn.client_id
);
let relay_roundtrip = protected::send_sealed_relay(
&unauthenticated_conn,
conn.client_id,
&keyring,
&server_bundle,
)
.await?;
println!(
"Sealed relay round-trip completed in {:.3}ms",
relay_roundtrip.as_secs_f64() * 1000.0
);
unauthenticated_conn.sender.close().await;
let roundtrip = messages::send_and_receive(&conn, &keyring, &server_bundle).await?;
builder.set_message_roundtrip(roundtrip);
println!(
"Direct protected round-trip: {:.3}ms",
direct_roundtrip.as_secs_f64() * 1000.0
);
println!("\n--- Pipe demo ---");
let pipe_results = pipes::run_pipe_demo(&conn, 1).await?;
for result in &pipe_results {

View file

@ -1,8 +1,9 @@
use std::time::{Duration, Instant};
use mtp::client::MTPConnection;
use mtp::codec::ProtectionPurpose;
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use mtp::crypto::{Ed25519Signer, EncryptionType, Keyring, PublicKeyBundle, SigAlgorithm};
use mtp::crypto::{Ed25519Signer, Keyring, PublicKeyBundle};
use mtp::type_map::TypeMap;
pub fn build_demo_message(
@ -11,7 +12,6 @@ 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,15 +26,16 @@ pub fn build_demo_message(
(version_id, DataValue::Str("secret inner data".into())),
(id_id, DataValue::UnsignedNumber(42)),
]);
let mut dv_enc = inner_enc;
dv_enc.encrypt_container(enc_type, server_bundle, b"demo-aad");
let dv_enc = inner_enc.encrypt_for(
std::slice::from_ref(server_bundle),
ProtectionPurpose::from(1),
)?;
let inner_sig = DataValue::Container(vec![
(version_id, DataValue::Str("signed by client".into())),
(id_id, DataValue::UnsignedNumber(99)),
]);
let mut dv_sig = inner_sig;
dv_sig.sign_container(SigAlgorithm::ED25519, &signer);
let dv_sig = inner_sig.sign(client_id, ProtectionPurpose::from(2), &signer)?;
let inner_sec = DataValue::Container(vec![
(
@ -43,18 +44,16 @@ pub fn build_demo_message(
),
(id_id, DataValue::UnsignedNumber(7)),
]);
let mut dv_sec = inner_sec;
dv_sec.sign_and_encrypt_container(
SigAlgorithm::ED25519,
&signer,
enc_type,
server_bundle,
b"demo-aad",
);
let dv_sec = inner_sec
.sign(client_id, ProtectionPurpose::from(3), &signer)?
.encrypt_for(
std::slice::from_ref(server_bundle),
ProtectionPurpose::from(4),
)?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs();
.as_millis();
let msg = CommunicationValue::new(CommunicationType::Ping)
.add_typed_default(
@ -101,7 +100,10 @@ pub async fn send_and_receive(
Ok(resp) => {
let roundtrip = start.elapsed();
println!("Received: {resp}");
println!("Message round-trip: {:.3}ms", roundtrip.as_secs_f64() * 1000.0);
println!(
"Message round-trip: {:.3}ms",
roundtrip.as_secs_f64() * 1000.0
);
Ok(roundtrip)
}
Err(e) => {

View file

@ -1,3 +1,4 @@
use mtp::common::unix_time_millis;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@ -8,6 +9,9 @@ fn now_epoch_secs() -> u64 {
.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();
@ -197,7 +201,7 @@ impl SessionBuilder {
pub fn new(auth_method: &str, auth_duration: Duration) -> Self {
Self {
session_id: generate_session_id(),
timestamp: now_epoch_secs(),
timestamp: now_epoch_millis(),
auth_method: auth_method.to_string(),
auth_duration_ms: auth_duration.as_secs_f64() * 1000.0,
error: None,
@ -219,11 +223,7 @@ impl SessionBuilder {
}
pub fn build(self) -> ClientSessionRecord {
let total_pipe_bytes: u64 = self
.pipe_results
.iter()
.map(|r| r.size as u64)
.sum();
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
@ -235,7 +235,10 @@ impl SessionBuilder {
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
.iter()
.map(|r| r.data_only_ms)
.sum::<f64>()
/ self.pipe_results.len() as f64
};

View file

@ -0,0 +1,198 @@
use std::time::{Duration, Instant};
use mtp::client::MTPConnection;
use mtp::codec::{
CommunicationType, DataType, DataValue, ProtectionPolicy, ProtectedMessageBuilder,
ProtectionPurpose, SealedRelayBuilder, SignaturePolicy, TypeMap, open_relay_content,
open_relay_metadata,
};
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(
&forwarded,
&final_recipient_keyring,
signer_id,
&signer_keyring.public_key_bundle(),
RELAY_SIGNATURE_POLICY,
)?;
let application_metadata = metadata
.metadata()
.ok_or("forwarded relay metadata was missing")?;
let content = open_relay_content(
&metadata,
&final_recipient_keyring,
&signer_keyring.public_key_bundle(),
FINAL_RECIPIENT_ID,
RELAY_SIGNATURE_POLICY,
)?;
if content.message_type != "ProtectedMessage" {
return Err(format!("unexpected relay message type: {}", content.message_type).into());
}
let expected_metadata = relay_metadata()?;
if application_metadata != &expected_metadata {
return Err("relay application metadata changed during forwarding".into());
}
let expected_content = application_value("sealed relay delivery", 41)?;
if content.content != expected_content {
return Err("relay application content changed during forwarding".into());
}
let elapsed = started.elapsed();
println!(
"Final recipient opened authenticated metadata and content in {:.3}ms (message_id={})",
elapsed.as_secs_f64() * 1000.0,
metadata.message_id()
);
Ok(elapsed)
}

View file

@ -4,4 +4,4 @@ version = "0.2.0"
edition = "2024"
[dependencies]
mtp = { version = "0.2.0", path = "../../", features = ["files"] }
mtp = { version = "0.2.0", path = "../../", features = ["files", "raw"] }

View file

@ -8,7 +8,7 @@ name = "server"
path = "src/main.rs"
[dependencies]
mtp = { version = "0.2.0", path = "../../", features = ["crypto", "tls", "web-server", "files", "pipes"] }
mtp = { version = "0.2.0", path = "../../", features = ["crypto", "tls", "web-server", "files", "pipes", "raw"] }
tokio = { version = "1", features = ["full"] }
http = "1"
serde_json = { version = "1" }

View file

@ -1,23 +1,208 @@
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, TypeMap};
use mtp::crypto::{CryptoError, Keyring, SignaturePublicKey, SignatureScheme, verify_ed25519};
use std::collections::HashMap;
struct Ed25519Verifier(SignaturePublicKey);
use mtp::codec::{
CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, InMemoryReplayGuard,
ProtectionPolicy, ProtectionPurpose, SignaturePolicy, TypeMap,
forward_relay_frame, open_protected_with, open_relay_content, open_relay_metadata_with,
};
use mtp::crypto::{Keyring, PublicKeyBundle};
impl SignatureScheme for Ed25519Verifier {
fn sign(&self, _msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
Err(CryptoError::SigningFailed)
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 as u128))
.map_err(|e| e.to_string())?
.add_data(data_id, DataValue::Str(data.into()))
.map_err(|e| e.to_string())
}
fn process_direct_protected(
msg: &CommunicationValue,
tm: &TypeMap,
client_pk: Option<&PublicKeyBundle>,
registered_clients: &HashMap<u64, PublicKeyBundle>,
host_keyring: &Keyring,
accepted_messages: &mut InMemoryReplayGuard,
) -> Result<CommunicationValue, String> {
if msg.receiver() != Some(DIRECT_DESTINATION_ID) {
return Err(format!(
"direct protected frame was addressed to {:?}, expected destination {DIRECT_DESTINATION_ID}",
msg.receiver()
));
}
fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError> {
verify_ed25519(&self.0, msg, signature)
let opened = open_protected_with(
msg,
std::slice::from_ref(&host_keyring),
None,
|signer_id| resolve_signer_key(signer_id, registered_clients).map(|key| vec![key]),
Some(DIRECT_DESTINATION_ID),
ProtectionPurpose::from(DIRECT_SIGNATURE_PURPOSE),
ProtectionPurpose::from(DIRECT_ENCRYPTION_PURPOSE),
SIGNATURE_POLICY,
Some(accepted_messages),
)
.map_err(|e| format!("direct protected message could not be authenticated: {e}"))?;
let signer_id = opened.signer_id;
let message_id = opened.message_id;
let value = opened
.content
.as_container()
.ok_or("direct protected application value is not a container")?;
let text_id = DataTypeId(
tm.data_id_enum(DataType::ExampleText)
.ok_or("missing ExampleText type mapping")?,
);
let number_id = DataTypeId(
tm.data_id_enum(DataType::ExampleNumber)
.ok_or("missing ExampleNumber type mapping")?,
);
let text = value
.iter()
.find(|(id, _)| *id == text_id)
.and_then(|(_, value)| value.as_str())
.ok_or("direct protected value is missing ExampleText")?;
let number = value
.iter()
.find(|(id, _)| *id == number_id)
.and_then(|(_, value)| value.as_unsigned_number())
.ok_or("direct protected value is missing ExampleNumber")?;
println!(
" Direct protected message: signer={signer_id}, message_id={message_id}, transport_key_available={}, ExampleText={text:?}, ExampleNumber={number}",
client_pk.is_some()
);
if client_pk.is_none() {
println!(
" Protected signer was verified from the registered key map; transport is unauthenticated"
);
}
pong(
tm,
format!("direct protected value verified for signer {signer_id}"),
)
}
fn process_sealed_relay(
msg: &CommunicationValue,
registered_clients: &HashMap<u64, PublicKeyBundle>,
host_keyring: &Keyring,
accepted_messages: &mut InMemoryReplayGuard,
) -> Result<CommunicationValue, String> {
if msg.receiver() != Some(METADATA_RELAY_ID) {
return Err(format!(
"sealed relay next hop was {:?}, expected metadata relay {METADATA_RELAY_ID}",
msg.receiver()
));
}
let metadata = open_relay_metadata_with(
msg,
std::slice::from_ref(&host_keyring),
None,
|signer_id| {
resolve_signer_key(signer_id, registered_clients).map(|key| vec![key])
},
SIGNATURE_POLICY,
Some(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(
&metadata,
host_keyring,
&resolve_signer_key(metadata.signer_id(), registered_clients)
.ok_or("metadata signer key disappeared")?,
FINAL_RECIPIENT_ID,
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")?,
@ -59,13 +244,34 @@ pub fn process_and_respond(
.ok_or("missing SecurePayload type mapping")?,
);
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);
let description = msg
.get_data(DataType::Description)
.cloned()
.unwrap_or(DataValue::Null);
let timestamp = msg
.get_data(DataType::Timestamp)
.cloned()
.unwrap_or(DataValue::Null);
let data = msg
.get_data(DataType::Data)
.cloned()
.unwrap_or(DataValue::Null);
let flags = msg
.get_data(DataType::Flags)
.cloned()
.unwrap_or(DataValue::Null);
let value = msg
.get_data(DataType::Value)
.cloned()
.unwrap_or(DataValue::Null);
let binary = msg
.get_data(DataType::BinaryData)
.cloned()
.unwrap_or(DataValue::Null);
let items = msg
.get_data(DataType::Items)
.cloned()
.unwrap_or(DataValue::Null);
println!(
" Description: {}",
@ -82,13 +288,8 @@ pub fn process_and_respond(
let mut sig_status = String::from("SignedPayload: not present");
let mut secure_status = String::from("SecurePayload: not present");
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(enc @ DataValue::Encrypted(_)) = msg.get_data(DataType::EncryptedPayload) {
if let Ok(dv) = enc.decrypt(host_keyring, mtp::codec::ProtectionPurpose::from(1)) {
if let Some(entries) = dv.as_container() {
println!(" Decrypted EncryptedPayload: {:?}", entries);
enc_status = format!("EncryptedPayload decrypted OK ({} entries)", entries.len());
@ -99,13 +300,19 @@ pub fn process_and_respond(
}
}
let sig = msg.get_data(DataType::SignedPayload);
if matches!(sig, DataValue::SignedContainer(_)) {
if let Some(sig @ DataValue::Signed(_)) = msg.get_data(DataType::SignedPayload) {
if let Some(pk_bundle) = client_pk {
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() {
let signer_id = sig.as_signed().map(|signed| signed.signer_id);
if let Some(signer_id) = signer_id
&& sig
.verify(signer_id, pk_bundle, mtp::codec::ProtectionPurpose::from(2))
.is_ok()
{
let dv = sig
.clone()
.into_verified(signer_id, pk_bundle, mtp::codec::ProtectionPurpose::from(2))
.ok();
if let Some(entries) = dv.and_then(|value| value.as_container()) {
println!(" Verified SignedPayload: {:?}", entries);
sig_status = format!("SignedPayload verified OK ({} entries)", entries.len());
}
@ -119,17 +326,23 @@ pub fn process_and_respond(
}
}
let secure = msg.get_data(DataType::SecurePayload);
if matches!(secure, DataValue::SignedEncryptedContainer(_)) {
if let Some(secure @ DataValue::Encrypted(_)) = msg.get_data(DataType::SecurePayload) {
if let Some(pk_bundle) = client_pk {
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()
if let Ok(opened) = secure.decrypt(host_keyring, mtp::codec::ProtectionPurpose::from(4))
&& let Some(signed) = opened.as_signed()
&& opened
.verify(
signed.signer_id,
pk_bundle,
mtp::codec::ProtectionPurpose::from(3),
)
.is_ok()
{
if let Some(entries) = dv.as_container() {
let signer_id = signed.signer_id;
let dv = opened
.into_verified(signer_id, pk_bundle, mtp::codec::ProtectionPurpose::from(3))
.ok();
if let Some(entries) = dv.and_then(|value| value.as_container()) {
println!(" Verified SecurePayload: {:?}", entries);
secure_status = format!(
"SecurePayload decrypted+verified OK ({} entries)",
@ -149,11 +362,13 @@ pub fn process_and_respond(
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_secs();
.as_millis();
Ok(CommunicationValue::from_comm(CommunicationType::Pong, tm)
.add_data(desc_id, description.clone())
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 as u128))
.map_err(|e| e.to_string())?
.add_data(
data_id,
DataValue::Str(format!(
@ -161,8 +376,14 @@ pub fn process_and_respond(
enc_status, sig_status, secure_status
)),
)
.add_data(flags_id, flags.clone())
.add_data(value_id, value.clone())
.add_data(bin_id, binary.clone())
.add_data(items_id, items.clone()))
.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)
}

View file

@ -6,7 +6,7 @@ mod tls;
#[path = "web-server.rs"]
mod web_server;
use mtp::host::HostConfig;
use mtp::host::{AuthenticationPolicy, AuthState, HostConfig};
use mtp::type_map::TypeMap;
use std::future::Future;
use std::path::Path;
@ -136,7 +136,8 @@ 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());
@ -158,9 +159,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
};
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,
@ -168,7 +176,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.map(|addr| addr.to_string())
.unwrap_or_else(|| "unknown".into())
);
println!("Client ID: {}", conn.client_id);
println!("Connection state: {connection_state}; MTP ID: {}", conn.client_id);
let mut session = metrics.start_session(conn.client_id, desc.to_string());
@ -177,6 +185,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Waiting for messages / pipe requests ...");
let mut pipe_open = true;
let mut message_open = true;
let mut accepted_direct_messages = mtp::codec::InMemoryReplayGuard::default();
let mut accepted_relay_messages = mtp::codec::InMemoryReplayGuard::default();
let mut exit_reason = "normal".to_string();
while pipe_open || message_open {
@ -215,11 +225,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(message) => {
println!("Received: {message}");
let msg_start = std::time::Instant::now();
let registered_clients = registered_clients
.lock()
.map(|clients| clients.clone())
.unwrap_or_default();
let result = handlers::process_and_respond(
&message,
tm,
conn.client_public_key.as_ref(),
&registered_clients,
&decrypt_keyring,
&mut accepted_direct_messages,
&mut accepted_relay_messages,
);
let latency = msg_start.elapsed();
let ok = result.is_ok();

View file

@ -331,7 +331,7 @@ impl ServerMetrics {
}
// ---------------------------------------------------------------------------
// Session handle local accumulators, no mutex contention during connection
// Session handle, local accumulators, no mutex contention during connection
// ---------------------------------------------------------------------------
pub struct SessionHandle<'a> {

View file

@ -1,29 +1,13 @@
protocol_version: "1.0"
protocol_version: "3.0"
type_maps:
"0.0":
"3.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
@ -32,3 +16,7 @@ type_maps:
SecurePayload: 40
CommunicationType: 41
DataType: 42
ExampleText: 43
ExampleNumber: 44
ExampleRole: 45
ExampleMetadata: 46

View file

@ -67,6 +67,9 @@
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>

View file

@ -20,6 +20,9 @@ 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;
@ -32,7 +35,6 @@ const HOST_PUBLIC_KEY_KEY = "mtp-web-client-host-public-key";
type SavedKeys = {
clientId: string | null;
keyring?: number[];
keyringBytes?: number[];
hostPublicKey?: number[];
};
@ -290,7 +292,7 @@ function loadKeys() {
const data = JSON.parse(raw) as SavedKeys;
clientId = data.clientId ? BigInt(data.clientId) : null;
const keyringLength = (data.keyring ?? data.keyringBytes ?? []).length;
const keyringLength = (data.keyring ?? []).length;
CLIENT_CREDENTIALS.value = renderStructured({
clientId: data.clientId,
keyringBytes: keyringLength,
@ -352,6 +354,7 @@ async function initWasm() {
const supported = MTPClient.isSupported();
log(`WASM loaded. WebTransport supported: ${supported}`);
CONNECT.disabled = !supported;
CONNECT_UNAUTHENTICATED.disabled = !supported;
}
async function createClient() {
@ -448,6 +451,39 @@ 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");
@ -719,6 +755,13 @@ CONNECT.addEventListener("click", () => {
});
});
CONNECT_UNAUTHENTICATED.addEventListener("click", () => {
connectUnauthenticated().catch((e) => {
log(`Unauthenticated connection failed: ${e}`, "error");
console.error(e);
});
});
CLEAR_KEYS.addEventListener("click", () => {
clientId = null;
CLIENT_CREDENTIALS.value = "";

View file

@ -7,7 +7,12 @@ edition = "2024"
# 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.2.0", path = "../crypto", default-features = false, features = ["chacha20poly1305", "hkdf"] }
argon2 = "0.5"
rand = "0.10.2"
thiserror = "2"
zeroize = "1.9"
[features]
# Plain private-key files are only needed by migration tooling and tests.
raw = []

View file

@ -13,7 +13,7 @@ use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use mtp_crypto::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305, derive_encryption_key};
use mtp_crypto::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305};
use rand::RngExt;
use thiserror::Error;
use zeroize::Zeroizing;
@ -29,11 +29,15 @@ 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 = 2;
const PROTECTED_FORMAT_VERSION: u8 = 3;
const BUNDLE_FORMAT_VERSION: u8 = 1;
const HEADER_LEN: usize = 4 + 1;
const SALT_LEN: usize = 32;
const KEYRING_KDF_CONTEXT: &[u8] = b"mtp-keyring-at-rest-v2";
const KDF_ID_ARGON2ID: u8 = 1;
const ARGON2_MEMORY_KIB: u32 = 19 * 1024;
const ARGON2_ITERATIONS: u32 = 2;
const ARGON2_LANES: u32 = 1;
const PROTECTED_PARAMS_LEN: usize = 1 + 4 + 4 + 4 + SALT_LEN;
#[derive(Error, Debug)]
pub enum FileError {
@ -145,7 +149,39 @@ fn write_secret_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
Ok(())
}
/// Save a keyring encrypted with XChaCha20-Poly1305 under an HKDF-derived key.
fn derive_key(
passphrase: &[u8],
salt: &[u8],
memory_kib: u32,
iterations: u32,
lanes: u32,
) -> Result<Zeroizing<[u8; 32]>, FileError> {
if salt.len() != SALT_LEN
|| !(8 * 1024..=256 * 1024).contains(&memory_kib)
|| !(1..=10).contains(&iterations)
|| !(1..=8).contains(&lanes)
{
return Err(FileError::Crypto(CryptoError::KdfError));
}
let params = argon2::Params::new(memory_kib, iterations, lanes, Some(32))
.map_err(|_| FileError::Crypto(CryptoError::KdfError))?;
let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = Zeroizing::new([0u8; 32]);
argon
.hash_password_into(passphrase, salt, key.as_mut())
.map_err(|_| FileError::Crypto(CryptoError::KdfError))?;
Ok(key)
}
fn protected_header_aad(parameters: &[u8]) -> Vec<u8> {
let mut aad = Vec::with_capacity(HEADER_LEN + parameters.len());
aad.extend_from_slice(&KEYRING_MAGIC);
aad.push(PROTECTED_FORMAT_VERSION);
aad.extend_from_slice(parameters);
aad
}
/// Save a keyring encrypted with XChaCha20-Poly1305 under Argon2id.
pub fn save_keyring(
keyring: &Keyring,
path: impl AsRef<Path>,
@ -156,16 +192,24 @@ pub fn save_keyring(
}
let mut salt = [0u8; SALT_LEN];
rand::rng().fill(&mut salt);
let key = Zeroizing::new(derive_encryption_key(
let key = derive_key(
passphrase,
&salt,
KEYRING_KDF_CONTEXT,
)?);
ARGON2_MEMORY_KIB,
ARGON2_ITERATIONS,
ARGON2_LANES,
)?;
let mut parameters = Vec::with_capacity(PROTECTED_PARAMS_LEN);
parameters.push(KDF_ID_ARGON2ID);
parameters.extend_from_slice(&ARGON2_MEMORY_KIB.to_be_bytes());
parameters.extend_from_slice(&ARGON2_ITERATIONS.to_be_bytes());
parameters.extend_from_slice(&ARGON2_LANES.to_be_bytes());
parameters.extend_from_slice(&salt);
let cipher = ChaCha20Poly1305::new(*key);
let plaintext = keyring.to_bytes();
let encrypted = cipher.encrypt(&plaintext, &KEYRING_MAGIC)?;
let mut payload = Vec::with_capacity(SALT_LEN + encrypted.len());
payload.extend_from_slice(&salt);
let encrypted = cipher.encrypt(&plaintext, &protected_header_aad(&parameters))?;
let mut payload = Vec::with_capacity(PROTECTED_PARAMS_LEN + encrypted.len());
payload.extend_from_slice(&parameters);
payload.extend_from_slice(&encrypted);
let bytes = encode(KEYRING_MAGIC, PROTECTED_FORMAT_VERSION, &payload);
write_secret_atomic(path.as_ref(), &bytes)?;
@ -187,23 +231,33 @@ pub fn load_keyring(path: impl AsRef<Path>, passphrase: &[u8]) -> Result<Keyring
found: version,
});
}
let salt = payload
.get(..SALT_LEN)
.ok_or(FileError::Truncated(bytes.len()))?;
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(SALT_LEN..)
.get(PROTECTED_PARAMS_LEN..)
.ok_or(FileError::Truncated(bytes.len()))?;
let key = Zeroizing::new(derive_encryption_key(
passphrase,
salt,
KEYRING_KDF_CONTEXT,
)?);
let key = derive_key(passphrase, salt, memory_kib, iterations, lanes)?;
let cipher = ChaCha20Poly1305::new(*key);
let plaintext = Zeroizing::new(cipher.decrypt(encrypted, &KEYRING_MAGIC)?);
let plaintext = Zeroizing::new(cipher.decrypt(
encrypted,
&protected_header_aad(&payload[..PROTECTED_PARAMS_LEN]),
)?);
Ok(Keyring::from_bytes(&plaintext)?)
}
/// Explicitly save the legacy plaintext format for tests and development.
#[cfg(any(test, feature = "raw"))]
pub fn save_keyring_raw(keyring: &Keyring, path: impl AsRef<Path>) -> Result<(), FileError> {
let payload = keyring.to_bytes();
let bytes = Zeroizing::new(encode(KEYRING_MAGIC, RAW_FORMAT_VERSION, &payload));
@ -212,6 +266,7 @@ pub fn save_keyring_raw(keyring: &Keyring, path: impl AsRef<Path>) -> Result<(),
}
/// 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")?;
@ -245,15 +300,16 @@ pub fn load_public_key_bundle(path: impl AsRef<Path>) -> Result<PublicKeyBundle,
found: version,
});
}
Ok(PublicKeyBundle::from_bytes(payload)?)
Ok(PublicKeyBundle::from_bytes_validated(payload)?)
}
#[cfg(test)]
mod tests {
use super::*;
use mtp_crypto::keypair::{
KemPrivateKey, KemPublicKey, SignaturePqPrivateKey, SignaturePqPublicKey,
SignaturePrivateKey, SignaturePublicKey,
KEM_PUBLIC_KEY_LEN, KemPrivateKey, KemPublicKey, SIG_CL_PUBLIC_KEY_LEN,
SIG_PQ_PUBLIC_KEY_LEN, SignaturePqPrivateKey, SignaturePqPublicKey, SignaturePrivateKey,
SignaturePublicKey,
};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
@ -268,11 +324,11 @@ mod tests {
fn sample_keyring() -> Keyring {
Keyring::new(
KemPublicKey::new(vec![1u8; 32]),
KemPublicKey::new(vec![1u8; KEM_PUBLIC_KEY_LEN]),
KemPrivateKey::new(vec![2u8; 32]),
SignaturePqPublicKey::new(vec![3u8; 64]),
SignaturePqPublicKey::new(vec![3u8; SIG_PQ_PUBLIC_KEY_LEN]),
SignaturePqPrivateKey::new(vec![4u8; 64]),
SignaturePublicKey::new(vec![5u8; 32]),
SignaturePublicKey::new(vec![5u8; SIG_CL_PUBLIC_KEY_LEN]),
SignaturePrivateKey::new(vec![6u8; 32]),
)
}
@ -377,4 +433,21 @@ 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(())
}
}

View file

@ -40,7 +40,7 @@
name = "mtp-clippy";
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
'';
};
@ -57,7 +57,7 @@
name = "mtp-build-all";
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
@ -69,6 +69,11 @@
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 check:boundary
pnpm --filter mtp-web-client run build
'';
};
@ -96,7 +101,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)"

View file

@ -1,8 +1,14 @@
use std::net::IpAddr;
#[cfg(feature = "crypto")]
use std::collections::HashMap;
#[cfg(feature = "crypto")]
use std::collections::HashSet;
#[cfg(feature = "crypto")]
use std::pin::Pin;
#[cfg(feature = "crypto")]
use std::sync::{Arc, Mutex};
#[cfg(feature = "crypto")]
use tokio::time::Duration;
pub use mtp_transport::Policy;
@ -26,18 +32,23 @@ pub type GetExistingClient = Box<
/// Callback that assigns a guest (unauthenticated) client ID.
///
/// 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.
/// Return `Some(id)` to accept the guest with the given full-width `u64` ID, or
/// `None` to reject the connection.
///
/// When set to `None` on `HostConfig`, the built-in generator produces a random
/// 48-bit ID that avoids collisions with registered clients.
/// full-width non-zero ID that avoids collisions with registered clients and
/// currently connected guests.
#[cfg(feature = "crypto")]
pub type GuestIdGenerator =
Box<dyn Fn() -> Pin<Box<dyn std::future::Future<Output = Option<u64>> + Send>> + Send + Sync>;
#[cfg(feature = "crypto")]
/// Callback that commits a new registration and returns its non-zero ID.
///
/// The host serializes registration commits and remembers successful identity
/// assignments for the lifetime of the host. Applications that need retry
/// recovery across a host restart should also configure [`FindRegisteredClient`]
/// to look up the public identity in persistent storage.
pub type CompleteRegister = Box<
dyn Fn(
mtp_crypto::PublicKeyBundle,
@ -47,6 +58,22 @@ 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 {
@ -75,9 +102,17 @@ 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>,
}
impl HostConfig {
@ -107,9 +142,17 @@ 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,
}
}
@ -160,4 +203,11 @@ 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
}
}

View file

@ -77,12 +77,30 @@ pub struct MTPConnection<
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")]
@ -133,6 +151,7 @@ where
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(),
@ -153,12 +172,15 @@ 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,
}
}
@ -182,6 +204,7 @@ where
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(),
@ -202,12 +225,15 @@ 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,
}
}
}
@ -252,12 +278,15 @@ 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,
}
}
}
@ -293,21 +322,33 @@ where
&self,
description: &str,
) -> Result<crate::pipe::PipeHandle<S>, mtp_common::PipeError> {
let pipe_id = rand::random::<u32>();
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
self.pipe_dispatcher
.pending_creations
.lock()
.await
.insert(pipe_id, response_tx);
let pipe_id = {
let mut pending = self.pipe_dispatcher.pending_creations.lock().await;
let pipe_id = loop {
let candidate = rand::random::<u32>();
if candidate != 0 && !pending.contains_key(&candidate) {
break candidate;
}
};
pending.insert(pipe_id, response_tx);
pipe_id
};
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)?;
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 {
self.pipe_dispatcher
.pending_creations
.lock()
.await
.remove(&pipe_id);
return Err(mtp_common::PipeError::from(error));
}
Ok(crate::pipe::PipeHandle {
pipe_id,

View file

@ -7,11 +7,15 @@
use crate::config::HostConfig;
use crate::error::AcceptError;
use mtp_codec::{
CommunicationType, CommunicationValue, DataType, DataValue, Version,
CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, Version,
registry::{Registry, VersionedCodec},
};
use mtp_common::{CommunicationError, RejectionReason};
#[cfg(feature = "crypto")]
use std::collections::HashSet;
use std::sync::Arc;
#[cfg(feature = "crypto")]
use std::sync::Mutex;
/// Trait for sending handshake messages during the opening exchange.
///
@ -24,6 +28,9 @@ pub trait HandshakeSender: Send + Sync {
fn finish_stream(
&self,
) -> impl std::future::Future<Output = Result<(), CommunicationError>> + Send;
fn set_type_map(&self, _type_map: &TypeMap) -> impl std::future::Future<Output = ()> + Send {
async {}
}
fn close(&self);
}
@ -34,6 +41,37 @@ pub trait HandshakeReceiver: Send + Sync {
fn receive(
&self,
) -> impl std::future::Future<Output = Result<CommunicationValue, CommunicationError>> + Send;
/// Bind subsequently decoded frames to the negotiated type map.
///
/// The opening frame must be decoded with the transport's bootstrap map so
/// that it can reveal the version. Once negotiation succeeds, all later
/// frames—including the remainder of the authentication exchange—must use
/// the negotiated map rather than whichever map happens to be latest at
/// compile time.
fn set_type_map(&self, _type_map: &TypeMap) -> impl std::future::Future<Output = ()> + Send {
async {}
}
}
/// A non-zero guest ID reserved for the lifetime of a connected session.
///
/// The lease is moved into the resulting `MTPConnection`, so dropping that
/// connection releases the ID for a later guest session.
#[cfg(feature = "crypto")]
#[derive(Debug)]
pub struct GuestIdLease {
active_ids: Arc<Mutex<HashSet<u64>>>,
id: u64,
}
#[cfg(feature = "crypto")]
impl Drop for GuestIdLease {
fn drop(&mut self) {
if let Ok(mut active_ids) = self.active_ids.lock() {
active_ids.remove(&self.id);
}
}
}
/// The result of a successful handshake, containing everything needed to
@ -49,6 +87,8 @@ pub struct HandshakeResult {
pub client_id: u64,
#[cfg(feature = "crypto")]
pub client_public_key: Option<mtp_crypto::PublicKeyBundle>,
#[cfg(feature = "crypto")]
pub guest_id_lease: Option<GuestIdLease>,
}
/// Transport-independent handshake state machine.
@ -90,13 +130,56 @@ impl HandshakeEngine {
) -> Result<HandshakeResult, AcceptError> {
#[cfg(feature = "crypto")]
{
let timeout = self.config.auth_timeout;
tokio::time::timeout(timeout, self.accept_inner(sender, receiver))
.await
.unwrap_or(Err(AcceptError::AuthenticationTimedOut))
self.accept_until(
sender,
receiver,
tokio::time::Instant::now() + self.config.auth_timeout,
)
.await
}
#[cfg(not(feature = "crypto"))]
self.accept_inner(sender, receiver).await
{
let result = self.accept_inner(sender, receiver).await;
if result.is_err() {
sender.close();
}
result
}
}
/// Run the crypto handshake until an absolute deadline.
///
/// WebTransport authentication may wait for a shared semaphore before it
/// reaches this engine. Passing the deadline through keeps that queueing
/// time from silently granting the handshake another full timeout.
#[cfg(feature = "crypto")]
pub async fn accept_until<S: HandshakeSender, R: HandshakeReceiver>(
&self,
sender: &S,
receiver: &R,
deadline: tokio::time::Instant,
) -> Result<HandshakeResult, AcceptError> {
match tokio::time::timeout_at(deadline, self.accept_inner(sender, receiver)).await {
Ok(result) => {
if result.is_err() {
sender.close();
}
result
}
Err(_) => {
let error = AcceptError::AuthenticationTimedOut;
send_rejection_generic(
sender,
RejectionReason::AuthenticationFailed {
detail: error.to_string(),
},
None,
)
.await;
sender.close();
Err(error)
}
}
}
async fn accept_inner<S: HandshakeSender, R: HandshakeReceiver>(
@ -104,16 +187,17 @@ impl HandshakeEngine {
sender: &S,
receiver: &R,
) -> Result<HandshakeResult, AcceptError> {
let first_msg = receiver.receive().await.map_err(AcceptError::Receive)?;
let mut first_msg = receiver.receive().await.map_err(AcceptError::Receive)?;
let version_str = match first_msg.get_data(DataType::Version) {
DataValue::Str(s) => s.clone(),
Some(DataValue::Str(s)) => s.clone(),
_ => {
send_rejection_generic(
sender,
RejectionReason::AuthenticationFailed {
detail: "opening message omitted a valid protocol version".into(),
},
None,
)
.await;
sender.close();
@ -128,6 +212,7 @@ impl HandshakeEngine {
RejectionReason::AuthenticationFailed {
detail: "opening message omitted a valid protocol version".into(),
},
None,
)
.await;
sender.close();
@ -150,6 +235,7 @@ impl HandshakeEngine {
.map(|v| v.to_string())
.collect(),
},
None,
)
.await;
sender.close();
@ -160,8 +246,12 @@ impl HandshakeEngine {
let codec = VersionedCodec::for_version(self.registry.clone(), negotiated.clone())
.ok_or_else(|| AcceptError::UnsupportedVersion(negotiated.clone()))?;
sender.set_type_map(codec.type_map()).await;
receiver.set_type_map(codec.type_map()).await;
first_msg.set_type_map(codec.type_map());
let description = match first_msg.get_data(DataType::Description) {
DataValue::Str(s) => Some(s.clone()),
Some(DataValue::Str(s)) => Some(s.clone()),
_ => None,
};
@ -209,9 +299,28 @@ impl HandshakeEngine {
#[cfg(not(feature = "crypto"))]
{
let _ = sender;
let authentication_requested = Some(first_msg.get_type())
== CommunicationType::Register.try_to_id(codec.type_map())
|| first_msg.get_data(DataType::PublicKeys).is_some();
if authentication_requested {
let error = AcceptError::AuthenticationFailed(
"authentication is unavailable on this non-crypto host".into(),
);
send_rejection_generic(
sender,
RejectionReason::AuthenticationFailed {
detail: error.to_string(),
},
Some(codec.type_map()),
)
.await;
sender.close();
return Err(error);
}
let _ = receiver;
let _ = first_msg;
send_accepted_generic(sender, &negotiated, codec.type_map(), Some(0))
.await
.map_err(AcceptError::Send)?;
Ok(HandshakeResult {
negotiated_version: negotiated,
codec,
@ -229,15 +338,21 @@ impl HandshakeEngine {
codec: VersionedCodec,
description: Option<String>,
) -> Result<HandshakeResult, AcceptError> {
let tm = mtp_codec::TypeMap::latest();
let tm = codec.type_map();
// Reject Register frames on unauthenticated hosts
if Some(first_msg.get_type()) == CommunicationType::Register.try_to_id(&tm) {
// Reject explicit authentication attempts on unauthenticated hosts.
// Authenticated clients include PublicKeys in Identification as an
// intent marker; this avoids acknowledging the opening as a guest
// connection and leaving the client waiting for a Challenge.
if Some(first_msg.get_type()) == CommunicationType::Register.try_to_id(&tm)
|| first_msg.get_data(DataType::PublicKeys).is_some()
{
send_rejection_generic(
sender,
RejectionReason::AuthenticationFailed {
detail: "authentication not allowed on this host".into(),
},
Some(tm),
)
.await;
sender.close();
@ -246,8 +361,15 @@ impl HandshakeEngine {
));
}
let guest_id = self.assign_guest_id().await?;
send_accepted_generic(sender, &negotiated, Some(guest_id))
let guest_id_lease = match self.assign_guest_id().await {
Ok(lease) => lease,
Err(error) => {
reject_error_generic(sender, &error, tm).await;
return Err(error);
}
};
let guest_id = guest_id_lease.id;
send_accepted_generic(sender, &negotiated, tm, Some(guest_id))
.await
.map_err(AcceptError::Send)?;
@ -258,6 +380,7 @@ impl HandshakeEngine {
auth_state: crate::error::AuthState::Unauthenticated,
client_id: guest_id,
client_public_key: None,
guest_id_lease: Some(guest_id_lease),
})
}
@ -274,11 +397,17 @@ impl HandshakeEngine {
version_str: &str,
client_version: Version,
) -> Result<HandshakeResult, AcceptError> {
let tm = mtp_codec::TypeMap::latest();
let tm = codec.type_map();
// Register frames always go through full authentication
if Some(first_msg.get_type()) == CommunicationType::Register.try_to_id(&tm) {
let bundle = extract_register_bundle(&first_msg)?;
let bundle = match extract_register_bundle(&first_msg) {
Ok(bundle) => bundle,
Err(error) => {
reject_error_generic(sender, &error, tm).await;
return Err(error);
}
};
let pk_bytes = bundle.as_bytes();
return self
.complete_auth_handshake(
@ -298,7 +427,7 @@ impl HandshakeEngine {
// Identification: try lookup, fall back to guest
if Some(first_msg.get_type()) == CommunicationType::Identification.try_to_id(&tm) {
let cid = match first_msg.get_data(DataType::Id) {
DataValue::UnsignedNumber(n) => *n as u64,
Some(DataValue::UnsignedNumber(n)) => u64::try_from(*n).unwrap_or(0),
_ => 0,
};
@ -321,9 +450,26 @@ impl HandshakeEngine {
.await;
}
// Unknown or zero ID: an Identification carrying PublicKeys is an
// explicit authentication attempt, not a guest connection.
if first_msg.get_data(DataType::PublicKeys).is_some() {
let error = AcceptError::AuthenticationFailed(
"unknown authenticated client identity".into(),
);
reject_error_generic(sender, &error, tm).await;
return Err(error);
}
// Unknown or zero ID: fall back to guest
let guest_id = self.assign_guest_id().await?;
send_accepted_generic(sender, &negotiated, Some(guest_id))
let guest_id_lease = match self.assign_guest_id().await {
Ok(lease) => lease,
Err(error) => {
reject_error_generic(sender, &error, tm).await;
return Err(error);
}
};
let guest_id = guest_id_lease.id;
send_accepted_generic(sender, &negotiated, tm, Some(guest_id))
.await
.map_err(AcceptError::Send)?;
return Ok(HandshakeResult {
@ -333,13 +479,13 @@ impl HandshakeEngine {
auth_state: crate::error::AuthState::Unauthenticated,
client_id: guest_id,
client_public_key: None,
guest_id_lease: Some(guest_id_lease),
});
}
sender.close();
Err(AcceptError::AuthenticationFailed(
"unexpected message type".into(),
))
let error = AcceptError::AuthenticationFailed("unexpected message type".into());
reject_error_generic(sender, &error, tm).await;
Err(error)
}
#[cfg(feature = "crypto")]
@ -355,30 +501,39 @@ impl HandshakeEngine {
version_str: &str,
client_version: Version,
) -> Result<HandshakeResult, AcceptError> {
let tm = mtp_codec::TypeMap::latest();
let tm = codec.type_map();
let (flow, response_type) = if Some(first_msg.get_type())
== CommunicationType::Identification.try_to_id(&tm)
{
let cid = match first_msg.get_data(DataType::Id) {
DataValue::UnsignedNumber(n) => *n as u64,
Some(DataValue::UnsignedNumber(n)) => match u64::try_from(*n) {
Ok(id) => id,
Err(_) => {
let error =
AcceptError::AuthenticationFailed("client id is out of range".into());
reject_error_generic(sender, &error, tm).await;
return Err(error);
}
},
_ => {
sender.close();
return Err(AcceptError::AuthenticationFailed(
"missing client id".into(),
));
let error = AcceptError::AuthenticationFailed("missing client id".into());
reject_error_generic(sender, &error, tm).await;
return Err(error);
}
};
let bundle = match (self.config.get_existing_client)(cid, description.clone()).await {
Some(b) => b,
None => {
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 rejection = CommunicationValue::new_with_type_map(
CommunicationType::IdentificationResponse,
tm,
)
.add_typed_default(DataType::Connected, DataValue::BoolFalse)
.add_typed_default(
DataType::ErrorMessage,
DataValue::Str("unknown client id".into()),
);
let _ = sender.send(&rejection).await;
sender.close();
return Err(AcceptError::AuthenticationFailed(
@ -391,17 +546,23 @@ impl HandshakeEngine {
CommunicationType::IdentificationResponse,
)
} else if Some(first_msg.get_type()) == CommunicationType::Register.try_to_id(&tm) {
let bundle = extract_register_bundle(&first_msg)?;
let bundle = match extract_register_bundle(&first_msg) {
Ok(bundle) => bundle,
Err(error) => {
reject_error_generic(sender, &error, tm).await;
return Err(error);
}
};
let pk_bytes = bundle.as_bytes();
(
Flow::Register { bundle, pk_bytes },
CommunicationType::RegisterResponse,
)
} else {
sender.close();
return Err(AcceptError::AuthenticationFailed(
"unexpected authentication message".into(),
));
let error =
AcceptError::AuthenticationFailed("unexpected authentication message".into());
reject_error_generic(sender, &error, tm).await;
return Err(error);
};
self.complete_auth_handshake(
@ -434,7 +595,7 @@ impl HandshakeEngine {
) -> Result<HandshakeResult, AcceptError> {
use mtp_crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme, auth, verify_ed25519};
let tm = mtp_codec::TypeMap::latest();
let tm = codec.type_map();
// PQ preflight: host requiring PQ must have a PQ key
let pq_enabled = !self
@ -457,6 +618,7 @@ impl HandshakeEngine {
RejectionReason::AuthenticationFailed {
detail: "host requires PQ authentication but has no PQ signing key".into(),
},
Some(tm),
)
.await;
sender.close();
@ -468,11 +630,17 @@ impl HandshakeEngine {
// Initialize host signers
let host_pq_signer = if pq_enabled {
Some(Arc::new(
MlDsaSigner::new(
match 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()))?,
) {
Ok(signer) => signer,
Err(error) => {
let error = AcceptError::AuthenticationFailed(error.to_string());
reject_error_generic(sender, &error, tm).await;
return Err(error);
}
},
))
} else {
None
@ -505,14 +673,21 @@ impl HandshakeEngine {
let server_challenge: u128 = rand::random();
let (chal_sig, chal_pq_sig) =
host_sign(auth::challenge_payload(challenge_id, server_challenge)).await?;
match host_sign(auth::challenge_payload(challenge_id, server_challenge)).await {
Ok(signatures) => signatures,
Err(error) => {
reject_error_generic(sender, &error, tm).await;
return Err(error);
}
};
let mut challenge_msg = CommunicationValue::new(CommunicationType::Challenge)
.add_typed_default(
DataType::ServerNonce,
DataValue::UnsignedNumber(server_challenge),
)
.add_typed_default(DataType::Signature, DataValue::Bytes(chal_sig));
let mut challenge_msg =
CommunicationValue::new_with_type_map(CommunicationType::Challenge, tm)
.add_typed_default(
DataType::ServerNonce,
DataValue::UnsignedNumber(server_challenge),
)
.add_typed_default(DataType::Signature, DataValue::Bytes(chal_sig));
challenge_msg = challenge_msg.add_typed_default(
DataType::RequirePq,
if self.config.require_pq {
@ -536,31 +711,28 @@ impl HandshakeEngine {
AcceptError::Receive(e)
})?;
if Some(proof.get_type()) != CommunicationType::ChallengeResponse.try_to_id(&tm) {
sender.close();
return Err(AcceptError::AuthenticationFailed(
"missing challenge response".into(),
));
let error = AcceptError::AuthenticationFailed("missing challenge response".into());
reject_error_generic(sender, &error, tm).await;
return Err(error);
}
let client_nonce = match proof.get_data(DataType::ClientNonce) {
DataValue::UnsignedNumber(n) => *n,
Some(DataValue::UnsignedNumber(n)) => *n,
_ => {
sender.close();
return Err(AcceptError::AuthenticationFailed(
"missing client nonce".into(),
));
let error = AcceptError::AuthenticationFailed("missing client nonce".into());
reject_error_generic(sender, &error, tm).await;
return Err(error);
}
};
let sig_bytes = match proof.get_data(DataType::Signature) {
DataValue::Bytes(b) => b.clone(),
Some(DataValue::Bytes(b)) => b.clone(),
_ => {
sender.close();
return Err(AcceptError::AuthenticationFailed(
"missing challenge signature".into(),
));
let error = AcceptError::AuthenticationFailed("missing challenge signature".into());
reject_error_generic(sender, &error, tm).await;
return Err(error);
}
};
let pq_sig_bytes: Vec<u8> = match proof.get_data(DataType::PqSignature) {
DataValue::Bytes(b) => b.clone(),
Some(DataValue::Bytes(b)) => b.clone(),
_ => vec![],
};
@ -601,6 +773,7 @@ impl HandshakeEngine {
RejectionReason::AuthenticationFailed {
detail: "client proof signature invalid".into(),
},
Some(tm),
)
.await;
sender.close();
@ -613,21 +786,66 @@ impl HandshakeEngine {
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;
let _registration_guard = self.config.registration_lock.lock().await;
let identity = bundle.as_bytes();
let cached_id = self
.config
.registration_ids
.lock()
.ok()
.and_then(|registrations| registrations.get(&identity).copied());
let new_id = if let Some(id) = cached_id {
id
} else if let Some(lookup) = &self.config.find_registered_client {
match lookup(bundle.clone(), description.clone()).await {
Some(id) => id,
None => {
(self.config.complete_register)(bundle.clone(), description.clone())
.await
}
}
} else {
(self.config.complete_register)(bundle.clone(), description.clone()).await
};
if new_id != 0
&& let Ok(mut registrations) = self.config.registration_ids.lock()
{
registrations.insert(identity, new_id);
}
if new_id == 0 {
let error = AcceptError::AuthenticationFailed(
"registration callback returned reserved client id 0".into(),
);
reject_error_generic(sender, &error, tm).await;
return Err(error);
}
(new_id, bundle)
}
};
if assigned_id == 0 {
let error = AcceptError::AuthenticationFailed(
"client id 0 is reserved for no authenticated identity".into(),
);
reject_error_generic(sender, &error, tm).await;
return Err(error);
}
// Sign and send final response
let (host_sig, host_pq_sig) = host_sign(auth::host_final_payload(
let (host_sig, host_pq_sig) = match host_sign(auth::host_final_payload(
assigned_id,
client_nonce,
server_challenge,
))
.await?;
.await
{
Ok(signatures) => signatures,
Err(error) => {
reject_error_generic(sender, &error, tm).await;
return Err(error);
}
};
let mut response = CommunicationValue::new(response_type)
let mut response = CommunicationValue::new_with_type_map(response_type, tm)
.add_typed_default(DataType::Connected, DataValue::BoolTrue)
.add_typed_default(DataType::Id, DataValue::UnsignedNumber(assigned_id as u128))
.add_typed_default(
@ -658,6 +876,7 @@ impl HandshakeEngine {
auth_state: crate::error::AuthState::Authenticated,
client_id: assigned_id,
client_public_key: Some(client_bundle),
guest_id_lease: None,
})
}
}
@ -682,36 +901,52 @@ enum Flow {
impl HandshakeEngine {
const GUEST_ID_MAX_RETRIES: u32 = 100;
async fn assign_guest_id(&self) -> Result<u64, AcceptError> {
async fn assign_guest_id(&self) -> Result<GuestIdLease, AcceptError> {
if let Some(ref generator) = self.config.guest_id_generator {
let id = generator().await.ok_or_else(|| {
AcceptError::AuthenticationFailed(
"guest id generator rejected the connection".into(),
)
})?;
if id > mtp_codec::MAX_WIRE_ID {
if id == 0 {
return Err(AcceptError::AuthenticationFailed(
"guest id exceeds wire limit".into(),
"guest id 0 is reserved for no authenticated identity".into(),
));
}
if (self.config.get_existing_client)(id, None).await.is_none() {
return Ok(id);
if let Some(lease) = self.try_reserve_guest_id(id).await? {
return Ok(lease);
}
}
self.random_guest_id().await
}
async fn random_guest_id(&self) -> Result<u64, AcceptError> {
async fn random_guest_id(&self) -> Result<GuestIdLease, AcceptError> {
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 Ok(id);
let id = rand::random::<u64>();
if let Some(lease) = self.try_reserve_guest_id(id).await? {
return Ok(lease);
}
}
Err(AcceptError::AuthenticationFailed(
"failed to allocate a unique guest id after retries".into(),
))
}
async fn try_reserve_guest_id(&self, id: u64) -> Result<Option<GuestIdLease>, AcceptError> {
if id == 0 || (self.config.get_existing_client)(id, None).await.is_some() {
return Ok(None);
}
let mut active_ids = self.config.active_guest_ids.lock().map_err(|_| {
AcceptError::AuthenticationFailed("guest ID registry is poisoned".into())
})?;
if !active_ids.insert(id) {
return Ok(None);
}
Ok(Some(GuestIdLease {
active_ids: Arc::clone(&self.config.active_guest_ids),
id,
}))
}
}
// ---------------------------------------------------------------------------
@ -723,7 +958,7 @@ fn extract_register_bundle(
msg: &CommunicationValue,
) -> Result<mtp_crypto::PublicKeyBundle, AcceptError> {
match msg.get_data(DataType::PublicKeys) {
DataValue::Bytes(b) => mtp_crypto::PublicKeyBundle::from_bytes(b)
Some(DataValue::Bytes(b)) => mtp_crypto::PublicKeyBundle::from_bytes(b)
.map_err(|_| AcceptError::AuthenticationFailed("invalid public key bundle".into())),
_ => Err(AcceptError::AuthenticationFailed(
"missing public keys".into(),
@ -731,32 +966,58 @@ fn extract_register_bundle(
}
}
async fn send_rejection_generic<S: HandshakeSender>(sender: &S, reason: RejectionReason) {
async fn send_rejection_generic<S: HandshakeSender>(
sender: &S,
reason: RejectionReason,
type_map: Option<&TypeMap>,
) {
let type_map = type_map.cloned().unwrap_or_else(TypeMap::latest);
let response = match &reason {
RejectionReason::BadVersion { supported_versions } => {
CommunicationValue::new(CommunicationType::ErrorBadVersion)
CommunicationValue::new_with_type_map(CommunicationType::ErrorBadVersion, &type_map)
.add_typed_default(
DataType::Version,
DataValue::Str(supported_versions.join(",")),
)
.add_typed_default(DataType::ErrorMessage, DataValue::Str(reason.to_string()))
}
_ => CommunicationValue::new(CommunicationType::IdentificationResponse)
.add_typed_default(DataType::Connected, DataValue::BoolFalse)
.add_typed_default(DataType::ErrorMessage, DataValue::Str(reason.to_string())),
_ => CommunicationValue::new_with_type_map(
CommunicationType::IdentificationResponse,
&type_map,
)
.add_typed_default(DataType::Connected, DataValue::BoolFalse)
.add_typed_default(DataType::ErrorMessage, DataValue::Str(reason.to_string())),
};
let _ = sender.send(&response).await;
}
#[cfg(feature = "crypto")]
async fn reject_error_generic<S: HandshakeSender>(
sender: &S,
error: &AcceptError,
type_map: &TypeMap,
) {
send_rejection_generic(
sender,
RejectionReason::AuthenticationFailed {
detail: error.to_string(),
},
Some(type_map),
)
.await;
sender.close();
}
async fn send_accepted_generic<S: HandshakeSender>(
sender: &S,
version: &Version,
type_map: &TypeMap,
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()));
let mut response =
CommunicationValue::new_with_type_map(CommunicationType::IdentificationResponse, type_map)
.add_typed_default(DataType::Connected, DataValue::BoolTrue)
.add_typed_default(DataType::Version, DataValue::Str(version.to_string()));
if let Some(id) = assigned_id {
response = response.add_typed_default(DataType::Id, DataValue::UnsignedNumber(id as u128));
}
@ -780,6 +1041,9 @@ impl HandshakeSender for mtp_transport::Sender {
) -> impl std::future::Future<Output = Result<(), CommunicationError>> + Send {
mtp_transport::Sender::finish_stream(self)
}
fn set_type_map(&self, type_map: &TypeMap) -> impl std::future::Future<Output = ()> + Send {
async move { self.set_type_map(type_map).await }
}
fn close(&self) {
let sender = self.clone();
tokio::spawn(async move { sender.close().await });
@ -793,6 +1057,10 @@ impl HandshakeReceiver for mtp_transport::Receiver {
{
mtp_transport::Receiver::receive(self)
}
fn set_type_map(&self, type_map: &TypeMap) -> impl std::future::Future<Output = ()> + Send {
async move { self.set_type_map(type_map).await }
}
}
impl<C: mtp_transport::TransportConnection> HandshakeSender for mtp_transport::GenericSender<C> {
@ -807,6 +1075,9 @@ impl<C: mtp_transport::TransportConnection> HandshakeSender for mtp_transport::G
) -> impl std::future::Future<Output = Result<(), CommunicationError>> + Send {
mtp_transport::GenericSender::finish_stream(self)
}
fn set_type_map(&self, type_map: &TypeMap) -> impl std::future::Future<Output = ()> + Send {
async move { self.set_type_map(type_map).await }
}
fn close(&self) {
mtp_transport::GenericSender::close(self);
}
@ -821,4 +1092,68 @@ impl<C: mtp_transport::TransportConnection> HandshakeReceiver
{
mtp_transport::GenericReceiver::receive(self)
}
fn set_type_map(&self, type_map: &TypeMap) -> impl std::future::Future<Output = ()> + Send {
async move { self.set_type_map(type_map).await }
}
}
#[cfg(all(test, feature = "crypto"))]
mod tests {
use super::*;
use std::sync::Mutex;
#[tokio::test]
async fn guest_generator_accepts_full_width_id_after_collision_check()
-> Result<(), Box<dyn std::error::Error>> {
let lookups = Arc::new(Mutex::new(Vec::new()));
let recorded_lookups = Arc::clone(&lookups);
let mut config = HostConfig::new("127.0.0.1".parse()?, 4433, Vec::new(), Vec::new())
.with_guest_id_generator(Box::new(|| Box::pin(async { Some(u64::MAX) })));
config.get_existing_client = Box::new(move |id, description| {
let recorded_lookups = Arc::clone(&recorded_lookups);
Box::pin(async move {
recorded_lookups
.lock()
.expect("guest ID lookup mutex should not be poisoned")
.push((id, description));
None
})
});
let engine = HandshakeEngine::new(Registry::builtin(), Arc::new(config));
assert_eq!(engine.assign_guest_id().await?.id, u64::MAX);
assert_eq!(
*lookups
.lock()
.expect("guest ID lookup mutex should not be poisoned"),
vec![(u64::MAX, None)]
);
Ok(())
}
#[tokio::test]
async fn active_guest_ids_are_unique_and_released() -> Result<(), Box<dyn std::error::Error>> {
let config = HostConfig::new("127.0.0.1".parse()?, 4433, Vec::new(), Vec::new());
let engine = HandshakeEngine::new(Registry::builtin(), Arc::new(config));
let first = engine.try_reserve_guest_id(1).await?.unwrap();
assert!(engine.try_reserve_guest_id(1).await?.is_none());
drop(first);
assert!(engine.try_reserve_guest_id(1).await?.is_some());
Ok(())
}
#[tokio::test]
async fn zero_is_rejected_as_a_guest_id() -> Result<(), Box<dyn std::error::Error>> {
let config = HostConfig::new("127.0.0.1".parse()?, 4433, Vec::new(), Vec::new())
.with_guest_id_generator(Box::new(|| Box::pin(async { Some(0) })));
let engine = HandshakeEngine::new(Registry::builtin(), Arc::new(config));
assert!(engine.assign_guest_id().await.is_err());
Ok(())
}
}

View file

@ -7,14 +7,13 @@ use mtp_codec::{CommunicationValue, DataType, DataValue};
#[cfg(feature = "crypto")]
pub(crate) fn random_client_id() -> u64 {
rand::random::<u64>() & mtp_codec::MAX_WIRE_ID
rand::random::<u64>()
}
#[cfg(test)]
pub(crate) fn extract_version(msg: &CommunicationValue) -> Option<Version> {
let value = msg.get_data(DataType::Version);
match value {
DataValue::Str(s) => Version::parse(s.as_str()),
match msg.get_data(DataType::Version) {
Some(DataValue::Str(s)) => Version::parse(s.as_str()),
_ => None,
}
}

View file

@ -164,6 +164,8 @@ 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 = result.codec.type_map().clone();
#[cfg(feature = "pipes")]
{
if self.config.send_pongs {
receiver.respond_to_pings(sender.clone());
@ -177,6 +179,7 @@ impl HandshakeContext {
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: type_map.clone(),
});
let dispatcher_clone = dispatcher.clone();
@ -202,9 +205,11 @@ impl HandshakeContext {
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,
}
}
@ -226,9 +231,11 @@ impl HandshakeContext {
_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,
}
}
}
@ -246,6 +253,8 @@ 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());
@ -259,6 +268,7 @@ impl HandshakeContext {
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();
@ -284,6 +294,7 @@ impl HandshakeContext {
pipe_dispatcher: dispatcher,
description,
_dispatcher_task: task,
_connection_guard: None,
}
}
@ -305,6 +316,7 @@ impl HandshakeContext {
_pipe_stream: std::marker::PhantomData,
description,
_dispatcher_task: task,
_connection_guard: None,
}
}
}

View file

@ -28,7 +28,10 @@ pub use pipe::PipeRequest;
pub use mtp_codec::registry::Registry;
#[cfg(feature = "crypto")]
pub use config::{AuthenticationPolicy, CompleteRegister, GetExistingClient, GuestIdGenerator};
pub use config::{
AuthenticationPolicy, CompleteRegister, FindRegisteredClient, GetExistingClient,
GuestIdGenerator,
};
#[cfg(feature = "crypto")]
pub use error::AuthState;
@ -51,9 +54,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("2.0".to_string()));
.add_typed(DataType::Version, &tm, DataValue::Str("3.0".to_string()));
let version = error::extract_version(&msg);
assert_eq!(version, Some(mtp_codec::Version(2, 0)));
assert_eq!(version, Some(mtp_codec::Version(3, 0)));
}
#[test]
@ -92,7 +95,7 @@ mod tests {
#[tokio::test]
async fn alternative_transports_use_the_shared_connection_type() {
let registry = Registry::builtin();
let version = mtp_codec::Version(1, 0);
let version = mtp_codec::Version(3, 0);
let codec = VersionedCodec::for_version(registry, version.clone()).unwrap();
let connection: MTPConnection<AlternateSender, AlternateReceiver> =
MTPConnection::from_transport_parts(

View file

@ -1,4 +1,4 @@
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, TypeMap};
use mtp_common::{CommunicationError, PipeError};
use mtp_transport::{PipeReader, PipeWriter, Policy, TransportEvent};
use std::collections::HashMap;
@ -152,24 +152,49 @@ where
.await
.insert(self.pipe_id, pipe_tx);
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)?;
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));
}
tokio::time::timeout(self.dispatcher.policy.read_timeout, pipe_rx)
.await
.map_err(|_| PipeError::HandshakeTimeout)?
.map_err(|_| PipeError::StreamClosed)
match tokio::time::timeout(self.dispatcher.policy.read_timeout, pipe_rx).await {
Ok(Ok(reader)) => Ok(reader),
Ok(Err(_)) => {
self.dispatcher
.pending_pipes
.lock()
.await
.remove(&self.pipe_id);
Err(PipeError::StreamClosed)
}
Err(_) => {
self.dispatcher
.pending_pipes
.lock()
.await
.remove(&self.pipe_id);
Err(PipeError::HandshakeTimeout)
}
}
}
pub async fn deny(self) -> Result<(), PipeError> {
let response = CommunicationValue::new(CommunicationType::PipeResponse)
.with_id(self.pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
let response = CommunicationValue::new_with_type_map(
CommunicationType::PipeResponse,
&self.dispatcher.type_map,
)
.with_id(self.pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
self.sender
.send_pipe_message(&response)
.await
@ -182,6 +207,7 @@ pub(crate) struct PipeDispatcher<P> {
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) async fn run_dispatcher<S, R, P>(
@ -195,15 +221,21 @@ pub(crate) async fn run_dispatcher<S, R, P>(
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 Some(message.get_type()) == pipe_req_type {
if message.is_type(CommunicationType::PipeRequest) {
let Some(pipe_id) = message.id().filter(|id| *id != 0) else {
let error = CommunicationError::Other(
"PipeRequest frame must contain a non-zero id".into(),
);
if app_tx.send(Err(error)).await.is_err() {
break;
}
continue;
};
let request = PipeRequest {
pipe_id: message.get_id(),
pipe_id,
description: message
.get_str(DataType::Description)
.unwrap_or("")
@ -214,14 +246,36 @@ pub(crate) async fn run_dispatcher<S, R, P>(
let _ = pipe_req_tx.send(request).await;
continue;
}
if Some(message.get_type()) == pipe_resp_type {
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 mut pending = dispatcher.pending_creations.lock().await;
if let Some(reply) = pending.remove(&message.get_id()) {
if let Some(reply) = pending.remove(&pipe_id) {
let _ =
reply.send(Ok(message.get_bool(DataType::Accepted).unwrap_or(false)));
}
continue;
}
if !matches!(message.id(), Some(id) if id != 0)
&& message
.get_type_name()
.is_some_and(|name| name.ends_with("Response"))
{
let error = CommunicationError::Other(
"response frame must contain a non-zero id".into(),
);
if app_tx.send(Err(error)).await.is_err() {
break;
}
continue;
}
if app_tx.send(Ok(message)).await.is_err() {
break;
}

View file

@ -67,7 +67,10 @@ pub(crate) async fn run_driver(
let host_config = host_config.clone();
let auth_semaphore = auth_semaphore.clone();
connection_tasks.spawn(async move {
let _permit = permit;
// The permit normally lives for this HTTP/3 connection.
// For an MTP session it is moved into the resulting
// connection so the limit covers the session lifetime.
let mut connection_permit = Some(permit);
let connect_start = std::time::Instant::now();
let connection = match incoming.await {
Ok(connection) => connection,
@ -149,27 +152,41 @@ pub(crate) async fn run_driver(
remote_addr,
));
let mtp_tx = mtp_tx.clone();
let mtp_queue_permit = match mtp_tx.clone().try_reserve_owned() {
Ok(permit) => permit,
Err(_) => {
tracing::debug!(
"rejecting MTP session because the application queue is full"
);
connection.close(
quinn::VarInt::from_u32(0),
b"mtp application queue is full",
);
return;
}
};
let auth_semaphore = auth_semaphore.clone();
let host_config = host_config.clone();
let connection_guard = connection_permit.take();
let connection = connection.clone();
let close_connection = connection.clone();
tokio::spawn(async move {
let result =
accept_web_connection(session, mtp_path, connection, send_pongs, policy, host_config, auth_semaphore)
.await;
match mtp_tx.try_send(result) {
Ok(()) => {}
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();
}
}
Err(tokio::sync::mpsc::error::TrySendError::Closed(result)) => {
if let Ok(connection) = result {
connection.sender.close();
}
}
let result = accept_web_connection(
session,
mtp_path,
connection,
send_pongs,
policy,
host_config,
auth_semaphore,
connection_guard,
)
.await;
if result.is_err() {
close_connection
.close(quinn::VarInt::from_u32(0), b"mtp handshake failed");
}
mtp_queue_permit.send(result);
});
return;
}

View file

@ -1,8 +1,5 @@
use bytes::Bytes;
use mtp_codec::{
DataType, DataValue, Version,
registry::{Registry, VersionedCodec},
};
use mtp_codec::registry::Registry;
use mtp_common::CommunicationError;
use mtp_host::AcceptError;
use mtp_host::HostConfig;
@ -11,14 +8,9 @@ use mtp_transport::{
TransportSendStream,
};
use std::sync::Arc;
#[cfg(feature = "crypto")]
use std::time::Instant;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tracing::error;
#[cfg(feature = "crypto")]
const GUEST_ID_MAX_RETRIES: u32 = 100;
type Session = h3_webtransport::server::WebTransportSession<h3_quinn::Connection, Bytes>;
type H3SendStream = h3_webtransport::stream::SendStream<h3_quinn::SendStream<Bytes>, Bytes>;
type H3RecvStream = h3_webtransport::stream::RecvStream<h3_quinn::RecvStream, Bytes>;
@ -228,38 +220,6 @@ pub type WebMtpReceiver = GenericReceiver<H3TransportConnection>;
pub type WebMTPConnection =
mtp_host::MTPConnection<WebMtpSender, WebMtpReceiver, H3TransportReceiver>;
#[cfg(feature = "crypto")]
impl H3TransportConnection {
/// Assign a unique guest ID, using the configured generator if present.
async fn assign_guest_id(host_config: &HostConfig) -> Result<u64, AcceptError> {
if let Some(ref generator) = host_config.guest_id_generator {
let id = generator().await.ok_or_else(|| {
AcceptError::AuthenticationFailed(
"guest id generator rejected the connection".into(),
)
})?;
if id > mtp_codec::MAX_WIRE_ID {
return Err(AcceptError::AuthenticationFailed(
"guest id exceeds wire limit".into(),
));
}
if (host_config.get_existing_client)(id, None).await.is_none() {
return Ok(id);
}
}
// Fall back to random ID with collision check
for _ in 0..GUEST_ID_MAX_RETRIES {
let id = rand::random::<u64>() & mtp_codec::MAX_WIRE_ID;
if (host_config.get_existing_client)(id, None).await.is_none() {
return Ok(id);
}
}
Err(AcceptError::AuthenticationFailed(
"failed to allocate a unique guest id after retries".into(),
))
}
}
pub(crate) async fn accept_web_connection(
session: Arc<Session>,
path: String,
@ -267,25 +227,45 @@ pub(crate) async fn accept_web_connection(
send_pongs: bool,
policy: Policy,
host_config: Arc<HostConfig>,
_auth_semaphore: Arc<tokio::sync::Semaphore>,
#[allow(unused_variables)] auth_semaphore: Arc<tokio::sync::Semaphore>,
connection_guard: Option<tokio::sync::OwnedSemaphorePermit>,
) -> Result<WebMTPConnection, AcceptError> {
#[cfg(feature = "crypto")]
{
let permit = _auth_semaphore.clone().acquire_owned().await.map_err(|_| {
AcceptError::AuthenticationFailed("authentication service stopped".into())
})?;
let result = tokio::time::timeout(
host_config.auth_timeout,
accept_web_connection_inner(session, path, quinn, send_pongs, policy, host_config),
let deadline = tokio::time::Instant::now() + host_config.auth_timeout;
let permit = tokio::time::timeout_at(deadline, auth_semaphore.clone().acquire_owned())
.await
.map_err(|_| AcceptError::AuthenticationTimedOut)?
.map_err(|_| {
AcceptError::AuthenticationFailed("authentication service stopped".into())
})?;
let result = accept_web_connection_inner(
session,
path,
quinn,
send_pongs,
policy,
host_config,
Some(deadline),
connection_guard,
)
.await
.unwrap_or(Err(AcceptError::AuthenticationTimedOut));
.await;
drop(permit);
result
}
#[cfg(not(feature = "crypto"))]
accept_web_connection_inner(session, path, quinn, send_pongs, policy, host_config).await
accept_web_connection_inner(
session,
path,
quinn,
send_pongs,
policy,
host_config,
None,
connection_guard,
)
.await
}
async fn accept_web_connection_inner(
@ -294,434 +274,74 @@ async fn accept_web_connection_inner(
quinn: quinn::Connection,
send_pongs: bool,
policy: Policy,
_host_config: Arc<HostConfig>,
host_config: Arc<HostConfig>,
#[allow(unused_variables)] deadline: Option<tokio::time::Instant>,
connection_guard: Option<tokio::sync::OwnedSemaphorePermit>,
) -> 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();
let policy = Arc::new(policy);
let receiver = WebMtpReceiver::new(transport.clone(), policy.clone());
let sender = WebMtpSender::new(transport.clone(), policy.clone());
let receiver = WebMtpReceiver::new(transport, policy.clone());
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.clone());
if send_pongs {
receiver.respond_to_pings(sender.clone()).await;
}
let engine = mtp_host::HandshakeEngine::new(Registry::builtin(), host_config);
#[cfg(feature = "crypto")]
let result = engine
.accept_until(
&sender,
&receiver,
deadline.expect("crypto WebTransport handshakes have a deadline"),
)
.await?;
#[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(
negotiated,
version,
codec,
sender,
receiver,
path,
description.clone(),
description,
Some(remote_addr),
policy,
);
#[cfg(not(feature = "pipes"))]
let connection: WebMTPConnection =
mtp_host::MTPConnection::from_transport_parts_with_remote_addr(
negotiated,
version,
codec,
sender,
receiver,
path,
description.clone(),
description,
Some(remote_addr),
);
#[cfg(not(feature = "crypto"))]
{
connection.receiver.set_max_message_size(max_message_size);
Ok(connection)
}
#[cfg(feature = "crypto")]
let mut connection = connection;
#[cfg(feature = "crypto")]
{
use mtp_codec::{CommunicationType, DataType, DataValue};
use mtp_crypto::{
Ed25519Signer, MlDsaSigner, PublicKeyBundle, SignatureScheme, auth, verify_ed25519,
};
let tm = mtp_codec::TypeMap::latest();
let is_allow_auth = matches!(
_host_config.authentication_policy,
mtp_host::AuthenticationPolicy::AllowAuthentication
);
let is_force_auth = matches!(
_host_config.authentication_policy,
mtp_host::AuthenticationPolicy::ForceAuthentication
);
// Unauthenticated: send accepted response with guest ID (or ID 0)
if !is_allow_auth && !is_force_auth {
let response =
mtp_codec::CommunicationValue::new(CommunicationType::IdentificationResponse)
.add_typed_default(DataType::Connected, DataValue::BoolTrue)
.add_typed_default(
DataType::Version,
DataValue::Str(connection.version.to_string()),
)
.add_typed_default(DataType::Id, 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);
return Ok(connection);
}
// AllowAuthentication / ForceAuthentication: perform authentication
let client_lookup_started = Instant::now();
let first_type = first.get_type();
let id_type = CommunicationType::Identification.try_to_id(&tm);
let reg_type = CommunicationType::Register.try_to_id(&tm);
let first_type_opt = Some(first_type);
let (client_id, client_bundle, response_type, is_guest) = if is_allow_auth
&& first_type_opt == id_type
{
// AllowAuthentication Identification: try lookup, fall back to guest
let id = match first.get_data(DataType::Id) {
DataValue::UnsignedNumber(value) => *value as u64,
_ => 0,
};
if id > 0 {
if let Some(bundle) =
(_host_config.get_existing_client)(id, description.clone()).await
{
(
id,
Some(bundle),
CommunicationType::IdentificationResponse,
false,
)
} else {
// Unknown client: fall back to guest
let guest_id = H3TransportConnection::assign_guest_id(&_host_config).await?;
(
guest_id,
None,
CommunicationType::IdentificationResponse,
true,
)
}
} else {
// ID zero or missing: fall back to guest
let guest_id = H3TransportConnection::assign_guest_id(&_host_config).await?;
(
guest_id,
None,
CommunicationType::IdentificationResponse,
true,
)
}
} else if first_type_opt == reg_type {
// Registration: always authenticate (both AllowAuth and ForceAuth)
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, Some(bundle), CommunicationType::RegisterResponse, false)
} else if first_type_opt == id_type {
// ForceAuthentication Identification: require lookup
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,
Some(bundle),
CommunicationType::IdentificationResponse,
false,
)
} else {
return Err(AcceptError::AuthenticationFailed(
"unexpected authentication message".into(),
));
};
tracing::debug!(elapsed = ?client_lookup_started.elapsed(), is_guest, "web authentication handshake: identify client");
// Guest path: skip challenge/response, send accepted with guest ID
if is_guest {
let guest_id = client_id;
let response =
mtp_codec::CommunicationValue::new(CommunicationType::IdentificationResponse)
.add_typed_default(DataType::Connected, DataValue::BoolTrue)
.add_typed_default(
DataType::Version,
DataValue::Str(connection.version.to_string()),
)
.add_typed_default(DataType::Id, DataValue::UnsignedNumber(guest_id as u128));
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);
connection.auth_state = mtp_host::AuthState::Unauthenticated;
connection.client_id = guest_id;
return Ok(connection);
}
let client_bundle = client_bundle.unwrap();
// PQ preflight: if host requires PQ, it must have a PQ key
let pq_enabled = !_host_config
.host_keyring
.sig_pq_secret_key
.as_bytes()
.is_empty();
if _host_config.require_pq
&& (!pq_enabled
|| _host_config
.host_keyring
.sig_pq_public_key
.as_bytes()
.is_empty())
{
return Err(AcceptError::AuthenticationFailed(
"host requires PQ authentication but has no PQ signing key".into(),
));
}
let signer_init_started = Instant::now();
let host_pq_signer = if pq_enabled {
Some(Arc::new(
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: Vec<u8>| {
let host_config = _host_config.clone();
let pq_signer = host_pq_signer.clone();
async move {
let signer = Ed25519Signer::new(&host_config.host_keyring.sig_cl_secret_key)
.map_err(|e| AcceptError::AuthenticationFailed(e.to_string()))?;
if let Some(pq_signer) = pq_signer {
mtp_crypto::sign_parallel::sign_dual_parallel_shared_pq(
signer, 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 sign_challenge_started = Instant::now();
let (sig, pq_sig) = host_sign(auth::challenge_payload(client_id, server_challenge)).await?;
tracing::debug!(elapsed = ?sign_challenge_started.elapsed(), "web authentication handshake: sign challenge");
let mut challenge = mtp_codec::CommunicationValue::new(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_enabled {
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()) != 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() == 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)
};
// Verify client proof: classical is always required; PQ is verified
// when supplied (even if not required), matching native behavior.
let has_client_pq_key = !client_bundle.sig_pq_public_key.as_bytes().is_empty();
let verify_proof_started = Instant::now();
let proof_ok = if pq_signature.is_empty() {
!_host_config.require_pq
&& verify_ed25519(&client_bundle.sig_cl_public_key, &payload, signature).is_ok()
} else if has_client_pq_key {
mtp_crypto::sign_parallel::verify_dual_parallel(
client_bundle.sig_cl_public_key.clone(),
client_bundle.sig_pq_public_key.clone(),
payload,
signature.to_vec(),
pq_signature.to_vec(),
)
.await
.is_ok()
} else {
false
};
tracing::debug!(elapsed = ?verify_proof_started.elapsed(), "web authentication handshake: verify client proof");
if !proof_ok {
let rejection =
mtp_codec::CommunicationValue::new(CommunicationType::IdentificationResponse)
.add_typed_default(DataType::Connected, DataValue::BoolFalse)
.add_typed_default(
DataType::ErrorMessage,
DataValue::Str("client proof signature invalid".into()),
);
let _ = connection.sender.send(&rejection).await;
connection.sender.close();
return Err(AcceptError::AuthenticationFailed(
"client proof signature invalid".into(),
));
}
let register_started = Instant::now();
let assigned_id = if response_type == 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,
))
.await?;
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 pq_enabled {
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);
Ok(connection)
connection.auth_state = result.auth_state;
connection.client_id = result.client_id;
connection.client_public_key = result.client_public_key;
connection.set_guest_id_lease(result.guest_id_lease);
}
if let Some(connection_guard) = connection_guard {
connection.set_connection_guard(connection_guard);
}
if send_pongs {
connection
.receiver
.respond_to_pings(connection.sender.clone())
.await;
}
connection.receiver.set_max_message_size(max_message_size);
Ok(connection)
}

View file

@ -40,6 +40,7 @@
"crypto/src/",
"type-map/Cargo.toml",
"type-map/build.rs",
"type-map/reserved.json",
"type-map/src/",
"wasm/.cargo/",
"wasm/Cargo.toml",
@ -51,7 +52,13 @@
"example": "pnpm install && pnpm run build:all && nix develop .#autoStart",
"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",
"dup": "jscpd --pattern '**/*.{rs,ts}' --ignore 'target/**' --ignore 'wasm/pkg/**' --ignore '.git/**' --min-lines 8 --min-tokens 80 --threshold 4 --reporters console --noTips ."
"dup": "jscpd --pattern '**/*.{rs,ts}' --ignore 'target/**' --ignore 'wasm/pkg/**' --ignore '.git/**' --min-lines 8 --min-tokens 80 --threshold 4 --reporters console --noTips .",
"test:e2e": "tsc && node test/e2ee.mjs",
"test:secrets": "tsc && node --test --test-isolation=none test/encrypted-secret.mjs",
"test:types": "tsc -p tsconfig.type-tests.json --noEmit",
"test:vite": "tsc && node test/vite-type-map.mjs",
"test": "pnpm run test:e2e && pnpm run test:secrets && pnpm run test:types && pnpm run test:vite",
"check:boundary": "node scripts/check-mtp-boundary.mjs"
},
"devDependencies": {
"@types/node": "^26.0.1",

View file

@ -1,74 +0,0 @@
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;
}
}

View file

@ -14,8 +14,8 @@ const HEADER_FIXED_LEN = 1 + 1 + 8 + 8 + 4 + 2 + 4;
export interface ParsedEncryptedMessage {
version: 1;
flags: number;
senderClientId: bigint;
recipientClientId: bigint;
senderId: bigint;
recipientId: bigint;
messageNumber: number;
kemCiphertext?: Uint8Array;
ciphertext: Uint8Array;
@ -28,8 +28,8 @@ export interface ParsedEncryptedMessage {
export interface EncryptedMessageHeader {
version: 1;
flags: number;
senderClientId: bigint;
recipientClientId: bigint;
senderId: bigint;
recipientId: bigint;
messageNumber: number;
kemCiphertext?: Uint8Array;
}
@ -105,8 +105,8 @@ export function serializeEncryptedMessage(
return concatBytes([
new Uint8Array([normalized.version]),
new Uint8Array([normalized.flags]),
writeU64BE(normalized.senderClientId),
writeU64BE(normalized.recipientClientId),
writeU64BE(normalized.senderId),
writeU64BE(normalized.recipientId),
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 senderClientId = readU64BE(bytes, offset);
const senderId = readU64BE(bytes, offset);
offset += 8;
const recipientClientId = readU64BE(bytes, offset);
const recipientId = 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,
senderClientId,
recipientClientId,
senderId,
recipientId,
messageNumber,
kemCiphertext,
ciphertext,
@ -185,8 +185,8 @@ export function parseEncryptedMessage(
parsed.header = {
version: parsed.version,
flags: parsed.flags,
senderClientId: parsed.senderClientId,
recipientClientId: parsed.recipientClientId,
senderId: parsed.senderId,
recipientId: parsed.recipientId,
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.senderClientId),
writeU64BE(header.recipientClientId),
writeU64BE(header.senderId),
writeU64BE(header.recipientId),
writeU32BE(header.messageNumber),
]);
}
@ -227,8 +227,8 @@ export async function encryptPayload(args: {
const header: EncryptedMessageHeader = {
version: 1,
flags: args.kemCiphertext ? FLAG_INIT : 0,
senderClientId: args.session.ownClientId,
recipientClientId: args.session.peerClientId,
senderId: args.session.localId,
recipientId: args.session.remoteId,
messageNumber: args.session.sendCount,
kemCiphertext: args.kemCiphertext,
};
@ -257,19 +257,18 @@ export async function encryptPayload(args: {
export async function decryptPayload(args: {
payload: Uint8Array;
session: MTPSessionState;
expectedRecipientClientId?: bigint;
expectedRecipientId?: bigint;
aad?: Uint8Array;
}): Promise<{
plaintext: Uint8Array;
session: MTPSessionState;
}> {
const parsed = parseEncryptedMessage(args.payload);
const expectedRecipientClientId =
args.expectedRecipientClientId ?? args.session.ownClientId;
if (parsed.recipientClientId !== expectedRecipientClientId) {
const expectedRecipientId = args.expectedRecipientId ?? args.session.localId;
if (parsed.recipientId !== expectedRecipientId) {
throw new Error("Encrypted message recipient mismatch");
}
if (parsed.senderClientId !== args.session.peerClientId) {
if (parsed.senderId !== args.session.remoteId) {
throw new Error("Encrypted message sender mismatch");
}
@ -322,8 +321,8 @@ export async function decryptPayload(args: {
const header: EncryptedMessageHeader = {
version: parsed.version,
flags: parsed.flags,
senderClientId: parsed.senderClientId,
recipientClientId: parsed.recipientClientId,
senderId: parsed.senderId,
recipientId: parsed.recipientId,
messageNumber: parsed.messageNumber,
kemCiphertext: parsed.kemCiphertext,
};

1523
src/sdk/encrypted-pipe.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,90 @@
/**
* 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));
}
}

File diff suppressed because it is too large Load diff

View file

@ -4,23 +4,29 @@ import { concatBytes, utf8Encode, writeU64BE } from "./utils.js";
export const HKDF_SALT_ROOT = "mtp-e2ee-v1-root";
const HKDF_INITIATOR_SEND = "mtp-e2ee-v1-initiator-send";
const HKDF_INITIATOR_RECV = "mtp-e2ee-v1-initiator-recv";
const SESSION_TRANSCRIPT_DOMAIN = "mtp-e2ee-session-transcript-v1";
export interface MTPSessionTranscriptContext {
senderUserId?: string;
senderClientId: bigint;
recipientUserId?: string;
recipientClientId: bigint;
/** Application-selected identity for this stateful MTP session. */
sessionId: string;
/** MTP identity that initiated key establishment. */
initiatorId: bigint;
/** MTP identity that receives the key-establishment message. */
recipientId: bigint;
/** Public key used by the recipient for this key establishment. */
recipientPublicKey: Uint8Array;
/** KEM ciphertext used by the key establishment. */
kemCiphertext: Uint8Array;
conversationId: string;
/** Opaque, application-owned context included by hash only. */
applicationContext?: Uint8Array;
}
export interface MTPSessionState {
version: 1;
conversationId: string;
ownClientId: bigint;
peerClientId: bigint;
peerPublicKey: Uint8Array;
sessionId: string;
localId: bigint;
remoteId: bigint;
remotePublicKey: Uint8Array;
sendChainKey: Uint8Array;
recvChainKey: Uint8Array;
sendCount: number;
@ -37,9 +43,26 @@ export interface SkippedMessageKey {
}
export interface MTPSessionStorage {
getSession(conversationId: string): Promise<MTPSessionState | null>;
getSession(sessionId: string): Promise<MTPSessionState | null>;
setSession(state: MTPSessionState): Promise<void>;
deleteSession(conversationId: string): Promise<void>;
deleteSession(sessionId: string): Promise<void>;
}
function requireSessionId(sessionId: string): string {
if (typeof sessionId !== "string" || sessionId.length === 0) {
throw new TypeError("sessionId must be a non-empty string");
}
return sessionId;
}
function requireMtpId(id: bigint, name: string): bigint {
if (typeof id !== "bigint") {
throw new TypeError(`${name} must be a bigint`);
}
// Validate the range once at the API boundary. The returned value is still
// the original bigint so callers do not observe a representation change.
writeU64BE(id);
return id;
}
export class InMemorySessionStorage implements MTPSessionStorage {
@ -48,7 +71,7 @@ export class InMemorySessionStorage implements MTPSessionStorage {
private cloneSession(state: MTPSessionState): MTPSessionState {
return {
...state,
peerPublicKey: state.peerPublicKey.slice(),
remotePublicKey: state.remotePublicKey.slice(),
sendChainKey: state.sendChainKey.slice(),
recvChainKey: state.recvChainKey.slice(),
skippedMessageKeys: (state.skippedMessageKeys ?? []).map((skipped) => ({
@ -64,26 +87,31 @@ export class InMemorySessionStorage implements MTPSessionStorage {
for (const skipped of state.skippedMessageKeys ?? []) skipped.key.fill(0);
}
async getSession(conversationId: string): Promise<MTPSessionState | null> {
const state = this.store.get(conversationId);
async getSession(sessionId: string): Promise<MTPSessionState | null> {
const state = this.store.get(requireSessionId(sessionId));
return state ? this.cloneSession(state) : null;
}
async setSession(state: MTPSessionState): Promise<void> {
const sessionId = requireSessionId(state.sessionId);
const replacement = this.cloneSession(state);
const previous = this.store.get(state.conversationId);
const previous = this.store.get(sessionId);
if (previous) this.zeroizeSession(previous);
this.store.set(state.conversationId, replacement);
this.store.set(sessionId, replacement);
}
async deleteSession(conversationId: string): Promise<void> {
const previous = this.store.get(conversationId);
async deleteSession(sessionId: string): Promise<void> {
const key = requireSessionId(sessionId);
const previous = this.store.get(key);
if (previous) this.zeroizeSession(previous);
this.store.delete(conversationId);
this.store.delete(key);
}
}
function writeU32BE(value: number): Uint8Array {
if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff) {
throw new Error("u32 value out of range");
}
return new Uint8Array([
(value >>> 24) & 0xff,
(value >>> 16) & 0xff,
@ -102,21 +130,34 @@ function transcriptField(label: string, value: Uint8Array): Uint8Array {
]);
}
/**
* Build a length-delimited transcript for one MTP session.
*
* Application context is intentionally hashed as an opaque byte string. MTP
* therefore provides domain separation without parsing or naming any fields
* owned by the consuming application.
*/
export function buildSessionTranscript(
args: MTPSessionTranscriptContext,
): Uint8Array {
const sessionId = requireSessionId(args.sessionId);
const initiatorId = requireMtpId(args.initiatorId, "initiatorId");
const recipientId = requireMtpId(args.recipientId, "recipientId");
const recipientPublicKeyHash = bindings.wasm_sha256(args.recipientPublicKey);
const kemHash = bindings.wasm_sha256(args.kemCiphertext);
const applicationContextHash = bindings.wasm_sha256(
args.applicationContext ?? new Uint8Array(0),
);
return concatBytes([
transcriptField("domain", utf8Encode("mtp-e2ee-session-transcript-v1")),
transcriptField("domain", utf8Encode(SESSION_TRANSCRIPT_DOMAIN)),
transcriptField("version", utf8Encode("1")),
transcriptField("senderUserId", utf8Encode(args.senderUserId ?? "")),
transcriptField("senderClientId", writeU64BE(args.senderClientId)),
transcriptField("recipientUserId", utf8Encode(args.recipientUserId ?? "")),
transcriptField("recipientClientId", writeU64BE(args.recipientClientId)),
transcriptField("sessionId", utf8Encode(sessionId)),
transcriptField("initiatorId", writeU64BE(initiatorId)),
transcriptField("recipientId", writeU64BE(recipientId)),
transcriptField("recipientPublicKeyHash", recipientPublicKeyHash),
transcriptField("kemCiphertextHash", kemHash),
transcriptField("conversationId", utf8Encode(args.conversationId)),
transcriptField("applicationContextHash", applicationContextHash),
]);
}
@ -150,62 +191,65 @@ export async function deriveSessionKeys(
return { root, initiatorSend, initiatorRecv };
}
export function getConversationId(
ownClientId: bigint,
peerClientId: bigint,
/**
* Derive a stable ID for the unordered pair of MTP identities.
*
* This helper is only a convenience. Session storage and the manager accept
* caller-selected IDs directly, so applications can keep multiple sessions
* between the same pair of identities.
*/
export function derivePeerSessionId(
localId: bigint,
remoteId: bigint,
): string {
const ids = [ownClientId, peerClientId].sort((a, b) =>
a < b ? -1 : a > b ? 1 : 0,
);
const ids = [
requireMtpId(localId, "localId"),
requireMtpId(remoteId, "remoteId"),
].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
return `${ids[0].toString(16)}:${ids[1].toString(16)}`;
}
export class MTPSessionManager {
constructor(private storage: MTPSessionStorage) {}
getConversationId(
ownClientId: bigint,
peerClientId: bigint,
): Promise<string> {
return Promise.resolve(getConversationId(ownClientId, peerClientId));
}
async getSession(
ownClientId: bigint,
peerClientId: bigint,
): Promise<MTPSessionState | null> {
return this.storage.getSession(
getConversationId(ownClientId, peerClientId),
);
getSession(sessionId: string): Promise<MTPSessionState | null> {
return this.storage.getSession(requireSessionId(sessionId));
}
async saveSession(state: MTPSessionState): Promise<void> {
await this.storage.setSession({ ...state, updatedAt: Date.now() });
await this.storage.setSession({
...state,
updatedAt: Date.now(),
});
}
async deleteSession(
ownClientId: bigint,
peerClientId: bigint,
): Promise<void> {
await this.storage.deleteSession(
getConversationId(ownClientId, peerClientId),
);
async deleteSession(sessionId: string): Promise<void> {
await this.storage.deleteSession(requireSessionId(sessionId));
}
async createSession(args: {
ownClientId: bigint;
peerClientId: bigint;
peerPublicKey: Uint8Array;
sessionId: string;
localId: bigint;
remoteId: bigint;
remotePublicKey: Uint8Array;
sharedSecret: Uint8Array;
role: "initiator" | "receiver";
transcript?: Uint8Array;
transcriptContext?: MTPSessionTranscriptContext;
}): Promise<MTPSessionState> {
const transcript =
args.transcript ??
(args.transcriptContext
const sessionId = requireSessionId(args.sessionId);
const localId = requireMtpId(args.localId, "localId");
const remoteId = requireMtpId(args.remoteId, "remoteId");
const transcript = args.transcript
? args.transcript.slice()
: args.transcriptContext
? buildSessionTranscript(args.transcriptContext)
: undefined);
: null;
if (!transcript || transcript.length === 0) {
throw new Error(
"session creation requires a non-empty authenticated transcript or transcriptContext",
);
}
const { root, initiatorSend, initiatorRecv } = await deriveSessionKeys(
args.sharedSecret,
transcript,
@ -213,10 +257,10 @@ export class MTPSessionManager {
const now = Date.now();
const state: MTPSessionState = {
version: 1,
conversationId: getConversationId(args.ownClientId, args.peerClientId),
ownClientId: args.ownClientId,
peerClientId: args.peerClientId,
peerPublicKey: args.peerPublicKey,
sessionId,
localId,
remoteId,
remotePublicKey: args.remotePublicKey.slice(),
sendChainKey: args.role === "initiator" ? initiatorSend : initiatorRecv,
recvChainKey: args.role === "initiator" ? initiatorRecv : initiatorSend,
sendCount: 0,

170
src/sdk/signature-policy.ts Normal file
View file

@ -0,0 +1,170 @@
import * as bindings from "mtp/raw";
export type MTPSignatureVerificationPolicy =
| "ed25519"
| "dual"
| "any-supported";
/**
* The SDK default is deliberately fixed. Senders also default to the
* interoperable Ed25519 suite; dual signatures require an explicit sender
* suite and receiver policy.
*/
export const DEFAULT_SIGNATURE_VERIFICATION_POLICY: MTPSignatureVerificationPolicy =
"ed25519";
export type MTPSignatureVerificationErrorCode =
| "unsupported-suite"
| "policy-rejected"
| "invalid-signature"
| "signer-keys-unavailable";
const POLICY_NAMES: Record<
MTPSignatureVerificationErrorCode,
string
> = {
"unsupported-suite": "unsupported signature suite",
"policy-rejected": "signature rejected by policy",
"invalid-signature": "signature cryptographically invalid",
"signer-keys-unavailable": "signer public keys unavailable",
};
/** Caller-facing signature verification failure without cryptographic detail. */
export class MTPSignatureVerificationError extends Error {
readonly code: MTPSignatureVerificationErrorCode;
readonly signerId?: bigint;
constructor(
code: MTPSignatureVerificationErrorCode,
signerId?: bigint,
) {
super(
signerId == null
? POLICY_NAMES[code]
: `${POLICY_NAMES[code]} for signer ${signerId}`,
);
this.name = "MTPSignatureVerificationError";
this.code = code;
this.signerId = signerId;
}
}
function validPolicy(
value: unknown,
): value is MTPSignatureVerificationPolicy {
return (
value === "ed25519" ||
value === "dual" ||
value === "any-supported"
);
}
/**
* Resolve receiver policy in operation, client, library order.
*/
export function resolveSignatureVerificationPolicy(
operationPolicy: MTPSignatureVerificationPolicy | undefined,
clientDefaultPolicy?: MTPSignatureVerificationPolicy,
): MTPSignatureVerificationPolicy {
if (operationPolicy != null && !validPolicy(operationPolicy)) {
throw new TypeError(
"signaturePolicy must be 'ed25519', 'dual', or 'any-supported'",
);
}
if (clientDefaultPolicy != null && !validPolicy(clientDefaultPolicy)) {
throw new TypeError(
"defaultSignatureVerificationPolicy must be 'ed25519', 'dual', or 'any-supported'",
);
}
return (
operationPolicy ??
clientDefaultPolicy ??
DEFAULT_SIGNATURE_VERIFICATION_POLICY
);
}
/** Convert the SDK policy into the raw WASM verifier's policy value. */
export function signatureVerificationPolicyValue(
policy: MTPSignatureVerificationPolicy,
): number {
switch (policy) {
case "ed25519":
return bindings.mtp_protection_signature_suite_ed25519();
case "dual":
return bindings.mtp_protection_signature_suite_dual();
case "any-supported":
return 0;
}
}
/** Verify one protected value and sanitize raw WASM failure details. */
export function verifyDataValueWithPolicy(
value: Uint8Array,
publicKeyBundle: Uint8Array,
expectedSignerId: bigint,
expectedPurpose: number,
policy: MTPSignatureVerificationPolicy,
): void {
try {
bindings.verify_data_value_with_policy(
value,
publicKeyBundle,
expectedSignerId,
expectedPurpose,
signatureVerificationPolicyValue(policy),
);
} catch (error) {
throw classifySignatureVerificationFailure(error, expectedSignerId);
}
}
function rawErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/** Classify a raw verifier failure without returning its cryptographic cause. */
export function classifySignatureVerificationFailure(
error: unknown,
signerId?: bigint,
): MTPSignatureVerificationError {
const message = rawErrorMessage(error).toLowerCase();
if (message.includes("unknown") && message.includes("signature suite")) {
return new MTPSignatureVerificationError("unsupported-suite", signerId);
}
if (message.includes("policy")) {
return new MTPSignatureVerificationError("policy-rejected", signerId);
}
return new MTPSignatureVerificationError("invalid-signature", signerId);
}
/** Select the most useful sanitized error after trying key history. */
export function signatureVerificationFailure(
errors: readonly unknown[],
signerId?: bigint,
): MTPSignatureVerificationError {
const classified = errors.map((error) =>
error instanceof MTPSignatureVerificationError
? error
: classifySignatureVerificationFailure(error, signerId),
);
const preferredCode = [
"unsupported-suite",
"policy-rejected",
"invalid-signature",
].find((code) =>
classified.some((error) => error.code === code),
) as MTPSignatureVerificationErrorCode | undefined;
return new MTPSignatureVerificationError(
preferredCode ?? "invalid-signature",
signerId,
);
}
export function signerKeysUnavailable(
signerId?: bigint,
): MTPSignatureVerificationError {
return new MTPSignatureVerificationError(
"signer-keys-unavailable",
signerId,
);
}

View file

@ -13,6 +13,11 @@ export function utf8Encode(text: string): Uint8Array {
return bytes.subarray(0, len);
}
/** Return the current Unix time in milliseconds for MTP protocol fields. */
export function unixTimeMillis(): bigint {
return BigInt(Date.now());
}
export function writeU64BE(value: bigint): Uint8Array {
if (value < 0n || value > 0xffff_ffff_ffff_ffffn) throw new Error("u64 value out of range");
const out = new Uint8Array(8);

View file

@ -1,16 +1,13 @@
export const RESERVED_COMMUNICATION_TYPES = [
"Identification", "IdentificationResponse", "Register", "RegisterResponse",
"Challenge", "ChallengeResponse", "Ping", "Pong", "Disconnect", "Redirect",
"Shutdown", "Error", "ErrorParsing", "ErrorBadVersion", "BadRequest",
"Unauthorized", "Forbidden", "NotFound", "TooManyRequests", "InternalServerError",
"BadGateway", "ServiceUnavailable", "GatewayTimeout", "PipeRequest", "PipeResponse",
"PipeAbort",
] as const;
import reserved from "../../type-map/reserved.json" with { type: "json" };
export const RESERVED_DATA_TYPES = [
"Version", "Id", "ClientNonce", "ServerNonce", "PublicKeys", "Signature",
"PqSignature", "Description", "Connected", "Timestamp", "Error", "ErrorParsing",
"ErrorMessage", "Accepted", "RequirePq",
] as const;
export const FIRST_USER_TYPE_ID = 32;
export const FIRST_USER_TYPE_ID = reserved.firstUserTypeId;
export const RESERVED_COMMUNICATION_TYPES = reserved.communication.map(
({ name }) => name,
);
export const RESERVED_DATA_TYPES = reserved.data.map(({ name }) => name);
export const RESERVED_COMMUNICATION_TYPE_IDS = Object.fromEntries(
reserved.communication.map(({ name, id }) => [name, id]),
);
export const RESERVED_DATA_TYPE_IDS = Object.fromEntries(
reserved.data.map(({ name, id }) => [name, id]),
);

View file

@ -61,7 +61,7 @@ function devServerPath(root: string, filePath: string) {
return `/${relativePath.split(path.sep).join("/")}`;
}
async function hashPackageInputs() {
export async function hashPackageInputs(root = packageRoot) {
const hash = crypto.createHash("sha256");
const inputs = [
"wasm/Cargo.toml",
@ -74,11 +74,12 @@ async function hashPackageInputs() {
"crypto/src",
"type-map/Cargo.toml",
"type-map/build.rs",
"type-map/reserved.json",
"type-map/src",
];
async function addPath(relativePath) {
const absolutePath = path.join(packageRoot, relativePath);
const absolutePath = path.join(root, relativePath);
const stat = await fs.stat(absolutePath).catch(() => null);
if (!stat) {
return;
@ -109,7 +110,7 @@ function quoteList(values: string[]): string {
: values.map((value) => JSON.stringify(value)).join(" | ");
}
function parseTypeMapYaml(source: string, filePath: string) {
export function parseTypeMapYaml(source: string, filePath: string) {
const document = YAML.parseDocument(source, { prettyErrors: false });
if (document.errors.length) {
const error = document.errors[0];
@ -120,27 +121,80 @@ function parseTypeMapYaml(source: string, filePath: string) {
throw new Error(`${filePath}:${line}: ${error.message}`);
}
const root = document.toJS() as {
type_maps?: Record<
string,
{
CommunicationTypes?: Record<string, unknown>;
DataTypes?: Record<string, unknown>;
}
>;
protocol_version?: unknown;
type_maps?: unknown;
};
if (
root === null ||
typeof root !== "object" ||
Array.isArray(root) ||
typeof root.protocol_version !== "string" ||
!/^\d+\.\d+$/.test(root.protocol_version)
) {
throw new Error(
`${filePath}: protocol_version must be a string matching '<major>.<minor>'`,
);
}
if (
root.type_maps === null ||
typeof root.type_maps !== "object" ||
Array.isArray(root.type_maps)
) {
throw new Error(`${filePath}: type_maps must be a mapping`);
}
const typeMaps = root.type_maps as Record<string, unknown>;
if (!Object.prototype.hasOwnProperty.call(typeMaps, root.protocol_version)) {
throw new Error(
`${filePath}: protocol_version '${root.protocol_version}' is not defined in type_maps`,
);
}
const reservedCommunicationTypes = new Set(RESERVED_COMMUNICATION_TYPES);
const reservedDataTypes = new Set(RESERVED_DATA_TYPES);
const communicationTypes = new Set<string>(RESERVED_COMMUNICATION_TYPES);
const dataTypes = new Set<string>(RESERVED_DATA_TYPES);
for (const [version, map] of Object.entries(root.type_maps ?? {})) {
for (const [version, rawMap] of Object.entries(typeMaps)) {
if (!/^\d+\.\d+$/.test(version))
throw new Error(`${filePath}: unparseable type-map version '${version}'`);
for (const [section, target] of [
["CommunicationTypes", communicationTypes],
["DataTypes", dataTypes],
] as const) {
if (
rawMap === null ||
typeof rawMap !== "object" ||
Array.isArray(rawMap)
) {
throw new Error(`${filePath}: ${version} must be a mapping`);
}
const map = rawMap as {
CommunicationTypes?: unknown;
DataTypes?: unknown;
};
for (const { section, reserved, selected } of [
{
section: "CommunicationTypes",
reserved: reservedCommunicationTypes,
selected: communicationTypes,
},
{
section: "DataTypes",
reserved: reservedDataTypes,
selected: dataTypes,
},
]) {
const sectionValue = map[section as "CommunicationTypes" | "DataTypes"];
if (
sectionValue !== undefined &&
(sectionValue === null ||
typeof sectionValue !== "object" ||
Array.isArray(sectionValue))
) {
throw new Error(`${filePath}: ${version}.${section} must be a mapping`);
}
const ids = new Map<number, string>();
for (const [name, value] of Object.entries(
map[section as "CommunicationTypes" | "DataTypes"] ?? {},
)) {
for (const [name, value] of Object.entries(sectionValue ?? {})) {
if (reserved.has(name)) {
throw new Error(
`${filePath}: ${version}.${section}.${name} uses a reserved type name`,
);
}
if (!Number.isInteger(value) || (value as number) < FIRST_USER_TYPE_ID)
throw new Error(
`${filePath}: ${version}.${section}.${name} must use an integer id >= ${FIRST_USER_TYPE_ID}`,
@ -152,7 +206,9 @@ function parseTypeMapYaml(source: string, filePath: string) {
`${filePath}: duplicate type id ${id} in ${section} (${previous} and ${name})`,
);
ids.set(id, name);
target.add(name);
if (version === root.protocol_version) {
selected.add(name);
}
}
}
}
@ -162,7 +218,7 @@ function parseTypeMapYaml(source: string, filePath: string) {
};
}
function generateTypeMapModule(metadata: {
export function generateTypeMapModule(metadata: {
communicationTypes: string[];
dataTypes: string[];
}) {
@ -171,7 +227,7 @@ function generateTypeMapModule(metadata: {
return { js, dts };
}
async function writeTypeMapModule(outDir, typeMapsPath) {
export async function writeTypeMapModule(outDir, typeMapsPath) {
const source = await fs.readFile(typeMapsPath, "utf8").catch((error) => {
throw new Error(
`Failed to read type map '${typeMapsPath}': ${error.message}`,
@ -186,7 +242,7 @@ async function writeTypeMapModule(outDir, typeMapsPath) {
return source;
}
async function copyWasmBuildInputs(buildRoot) {
export async function copyWasmBuildInputs(buildRoot) {
const inputs = [
"Cargo.lock",
"wasm",
@ -416,18 +472,19 @@ export function mtp(options: MTPVitePluginOptions): VitePlugin {
});
}
const sourceWatchDirs = [
const sourceWatchPaths = [
"wasm/src",
"common/src",
"codec/src",
"crypto/src",
"type-map/src",
"type-map/reserved.json",
].map((rel) => path.join(packageRoot, rel));
server.watcher.add(state.typeMapsPath);
for (const dir of sourceWatchDirs) {
if (await pathExists(dir)) {
server.watcher.add(dir);
for (const sourcePath of sourceWatchPaths) {
if (await pathExists(sourcePath)) {
server.watcher.add(sourcePath);
}
}
@ -435,8 +492,10 @@ export function mtp(options: MTPVitePluginOptions): VitePlugin {
const scheduleRebuild = (changedPath: string) => {
const resolved = path.resolve(changedPath);
const isTypeMap = resolved === state.typeMapsPath;
const isSource = sourceWatchDirs.some((dir) =>
resolved.startsWith(`${dir}${path.sep}`),
const isSource = sourceWatchPaths.some(
(sourcePath) =>
resolved === sourcePath ||
resolved.startsWith(`${sourcePath}${path.sep}`),
);
if (!isTypeMap && !isSource) {
return;

File diff suppressed because it is too large Load diff

95
test/encrypted-secret.mjs Normal file
View file

@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { test } from "node:test";
import { InMemoryEncryptedSecretProvider } from "../dist/sdk/encrypted-secret.js";
function record(id, bytes, updatedAt = 2) {
return {
id,
encryptedSecret: new Uint8Array(bytes),
formatVersion: 1,
wrappingScheme: "test-wrap-v1",
wrappingKeyId: "wrapping-key-1",
createdAt: 1,
updatedAt,
};
}
test("encrypted secret provider stores, reads, replaces, and deletes", async () => {
const provider = new InMemoryEncryptedSecretProvider();
const initial = record("session-secret:alpha", [1, 2, 3]);
await provider.set(initial);
initial.encryptedSecret[0] = 99;
assert.deepEqual(await provider.get("session-secret:alpha"), record(
"session-secret:alpha",
[1, 2, 3],
));
const replacement = record("session-secret:alpha", [4, 5], 3);
await provider.set(replacement);
assert.deepEqual(
await provider.get("session-secret:alpha"),
replacement,
);
const fetched = await provider.get("session-secret:alpha");
fetched.encryptedSecret[0] = 88;
assert.deepEqual(
await provider.get("session-secret:alpha"),
replacement,
);
await provider.delete("session-secret:alpha");
assert.equal(await provider.get("session-secret:alpha"), null);
await provider.delete("session-secret:missing");
});
test("encrypted secret provider isolates unrelated opaque IDs", async () => {
const provider = new InMemoryEncryptedSecretProvider();
const session = record("session-secret:one", [1]);
const pipe = record("pipe-secret:one", [2]);
await provider.set(session);
await provider.set(pipe);
assert.deepEqual(await provider.get(session.id), session);
assert.deepEqual(await provider.get(pipe.id), pipe);
assert.equal(await provider.get("identity-secret:one"), null);
});
test("encrypted secret provider has no application identity hierarchy", async () => {
const source = await readFile(
new URL("../src/sdk/encrypted-secret.ts", import.meta.url),
"utf8",
);
const forbiddenFields = [
["user", "Id"],
["device", "Id"],
["chat", "Id"],
["conversation", "Id"],
].map(([prefix, suffix]) => `${prefix}${suffix}`);
for (const field of forbiddenFields) {
assert.doesNotMatch(source, new RegExp(`\\b${field}\\b`));
}
});
test("encrypted secret provider validates IDs and records", async () => {
const provider = new InMemoryEncryptedSecretProvider();
await assert.rejects(() => provider.get(""), /non-empty string/);
await assert.rejects(() => provider.delete(""), /non-empty string/);
await assert.rejects(
() => provider.set(record("", [1])),
/non-empty string/,
);
await assert.rejects(
() => provider.set({
...record("invalid", [1]),
encryptedSecret: new Uint8Array(),
}),
/non-empty encryptedSecret bytes/,
);
});

View file

@ -0,0 +1,78 @@
import type {
MTPDataValueInput,
MTPClientOptions,
MTPOpenRelayMetadataOptions,
MTPOpenRelayContentOptions,
MTPClient,
MTPSendProtectedOptions,
MTPSendSealedRelayOptions,
} from "../src/sdk/index.js";
const recipients = [new Uint8Array([1])];
const clientOptions: MTPClientOptions = {
url: "https://example.invalid",
credentials: {
clientId: 1,
keyring: recipients[0],
// @ts-expect-error keyringBytes was removed from the stable credential API
keyringBytes: recipients[0],
},
};
const protectedOptions: MTPSendProtectedOptions = {
receiverId: 2,
recipients,
signaturePurpose: 32,
encryptionPurpose: 33,
// @ts-expect-error sendProtected has only receiverId
receiver: 3,
};
const relayOptions: MTPSendSealedRelayOptions = {
finalRecipientId: 2,
nextHopId: 3,
metadataRecipients: recipients,
contentRecipients: recipients,
// @ts-expect-error sealed relay frames never expose an outer sender
sender: 1,
};
const metadataValues: MTPDataValueInput[] = [
null,
true,
42,
42n,
"metadata",
new Uint8Array([1, 2]),
["nested", false],
{ Metadata: "typed container" },
];
const relayReceiveOptions: MTPOpenRelayMetadataOptions = {
resolveSignerPublicKeys: () => recipients,
};
const contentReceiveOptions: MTPOpenRelayContentOptions = {
// @ts-expect-error replayGuard belongs to metadata opening or subscriptions
replayGuard: { accept: () => true },
};
declare const client: MTPClient;
void client.sendProtected("ProtectedMessage", "scalar protected content", protectedOptions);
void client.sendSealedRelay("ProtectedMessage", new Uint8Array([1, 2, 3]), relayOptions);
// @ts-expect-error sealed relay subscriptions accept application type names, not numeric IDs
client.subscribeSealedRelay(32, () => {});
// @ts-expect-error relay receive uses the key-history resolver API
relayReceiveOptions.senderPublicKey = recipients[0];
// @ts-expect-error relay receive selects a verification policy, not a signature suite
relayReceiveOptions.signatureSuite = "dual";
void protectedOptions;
void relayOptions;
void metadataValues;
void relayReceiveOptions;
void contentReceiveOptions;
void clientOptions;

243
test/vite-type-map.mjs Normal file
View file

@ -0,0 +1,243 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import {
chmod,
mkdir,
mkdtemp,
readdir,
readFile,
rename,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { existsSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import {
copyWasmBuildInputs,
generateTypeMapModule,
hashPackageInputs,
parseTypeMapYaml,
} from "../dist/vite/index.js";
const run = promisify(execFile);
const repositoryRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
const repeatedNames = `
protocol_version: "2.0"
type_maps:
"1.0":
CommunicationTypes:
Message: 32
LegacyMessage: 33
DataTypes:
Payload: 32
LegacyPayload: 33
"2.0":
CommunicationTypes:
Message: 35
CurrentMessage: 36
DataTypes:
Payload: 35
CurrentPayload: 36
`;
const selected = parseTypeMapYaml(repeatedNames, "repeated.yaml");
assert.ok(selected.communicationTypes.includes("Message"));
assert.ok(selected.communicationTypes.includes("CurrentMessage"));
assert.ok(!selected.communicationTypes.includes("LegacyMessage"));
assert.ok(selected.dataTypes.includes("Payload"));
assert.ok(selected.dataTypes.includes("CurrentPayload"));
assert.ok(!selected.dataTypes.includes("LegacyPayload"));
assert.ok(selected.communicationTypes.includes("Ping"));
assert.ok(selected.dataTypes.includes("Version"));
const generated = generateTypeMapModule(selected);
assert.match(generated.dts, /"Message"/);
assert.match(generated.dts, /"CurrentMessage"/);
assert.doesNotMatch(generated.dts, /LegacyMessage/);
assert.doesNotMatch(generated.dts, /LegacyPayload/);
assert.throws(
() =>
parseTypeMapYaml(
`protocol_version: "1.0"\ntype_maps:\n "1.0":\n CommunicationTypes:\n Ping: 32\n`,
"reserved.yaml",
),
/uses a reserved type name/,
);
assert.throws(
() =>
parseTypeMapYaml(
`protocol_version: "2.0"\ntype_maps:\n "1.0": {}\n`,
"missing-version.yaml",
),
/is not defined in type_maps/,
);
assert.throws(
() => parseTypeMapYaml(`protocol_version: "1.0"\ntype_maps: {}`, "empty.yaml"),
/is not defined in type_maps/,
);
assert.throws(
() =>
parseTypeMapYaml(
`protocol_version: "latest"\ntype_maps:\n "latest": {}\n`,
"invalid-version.yaml",
),
/protocol_version must be a string/,
);
const temporaryBuildRoot = await mkdtemp(path.join(os.tmpdir(), "mtp-vite-inputs-"));
try {
await copyWasmBuildInputs(temporaryBuildRoot);
for (const requiredInput of [
"Cargo.lock",
"wasm/Cargo.toml",
"wasm/src/lib.rs",
"common/Cargo.toml",
"codec/Cargo.toml",
"crypto/Cargo.toml",
"type-map/Cargo.toml",
"type-map/build.rs",
"type-map/reserved.json",
]) {
assert.equal(
existsSync(path.join(temporaryBuildRoot, requiredInput)),
true,
`temporary WASM build input is missing: ${requiredInput}`,
);
}
assert.equal(
await readFile(path.join(temporaryBuildRoot, "type-map/reserved.json"), "utf8"),
await readFile(path.join(repositoryRoot, "type-map/reserved.json"), "utf8"),
);
assert.match(
await readFile(path.join(temporaryBuildRoot, "type-map/build.rs"), "utf8"),
/include_str!\("reserved\.json"\)/,
);
} finally {
await rm(temporaryBuildRoot, { recursive: true, force: true });
}
const hashTestRoot = await mkdtemp(path.join(os.tmpdir(), "mtp-vite-hash-"));
try {
await mkdir(path.join(hashTestRoot, "type-map"), { recursive: true });
const manifestPath = path.join(hashTestRoot, "type-map/reserved.json");
await writeFile(manifestPath, "{\"version\":1}");
const firstHash = await hashPackageInputs(hashTestRoot);
await writeFile(manifestPath, "{\"version\":2}");
const secondHash = await hashPackageInputs(hashTestRoot);
assert.notEqual(firstHash, secondHash);
} finally {
await rm(hashTestRoot, { recursive: true, force: true });
}
const viteCandidates = [
path.join(repositoryRoot, "example/web-client/node_modules/.bin/vite"),
path.join(repositoryRoot, "node_modules/.bin/vite"),
];
const viteBin = viteCandidates.find((candidate) => existsSync(candidate));
if (!viteBin) {
console.warn("Skipping packed Vite smoke test: Vite is not installed");
} else {
const temporaryPackageRoot = await mkdtemp(path.join(os.tmpdir(), "mtp-vite-package-"));
try {
await run(
"npm",
["pack", "--pack-destination", temporaryPackageRoot],
{
cwd: repositoryRoot,
env: {
...process.env,
npm_config_cache: path.join(temporaryPackageRoot, "npm-cache"),
},
},
);
const packageName = (await readdir(temporaryPackageRoot)).find((entry) =>
entry.endsWith(".tgz"),
);
assert.ok(packageName, "npm pack did not report a tarball");
const extractedRoot = path.join(temporaryPackageRoot, "extracted");
const appRoot = path.join(temporaryPackageRoot, "app");
await mkdir(extractedRoot, { recursive: true });
await run("tar", ["-xzf", path.join(temporaryPackageRoot, packageName), "-C", extractedRoot]);
await mkdir(path.join(appRoot, "node_modules"), { recursive: true });
await rename(path.join(extractedRoot, "package"), path.join(appRoot, "node_modules/mtp"));
await symlink(
path.join(repositoryRoot, "node_modules/yaml"),
path.join(appRoot, "node_modules/yaml"),
"dir",
);
await symlink(
path.join(path.dirname(path.dirname(viteBin)), "vite"),
path.join(appRoot, "node_modules/vite"),
"dir",
);
await writeFile(
path.join(appRoot, "package.json"),
JSON.stringify({ type: "module", private: true }),
);
await writeFile(
path.join(appRoot, "index.html"),
'<script type="module" src="/main.js"></script>',
);
await writeFile(
path.join(appRoot, "main.js"),
'import { communicationTypes } from "mtp/type-map";\n' +
'if (!communicationTypes.includes("CurrentMessage") || communicationTypes.includes("LegacyMessage")) throw new Error("wrong browser type-map selection");\n',
);
await writeFile(
path.join(appRoot, "type-maps.yaml"),
repeatedNames,
);
await writeFile(
path.join(appRoot, "vite.config.mjs"),
'import { defineConfig } from "vite";\n' +
'import { mtp } from "mtp/vite";\n' +
'export default defineConfig({ plugins: [mtp({ typeMaps: "./type-maps.yaml", release: false })] });\n',
);
const fakeBin = path.join(temporaryPackageRoot, "bin");
const fakeWasmPack = path.join(fakeBin, "wasm-pack");
await mkdir(fakeBin, { recursive: true });
await writeFile(
fakeWasmPack,
`#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
const args = process.argv.slice(2);
const outIndex = args.indexOf("--out-dir");
if (outIndex < 0) throw new Error("fake wasm-pack did not receive --out-dir");
if (!fs.existsSync(path.join(process.cwd(), "type-map", "reserved.json"))) {
throw new Error("temporary wasm build is missing type-map/reserved.json");
}
const outDir = args[outIndex + 1];
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, "mtp_wasm.js"), "export default function init() {}\\n");
fs.writeFileSync(path.join(outDir, "mtp_wasm_bg.wasm"), Buffer.from([0, 97, 115, 109, 1, 0, 0, 0]));
`,
);
await chmod(fakeWasmPack, 0o755);
await run(viteBin, ["build", "--config", "vite.config.mjs"], {
cwd: appRoot,
env: {
...process.env,
PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ""}`,
},
});
assert.equal(existsSync(path.join(appRoot, "dist/index.html")), true);
} finally {
await rm(temporaryPackageRoot, { recursive: true, force: true });
}
}
console.log("Vite type-map tests passed");

View file

@ -19,6 +19,8 @@ rcgen = "0.14"
tracing = "0.1"
async-trait = "0.1"
sha2 = "0.11"
rand = "0.10.2"
zeroize = "1.9"
[dev-dependencies]
@ -30,7 +32,7 @@ required-features = ["host", "insecure-tls"]
# Enables hosting a MTP server
host = []
pipes = ["mtp-codec/pipes"]
pipes = ["mtp-codec/pipes", "mtp-codec/crypto"]
# Compiles the insecure certificate verifier (NoopCertVerifier).
# Even with this feature enabled, the verifier requires the environment

View file

@ -1,7 +1,7 @@
use crate::ConnectionHandle;
#[cfg(feature = "pipes")]
use crate::pipe::PipeReader;
use mtp_codec::CommunicationValue;
use mtp_codec::{CommunicationValue, DecodeLimits, TypeMap};
use mtp_common::CommunicationError;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
@ -148,6 +148,7 @@ pub struct Sender {
handle: Arc<ConnectionHandle>,
connection: Connection,
policy: Arc<Policy>,
type_map: Arc<RwLock<TypeMap>>,
}
impl Sender {
@ -158,9 +159,15 @@ impl Sender {
handle,
connection,
policy,
type_map: Arc::new(RwLock::new(TypeMap::latest())),
}
}
/// Bind control frames created by this sender to the negotiated protocol map.
pub async fn set_type_map(&self, type_map: &TypeMap) {
*self.type_map.write().await = type_map.clone();
}
#[instrument(skip(stream, data, policy), level = "trace")]
async fn write_frame(
stream: &mut wtransport::SendStream,
@ -174,9 +181,7 @@ impl Sender {
return Err(CommunicationError::MessageTooLarge);
}
let len_bytes = (bytes.len() as u32).to_be_bytes();
let write_result = async {
stream.write_all(&len_bytes).await?;
stream.write_all(&bytes).await?;
Ok::<(), wtransport::error::StreamWriteError>(())
};
@ -458,12 +463,16 @@ impl Sender {
let mut stream = Self::open_uni_stream(&self.connection, &self.policy).await?;
let request = CommunicationValue::new(mtp_codec::CommunicationType::PipeRequest)
.with_id(pipe_id)
.add_typed_default(
mtp_codec::DataType::Description,
mtp_codec::DataValue::Str(description.to_string()),
);
let type_map = self.type_map.read().await.clone();
let request = CommunicationValue::new_with_type_map(
mtp_codec::CommunicationType::PipeRequest,
&type_map,
)
.with_id(pipe_id)
.add_typed_default(
mtp_codec::DataType::Description,
mtp_codec::DataValue::Str(description.to_string()),
);
Self::write_frame(&mut stream, &request, &self.policy).await?;
@ -595,6 +604,7 @@ struct ReceiverInner {
ping_control: Arc<RwLock<PingControl>>,
queue_notify: Arc<Notify>,
max_message_size: Arc<AtomicU64>,
type_map: Arc<RwLock<TypeMap>>,
}
impl Clone for Receiver {
@ -624,6 +634,7 @@ impl Receiver {
Self::new_with_max_message_size(connection, handle, policy.clone(), policy.max_message_size)
}
#[cfg(feature = "host")]
pub(crate) fn new_for_handshake(
connection: Connection,
handle: Arc<ConnectionHandle>,
@ -661,6 +672,8 @@ impl Receiver {
let accept_queue_notify = queue_notify.clone();
let max_message_size = Arc::new(AtomicU64::new(initial_max_message_size));
let accept_max_message_size = max_message_size.clone();
let type_map = Arc::new(RwLock::new(TypeMap::latest()));
let accept_type_map = type_map.clone();
let stream_limit = Arc::new(Semaphore::new(policy.max_concurrent_stream_tasks.max(1)));
let accept_stream_limit = stream_limit.clone();
debug!(
@ -728,6 +741,7 @@ impl Receiver {
let stream_policy = accept_policy.clone();
let stream_ping_control = accept_ping_control.clone();
let stream_max_message_size = accept_max_message_size.clone();
let stream_type_map = accept_type_map.clone();
tokio::spawn(async move {
let _permit = permit;
@ -748,18 +762,25 @@ impl Receiver {
let frame_limit = stream_max_message_size.load(Ordering::Relaxed);
match Self::read_one_frame(&mut s, &stream_policy, frame_limit).await {
Ok(ReceivedFrame::Message(msg)) => {
Ok(ReceivedFrame::Message(mut msg)) => {
let negotiated_type_map =
stream_type_map.read().await.clone();
msg.set_type_map(&negotiated_type_map);
frame_count += 1;
#[cfg(feature = "pipes")]
{
let pipe_request_type =
mtp_codec::CommunicationType::PipeRequest
.try_to_id(&mtp_codec::TypeMap::latest());
if Some(msg.get_type()) == pipe_request_type
if msg.is_type(mtp_codec::CommunicationType::PipeRequest)
&& frame_count == 1
{
let pipe_id = msg.get_id();
let Some(pipe_id) = msg.id().filter(|id| *id != 0) else {
let error = CommunicationError::Other(
"PipeRequest frame must contain a non-zero id".into(),
);
let _ = msg_tx_stream.send(Err(error.clone())).await;
stream_handle.close(Some(error));
break;
};
let description = msg
.get_str(mtp_codec::DataType::Description)
.unwrap_or("")
@ -784,18 +805,14 @@ impl Receiver {
}
}
let ping_type = mtp_codec::CommunicationType::Ping
.try_to_id(&mtp_codec::TypeMap::latest());
let pong_type = mtp_codec::CommunicationType::Pong
.try_to_id(&mtp_codec::TypeMap::latest());
let control = {
let control = stream_ping_control.read().await;
if Some(msg.get_type()) == ping_type {
if msg.is_type(mtp_codec::CommunicationType::Ping) {
control
.pong_sender
.clone()
.map(|sender| (Some(sender), None))
} else if Some(msg.get_type()) == pong_type {
} else if msg.is_type(mtp_codec::CommunicationType::Pong) {
control
.pong_observer
.clone()
@ -806,9 +823,16 @@ impl Receiver {
};
if let Some((Some(sender), _)) = control {
let mut pong = CommunicationValue::new(mtp_codec::CommunicationType::Pong)
.with_id(msg.get_id());
if let Some(timestamp) = msg.get_data_opt(mtp_codec::DataType::Timestamp) {
let mut pong = CommunicationValue::new_with_type_map(
mtp_codec::CommunicationType::Pong,
&negotiated_type_map,
);
if let Some(id) = msg.id() {
pong = pong.with_id(id);
} else {
pong = pong.without_id();
}
if let Some(timestamp) = msg.get_data(mtp_codec::DataType::Timestamp) {
pong = pong.add_typed_default(
mtp_codec::DataType::Timestamp,
timestamp.clone(),
@ -917,6 +941,7 @@ impl Receiver {
ping_control,
queue_notify,
max_message_size,
type_map,
}),
}
}
@ -928,6 +953,11 @@ impl Receiver {
.store(max_message_size, Ordering::Relaxed);
}
/// Bind subsequently decoded frames to the negotiated protocol version.
pub async fn set_type_map(&self, type_map: &TypeMap) {
*self.inner.type_map.write().await = type_map.clone();
}
/* Respond to reserved Ping frames without exposing them to application I/O. */
pub fn respond_to_pings(&self, sender: Sender) {
if let Ok(mut control) = self.inner.ping_control.try_write() {
@ -982,18 +1012,21 @@ impl Receiver {
return Ok(ReceivedFrame::ClosedByPeer);
}
let len_usize = len as usize;
if len as u64 > max_message_size {
let body_len = len as usize;
let frame_len = body_len
.checked_add(4)
.ok_or(CommunicationError::MessageTooLarge)?;
if frame_len as u64 > max_message_size {
return Err(CommunicationError::MessageTooLarge);
}
// Grow in bounded chunks instead of trusting the peer's length prefix
// enough to allocate the complete frame up front.
let mut buf = Vec::new();
buf.try_reserve(len_usize.min(16 * 1024))
buf.try_reserve(body_len.min(16 * 1024))
.map_err(|_| CommunicationError::MessageTooLarge)?;
while buf.len() < len_usize {
let chunk_len = (len_usize - buf.len()).min(16 * 1024);
while buf.len() < body_len {
let chunk_len = (body_len - buf.len()).min(16 * 1024);
let mut chunk = [0u8; 16 * 1024];
match timeout(
policy.read_timeout,
@ -1008,7 +1041,7 @@ impl Receiver {
}
Ok(Err(StreamReadExactError::FinishedEarly(n))) => {
warn!(
"[Receiver] body read ended early ({}/{len_usize} bytes): stream closed by peer",
"[Receiver] body read ended early ({}/{body_len} bytes): stream closed by peer",
buf.len() + n
);
return Err(CommunicationError::StreamError);
@ -1024,14 +1057,20 @@ impl Receiver {
return Err(CommunicationError::StreamError);
}
Err(_) => {
warn!("[Receiver] body read timed out (len={len_usize})");
warn!("[Receiver] body read timed out (len={body_len})");
return Err(CommunicationError::StreamError);
}
}
}
let message = CommunicationValue::from_bytes(&buf)
.map_err(|_| CommunicationError::ParseCommunicationValue)?;
let mut frame = Vec::with_capacity(frame_len);
frame.extend_from_slice(&len_buf);
frame.extend_from_slice(&buf);
let message = CommunicationValue::from_bytes_with_limits(
&frame,
DecodeLimits::for_transport_message_size(max_message_size),
)
.map_err(|_| CommunicationError::ParseCommunicationValue)?;
Ok(ReceivedFrame::Message(message))
}

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,11 @@ use crate::{Policy, TransportSendStream};
use mtp_codec::CommunicationValue;
use mtp_common::CommunicationError;
/// Writes the canonical length-prefixed MTP frame used by every transport.
/// Writes the canonical self-framed MTP value used by every transport.
///
/// `CommunicationValue` already begins with the four-byte body length. The
/// transport writes that representation directly so a frame does not carry a
/// redundant outer length prefix.
pub(crate) async fn write_frame<S: TransportSendStream>(
stream: &mut S,
value: &CommunicationValue,
@ -14,8 +18,70 @@ pub(crate) async fn write_frame<S: TransportSendStream>(
{
return Err(CommunicationError::MessageTooLarge);
}
stream
.write_all(&(bytes.len() as u32).to_be_bytes())
.await?;
stream.write_all(&bytes).await
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use mtp_codec::{CommunicationType, DataValue};
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::AsyncWrite;
#[derive(Default)]
struct BufferStream {
bytes: Vec<u8>,
}
impl AsyncWrite for BufferStream {
fn poll_write(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
self.bytes.extend_from_slice(buf);
Poll::Ready(Ok(buf.len()))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
}
#[async_trait]
impl TransportSendStream for BufferStream {
async fn write_all(&mut self, buf: &[u8]) -> Result<(), CommunicationError> {
self.bytes.extend_from_slice(buf);
Ok(())
}
async fn finish(&mut self) -> Result<(), CommunicationError> {
Ok(())
}
}
#[tokio::test]
async fn framing_preserves_a_generic_payload() {
let payload = DataValue::Array(vec![
DataValue::Str("payload".into()),
DataValue::Bytes(vec![1, 2, 3]),
]);
let frame = CommunicationValue::new(CommunicationType::Pong).with_payload(payload.clone());
let mut stream = BufferStream::default();
write_frame(&mut stream, &frame, &Policy::default())
.await
.unwrap();
let body_len = u32::from_be_bytes(stream.bytes[..4].try_into().unwrap()) as usize;
assert_eq!(body_len, stream.bytes.len() - 4);
let decoded = CommunicationValue::from_bytes(&stream.bytes).unwrap();
assert_eq!(decoded.into_payload(), payload);
}
}

View file

@ -7,7 +7,7 @@
use crate::{
Policy, TransportConnection, TransportRecvStream, TransportSendStream, framing::write_frame,
};
use mtp_codec::CommunicationValue;
use mtp_codec::{CommunicationValue, DecodeLimits, TypeMap};
use mtp_common::CommunicationError;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
@ -22,6 +22,7 @@ pub struct GenericSender<C: TransportConnection> {
policy: Arc<Policy>,
persistent: Arc<Mutex<Option<C::SendStream>>>,
send_lock: Arc<Mutex<()>>,
type_map: Arc<RwLock<TypeMap>>,
}
impl<C: TransportConnection> Clone for GenericSender<C> {
@ -31,6 +32,7 @@ impl<C: TransportConnection> Clone for GenericSender<C> {
policy: self.policy.clone(),
persistent: self.persistent.clone(),
send_lock: self.send_lock.clone(),
type_map: self.type_map.clone(),
}
}
}
@ -42,9 +44,15 @@ impl<C: TransportConnection> GenericSender<C> {
policy,
persistent: Arc::new(Mutex::new(None)),
send_lock: Arc::new(Mutex::new(())),
type_map: Arc::new(RwLock::new(TypeMap::latest())),
}
}
/// Bind control frames created by this sender to the negotiated protocol map.
pub async fn set_type_map(&self, type_map: &TypeMap) {
*self.type_map.write().await = type_map.clone();
}
async fn open(&self) -> Result<C::SendStream, CommunicationError> {
timeout(self.policy.open_stream_timeout, self.connection.open_uni())
.await
@ -112,12 +120,16 @@ impl<C: TransportConnection> GenericSender<C> {
let mut stream = self.open().await?;
let request = CommunicationValue::new(mtp_codec::CommunicationType::PipeRequest)
.with_id(pipe_id)
.add_typed_default(
mtp_codec::DataType::Description,
mtp_codec::DataValue::Str(description.to_string()),
);
let type_map = self.type_map.read().await.clone();
let request = CommunicationValue::new_with_type_map(
mtp_codec::CommunicationType::PipeRequest,
&type_map,
)
.with_id(pipe_id)
.add_typed_default(
mtp_codec::DataType::Description,
mtp_codec::DataValue::Str(description.to_string()),
);
timeout(
self.policy.write_timeout,
@ -166,6 +178,7 @@ pub struct GenericReceiver<C: TransportConnection> {
connection: C,
ping_sender: Arc<RwLock<Option<GenericSender<C>>>>,
max_message_size: Arc<AtomicU64>,
type_map: Arc<RwLock<TypeMap>>,
_accept_task: Arc<tokio::task::JoinHandle<()>>,
}
@ -178,6 +191,7 @@ impl<C: TransportConnection> Clone for GenericReceiver<C> {
connection: self.connection.clone(),
ping_sender: self.ping_sender.clone(),
max_message_size: self.max_message_size.clone(),
type_map: self.type_map.clone(),
_accept_task: self._accept_task.clone(),
}
}
@ -206,6 +220,8 @@ impl<C: TransportConnection> GenericReceiver<C> {
let task_connection = connection.clone();
let task_policy = policy.clone();
let task_max_message_size = max_message_size.clone();
let type_map = Arc::new(RwLock::new(TypeMap::latest()));
let task_type_map = type_map.clone();
let task_accept_task_tx = tx.clone();
#[cfg(feature = "pipes")]
let task_accept_task_pipe_tx = pipe_tx.clone();
@ -260,6 +276,7 @@ impl<C: TransportConnection> GenericReceiver<C> {
let max_message_size = task_max_message_size.clone();
let ping_sender = task_ping_sender.clone();
let connection = task_connection.clone();
let type_map = task_type_map.clone();
tokio::spawn(async move {
let _permit = permit;
let mut stream = stream;
@ -298,13 +315,22 @@ impl<C: TransportConnection> GenericReceiver<C> {
break;
}
let frame_limit = max_message_size.load(Ordering::Relaxed);
if len as u64 > frame_limit {
tracing::warn!(len, "MTP receive stream frame is too large");
let body_len = len as usize;
let frame_len = match body_len.checked_add(4) {
Some(frame_len) => frame_len,
None => {
let _ = tx.send(Err(CommunicationError::MessageTooLarge)).await;
connection.close(policy.application_close_code, b"frame too large");
break;
}
};
if frame_len as u64 > frame_limit {
tracing::warn!(frame_len, "MTP receive stream frame is too large");
let _ = tx.send(Err(CommunicationError::MessageTooLarge)).await;
connection.close(policy.application_close_code, b"frame too large");
break;
}
let target_len = len as usize;
let target_len = body_len;
let mut body = Vec::new();
if body.try_reserve(target_len.min(16 * 1024)).is_err() {
tracing::warn!(
@ -343,7 +369,13 @@ impl<C: TransportConnection> GenericReceiver<C> {
break;
}
frames += 1;
let message = match CommunicationValue::from_bytes(&body) {
let mut frame = Vec::with_capacity(frame_len);
frame.extend_from_slice(&len.to_be_bytes());
frame.extend_from_slice(&body);
let mut message = match CommunicationValue::from_bytes_with_limits(
&frame,
DecodeLimits::for_transport_message_size(frame_limit),
) {
Ok(message) => message,
Err(_) => {
tracing::warn!("MTP receive stream contained an invalid frame");
@ -354,13 +386,25 @@ impl<C: TransportConnection> GenericReceiver<C> {
break;
}
};
let negotiated_type_map = type_map.read().await.clone();
message.set_type_map(&negotiated_type_map);
#[cfg(feature = "pipes")]
{
let pipe_request_type = mtp_codec::CommunicationType::PipeRequest
.try_to_id(&mtp_codec::TypeMap::latest());
if Some(message.get_type()) == pipe_request_type && frames == 1 {
let pipe_id = message.get_id();
if message.is_type(mtp_codec::CommunicationType::PipeRequest)
&& frames == 1
{
let Some(pipe_id) = message.id().filter(|id| *id != 0) else {
let error = CommunicationError::Other(
"PipeRequest frame must contain a non-zero id".into(),
);
let _ = tx.send(Err(error.clone())).await;
connection.close(
policy.application_close_code,
b"pipe request missing id",
);
break;
};
let description = message
.get_str(mtp_codec::DataType::Description)
.unwrap_or("")
@ -383,11 +427,17 @@ impl<C: TransportConnection> GenericReceiver<C> {
if message.is_type(mtp_codec::CommunicationType::Ping) {
if let Some(sender) = ping_sender.read().await.clone() {
let mut pong =
CommunicationValue::new(mtp_codec::CommunicationType::Pong)
.with_id(message.get_id());
let mut pong = CommunicationValue::new_with_type_map(
mtp_codec::CommunicationType::Pong,
&negotiated_type_map,
);
if let Some(id) = message.id() {
pong = pong.with_id(id);
} else {
pong = pong.without_id();
}
if let Some(timestamp) =
message.get_data_opt(mtp_codec::DataType::Timestamp)
message.get_data(mtp_codec::DataType::Timestamp)
{
pong = pong.add_typed_default(
mtp_codec::DataType::Timestamp,
@ -412,6 +462,7 @@ impl<C: TransportConnection> GenericReceiver<C> {
connection,
ping_sender,
max_message_size,
type_map,
_accept_task: Arc::new(accept_task),
}
}
@ -424,6 +475,11 @@ impl<C: TransportConnection> GenericReceiver<C> {
self.max_message_size
.store(max_message_size, Ordering::Relaxed);
}
/// Bind subsequently decoded frames to the negotiated protocol version.
pub async fn set_type_map(&self, type_map: &TypeMap) {
*self.type_map.write().await = type_map.clone();
}
pub async fn receive(&self) -> Result<CommunicationValue, CommunicationError> {
self.incoming
.lock()

View file

@ -6,6 +6,8 @@ pub mod generic_connection;
pub mod pinning;
pub mod transport_traits;
#[cfg(feature = "pipes")]
pub mod encrypted_pipe;
#[cfg(feature = "pipes")]
pub mod pipe;
@ -15,6 +17,15 @@ pub use generic_connection::{GenericReceiver, GenericSender};
#[cfg(feature = "pipes")]
pub use connection::TransportEvent;
#[cfg(feature = "pipes")]
pub use encrypted_pipe::{
EncryptedPipeError, EncryptedPipeReader, EncryptedPipeWriter, MAX_ENCRYPTED_PIPE_RECORD,
MAX_PIPE_SESSION_OFFER, PIPE_SESSION_ENCRYPTION_PURPOSE, PIPE_SESSION_SIGNATURE_PURPOSE,
PipeProtectionContext, PipeSessionError, PipeSessionParameters,
accept_forward_secure_pipe_session, accept_forward_secure_pipe_session_with_key_history,
accept_pipe_session, accept_pipe_session_with_key_history, accept_pipe_session_with_policy,
initiate_forward_secure_pipe_session, initiate_group_pipe_session, initiate_pipe_session,
};
#[cfg(feature = "pipes")]
pub use pipe::{PipeReader, PipeWriter};
pub use client::{ClientConfig, connect, connect_with_config};

View file

@ -22,6 +22,10 @@ impl PipeWriter {
}
impl<S: tokio::io::AsyncWrite + Send + Unpin> PipeWriter<S> {
pub fn into_inner(self) -> S {
self.stream
}
pub async fn finish_async(mut self) -> Result<(), mtp_common::CommunicationError> {
tokio::io::AsyncWriteExt::shutdown(&mut self)
.await
@ -58,6 +62,10 @@ pub struct PipeReader<R = wtransport::RecvStream> {
}
impl<R> PipeReader<R> {
pub fn into_inner(self) -> R {
self.stream
}
pub fn description(&self) -> &str {
&self.description
}

View file

@ -50,12 +50,14 @@ async fn connected_pair()
}
fn numbered_message(comm_type: CommunicationType, value: u128, tm: &TypeMap) -> CommunicationValue {
CommunicationValue::new(comm_type).add_data(
DataType::PqSignature
.try_to_id(tm)
.expect("test type must be mapped"),
DataValue::UnsignedNumber(value),
)
CommunicationValue::new(comm_type)
.add_data(
DataType::PqSignature
.try_to_id(tm)
.expect("test type must be mapped"),
DataValue::UnsignedNumber(value),
)
.expect("numbered message must have a container payload")
}
fn assert_numbered_message(
@ -69,8 +71,8 @@ fn assert_numbered_message(
comm_type.try_to_id(tm).expect("test type must be mapped")
);
assert_eq!(
message.get_data(DataType::PqSignature).clone(),
DataValue::UnsignedNumber(value)
message.get_data(DataType::PqSignature),
Some(&DataValue::UnsignedNumber(value))
);
}
@ -138,6 +140,25 @@ async fn test_send_receive_roundtrip() -> Result<(), Box<dyn std::error::Error>>
Ok(())
}
#[tokio::test]
async fn test_generic_payload_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
let (_h, client_tx, _client_rx, host_tx, host_rx) = connected_pair().await?;
let payload = DataValue::Array(vec![
DataValue::Str("generic payload".into()),
DataValue::Bytes(vec![1, 2, 3]),
]);
let message =
CommunicationValue::new(CommunicationType::BadRequest).with_payload(payload.clone());
client_tx.send(&message).await?;
let received = host_rx.receive().await?;
assert_eq!(received.into_payload(), payload);
client_tx.close().await;
host_tx.close().await;
Ok(())
}
#[tokio::test]
async fn test_concurrent_messages() -> Result<(), Box<dyn std::error::Error>> {
let (_h, client_tx, _client_rx, _host_tx, host_rx) = connected_pair().await?;

View file

@ -19,6 +19,7 @@
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"resolveJsonModule": true,
},
"include": [
"src/raw/**/*.ts",

8
tsconfig.type-tests.json Normal file
View file

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": ["src/**/*.ts", "test/**/*.type-test.ts"]
}

View file

@ -2,9 +2,9 @@ use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
const DEFAULT_TYPE_MAPS_PATH: &str = "./type-maps.yaml";
const FIRST_USER_TYPE_ID: u16 = 32;
#[derive(Deserialize)]
struct Config {
@ -22,186 +22,54 @@ struct TypeMapConfig {
data_types: BTreeMap<String, u16>,
}
struct ReservedEntry {
name: &'static str,
#[derive(Deserialize)]
struct ReservedManifest {
#[serde(rename = "firstUserTypeId")]
first_user_type_id: u16,
communication: Vec<ManifestEntry>,
data: Vec<ManifestEntry>,
}
#[derive(Deserialize)]
struct ManifestEntry {
name: String,
id: u16,
}
const RESERVED_COMM_TYPES: &[ReservedEntry] = &[
ReservedEntry {
name: "Identification",
id: 0,
},
ReservedEntry {
name: "IdentificationResponse",
id: 1,
},
ReservedEntry {
name: "Register",
id: 2,
},
ReservedEntry {
name: "RegisterResponse",
id: 3,
},
ReservedEntry {
name: "Challenge",
id: 4,
},
ReservedEntry {
name: "ChallengeResponse",
id: 5,
},
ReservedEntry {
name: "Ping",
id: 6,
},
ReservedEntry {
name: "Pong",
id: 7,
},
ReservedEntry {
name: "Disconnect",
id: 8,
},
ReservedEntry {
name: "Redirect",
id: 9,
},
ReservedEntry {
name: "Shutdown",
id: 10,
},
ReservedEntry {
name: "Error",
id: 11,
},
ReservedEntry {
name: "ErrorParsing",
id: 12,
},
ReservedEntry {
name: "ErrorBadVersion",
id: 13,
},
ReservedEntry {
name: "BadRequest",
id: 14,
},
ReservedEntry {
name: "Unauthorized",
id: 15,
},
ReservedEntry {
name: "Forbidden",
id: 16,
},
ReservedEntry {
name: "NotFound",
id: 17,
},
ReservedEntry {
name: "TooManyRequests",
id: 18,
},
ReservedEntry {
name: "InternalServerError",
id: 19,
},
ReservedEntry {
name: "BadGateway",
id: 20,
},
ReservedEntry {
name: "ServiceUnavailable",
id: 21,
},
ReservedEntry {
name: "GatewayTimeout",
id: 22,
},
#[cfg(feature = "pipes")]
ReservedEntry {
name: "PipeRequest",
id: 23,
},
#[cfg(feature = "pipes")]
ReservedEntry {
name: "PipeResponse",
id: 24,
},
#[cfg(feature = "pipes")]
ReservedEntry {
name: "PipeAbort",
id: 25,
},
];
fn reserved_manifest() -> &'static ReservedManifest {
static MANIFEST: OnceLock<ReservedManifest> = OnceLock::new();
MANIFEST.get_or_init(|| {
serde_yaml::from_str(include_str!("reserved.json"))
.expect("type-map/reserved.json must be valid JSON/YAML")
})
}
const RESERVED_DATA_TYPES: &[ReservedEntry] = &[
ReservedEntry {
name: "Version",
id: 0,
},
ReservedEntry { name: "Id", id: 1 },
ReservedEntry {
name: "ClientNonce",
id: 2,
},
ReservedEntry {
name: "ServerNonce",
id: 3,
},
ReservedEntry {
name: "PublicKeys",
id: 4,
},
ReservedEntry {
name: "Signature",
id: 5,
},
ReservedEntry {
name: "PqSignature",
id: 6,
},
ReservedEntry {
name: "Description",
id: 7,
},
ReservedEntry {
name: "Connected",
id: 8,
},
ReservedEntry {
name: "Timestamp",
id: 9,
},
ReservedEntry {
name: "Error",
id: 10,
},
ReservedEntry {
name: "ErrorParsing",
id: 11,
},
ReservedEntry {
name: "ErrorMessage",
id: 12,
},
ReservedEntry {
name: "Accepted",
id: 13,
},
ReservedEntry {
name: "RequirePq",
id: 14,
},
];
fn first_user_type_id() -> u16 {
reserved_manifest().first_user_type_id
}
fn all_reserved_comm_types() -> &'static [ManifestEntry] {
&reserved_manifest().communication
}
fn generated_reserved_comm_types() -> Vec<&'static ManifestEntry> {
all_reserved_comm_types()
.iter()
.filter(|entry| cfg!(feature = "pipes") || !entry.name.starts_with("Pipe"))
.collect()
}
fn all_reserved_data_types() -> &'static [ManifestEntry] {
&reserved_manifest().data
}
fn main() {
let out = PathBuf::from(std::env::var("OUT_DIR").unwrap());
let multi_version = std::env::var("CARGO_FEATURE_REGISTRY").is_ok();
println!("cargo:rerun-if-env-changed=MTP_TYPE_MAPS");
println!("cargo:rerun-if-changed=reserved.json");
validate_reserved_manifest();
let loaded = match std::env::var_os("MTP_TYPE_MAPS") {
Some(config_path) => load_config(&PathBuf::from(config_path)),
@ -231,6 +99,35 @@ fn main() {
std::fs::write(out.join("types.rs"), code).unwrap();
}
fn validate_reserved_manifest() {
assert!(first_user_type_id() > 0);
for (category, entries) in [
("communication", all_reserved_comm_types()),
("data", all_reserved_data_types()),
] {
let mut names = BTreeSet::new();
let mut ids = BTreeSet::new();
for entry in entries {
assert!(
entry.id < first_user_type_id(),
"reserved {category} type {} has a user-range ID {}",
entry.name,
entry.id
);
assert!(
names.insert(entry.name.as_str()),
"duplicate reserved {category} type name {}",
entry.name
);
assert!(
ids.insert(entry.id),
"duplicate reserved {category} type ID {}",
entry.id
);
}
}
}
struct LoadedConfig {
config: Config,
path: Option<PathBuf>,
@ -292,8 +189,15 @@ fn validate_config(loaded: &LoadedConfig) {
version,
"CommunicationTypes",
&type_map.communication_types,
all_reserved_comm_types(),
);
validate_ids(
loaded,
version,
"DataTypes",
&type_map.data_types,
all_reserved_data_types(),
);
validate_ids(loaded, version, "DataTypes", &type_map.data_types);
}
}
@ -319,16 +223,28 @@ fn validate_ids(
version: &str,
category: &str,
entries: &BTreeMap<String, u16>,
reserved: &[ManifestEntry],
) {
let mut names_by_id = BTreeMap::new();
for (name, id) in entries {
if *id < FIRST_USER_TYPE_ID {
if reserved.iter().any(|entry| entry.name == *name) {
validation_error(
loaded,
&["type_maps", version, category],
name,
format!(
"{category}.{name} in type-map version {version} uses reserved id {id}; user ids must be {FIRST_USER_TYPE_ID} or greater"
"{category}.{name} in type-map version {version} uses reserved name {name:?}"
),
);
}
if *id < first_user_type_id() {
validation_error(
loaded,
&["type_maps", version, category],
name,
format!(
"{category}.{name} in type-map version {version} uses reserved id {id}; user ids must be {} or greater",
first_user_type_id()
),
);
}
@ -540,7 +456,7 @@ fn generate_comm_type_enum(out: &mut String, user_names: &BTreeSet<&str>) {
writeln!(out, "#[allow(clippy::enum_variant_names)]").unwrap();
writeln!(out, "pub enum CommunicationType {{").unwrap();
for entry in RESERVED_COMM_TYPES {
for entry in generated_reserved_comm_types() {
writeln!(out, " {},", entry.name).unwrap();
}
for name in user_names {
@ -554,7 +470,7 @@ fn generate_comm_type_enum(out: &mut String, user_names: &BTreeSet<&str>) {
writeln!(out, " pub fn name(self) -> &'static str {{").unwrap();
writeln!(out, " match self {{").unwrap();
for entry in RESERVED_COMM_TYPES {
for entry in generated_reserved_comm_types() {
writeln!(
out,
" CommunicationType::{} => \"{}\",",
@ -580,7 +496,7 @@ fn generate_comm_type_enum(out: &mut String, user_names: &BTreeSet<&str>) {
writeln!(out, " pub fn from_name(s: &str) -> Option<Self> {{").unwrap();
writeln!(out, " match s {{").unwrap();
for entry in RESERVED_COMM_TYPES {
for entry in generated_reserved_comm_types() {
writeln!(
out,
" \"{}\" => Some(CommunicationType::{}),",
@ -625,7 +541,7 @@ fn generate_data_type_enum(out: &mut String, user_names: &BTreeSet<&str>) {
writeln!(out, "#[allow(clippy::enum_variant_names)]").unwrap();
writeln!(out, "pub enum DataType {{").unwrap();
for entry in RESERVED_DATA_TYPES {
for entry in all_reserved_data_types() {
writeln!(out, " {},", entry.name).unwrap();
}
for name in user_names {
@ -639,7 +555,7 @@ fn generate_data_type_enum(out: &mut String, user_names: &BTreeSet<&str>) {
writeln!(out, " pub fn name(self) -> &'static str {{").unwrap();
writeln!(out, " match self {{").unwrap();
for entry in RESERVED_DATA_TYPES {
for entry in all_reserved_data_types() {
writeln!(
out,
" DataType::{} => \"{}\",",
@ -660,7 +576,7 @@ fn generate_data_type_enum(out: &mut String, user_names: &BTreeSet<&str>) {
writeln!(out, " pub fn from_name(s: &str) -> Option<Self> {{").unwrap();
writeln!(out, " match s {{").unwrap();
for entry in RESERVED_DATA_TYPES {
for entry in all_reserved_data_types() {
writeln!(
out,
" \"{}\" => Some(DataType::{}),",
@ -733,7 +649,7 @@ fn generate_lookup_methods(
major, minor
)
.unwrap();
for entry in RESERVED_COMM_TYPES {
for entry in generated_reserved_comm_types() {
writeln!(
out,
" CommunicationType::{} => Some({}),",
@ -772,7 +688,7 @@ fn generate_lookup_methods(
major, minor
)
.unwrap();
for entry in RESERVED_DATA_TYPES {
for entry in all_reserved_data_types() {
writeln!(
out,
" DataType::{} => Some({}),",
@ -806,7 +722,7 @@ fn generate_lookup_methods(
major, minor
)
.unwrap();
for entry in RESERVED_COMM_TYPES {
for entry in generated_reserved_comm_types() {
writeln!(
out,
" {} => Some(CommunicationType::{}),",
@ -844,7 +760,7 @@ fn generate_lookup_methods(
major, minor
)
.unwrap();
for entry in RESERVED_DATA_TYPES {
for entry in all_reserved_data_types() {
writeln!(
out,
" {} => Some(DataType::{}),",
@ -877,7 +793,7 @@ fn generate_single_version_lookup(out: &mut String, config: &Config) {
.unwrap();
writeln!(out, " match self.version {{").unwrap();
writeln!(out, " PROTOCOL_VERSION => match ct {{").unwrap();
for entry in RESERVED_COMM_TYPES {
for entry in generated_reserved_comm_types() {
writeln!(
out,
" CommunicationType::{} => Some({}),",
@ -910,7 +826,7 @@ fn generate_single_version_lookup(out: &mut String, config: &Config) {
.unwrap();
writeln!(out, " match self.version {{").unwrap();
writeln!(out, " PROTOCOL_VERSION => match dt {{").unwrap();
for entry in RESERVED_DATA_TYPES {
for entry in all_reserved_data_types() {
writeln!(
out,
" DataType::{} => Some({}),",
@ -938,7 +854,7 @@ fn generate_single_version_lookup(out: &mut String, config: &Config) {
.unwrap();
writeln!(out, " match self.version {{").unwrap();
writeln!(out, " PROTOCOL_VERSION => match id {{").unwrap();
for entry in RESERVED_COMM_TYPES {
for entry in generated_reserved_comm_types() {
writeln!(
out,
" {} => Some(CommunicationType::{}),",
@ -970,7 +886,7 @@ fn generate_single_version_lookup(out: &mut String, config: &Config) {
.unwrap();
writeln!(out, " match self.version {{").unwrap();
writeln!(out, " PROTOCOL_VERSION => match id {{").unwrap();
for entry in RESERVED_DATA_TYPES {
for entry in all_reserved_data_types() {
writeln!(
out,
" {} => Some(DataType::{}),",
@ -1077,7 +993,7 @@ fn generate_all_types_methods(
let tm_cfg = &config.type_maps[version_key];
write!(out, " Version({}, {}) => &[", major, minor).unwrap();
let mut first = true;
for entry in RESERVED_COMM_TYPES {
for entry in generated_reserved_comm_types() {
if !first {
write!(out, ", ").unwrap();
}
@ -1108,7 +1024,7 @@ fn generate_all_types_methods(
let tm_cfg = &config.type_maps[version_key];
write!(out, " Version({}, {}) => &[", major, minor).unwrap();
let mut first = true;
for entry in RESERVED_DATA_TYPES {
for entry in all_reserved_data_types() {
if !first {
write!(out, ", ").unwrap();
}
@ -1144,7 +1060,7 @@ fn generate_all_types_methods_single(out: &mut String, config: &Config) {
writeln!(out, " match self.version {{").unwrap();
write!(out, " PROTOCOL_VERSION => &[").unwrap();
let mut first = true;
for entry in RESERVED_COMM_TYPES {
for entry in generated_reserved_comm_types() {
if !first {
write!(out, ", ").unwrap();
}
@ -1174,7 +1090,7 @@ fn generate_all_types_methods_single(out: &mut String, config: &Config) {
writeln!(out, " match self.version {{").unwrap();
write!(out, " PROTOCOL_VERSION => &[").unwrap();
let mut first = true;
for entry in RESERVED_DATA_TYPES {
for entry in all_reserved_data_types() {
if !first {
write!(out, ", ").unwrap();
}

57
type-map/reserved.json Normal file
View file

@ -0,0 +1,57 @@
{
"firstUserTypeId": 32,
"communication": [
{ "name": "Identification", "id": 0 },
{ "name": "IdentificationResponse", "id": 1 },
{ "name": "Register", "id": 2 },
{ "name": "RegisterResponse", "id": 3 },
{ "name": "Challenge", "id": 4 },
{ "name": "ChallengeResponse", "id": 5 },
{ "name": "Ping", "id": 6 },
{ "name": "Pong", "id": 7 },
{ "name": "Disconnect", "id": 8 },
{ "name": "Redirect", "id": 9 },
{ "name": "Shutdown", "id": 10 },
{ "name": "Error", "id": 11 },
{ "name": "ErrorParsing", "id": 12 },
{ "name": "ErrorBadVersion", "id": 13 },
{ "name": "BadRequest", "id": 14 },
{ "name": "Unauthorized", "id": 15 },
{ "name": "Forbidden", "id": 16 },
{ "name": "NotFound", "id": 17 },
{ "name": "TooManyRequests", "id": 18 },
{ "name": "InternalServerError", "id": 19 },
{ "name": "BadGateway", "id": 20 },
{ "name": "ServiceUnavailable", "id": 21 },
{ "name": "GatewayTimeout", "id": 22 },
{ "name": "PipeRequest", "id": 23 },
{ "name": "PipeResponse", "id": 24 },
{ "name": "PipeAbort", "id": 25 },
{ "name": "Relay", "id": 26 }
],
"data": [
{ "name": "Version", "id": 0 },
{ "name": "Id", "id": 1 },
{ "name": "ClientNonce", "id": 2 },
{ "name": "ServerNonce", "id": 3 },
{ "name": "PublicKeys", "id": 4 },
{ "name": "Signature", "id": 5 },
{ "name": "PqSignature", "id": 6 },
{ "name": "Description", "id": 7 },
{ "name": "Connected", "id": 8 },
{ "name": "Timestamp", "id": 9 },
{ "name": "Error", "id": 10 },
{ "name": "ErrorParsing", "id": 11 },
{ "name": "ErrorMessage", "id": 12 },
{ "name": "Accepted", "id": 13 },
{ "name": "RequirePq", "id": 14 },
{ "name": "MessageId", "id": 15 },
{ "name": "FinalRecipientId", "id": 18 },
{ "name": "CreatedAt", "id": 21 },
{ "name": "MessageType", "id": 22 },
{ "name": "Content", "id": 23 },
{ "name": "Metadata", "id": 24 },
{ "name": "RelayVersion", "id": 25 },
{ "name": "ProtectedVersion", "id": 26 }
]
}

View file

@ -92,6 +92,51 @@ impl TypeMap {
include!(concat!(env!("OUT_DIR"), "/types.rs"));
#[cfg(test)]
mod tests {
use super::{DataType, TypeMap};
#[test]
fn current_relay_reserved_fields_use_generic_layout() {
let type_map = TypeMap::latest();
assert_eq!(
DataType::MessageId.try_to_id(&type_map).map(|id| id.0),
Some(15)
);
assert_eq!(
DataType::FinalRecipientId
.try_to_id(&type_map)
.map(|id| id.0),
Some(18)
);
assert_eq!(
DataType::CreatedAt.try_to_id(&type_map).map(|id| id.0),
Some(21)
);
assert_eq!(
DataType::MessageType.try_to_id(&type_map).map(|id| id.0),
Some(22)
);
assert_eq!(
DataType::Content.try_to_id(&type_map).map(|id| id.0),
Some(23)
);
assert_eq!(
DataType::Metadata.try_to_id(&type_map).map(|id| id.0),
Some(24)
);
assert_eq!(
DataType::RelayVersion.try_to_id(&type_map).map(|id| id.0),
Some(25)
);
for tombstoned_id in [16, 17, 19, 20] {
assert_eq!(type_map.data_type_name(tombstoned_id), None);
}
}
}
/* ============================= REGISTRY ============================= */
#[cfg(feature = "registry")]
pub use registry::*;
@ -169,9 +214,17 @@ mod registry {
use super::*;
#[test]
fn builtin_contains_versions() {
fn builtin_registers_only_the_current_codec_version() {
let r = Registry::builtin();
assert!(r.supports(&Version(0, 0)));
assert!(r.supports(&Version(3, 0)));
for removed_version in [Version(0, 0), Version(1, 0), Version(2, 0)] {
assert!(!r.supports(&removed_version));
assert_eq!(r.negotiate(&[removed_version]), None);
}
assert_eq!(
r.negotiate(&[Version(3, 0), Version(2, 0)]),
Some(Version(3, 0))
);
}
#[test]

View file

@ -26,7 +26,7 @@ getrandom-v04 = { package = "getrandom", version = "0.4.3", features = ["wasm_js
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", features = ["crypto", "pipes"] }
mtp-codec = { version = "0.2.0", path = "../codec", features = ["crypto", "pipes", "registry"] }
mtp-crypto = { version = "0.2.0", path = "../crypto", features = ["wasm"] }
zeroize = "1.9"
wasm-bindgen-test = "0.3.76"

View file

@ -38,15 +38,15 @@ pub(crate) fn verify_host_challenge(
require_pq: bool,
) -> Result<(), JsValue> {
let sig = match challenge.get_data(DataType::Signature) {
DataValue::Bytes(b) => b.clone(),
Some(DataValue::Bytes(b)) => b.clone(),
_ => return Err(js_error("missing host challenge signature")),
};
let pq_sig = match challenge.get_data(DataType::PqSignature) {
DataValue::Bytes(b) => b.clone(),
Some(DataValue::Bytes(b)) => b.clone(),
_ => vec![],
};
let host_requires_pq = challenge.get_data(DataType::RequirePq) == &DataValue::BoolTrue;
let host_requires_pq = challenge.get_data(DataType::RequirePq) == Some(&DataValue::BoolTrue);
if host_requires_pq && host_pk.sig_pq_public_key.as_bytes().is_empty() {
return Err(js_error(
"host requires post-quantum authentication but its PQ public key is absent",
@ -77,15 +77,15 @@ pub(crate) fn verify_host_final(
server_challenge: u128,
require_pq: bool,
) -> Result<(), JsValue> {
if *resp.get_data(DataType::ClientNonce) != DataValue::UnsignedNumber(client_nonce) {
if resp.get_data(DataType::ClientNonce) != Some(&DataValue::UnsignedNumber(client_nonce)) {
return Err(js_error("nonce mismatch"));
}
let host_sig = match resp.get_data(DataType::Signature) {
DataValue::Bytes(b) => b.clone(),
Some(DataValue::Bytes(b)) => b.clone(),
_ => return Err(js_error("missing host signature")),
};
let host_pq_sig = match resp.get_data(DataType::PqSignature) {
DataValue::Bytes(b) => b.clone(),
Some(DataValue::Bytes(b)) => b.clone(),
_ => vec![],
};
if require_pq && host_pq_sig.is_empty() {
@ -114,6 +114,7 @@ pub(crate) fn signed_challenge_response_bytes(
keyring: &mtp_crypto::Keyring,
proof_payload: &[u8],
client_nonce: u128,
type_map: &mtp_codec::TypeMap,
) -> Result<Vec<u8>, JsValue> {
use mtp_crypto::SignatureScheme;
@ -123,12 +124,13 @@ pub(crate) fn signed_challenge_response_bytes(
.sign(proof_payload)
.map_err(|e| js_error(format!("signature failed: {}", e)))?;
let mut proof = CommunicationValue::new(CommunicationType::ChallengeResponse)
.add_typed_default(
DataType::ClientNonce,
DataValue::UnsignedNumber(client_nonce),
)
.add_typed_default(DataType::Signature, DataValue::Bytes(signature));
let mut proof =
CommunicationValue::new_with_type_map(CommunicationType::ChallengeResponse, type_map)
.add_typed_default(
DataType::ClientNonce,
DataValue::UnsignedNumber(client_nonce),
)
.add_typed_default(DataType::Signature, DataValue::Bytes(signature));
if !keyring.sig_pq_secret_key.as_bytes().is_empty() {
let pq_signer =

File diff suppressed because it is too large Load diff

View file

@ -13,11 +13,27 @@ use crate::pipe::PipeReader;
use crate::transport::WasmTransport;
pub(crate) struct PendingRequest {
pub(crate) generation: u32,
pub(crate) token: Rc<()>,
pub(crate) response_type: Option<String>,
pub(crate) sender: oneshot::Sender<Result<JsValue, JsValue>>,
}
pub(crate) struct PendingPipeCreation {
pub(crate) generation: u32,
pub(crate) sender: oneshot::Sender<Result<bool, JsValue>>,
}
pub(crate) type PendingPipeCreations = Rc<RefCell<HashMap<u32, PendingPipeCreation>>>;
type PipeResponseReceiver = oneshot::Receiver<Result<bool, JsValue>>;
type PipeResponseCell = Rc<RefCell<Option<PipeResponseReceiver>>>;
pub(crate) struct PendingPipe {
pub(crate) generation: u32,
pub(crate) sender: oneshot::Sender<Result<PipeReader, JsValue>>,
}
pub(crate) type PendingPipes = Rc<RefCell<HashMap<u32, PendingPipe>>>;
pub(crate) fn remove_pending_request(
pending_requests: &Rc<RefCell<HashMap<u32, PendingRequest>>>,
request_id: u32,
@ -32,6 +48,57 @@ pub(crate) fn remove_pending_request(
}
}
const EXPIRED_REQUEST_TOMBSTONE_TTL_MS: f64 = 60_000.0;
const MAX_EXPIRED_REQUEST_TOMBSTONES: usize = 1024;
pub(crate) fn expire_pending_request(
pending_requests: &Rc<RefCell<HashMap<u32, PendingRequest>>>,
expired_requests: &Rc<RefCell<HashMap<u32, f64>>>,
request_id: u32,
token: &Rc<()>,
) {
let mut pending = pending_requests.borrow_mut();
if pending
.get(&request_id)
.is_some_and(|entry| Rc::ptr_eq(&entry.token, token))
{
pending.remove(&request_id);
drop(pending);
let now = js_sys::Date::now();
let mut expired = expired_requests.borrow_mut();
expired.retain(|_, expires_at| *expires_at > now);
if expired.len() >= MAX_EXPIRED_REQUEST_TOMBSTONES
&& let Some(oldest) = expired
.iter()
.min_by(|(_, left), (_, right)| left.total_cmp(right))
.map(|(id, _)| *id)
{
expired.remove(&oldest);
}
expired.insert(request_id, now + EXPIRED_REQUEST_TOMBSTONE_TTL_MS);
}
}
pub(crate) fn consume_expired_request(
expired_requests: &Rc<RefCell<HashMap<u32, f64>>>,
request_id: u32,
) -> bool {
let now = js_sys::Date::now();
let mut expired = expired_requests.borrow_mut();
expired.retain(|_, expires_at| *expires_at > now);
expired.remove(&request_id).is_some()
}
pub(crate) fn is_expired_request(
expired_requests: &Rc<RefCell<HashMap<u32, f64>>>,
request_id: u32,
) -> bool {
let now = js_sys::Date::now();
let mut expired = expired_requests.borrow_mut();
expired.retain(|_, expires_at| *expires_at > now);
expired.contains_key(&request_id)
}
#[wasm_bindgen(typescript_custom_section)]
const PIPE_HANDLE_TS: &str = r#"
export interface WasmPipeHandle {
@ -46,7 +113,7 @@ pub struct WasmPipeHandle {
pipe_id: u32,
description: String,
transport: WasmTransport,
response_rx: Rc<RefCell<Option<oneshot::Receiver<Result<bool, JsValue>>>>>,
response_rx: PipeResponseCell,
}
#[wasm_bindgen]
@ -92,13 +159,17 @@ pub(crate) fn random_pipe_id() -> Result<u32, JsValue> {
Ok(u32::from_be_bytes(bytes))
}
pub(crate) fn reject_pending_pipe_creations(
pending: &Rc<RefCell<HashMap<u32, oneshot::Sender<Result<bool, JsValue>>>>>,
message: &str,
) {
pub(crate) fn reject_pending_pipe_creations(pending: &PendingPipeCreations, message: &str) {
let pending = std::mem::take(&mut *pending.borrow_mut());
for (_, tx) in pending {
let _ = tx.send(Err(js_error(message)));
for (_, entry) in pending {
let _ = entry.sender.send(Err(js_error(message)));
}
}
pub(crate) fn reject_pending_pipes(pending: &PendingPipes, message: &str) {
let pending = std::mem::take(&mut *pending.borrow_mut());
for (_, entry) in pending {
let _ = entry.sender.send(Err(js_error(message)));
}
}
@ -106,12 +177,24 @@ pub(crate) async fn wasm_create_pipe(
transport: &WasmTransport,
description: &str,
pipe_id: u32,
pending_pipe_creations: &Rc<RefCell<HashMap<u32, oneshot::Sender<Result<bool, JsValue>>>>>,
pending_pipe_creations: &PendingPipeCreations,
generation: u32,
current_generation: &Rc<std::cell::Cell<u32>>,
) -> Result<WasmPipeHandle, JsValue> {
let (tx, rx) = oneshot::channel();
pending_pipe_creations.borrow_mut().insert(pipe_id, tx);
let request = CommunicationValue::new(CommunicationType::PipeRequest)
let mut pipe_id = pipe_id;
for _ in 0..128 {
let occupied = pipe_id == 0 || pending_pipe_creations.borrow().contains_key(&pipe_id);
if !occupied {
break;
}
pipe_id = random_pipe_id()?;
}
if pipe_id == 0 || pending_pipe_creations.borrow().contains_key(&pipe_id) {
return Err(js_error("could not allocate a unique pipe id"));
}
let type_map = transport.type_map();
let request = CommunicationValue::new_with_type_map(CommunicationType::PipeRequest, &type_map)
.with_id(pipe_id)
.add_typed_default(
DataType::Description,
@ -120,6 +203,13 @@ pub(crate) async fn wasm_create_pipe(
let request_bytes = request
.to_bytes()
.map_err(|e| js_error(format!("encode failed: {}", e)))?;
pending_pipe_creations.borrow_mut().insert(
pipe_id,
PendingPipeCreation {
generation,
sender: tx,
},
);
debug!(
target = "mtp.wasm",
pipe_id,
@ -127,7 +217,26 @@ pub(crate) async fn wasm_create_pipe(
frame_len = request_bytes.len(),
"sending pipe request"
);
transport.send_frame(&request_bytes).await?;
if let Err(error) = transport.send_frame(&request_bytes).await {
let mut pending = pending_pipe_creations.borrow_mut();
if pending
.get(&pipe_id)
.is_some_and(|entry| entry.generation == generation)
{
pending.remove(&pipe_id);
}
return Err(error);
}
if current_generation.get() != generation {
let mut pending = pending_pipe_creations.borrow_mut();
if pending
.get(&pipe_id)
.is_some_and(|entry| entry.generation == generation)
{
pending.remove(&pipe_id);
}
return Err(js_error("connection attempt superseded"));
}
Ok(WasmPipeHandle {
pipe_id,
@ -140,14 +249,40 @@ pub(crate) async fn wasm_create_pipe(
pub(crate) async fn wasm_accept_pipe(
transport: &WasmTransport,
pipe_id: u32,
pending_pipes: &Rc<RefCell<HashMap<u32, oneshot::Sender<PipeReader>>>>,
pending_pipes: &PendingPipes,
generation: u32,
current_generation: &Rc<std::cell::Cell<u32>>,
) -> Result<PipeReader, JsValue> {
let resp = CommunicationValue::new(CommunicationType::PipeResponse)
if pipe_id == 0 {
return Err(js_error("pipe id must be non-zero"));
}
if current_generation.get() != generation {
return Err(js_error("connection attempt superseded"));
}
let type_map = transport.type_map();
let resp = CommunicationValue::new_with_type_map(CommunicationType::PipeResponse, &type_map)
.with_id(pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolTrue);
let resp_bytes = resp
.to_bytes()
.map_err(|e| js_error(format!("encode failed: {}", e)))?;
let (tx, rx) = oneshot::channel();
{
let mut pending = pending_pipes.borrow_mut();
if pending.contains_key(&pipe_id) {
return Err(js_error(format!("pipe {pipe_id} is already pending")));
}
pending.insert(
pipe_id,
PendingPipe {
generation,
sender: tx,
},
);
}
debug!(
target = "mtp.wasm",
pipe_id,
@ -155,17 +290,37 @@ pub(crate) async fn wasm_accept_pipe(
frame_len = resp_bytes.len(),
"sending pipe response"
);
transport.send_frame(&resp_bytes).await?;
let (tx, rx) = oneshot::channel();
pending_pipes.borrow_mut().insert(pipe_id, tx);
if let Err(error) = transport.send_frame(&resp_bytes).await {
let mut pending = pending_pipes.borrow_mut();
if pending
.get(&pipe_id)
.is_some_and(|entry| entry.generation == generation)
{
pending.remove(&pipe_id);
}
return Err(error);
}
if current_generation.get() != generation {
let mut pending = pending_pipes.borrow_mut();
if pending
.get(&pipe_id)
.is_some_and(|entry| entry.generation == generation)
{
pending.remove(&pipe_id);
}
return Err(js_error("connection attempt superseded"));
}
rx.await
.map_err(|_| js_error("pipe closed before stream arrived"))
.map_err(|_| js_error("pipe closed before stream arrived"))?
}
pub(crate) async fn wasm_deny_pipe(transport: &WasmTransport, pipe_id: u32) -> Result<(), JsValue> {
let resp = CommunicationValue::new(CommunicationType::PipeResponse)
if pipe_id == 0 {
return Err(js_error("pipe id must be non-zero"));
}
let type_map = transport.type_map();
let resp = CommunicationValue::new_with_type_map(CommunicationType::PipeResponse, &type_map)
.with_id(pipe_id)
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
let resp_bytes = resp

View file

@ -1,13 +1,67 @@
use wasm_bindgen::prelude::*;
use zeroize::Zeroizing;
use mtp_codec::{
DataValue, MtpProtectionPurpose, PROTOCOL_VERSION, ProtectionPolicy, ProtectionPurpose,
SealedRelayBuilder, SignaturePolicy, TypeMap,
};
use mtp_crypto::{
AeadDecrypt, AeadEncrypt, ChaCha20Poly1305, Ed25519Signer, HybridKem, KemPrivateKey,
KemPublicKey, Keyring, PublicKeyBundle, SignaturePqPrivateKey, SignaturePqPublicKey,
SignaturePrivateKey, SignaturePublicKey, SignatureScheme, sha256, sha256_double,
AeadDecrypt, AeadEncrypt, DualSigner, Ed25519Signer, HybridKem, KemPrivateKey, KemPublicKey,
Keyring, PublicKeyBundle, SignaturePqPrivateKey, SignaturePqPublicKey, SignaturePrivateKey,
SignaturePublicKey, SignatureScheme, XChaCha20Poly1305, sha256, sha256_double,
};
use crate::error::js_error;
use crate::error::{from_protection_error, js_error};
use crate::relay::{decode_frame, relay_error, structured_error};
fn decode_data_value(value: &[u8]) -> Result<DataValue, JsValue> {
DataValue::from_bytes(value).ok_or_else(|| js_error("invalid DataValue"))
}
fn decode_public_key_bundle(
bytes: &[u8],
index: Option<usize>,
) -> Result<PublicKeyBundle, JsValue> {
PublicKeyBundle::from_bytes(bytes).map_err(|e| {
let prefix = index
.map(|index| format!("recipient {index}: "))
.unwrap_or_default();
js_error(format!("{prefix}public bundle initialization failed: {e}"))
})
}
pub(crate) fn public_key_bundles_from_js(value: &JsValue) -> Result<Vec<PublicKeyBundle>, JsValue> {
if js_sys::Uint8Array::instanceof(value) {
return Ok(vec![decode_public_key_bundle(
&js_sys::Uint8Array::new(value).to_vec(),
None,
)?]);
}
if !js_sys::Array::is_array(value) {
return Err(js_error(
"recipient public key bundles must be a Uint8Array or an array of Uint8Arrays",
));
}
let array = js_sys::Array::from(value);
if array.length() == 0 {
return Err(js_error(
"at least one recipient public key bundle is required",
));
}
array
.iter()
.enumerate()
.map(|(index, value)| {
if !js_sys::Uint8Array::instanceof(&value) {
return Err(js_error(format!("recipient {index} must be a Uint8Array")));
}
decode_public_key_bundle(&js_sys::Uint8Array::new(&value).to_vec(), Some(index))
})
.collect()
}
// ===========================================================================
// Keyring
@ -41,6 +95,25 @@ impl WasmKeyring {
inner: self.inner.public_key_bundle(),
}
}
/// Validate that all full-suite public/private components correspond.
/// Role-specific browser keyrings may intentionally fail this check.
#[wasm_bindgen]
pub fn validate_full(&self) -> Result<(), JsValue> {
self.inner
.validate_full()
.map_err(|e| js_error(format!("Keyring::validate_full: {e}")))
}
/// Validate the KEM public/private pair without requiring PQ signing
/// material. This is the invariant needed by envelope recipients and
/// sealed-relay clients that explicitly choose Ed25519 signatures.
#[wasm_bindgen]
pub fn validate_encryption(&self) -> Result<(), JsValue> {
self.inner
.validate_encryption()
.map_err(|e| js_error(format!("Keyring::validate_encryption: {e}")))
}
}
/// Generate a full keyring with KEM, ML-DSA, and Ed25519 keys.
@ -109,6 +182,16 @@ impl WasmPublicKeyBundle {
.map_err(|e| js_error(format!("PublicKeyBundle::from_bytes: {}", e)))?;
Ok(Self { inner })
}
/// Deserialise an explicitly partial bundle for development-only key
/// material. Protocol encryption and signature verification use the
/// strict `from_bytes` parser above.
#[wasm_bindgen]
pub fn from_bytes_unvalidated(bytes: &[u8]) -> Result<WasmPublicKeyBundle, JsValue> {
let inner = PublicKeyBundle::from_bytes_unvalidated(bytes)
.map_err(|e| js_error(format!("PublicKeyBundle::from_bytes_unvalidated: {}", e)))?;
Ok(Self { inner })
}
}
// ===========================================================================
@ -126,6 +209,36 @@ pub struct WasmEncapsulated {
inner_ciphertext: Vec<u8>,
}
/// A short-lived ephemeral hybrid-KEM keypair for the forward-secure pipe
/// handshake. The secret is zeroized when the object is freed.
#[wasm_bindgen]
pub struct WasmKemKeypair {
secret: Zeroizing<Vec<u8>>,
public: Vec<u8>,
}
#[wasm_bindgen]
impl WasmKemKeypair {
#[wasm_bindgen(getter)]
pub fn public_key(&self) -> Vec<u8> {
self.public.clone()
}
#[wasm_bindgen(getter)]
pub fn secret_key(&self) -> Vec<u8> {
self.secret.to_vec()
}
}
#[wasm_bindgen]
pub fn wasm_kem_generate_keypair() -> WasmKemKeypair {
let (secret, public) = HybridKem::generate_keypair();
WasmKemKeypair {
secret: Zeroizing::new(secret.as_bytes().to_vec()),
public: public.as_bytes().to_vec(),
}
}
#[wasm_bindgen]
impl WasmEncapsulated {
/// Symmetric secret derived during encapsulation.
@ -178,7 +291,7 @@ pub fn wasm_kem_decapsulate(
#[wasm_bindgen]
pub struct WasmChaCha20Poly1305 {
inner: ChaCha20Poly1305,
inner: XChaCha20Poly1305,
}
#[wasm_bindgen]
@ -192,7 +305,7 @@ impl WasmChaCha20Poly1305 {
let mut k = [0u8; 32];
k.copy_from_slice(&key);
Ok(Self {
inner: ChaCha20Poly1305::new(k),
inner: XChaCha20Poly1305::new(k),
})
}
@ -339,6 +452,401 @@ pub fn wasm_derive_encryption_key(
.map_err(|e| js_error(format!("derive_encryption_key failed: {}", e)))
}
/// Signature suites accepted by high-level protected-value APIs.
pub const PROTECTION_SIGNATURE_SUITE_ED25519: u8 = 0x01;
pub const PROTECTION_SIGNATURE_SUITE_DUAL: u8 = 0x03;
pub(crate) fn protection_policy_from_suite(suite: u8) -> Result<ProtectionPolicy, JsValue> {
let signature = match suite {
0 => SignaturePolicy::AnySupported,
PROTECTION_SIGNATURE_SUITE_ED25519 => SignaturePolicy::Ed25519,
PROTECTION_SIGNATURE_SUITE_DUAL => SignaturePolicy::Dual,
_ => {
return Err(js_error(format!(
"unknown protection signature suite: {suite}"
)));
}
};
Ok(ProtectionPolicy { signature })
}
pub(crate) enum RelaySigner {
Ed25519(Ed25519Signer),
Dual(DualSigner),
}
impl SignatureScheme for RelaySigner {
fn algorithm(&self) -> u8 {
match self {
Self::Ed25519(signer) => signer.algorithm(),
Self::Dual(signer) => signer.algorithm(),
}
}
fn sign(&self, message: &[u8]) -> Result<Vec<u8>, mtp_crypto::CryptoError> {
match self {
Self::Ed25519(signer) => signer.sign(message),
Self::Dual(signer) => signer.sign(message),
}
}
fn verify(&self, message: &[u8], signature: &[u8]) -> Result<(), mtp_crypto::CryptoError> {
match self {
Self::Ed25519(signer) => signer.verify(message, signature),
Self::Dual(signer) => signer.verify(message, signature),
}
}
}
pub(crate) fn relay_signer_from_keyring(
keyring: &Keyring,
suite: u8,
) -> Result<RelaySigner, JsValue> {
match suite {
PROTECTION_SIGNATURE_SUITE_ED25519 => {
keyring
.validate_ed25519_signing()
.map_err(|e| js_error(format!("signing key validation failed: {e}")))?;
Ed25519Signer::new(&keyring.sig_cl_secret_key)
.map(RelaySigner::Ed25519)
.map_err(|e| js_error(format!("signer initialization failed: {e}")))
}
PROTECTION_SIGNATURE_SUITE_DUAL => {
keyring
.validate_dual_signing()
.map_err(|e| js_error(format!("dual signing key validation failed: {e}")))?;
DualSigner::new(
&keyring.sig_cl_secret_key,
&keyring.sig_pq_secret_key,
&keyring.sig_pq_public_key,
)
.map(RelaySigner::Dual)
.map_err(|e| js_error(format!("dual signer initialization failed: {e}")))
}
_ => Err(js_error(format!(
"unknown protection signature suite: {suite}"
))),
}
}
/// Sign a serialized `DataValue` using the selected suite from a serialized
/// keyring.
#[wasm_bindgen]
pub fn sign_data_value_with_keyring(
value: &[u8],
signer_id: u64,
purpose: u8,
keyring: &[u8],
signature_suite: u8,
) -> Result<Vec<u8>, JsValue> {
let value = decode_data_value(value)?;
let keyring = Keyring::from_bytes(keyring)
.map_err(|e| js_error(format!("keyring initialization failed: {e}")))?;
let signer = relay_signer_from_keyring(&keyring, signature_suite)?;
value
.sign(signer_id, ProtectionPurpose::from(purpose), &signer)
.map_err(from_protection_error)?
.to_bytes()
.map_err(|e| js_error(format!("sign failed: {e}")))
}
/// Verify a serialized `Signed<Value>` wrapper while enforcing the receiver's
/// required signature suite. `0` retains the legacy any-supported behavior;
/// new protocol callers should pass one of the exported suite constants.
#[wasm_bindgen]
pub fn verify_data_value_with_policy(
value: &[u8],
public_key_bundle: &[u8],
expected_signer_id: u64,
expected_purpose: u8,
signature_suite: u8,
) -> Result<(), JsValue> {
let value = decode_data_value(value)?;
let bundle = decode_public_key_bundle(public_key_bundle, None)?;
let result = if signature_suite == 0 {
value.verify(
expected_signer_id,
&bundle,
ProtectionPurpose::from(expected_purpose),
)
} else {
value.verify_with_policy(
expected_signer_id,
&bundle,
ProtectionPurpose::from(expected_purpose),
protection_policy_from_suite(signature_suite)?,
)
};
result.map_err(from_protection_error)
}
/// Encrypt a serialized `DataValue` for one recipient using the canonical
/// multi-recipient envelope.
#[wasm_bindgen]
pub fn encrypt_data_value(
value: &[u8],
recipient_public_key_bundle: &[u8],
purpose: u8,
) -> Result<Vec<u8>, JsValue> {
let value = decode_data_value(value)?;
let recipient = decode_public_key_bundle(recipient_public_key_bundle, None)?;
let encrypted = value
.encrypt_for(&[recipient], ProtectionPurpose::from(purpose))
.map_err(from_protection_error)?;
encrypted
.to_bytes()
.map_err(|e| js_error(format!("encryption failed: {e}")))
}
/// Encrypt a serialized `DataValue` for one or more recipients.
///
/// `recipient_public_key_bundles` may be a single `Uint8Array` for the common
/// case or an array of serialized public-key bundles. The array form uses the
/// same canonical envelope as native multi-recipient encryption.
#[wasm_bindgen]
pub fn encrypt_data_value_for_recipients(
value: &[u8],
recipient_public_key_bundles: JsValue,
purpose: u8,
) -> Result<Vec<u8>, JsValue> {
let value = decode_data_value(value)?;
let recipients = public_key_bundles_from_js(&recipient_public_key_bundles)?;
value
.encrypt_for(&recipients, ProtectionPurpose::from(purpose))
.map_err(from_protection_error)?
.to_bytes()
.map_err(|e| js_error(format!("encryption failed: {e}")))
}
/// Decrypt a serialized `Encrypted<Value>` wrapper with a serialized keyring.
/// The expected purpose is supplied by the protocol caller, not taken from
/// the untrusted encrypted wrapper.
#[wasm_bindgen]
pub fn decrypt_data_value(
value: &[u8],
keyring: &[u8],
expected_purpose: u8,
) -> Result<Vec<u8>, JsValue> {
let value = decode_data_value(value)?;
let keyring = Keyring::from_bytes(keyring)
.map_err(|e| js_error(format!("keyring initialization failed: {e}")))?;
let opened = value
.decrypt(&keyring, ProtectionPurpose::from(expected_purpose))
.map_err(from_protection_error)?;
opened
.to_bytes()
.map_err(|e| js_error(format!("decryption failed: {e}")))
}
/// Decrypt using a caller-supplied local key history. Recipient key
/// identifiers remain absent from the serialized envelope.
#[wasm_bindgen]
pub fn decrypt_data_value_with_keyrings(
value: &[u8],
keyrings: JsValue,
expected_purpose: u8,
) -> Result<Vec<u8>, JsValue> {
let value = decode_data_value(value)?;
let keyrings = keyrings_from_js(&keyrings)?;
let references: Vec<&Keyring> = keyrings.iter().collect();
value
.decrypt_with_keyrings(&references, ProtectionPurpose::from(expected_purpose))
.map_err(from_protection_error)?
.to_bytes()
.map_err(|e| js_error(format!("decryption failed: {e}")))
}
pub(crate) fn keyrings_from_js(value: &JsValue) -> Result<Vec<Keyring>, JsValue> {
let keyring_bytes: Vec<Vec<u8>> = if js_sys::Uint8Array::instanceof(value) {
vec![js_sys::Uint8Array::new(value).to_vec()]
} else if js_sys::Array::is_array(value) {
let array = js_sys::Array::from(value);
array
.iter()
.enumerate()
.map(|(index, value)| {
if !js_sys::Uint8Array::instanceof(&value) {
return Err(js_error(format!("keyring {index} must be a Uint8Array")));
}
Ok(js_sys::Uint8Array::new(&value).to_vec())
})
.collect::<Result<_, _>>()?
} else {
return Err(js_error(
"keyrings must be a Uint8Array or an array of Uint8Arrays",
));
};
if keyring_bytes.is_empty() {
return Err(js_error("at least one keyring is required"));
}
keyring_bytes
.iter()
.map(|bytes| {
Keyring::from_bytes(bytes)
.map_err(|e| js_error(format!("keyring initialization failed: {e}")))
})
.collect()
}
/// Protection purposes used by the generic browser relay envelope.
///
/// The outer encryption purpose is intentionally generic: the actual
/// application operation is inside the encrypted metadata container.
pub const RELAY_METADATA_ENCRYPTION_PURPOSE: u8 =
MtpProtectionPurpose::RelayMetadataEncryption.value();
pub const RELAY_CONTENT_SIGNATURE_PURPOSE: u8 = MtpProtectionPurpose::RelayContentSignature.value();
pub const RELAY_CONTENT_ENCRYPTION_PURPOSE: u8 =
MtpProtectionPurpose::RelayContentEncryption.value();
pub const RELAY_METADATA_SIGNATURE_PURPOSE: u8 =
MtpProtectionPurpose::RelayMetadataSignature.value();
/// Return the canonical MTP relay metadata-encryption purpose.
#[wasm_bindgen]
pub fn mtp_relay_metadata_encryption_purpose() -> u8 {
MtpProtectionPurpose::RelayMetadataEncryption.value()
}
/// Return the canonical MTP relay content-signature purpose.
#[wasm_bindgen]
pub fn mtp_relay_content_signature_purpose() -> u8 {
MtpProtectionPurpose::RelayContentSignature.value()
}
/// Return the canonical MTP relay content-encryption purpose.
#[wasm_bindgen]
pub fn mtp_relay_content_encryption_purpose() -> u8 {
MtpProtectionPurpose::RelayContentEncryption.value()
}
/// Return the canonical MTP relay metadata-signature purpose.
#[wasm_bindgen]
pub fn mtp_relay_metadata_signature_purpose() -> u8 {
MtpProtectionPurpose::RelayMetadataSignature.value()
}
/// Return the canonical MTP pipe-session signature purpose.
#[wasm_bindgen]
pub fn mtp_pipe_session_signature_purpose() -> u8 {
MtpProtectionPurpose::PipeSessionSignature.value()
}
/// Return the canonical MTP pipe-session encryption purpose.
#[wasm_bindgen]
pub fn mtp_pipe_session_encryption_purpose() -> u8 {
MtpProtectionPurpose::PipeSessionEncryption.value()
}
#[wasm_bindgen]
pub fn mtp_protection_signature_suite_ed25519() -> u8 {
PROTECTION_SIGNATURE_SUITE_ED25519
}
#[wasm_bindgen]
pub fn mtp_protection_signature_suite_dual() -> u8 {
PROTECTION_SIGNATURE_SUITE_DUAL
}
/// Forward a sealed relay frame to another clear next hop without opening or
/// re-encoding its authenticated encrypted payload.
#[wasm_bindgen]
pub fn forward_encrypted_relay_frame(
frame: &[u8],
next_hop_receiver_id: u64,
) -> Result<Vec<u8>, JsValue> {
let frame = decode_frame(frame)?;
mtp_codec::forward_relay_frame(&frame, next_hop_receiver_id)
.map_err(relay_error)?
.to_bytes()
.map_err(|e| structured_error("invalid-frame", format!("relay frame encoding failed: {e}")))
}
/// Convert browser values and build a sealed relay frame through the native
/// codec builder. The builder owns the protected relay layout so native and
/// browser callers cannot silently diverge.
#[allow(clippy::too_many_arguments)]
fn build_encrypted_relay_frame_impl(
message_type: &str,
data: JsValue,
signer_id: u64,
final_recipient_id: u64,
next_hop_id: u64,
message_id: &str,
created_at: u64,
encoded_metadata: Option<Vec<u8>>,
signer: &dyn SignatureScheme,
metadata_recipient_public_key_bundles: JsValue,
content_recipient_public_key_bundles: JsValue,
) -> Result<Vec<u8>, JsValue> {
let tm = TypeMap::new(PROTOCOL_VERSION);
let application_content = crate::frame::js_to_data_value(&data, &tm)?;
let application_metadata = encoded_metadata
.as_deref()
.map(decode_data_value)
.transpose()?;
let content_recipients = public_key_bundles_from_js(&content_recipient_public_key_bundles)?;
let metadata_recipients = public_key_bundles_from_js(&metadata_recipient_public_key_bundles)?;
let builder = SealedRelayBuilder::new(
message_type,
application_content,
signer_id,
final_recipient_id,
next_hop_id,
signer,
)
.message_id(message_id)
.created_at(created_at)
.metadata_recipients(metadata_recipients)
.content_recipients(content_recipients)
.type_map(&tm);
let builder = match application_metadata {
Some(metadata) => builder.metadata(metadata),
None => builder,
};
builder
.build()
.map_err(relay_error)?
.to_bytes()
.map_err(|e| js_error(format!("relay frame encoding failed: {e}")))
}
/// Build a relay frame using an explicit Ed25519 or dual-signature policy.
/// `created_at` is Unix epoch milliseconds.
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn build_encrypted_relay_frame_with_keyring(
message_type: &str,
data: JsValue,
signer_id: u64,
final_recipient_id: u64,
next_hop_id: u64,
message_id: &str,
created_at: u64,
encoded_metadata: Option<Vec<u8>>,
keyring_bytes: &[u8],
signature_suite: u8,
metadata_recipient_public_key_bundles: JsValue,
content_recipient_public_key_bundles: JsValue,
) -> Result<Vec<u8>, JsValue> {
let keyring = Keyring::from_bytes(keyring_bytes)
.map_err(|e| js_error(format!("keyring initialization failed: {e}")))?;
let signer = relay_signer_from_keyring(&keyring, signature_suite)?;
build_encrypted_relay_frame_impl(
message_type,
data,
signer_id,
final_recipient_id,
next_hop_id,
message_id,
created_at,
encoded_metadata,
&signer,
metadata_recipient_public_key_bundles,
content_recipient_public_key_bundles,
)
}
#[cfg(test)]
#[cfg(target_arch = "wasm32")]
mod tests {
@ -384,7 +892,8 @@ mod tests {
};
let bytes = bundle.to_bytes();
let restored = WasmPublicKeyBundle::from_bytes(&bytes).expect("from_bytes failed");
let restored = WasmPublicKeyBundle::from_bytes_unvalidated(&bytes)
.expect("from_bytes_unvalidated failed");
assert_eq!(restored.sig_cl_public_key(), pk);
}
@ -578,4 +1087,72 @@ mod tests {
wasm_derive_encryption_key(b"pass2", b"salt", b"context").expect("derive failed");
assert_ne!(key, key2);
}
// ------------------------------------------------------------------
// DataValue protection
// ------------------------------------------------------------------
#[wasm_bindgen_test]
fn signed_data_value_can_be_verified_through_wasm() {
let keyring = Keyring::generate();
let value = DataValue::Str("signed through wasm".into())
.to_bytes()
.expect("value encoding failed");
let keyring_bytes = keyring.to_bytes();
let signed = sign_data_value_with_keyring(
&value,
0xfeed_beef,
7,
&keyring_bytes,
PROTECTION_SIGNATURE_SUITE_ED25519,
)
.expect("sign_data_value_with_keyring failed");
let bundle = keyring.public_key_bundle();
verify_data_value_with_policy(
&signed,
&bundle.as_bytes(),
0xfeed_beef,
7,
PROTECTION_SIGNATURE_SUITE_ED25519,
)
.expect("verify_data_value_with_policy failed");
let wrong_bundle = Keyring::generate().public_key_bundle();
assert!(
verify_data_value_with_policy(
&signed,
&wrong_bundle.as_bytes(),
0xfeed_beef,
7,
PROTECTION_SIGNATURE_SUITE_ED25519,
)
.is_err()
);
}
#[wasm_bindgen_test]
fn encrypted_data_value_can_be_opened_through_wasm() {
let keyring = Keyring::generate();
let recipient = keyring.public_key_bundle();
let value = DataValue::Array(vec![DataValue::BoolTrue, DataValue::UnsignedNumber(42)])
.to_bytes()
.expect("value encoding failed");
let encrypted = encrypt_data_value(&value, &recipient.as_bytes(), 9)
.expect("encrypt_data_value failed");
let decrypted = decrypt_data_value(&encrypted, &keyring.to_bytes(), 9)
.expect("decrypt_data_value failed");
assert_eq!(decrypted, value);
let second_keyring = Keyring::generate();
let second_recipient = second_keyring.public_key_bundle();
let recipients = js_sys::Array::new();
recipients.push(&js_sys::Uint8Array::from(&recipient.as_bytes()[..]));
recipients.push(&js_sys::Uint8Array::from(&second_recipient.as_bytes()[..]));
let multi = encrypt_data_value_for_recipients(&value, recipients.into(), 9)
.expect("multi-recipient encryption failed");
let opened_by_second = decrypt_data_value(&multi, &second_keyring.to_bytes(), 9)
.expect("second recipient could not decrypt");
assert_eq!(opened_by_second, value);
}
}

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