Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
3c09493b00 |
|||
|
7f0231e3f1 |
109 changed files with 19696 additions and 5212 deletions
|
|
@ -41,6 +41,9 @@ jobs:
|
||||||
pnpm --filter mtp-web-client run build
|
pnpm --filter mtp-web-client run build
|
||||||
|
|
||||||
node test/e2ee.mjs
|
node test/e2ee.mjs
|
||||||
|
pnpm run test:secrets
|
||||||
|
pnpm run test:types
|
||||||
|
pnpm run check:boundary
|
||||||
|
|
||||||
(
|
(
|
||||||
cd example
|
cd example
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,3 +5,4 @@ node_modules/
|
||||||
dist/
|
dist/
|
||||||
*.tgz
|
*.tgz
|
||||||
wasm/pkg/
|
wasm/pkg/
|
||||||
|
web_client/
|
||||||
|
|
|
||||||
39
Cargo.lock
generated
39
Cargo.lock
generated
|
|
@ -37,6 +37,18 @@ dependencies = [
|
||||||
"subtle",
|
"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]]
|
[[package]]
|
||||||
name = "asn1-rs"
|
name = "asn1-rs"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
|
|
@ -150,6 +162,15 @@ version = "2.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
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]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
|
|
@ -465,6 +486,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.10.4",
|
"block-buffer 0.10.4",
|
||||||
"crypto-common 0.1.7",
|
"crypto-common 0.1.7",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1320,6 +1342,7 @@ dependencies = [
|
||||||
"mtp-crypto",
|
"mtp-crypto",
|
||||||
"mtp-type-map",
|
"mtp-type-map",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
|
"thiserror 2.0.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1345,7 +1368,7 @@ dependencies = [
|
||||||
"ml-dsa",
|
"ml-dsa",
|
||||||
"mlkem-tls",
|
"mlkem-tls",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.6.4",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -1360,6 +1383,7 @@ dependencies = [
|
||||||
name = "mtp-files"
|
name = "mtp-files"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
"mtp-crypto",
|
"mtp-crypto",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
|
|
@ -1388,6 +1412,7 @@ dependencies = [
|
||||||
"mtp-codec",
|
"mtp-codec",
|
||||||
"mtp-common",
|
"mtp-common",
|
||||||
"mtp-crypto",
|
"mtp-crypto",
|
||||||
|
"rand 0.10.2",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-native-certs",
|
"rustls-native-certs",
|
||||||
|
|
@ -1395,6 +1420,7 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"wtransport",
|
"wtransport",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1573,6 +1599,17 @@ dependencies = [
|
||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "pem"
|
name = "pem"
|
||||||
version = "3.0.6"
|
version = "3.0.6"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
# Pulls in `crypto` so the `Keyring` / `PublicKeyBundle` types are in scope.
|
||||||
files = ["dep:mtp-files", "crypto"]
|
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.
|
# HTTP/3 routing and WebTransport-based MTP hosting on one QUIC endpoint.
|
||||||
web-server = ["dep:mtp-webserver", "dep:mtp-host", "mtp-codec/registry", "transport"]
|
web-server = ["dep:mtp-webserver", "dep:mtp-host", "mtp-codec/registry", "transport"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ The `mtp` facade re-exports the following modules:
|
||||||
|
|
||||||
### Codec
|
### 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
|
### Transport
|
||||||
|
|
||||||
|
|
@ -90,7 +90,7 @@ The type-map build script reads YAML and generates `CommunicationType` and `Data
|
||||||
|
|
||||||
### Crypto
|
### 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
|
## Examples
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mtp-common = { version = "0.2.0", path = "../common" }
|
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-transport = { version = "0.2.0", path = "../transport" }
|
||||||
mtp-crypto = { version = "0.2.0", path = "../crypto", optional = true }
|
mtp-crypto = { version = "0.2.0", path = "../crypto", optional = true }
|
||||||
rand = "0.10.1"
|
rand = "0.10.1"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use mtp_codec::{CommunicationValue, Version};
|
use mtp_codec::{CommunicationValue, Version, registry::VersionedCodec};
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
use mtp_codec::{DataType, DataValue};
|
use mtp_codec::{DataType, DataValue};
|
||||||
use mtp_common::CommunicationError;
|
use mtp_common::CommunicationError;
|
||||||
|
|
@ -17,6 +17,7 @@ use crate::pipe::{PendingRequest, PipeDispatcher, run_dispatcher};
|
||||||
|
|
||||||
pub struct MTPConnection {
|
pub struct MTPConnection {
|
||||||
pub version: Version,
|
pub version: Version,
|
||||||
|
pub codec: VersionedCodec,
|
||||||
pub sender: mtp_transport::Sender,
|
pub sender: mtp_transport::Sender,
|
||||||
pub receiver: mtp_transport::Receiver,
|
pub receiver: mtp_transport::Receiver,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
|
@ -45,12 +46,19 @@ impl MTPConnection {
|
||||||
request: &CommunicationValue,
|
request: &CommunicationValue,
|
||||||
expected_response: Option<mtp_codec::CommunicationType>,
|
expected_response: Option<mtp_codec::CommunicationType>,
|
||||||
) -> Result<CommunicationValue, CommunicationError> {
|
) -> 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 {
|
if request_id == 0 {
|
||||||
return Err(CommunicationError::Other(
|
return Err(CommunicationError::Other(
|
||||||
"request frame must have a non-zero id".into(),
|
"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 (sender, receiver) = tokio::sync::oneshot::channel();
|
||||||
let token = Arc::new(());
|
let token = Arc::new(());
|
||||||
|
|
@ -86,7 +94,7 @@ impl MTPConnection {
|
||||||
result?
|
result?
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
crate::pipe::remove_pending_request(&self.pipe_dispatcher, request_id, &token)
|
crate::pipe::expire_pending_request(&self.pipe_dispatcher, request_id, &token)
|
||||||
.await;
|
.await;
|
||||||
return Err(CommunicationError::Other(format!(
|
return Err(CommunicationError::Other(format!(
|
||||||
"request {request_id} timed out after {:?}",
|
"request {request_id} timed out after {:?}",
|
||||||
|
|
@ -96,7 +104,7 @@ impl MTPConnection {
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(expected) = expected_response {
|
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 {
|
if Some(response.get_type()) != expected_type {
|
||||||
return Err(CommunicationError::Other(format!(
|
return Err(CommunicationError::Other(format!(
|
||||||
"unexpected response type: expected {:?}, got {:?}; parsed {}",
|
"unexpected response type: expected {:?}, got {:?}; parsed {}",
|
||||||
|
|
@ -125,23 +133,35 @@ impl MTPConnection {
|
||||||
&self,
|
&self,
|
||||||
description: &str,
|
description: &str,
|
||||||
) -> Result<crate::pipe::PipeHandle, mtp_common::PipeError> {
|
) -> Result<crate::pipe::PipeHandle, mtp_common::PipeError> {
|
||||||
let pipe_id = rand::random::<u32>();
|
|
||||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
let pipe_id = {
|
||||||
{
|
|
||||||
let mut pending = self.pipe_dispatcher.pending_creations.lock().await;
|
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);
|
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 {
|
Ok(crate::pipe::PipeHandle {
|
||||||
pipe_id,
|
pipe_id,
|
||||||
description: description.to_string(),
|
description: description.to_string(),
|
||||||
|
|
@ -164,11 +184,26 @@ pub(crate) async fn connection_from_parts(
|
||||||
sender: mtp_transport::Sender,
|
sender: mtp_transport::Sender,
|
||||||
receiver: mtp_transport::Receiver,
|
receiver: mtp_transport::Receiver,
|
||||||
version: Version,
|
version: Version,
|
||||||
|
codec: VersionedCodec,
|
||||||
#[cfg(feature = "crypto")] auth_state: AuthState,
|
#[cfg(feature = "crypto")] auth_state: AuthState,
|
||||||
#[cfg(feature = "crypto")] client_id: u64,
|
#[cfg(feature = "crypto")] client_id: u64,
|
||||||
) -> MTPConnection {
|
) -> 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 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")]
|
#[cfg(feature = "pipes")]
|
||||||
{
|
{
|
||||||
|
|
@ -180,6 +215,9 @@ pub(crate) async fn connection_from_parts(
|
||||||
|
|
||||||
let dispatcher = Arc::new(PipeDispatcher {
|
let dispatcher = Arc::new(PipeDispatcher {
|
||||||
pending_requests: Mutex::new(std::collections::HashMap::new()),
|
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_creations: Mutex::new(std::collections::HashMap::new()),
|
||||||
pending_pipes: Mutex::new(std::collections::HashMap::new()),
|
pending_pipes: Mutex::new(std::collections::HashMap::new()),
|
||||||
policy: Arc::new(config.policy),
|
policy: Arc::new(config.policy),
|
||||||
|
|
@ -197,6 +235,7 @@ pub(crate) async fn connection_from_parts(
|
||||||
|
|
||||||
MTPConnection {
|
MTPConnection {
|
||||||
version,
|
version,
|
||||||
|
codec,
|
||||||
sender,
|
sender,
|
||||||
receiver,
|
receiver,
|
||||||
app_rx: Mutex::new(app_rx),
|
app_rx: Mutex::new(app_rx),
|
||||||
|
|
@ -221,11 +260,15 @@ pub(crate) async fn connection_from_parts(
|
||||||
);
|
);
|
||||||
let dispatcher = Arc::new(PipeDispatcher {
|
let dispatcher = Arc::new(PipeDispatcher {
|
||||||
pending_requests: Mutex::new(std::collections::HashMap::new()),
|
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()));
|
let task = tokio::spawn(run_dispatcher(receiver.clone(), app_tx, dispatcher.clone()));
|
||||||
|
|
||||||
MTPConnection {
|
MTPConnection {
|
||||||
version,
|
version,
|
||||||
|
codec,
|
||||||
sender,
|
sender,
|
||||||
receiver,
|
receiver,
|
||||||
app_rx: Mutex::new(app_rx),
|
app_rx: Mutex::new(app_rx),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
use mtp_common::CommunicationError;
|
||||||
|
|
||||||
pub(crate) fn unexpected_response_type_error(
|
pub(crate) fn unexpected_response_type_error(
|
||||||
|
|
@ -24,7 +24,7 @@ pub(crate) async fn verify_host_challenge(
|
||||||
use mtp_crypto::{auth, verify_ed25519};
|
use mtp_crypto::{auth, verify_ed25519};
|
||||||
|
|
||||||
let sig = match challenge.get_data(DataType::Signature) {
|
let sig = match challenge.get_data(DataType::Signature) {
|
||||||
DataValue::Bytes(b) => b.clone(),
|
Some(DataValue::Bytes(b)) => b.clone(),
|
||||||
_ => {
|
_ => {
|
||||||
return Err(CommunicationError::AuthenticationFailed(
|
return Err(CommunicationError::AuthenticationFailed(
|
||||||
"Missing host challenge signature".into(),
|
"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) {
|
let pq_sig = match challenge.get_data(DataType::PqSignature) {
|
||||||
DataValue::Bytes(b) => b.clone(),
|
Some(DataValue::Bytes(b)) => b.clone(),
|
||||||
_ => vec![],
|
_ => 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() {
|
if host_requires_pq && host_pk.sig_pq_public_key.as_bytes().is_empty() {
|
||||||
return Err(CommunicationError::AuthenticationFailed(
|
return Err(CommunicationError::AuthenticationFailed(
|
||||||
"Host requires post-quantum authentication but its PQ public key is absent".into(),
|
"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};
|
use mtp_crypto::{auth, verify_ed25519};
|
||||||
|
|
||||||
match response.get_data(DataType::ClientNonce) {
|
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(
|
return Err(CommunicationError::AuthenticationFailed(
|
||||||
"Nonce mismatch".into(),
|
"Nonce mismatch".into(),
|
||||||
|
|
@ -89,7 +89,7 @@ pub(crate) async fn verify_host_final(
|
||||||
}
|
}
|
||||||
|
|
||||||
let sig = match response.get_data(DataType::Signature) {
|
let sig = match response.get_data(DataType::Signature) {
|
||||||
DataValue::Bytes(b) => b.clone(),
|
Some(DataValue::Bytes(b)) => b.clone(),
|
||||||
_ => {
|
_ => {
|
||||||
return Err(CommunicationError::AuthenticationFailed(
|
return Err(CommunicationError::AuthenticationFailed(
|
||||||
"Missing signature".into(),
|
"Missing signature".into(),
|
||||||
|
|
@ -97,7 +97,7 @@ pub(crate) async fn verify_host_final(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let pq_sig = match response.get_data(DataType::PqSignature) {
|
let pq_sig = match response.get_data(DataType::PqSignature) {
|
||||||
DataValue::Bytes(b) => b.clone(),
|
Some(DataValue::Bytes(b)) => b.clone(),
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
};
|
};
|
||||||
if require_pq && pq_sig.is_empty() {
|
if require_pq && pq_sig.is_empty() {
|
||||||
|
|
@ -130,8 +130,8 @@ pub(crate) fn check_connected(
|
||||||
reject_msg: &str,
|
reject_msg: &str,
|
||||||
) -> Result<(), CommunicationError> {
|
) -> Result<(), CommunicationError> {
|
||||||
match response.get_data(DataType::Connected) {
|
match response.get_data(DataType::Connected) {
|
||||||
DataValue::BoolTrue => Ok(()),
|
Some(DataValue::BoolTrue) => Ok(()),
|
||||||
DataValue::BoolFalse => Err(CommunicationError::AuthenticationFailed(
|
Some(DataValue::BoolFalse) => Err(CommunicationError::AuthenticationFailed(
|
||||||
response
|
response
|
||||||
.get_str(DataType::ErrorMessage)
|
.get_str(DataType::ErrorMessage)
|
||||||
.unwrap_or(reject_msg)
|
.unwrap_or(reject_msg)
|
||||||
|
|
@ -147,7 +147,7 @@ pub(crate) fn negotiated_version(
|
||||||
response: &CommunicationValue,
|
response: &CommunicationValue,
|
||||||
) -> Result<Version, CommunicationError> {
|
) -> Result<Version, CommunicationError> {
|
||||||
match response.get_data(DataType::Version) {
|
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(
|
CommunicationError::AuthenticationFailed(
|
||||||
"Host returned an invalid negotiated protocol version".into(),
|
"Host returned an invalid negotiated protocol version".into(),
|
||||||
)
|
)
|
||||||
|
|
@ -162,16 +162,18 @@ pub(crate) async fn signed_challenge_response(
|
||||||
keys: &mtp_crypto::Keyring,
|
keys: &mtp_crypto::Keyring,
|
||||||
proof_payload: Vec<u8>,
|
proof_payload: Vec<u8>,
|
||||||
client_nonce: u128,
|
client_nonce: u128,
|
||||||
|
type_map: &TypeMap,
|
||||||
) -> Result<CommunicationValue, CommunicationError> {
|
) -> Result<CommunicationValue, CommunicationError> {
|
||||||
use mtp_crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme};
|
use mtp_crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme};
|
||||||
|
|
||||||
let signer = Ed25519Signer::new(&keys.sig_cl_secret_key)
|
let signer = Ed25519Signer::new(&keys.sig_cl_secret_key)
|
||||||
.map_err(|e| CommunicationError::Other(e.to_string()))?;
|
.map_err(|e| CommunicationError::Other(e.to_string()))?;
|
||||||
let mut proof = CommunicationValue::new(CommunicationType::ChallengeResponse)
|
let mut proof =
|
||||||
.add_typed_default(
|
CommunicationValue::new_with_type_map(CommunicationType::ChallengeResponse, type_map)
|
||||||
DataType::ClientNonce,
|
.add_typed_default(
|
||||||
DataValue::UnsignedNumber(client_nonce),
|
DataType::ClientNonce,
|
||||||
);
|
DataValue::UnsignedNumber(client_nonce),
|
||||||
|
);
|
||||||
|
|
||||||
if keys.sig_pq_secret_key.as_bytes().is_empty() {
|
if keys.sig_pq_secret_key.as_bytes().is_empty() {
|
||||||
let signature = signer
|
let signature = signer
|
||||||
|
|
@ -213,14 +215,14 @@ pub(crate) async fn receive_verified_challenge(
|
||||||
}
|
}
|
||||||
|
|
||||||
let server_challenge = match challenge.get_data(DataType::ServerNonce) {
|
let server_challenge = match challenge.get_data(DataType::ServerNonce) {
|
||||||
DataValue::UnsignedNumber(n) => *n,
|
Some(DataValue::UnsignedNumber(n)) => *n,
|
||||||
_ => {
|
_ => {
|
||||||
return Err(CommunicationError::AuthenticationFailed(
|
return Err(CommunicationError::AuthenticationFailed(
|
||||||
"Missing server challenge".into(),
|
"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(
|
return Err(CommunicationError::AuthenticationFailed(
|
||||||
"Host requires post-quantum authentication but the client PQ key is absent".into(),
|
"Host requires post-quantum authentication but the client PQ key is absent".into(),
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -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 mtp_common::{CommunicationError, HandshakeOutcome, RejectionReason};
|
||||||
|
|
||||||
use connection::connection_from_parts;
|
use connection::connection_from_parts;
|
||||||
|
|
||||||
fn parse_handshake_response(
|
fn parse_handshake_response(
|
||||||
response: &CommunicationValue,
|
response: &CommunicationValue,
|
||||||
|
type_map: &mtp_codec::TypeMap,
|
||||||
) -> Result<HandshakeOutcome, CommunicationError> {
|
) -> Result<HandshakeOutcome, CommunicationError> {
|
||||||
let tm = mtp_codec::TypeMap::latest();
|
let bad_version = mtp_codec::CommunicationType::ErrorBadVersion.try_to_id(type_map);
|
||||||
|
|
||||||
let bad_version = mtp_codec::CommunicationType::ErrorBadVersion.try_to_id(&tm);
|
|
||||||
if Some(response.get_type()) == bad_version {
|
if Some(response.get_type()) == bad_version {
|
||||||
let supported_versions = match response.get_data(DataType::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![],
|
_ => vec![],
|
||||||
};
|
};
|
||||||
return Ok(HandshakeOutcome::Rejected {
|
return Ok(HandshakeOutcome::Rejected {
|
||||||
|
|
@ -53,7 +55,7 @@ fn parse_handshake_response(
|
||||||
}
|
}
|
||||||
|
|
||||||
let expected = mtp_codec::CommunicationType::IdentificationResponse
|
let expected = mtp_codec::CommunicationType::IdentificationResponse
|
||||||
.try_to_id(&tm)
|
.try_to_id(type_map)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
CommunicationError::Other("IdentificationResponse is absent from the type map".into())
|
CommunicationError::Other("IdentificationResponse is absent from the type map".into())
|
||||||
})?;
|
})?;
|
||||||
|
|
@ -68,9 +70,9 @@ fn parse_handshake_response(
|
||||||
}
|
}
|
||||||
|
|
||||||
match response.get_data(DataType::Connected) {
|
match response.get_data(DataType::Connected) {
|
||||||
DataValue::BoolTrue => {
|
Some(DataValue::BoolTrue) => {
|
||||||
let version = match response.get_data(DataType::Version) {
|
let version = match response.get_data(DataType::Version) {
|
||||||
DataValue::Str(v) => v.clone(),
|
Some(DataValue::Str(v)) => v.clone(),
|
||||||
_ => {
|
_ => {
|
||||||
return Err(CommunicationError::Other(
|
return Err(CommunicationError::Other(
|
||||||
"host omitted the negotiated version".into(),
|
"host omitted the negotiated version".into(),
|
||||||
|
|
@ -78,15 +80,21 @@ fn parse_handshake_response(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let assigned_id = match response.get_data(DataType::Id) {
|
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(|_| {
|
||||||
_ => 0,
|
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 {
|
Ok(HandshakeOutcome::Accepted {
|
||||||
version,
|
version,
|
||||||
assigned_id,
|
assigned_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
DataValue::BoolFalse => {
|
Some(DataValue::BoolFalse) => {
|
||||||
let detail = response
|
let detail = response
|
||||||
.get_str(DataType::ErrorMessage)
|
.get_str(DataType::ErrorMessage)
|
||||||
.unwrap_or("host rejected the connection")
|
.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;
|
pub struct MTPClient;
|
||||||
|
|
||||||
impl MTPClient {
|
impl MTPClient {
|
||||||
pub async fn connect(config: ClientConfig) -> Result<MTPConnection, CommunicationError> {
|
pub async fn connect(config: ClientConfig) -> Result<MTPConnection, CommunicationError> {
|
||||||
let (sender, receiver) =
|
let (sender, receiver) =
|
||||||
mtp_transport::connect(&config.url, config.server_cert(), config.policy).await?;
|
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 version_str = format!("{}", PROTOCOL_VERSION);
|
||||||
let mut ident = CommunicationValue::new(mtp_codec::CommunicationType::Identification)
|
let mut ident = CommunicationValue::new_with_type_map(
|
||||||
.add_typed_default(DataType::Version, DataValue::Str(version_str))
|
mtp_codec::CommunicationType::Identification,
|
||||||
.add_typed_default(
|
opening_codec.type_map(),
|
||||||
DataType::Id,
|
)
|
||||||
DataValue::UnsignedNumber(config.client_id.into()),
|
.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 {
|
if let Some(desc) = &config.description {
|
||||||
ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone()));
|
ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone()));
|
||||||
}
|
}
|
||||||
|
|
@ -120,32 +142,47 @@ impl MTPClient {
|
||||||
sender.send(&ident).await?;
|
sender.send(&ident).await?;
|
||||||
|
|
||||||
let response = receiver.receive().await?;
|
let response = receiver.receive().await?;
|
||||||
let outcome = parse_handshake_response(&response)?;
|
let outcome = parse_handshake_response(&response, opening_codec.type_map())?;
|
||||||
let negotiated = match outcome {
|
let (negotiated, assigned_id) = match outcome {
|
||||||
mtp_common::HandshakeOutcome::Accepted { version, .. } => Version::parse(&version)
|
mtp_common::HandshakeOutcome::Accepted {
|
||||||
.ok_or_else(|| {
|
version,
|
||||||
|
assigned_id,
|
||||||
|
} => (
|
||||||
|
Version::parse(&version).ok_or_else(|| {
|
||||||
CommunicationError::Other("host returned an invalid negotiated version".into())
|
CommunicationError::Other("host returned an invalid negotiated version".into())
|
||||||
})?,
|
})?,
|
||||||
|
assigned_id,
|
||||||
|
),
|
||||||
mtp_common::HandshakeOutcome::Rejected { reason } => {
|
mtp_common::HandshakeOutcome::Rejected { reason } => {
|
||||||
sender.close().await;
|
sender.close().await;
|
||||||
return Err(CommunicationError::Other(reason.to_string()));
|
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")]
|
#[cfg(feature = "crypto")]
|
||||||
let client_id = config.client_id;
|
let client_id = assigned_id;
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
return Ok(connection_from_parts(
|
return Ok(connection_from_parts(
|
||||||
config,
|
config,
|
||||||
sender,
|
sender,
|
||||||
receiver,
|
receiver,
|
||||||
negotiated,
|
negotiated,
|
||||||
|
codec,
|
||||||
error::AuthState::Unauthenticated,
|
error::AuthState::Unauthenticated,
|
||||||
client_id,
|
client_id,
|
||||||
)
|
)
|
||||||
.await);
|
.await);
|
||||||
#[cfg(not(feature = "crypto"))]
|
#[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) =
|
let (sender, receiver) =
|
||||||
mtp_transport::connect(&config.url, config.server_cert(), config.policy).await?;
|
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 version_str = format!("{}", PROTOCOL_VERSION);
|
||||||
|
|
||||||
let mut ident = CommunicationValue::new(CommunicationType::Identification)
|
let mut ident =
|
||||||
.add_typed_default(DataType::Version, DataValue::Str(version_str.clone()))
|
CommunicationValue::new_with_type_map(CommunicationType::Identification, &tm)
|
||||||
.add_typed_default(
|
.add_typed_default(DataType::Version, DataValue::Str(version_str.clone()))
|
||||||
DataType::Id,
|
.add_typed_default(
|
||||||
DataValue::UnsignedNumber(config.client_id as u128),
|
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 {
|
if let Some(desc) = &config.description {
|
||||||
ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone()));
|
ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone()));
|
||||||
}
|
}
|
||||||
|
|
@ -223,14 +271,14 @@ impl MTPClient {
|
||||||
client_nonce,
|
client_nonce,
|
||||||
);
|
);
|
||||||
|
|
||||||
let proof = match crypto::signed_challenge_response(keys, proof_payload, client_nonce).await
|
let proof =
|
||||||
{
|
match crypto::signed_challenge_response(keys, proof_payload, client_nonce, &tm).await {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
sender.close().await;
|
sender.close().await;
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(e) = sender.send(&proof).await {
|
if let Err(e) = sender.send(&proof).await {
|
||||||
sender.close().await;
|
sender.close().await;
|
||||||
return Err(e);
|
return Err(e);
|
||||||
|
|
@ -276,12 +324,41 @@ impl MTPClient {
|
||||||
return Err(e);
|
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;
|
let client_id = config.client_id;
|
||||||
Ok(connection_from_parts(
|
Ok(connection_from_parts(
|
||||||
config,
|
config,
|
||||||
sender,
|
sender,
|
||||||
receiver,
|
receiver,
|
||||||
crypto::negotiated_version(&response)?,
|
negotiated,
|
||||||
|
codec,
|
||||||
error::AuthState::Authenticated,
|
error::AuthState::Authenticated,
|
||||||
client_id,
|
client_id,
|
||||||
)
|
)
|
||||||
|
|
@ -332,12 +409,15 @@ impl MTPClient {
|
||||||
let (sender, receiver) =
|
let (sender, receiver) =
|
||||||
mtp_transport::connect(&config.url, config.server_cert(), config.policy).await?;
|
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 version_str = format!("{}", PROTOCOL_VERSION);
|
||||||
let pk_bundle = keys.public_key_bundle();
|
let pk_bundle = keys.public_key_bundle();
|
||||||
let pk_bytes = pk_bundle.as_bytes();
|
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::Version, DataValue::Str(version_str.clone()))
|
||||||
.add_typed_default(DataType::PublicKeys, DataValue::Bytes(pk_bytes.clone()));
|
.add_typed_default(DataType::PublicKeys, DataValue::Bytes(pk_bytes.clone()));
|
||||||
if let Some(desc) = &config.description {
|
if let Some(desc) = &config.description {
|
||||||
|
|
@ -375,14 +455,14 @@ impl MTPClient {
|
||||||
client_nonce,
|
client_nonce,
|
||||||
);
|
);
|
||||||
|
|
||||||
let proof = match crypto::signed_challenge_response(keys, proof_payload, client_nonce).await
|
let proof =
|
||||||
{
|
match crypto::signed_challenge_response(keys, proof_payload, client_nonce, &tm).await {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
sender.close().await;
|
sender.close().await;
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(e) = sender.send(&proof).await {
|
if let Err(e) = sender.send(&proof).await {
|
||||||
sender.close().await;
|
sender.close().await;
|
||||||
return Err(e);
|
return Err(e);
|
||||||
|
|
@ -413,7 +493,11 @@ impl MTPClient {
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
let assigned_id = match response.get_data(DataType::Id) {
|
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;
|
sender.close().await;
|
||||||
return Err(CommunicationError::AuthenticationFailed(
|
return Err(CommunicationError::AuthenticationFailed(
|
||||||
|
|
@ -435,11 +519,20 @@ impl MTPClient {
|
||||||
return Err(e);
|
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(
|
Ok(connection_from_parts(
|
||||||
config,
|
config,
|
||||||
sender,
|
sender,
|
||||||
receiver,
|
receiver,
|
||||||
crypto::negotiated_version(&response)?,
|
negotiated,
|
||||||
|
codec,
|
||||||
error::AuthState::Authenticated,
|
error::AuthState::Authenticated,
|
||||||
assigned_id,
|
assigned_id,
|
||||||
)
|
)
|
||||||
|
|
@ -504,6 +597,9 @@ mod tests {
|
||||||
|
|
||||||
let dispatcher = pipe::PipeDispatcher {
|
let dispatcher = pipe::PipeDispatcher {
|
||||||
pending_requests: Mutex::new(HashMap::new()),
|
pending_requests: Mutex::new(HashMap::new()),
|
||||||
|
expired_requests: Mutex::new(HashMap::new()),
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
type_map: mtp_codec::TypeMap::latest(),
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
pending_creations: Mutex::new(HashMap::new()),
|
pending_creations: Mutex::new(HashMap::new()),
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
|
|
@ -523,11 +619,47 @@ mod tests {
|
||||||
|
|
||||||
let unrelated = CommunicationValue::new(mtp_codec::CommunicationType::Ping).with_id(8);
|
let unrelated = CommunicationValue::new(mtp_codec::CommunicationType::Ping).with_id(8);
|
||||||
assert!(pipe::route_message(unrelated, &app_tx, &dispatcher).await);
|
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);
|
let response = CommunicationValue::new(mtp_codec::CommunicationType::Pong).with_id(7);
|
||||||
assert!(pipe::route_message(response, &app_tx, &dispatcher).await);
|
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());
|
assert!(app_rx.try_recv().is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||||
use tokio::sync::{Mutex, mpsc};
|
use tokio::sync::{Mutex, mpsc};
|
||||||
use tokio::time::{Duration, Instant};
|
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};
|
use mtp_transport::{Receiver, Sender};
|
||||||
|
|
||||||
pub(crate) struct PingSession {
|
pub(crate) struct PingSession {
|
||||||
|
|
@ -59,6 +59,8 @@ pub(crate) async fn start_ping_session(
|
||||||
config: &crate::config::ClientConfig,
|
config: &crate::config::ClientConfig,
|
||||||
sender: Sender,
|
sender: Sender,
|
||||||
receiver: &Receiver,
|
receiver: &Receiver,
|
||||||
|
type_map: &TypeMap,
|
||||||
|
client_id: u64,
|
||||||
) -> Option<PingSession> {
|
) -> Option<PingSession> {
|
||||||
if config.ping_interval.is_zero() {
|
if config.ping_interval.is_zero() {
|
||||||
return None;
|
return None;
|
||||||
|
|
@ -72,6 +74,7 @@ pub(crate) async fn start_ping_session(
|
||||||
let ping_jitter = config.ping_jitter;
|
let ping_jitter = config.ping_jitter;
|
||||||
let max_missed_pings = config.max_missed_pings;
|
let max_missed_pings = config.max_missed_pings;
|
||||||
let ping_timestamp = config.ping_timestamp;
|
let ping_timestamp = config.ping_timestamp;
|
||||||
|
let type_map = type_map.clone();
|
||||||
let mut close_rx = receiver.handle().subscribe_close();
|
let mut close_rx = receiver.handle().subscribe_close();
|
||||||
|
|
||||||
let task = tokio::spawn(async move {
|
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;
|
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 {
|
if ping_timestamp {
|
||||||
let sent_at = std::time::SystemTime::now()
|
let sent_at = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|
@ -110,7 +117,10 @@ pub(crate) async fn start_ping_session(
|
||||||
DataValue::UnsignedNumber(sent_at),
|
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() {
|
if sender.send(&ping).await.is_err() {
|
||||||
sender.close().await;
|
sender.close().await;
|
||||||
break;
|
break;
|
||||||
|
|
@ -119,7 +129,9 @@ pub(crate) async fn start_ping_session(
|
||||||
}
|
}
|
||||||
pong = pong_rx.recv() => match pong {
|
pong = pong_rx.recv() => match pong {
|
||||||
Some(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;
|
let mut last_ping = ping_state.lock().await;
|
||||||
*last_ping = Some(ping);
|
*last_ping = Some(ping);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
use mtp_codec::CommunicationValue;
|
use mtp_codec::CommunicationValue;
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
use mtp_codec::TypeMap;
|
||||||
use mtp_common::CommunicationError;
|
use mtp_common::CommunicationError;
|
||||||
use mtp_transport::Receiver;
|
use mtp_transport::Receiver;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{Mutex, mpsc};
|
use tokio::sync::{Mutex, mpsc};
|
||||||
|
use tokio::time::{Duration, Instant};
|
||||||
|
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
use mtp_codec::{CommunicationType, DataType, DataValue};
|
use mtp_codec::{CommunicationType, DataType, DataValue};
|
||||||
|
|
@ -72,22 +75,50 @@ impl PipeRequest {
|
||||||
pending.insert(self.pipe_id, pipe_tx);
|
pending.insert(self.pipe_id, pipe_tx);
|
||||||
}
|
}
|
||||||
|
|
||||||
let resp = CommunicationValue::new(CommunicationType::PipeResponse)
|
let resp = CommunicationValue::new_with_type_map(
|
||||||
.with_id(self.pipe_id)
|
CommunicationType::PipeResponse,
|
||||||
.add_typed_default(DataType::Accepted, DataValue::BoolTrue);
|
&self.dispatcher.type_map,
|
||||||
self.sender.send(&resp).await.map_err(PipeError::from)?;
|
)
|
||||||
|
.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;
|
let timeout = self.dispatcher.policy.read_timeout;
|
||||||
tokio::time::timeout(timeout, pipe_rx)
|
match tokio::time::timeout(timeout, pipe_rx).await {
|
||||||
.await
|
Ok(Ok(reader)) => Ok(reader),
|
||||||
.map_err(|_| PipeError::HandshakeTimeout)?
|
Ok(Err(_)) => {
|
||||||
.map_err(|_| PipeError::StreamClosed)
|
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> {
|
pub async fn deny(self) -> Result<(), PipeError> {
|
||||||
let resp = CommunicationValue::new(CommunicationType::PipeResponse)
|
let resp = CommunicationValue::new_with_type_map(
|
||||||
.with_id(self.pipe_id)
|
CommunicationType::PipeResponse,
|
||||||
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
|
&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)?;
|
self.sender.send(&resp).await.map_err(PipeError::from)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -100,6 +131,9 @@ pub(crate) struct PendingRequest {
|
||||||
|
|
||||||
pub(crate) struct PipeDispatcher {
|
pub(crate) struct PipeDispatcher {
|
||||||
pub(crate) pending_requests: Mutex<HashMap<u32, PendingRequest>>,
|
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")]
|
#[cfg(feature = "pipes")]
|
||||||
pub(crate) pending_creations:
|
pub(crate) pending_creations:
|
||||||
Mutex<HashMap<u32, tokio::sync::oneshot::Sender<Result<bool, PipeError>>>>,
|
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>>,
|
app_tx: &mpsc::Sender<Result<CommunicationValue, CommunicationError>>,
|
||||||
dispatcher: &PipeDispatcher,
|
dispatcher: &PipeDispatcher,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let pending = dispatcher
|
if !matches!(msg.id(), Some(id) if id != 0)
|
||||||
.pending_requests
|
&& msg
|
||||||
.lock()
|
.get_type_name()
|
||||||
.await
|
.is_some_and(|name| name.ends_with("Response"))
|
||||||
.remove(&msg.get_id());
|
{
|
||||||
if let Some(tx) = pending {
|
return app_tx
|
||||||
let _ = tx.sender.send(Ok(msg));
|
.send(Err(CommunicationError::Other(
|
||||||
return true;
|
"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()
|
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(
|
pub(crate) async fn remove_pending_request(
|
||||||
dispatcher: &PipeDispatcher,
|
dispatcher: &PipeDispatcher,
|
||||||
request_id: u32,
|
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")]
|
#[cfg(feature = "pipes")]
|
||||||
pub(crate) async fn run_dispatcher(
|
pub(crate) async fn run_dispatcher(
|
||||||
receiver: Receiver,
|
receiver: Receiver,
|
||||||
|
|
@ -157,14 +249,19 @@ pub(crate) async fn run_dispatcher(
|
||||||
pipe_req_tx: mpsc::Sender<PipeRequest>,
|
pipe_req_tx: mpsc::Sender<PipeRequest>,
|
||||||
dispatcher: Arc<PipeDispatcher>,
|
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 {
|
loop {
|
||||||
match receiver.receive_event().await {
|
match receiver.receive_event().await {
|
||||||
Ok(mtp_transport::TransportEvent::Message(msg)) => {
|
Ok(mtp_transport::TransportEvent::Message(msg)) => {
|
||||||
if Some(msg.get_type()) == pipe_req_type {
|
if msg.is_type(CommunicationType::PipeRequest) {
|
||||||
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(),
|
||||||
|
);
|
||||||
|
if app_tx.send(Err(error)).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let description = msg.get_str(DataType::Description).unwrap_or("").to_string();
|
let description = msg.get_str(DataType::Description).unwrap_or("").to_string();
|
||||||
let req = PipeRequest {
|
let req = PipeRequest {
|
||||||
pipe_id,
|
pipe_id,
|
||||||
|
|
@ -176,8 +273,16 @@ pub(crate) async fn run_dispatcher(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if Some(msg.get_type()) == pipe_resp_type {
|
if msg.is_type(CommunicationType::PipeResponse) {
|
||||||
let pipe_id = msg.get_id();
|
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 accepted = msg.get_bool(DataType::Accepted).unwrap_or(false);
|
||||||
let mut pending = dispatcher.pending_creations.lock().await;
|
let mut pending = dispatcher.pending_creations.lock().await;
|
||||||
if let Some(tx) = pending.remove(&pipe_id) {
|
if let Some(tx) = pending.remove(&pipe_id) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ mtp-crypto = { version = "0.2.0", path = "../crypto", optional = true }
|
||||||
base64 = "0.23"
|
base64 = "0.23"
|
||||||
byteorder = "1.5"
|
byteorder = "1.5"
|
||||||
rand = { version = "0.10.1", features = ["std", "std_rng"] }
|
rand = { version = "0.10.1", features = ["std", "std_rng"] }
|
||||||
|
thiserror = "2.0.18"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
registry = ["mtp-type-map/registry"]
|
registry = ["mtp-type-map/registry"]
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,11 +1,31 @@
|
||||||
pub mod communication_value;
|
pub mod communication_value;
|
||||||
pub mod data_value;
|
pub mod data_value;
|
||||||
|
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
pub use communication_value::EncryptedPayload;
|
pub mod protected;
|
||||||
pub use communication_value::{CommunicationValue, MAX_WIRE_ID};
|
#[cfg(feature = "crypto")]
|
||||||
pub use data_value::{DataKind, DataValue};
|
pub mod relay;
|
||||||
pub use mtp_common::CodecError;
|
|
||||||
|
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::{
|
pub use mtp_type_map::{
|
||||||
CommunicationType, CommunicationTypeId, DataType, DataTypeId, PROTOCOL_VERSION, TypeMap,
|
CommunicationType, CommunicationTypeId, DataType, DataTypeId, PROTOCOL_VERSION, TypeMap,
|
||||||
|
|
@ -13,7 +33,12 @@ pub use mtp_type_map::{
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) fn rand_u32() -> u32 {
|
pub(crate) fn rand_u32() -> u32 {
|
||||||
rand::random()
|
loop {
|
||||||
|
let value = rand::random();
|
||||||
|
if value != 0 {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "registry")]
|
#[cfg(feature = "registry")]
|
||||||
|
|
|
||||||
1186
codec/src/protected.rs
Normal file
1186
codec/src/protected.rs
Normal file
File diff suppressed because it is too large
Load diff
1280
codec/src/relay.rs
Normal file
1280
codec/src/relay.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,32 @@
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
/// Errors returned when the system clock cannot be represented as MTP time.
|
||||||
|
#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum TimeError {
|
||||||
|
#[error("system clock is before the Unix epoch")]
|
||||||
|
BeforeUnixEpoch,
|
||||||
|
#[error("Unix epoch milliseconds exceed the u64 range")]
|
||||||
|
OutOfRange,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duration_to_unix_time_millis(duration: Duration) -> Result<u64, TimeError> {
|
||||||
|
u64::try_from(duration.as_millis()).map_err(|_| TimeError::OutOfRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the current Unix time in milliseconds.
|
||||||
|
///
|
||||||
|
/// MTP protocol fields that use `CreatedAt` store this value as an unsigned
|
||||||
|
/// integer. The conversion is centralized here so native writers do not
|
||||||
|
/// accidentally use seconds.
|
||||||
|
pub fn unix_time_millis() -> Result<u64, TimeError> {
|
||||||
|
let duration = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|_| TimeError::BeforeUnixEpoch)?;
|
||||||
|
duration_to_unix_time_millis(duration)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||||
pub enum CodecError {
|
pub enum CodecError {
|
||||||
#[error("Unknown version")]
|
#[error("Unknown version")]
|
||||||
|
|
@ -25,6 +52,24 @@ pub enum CodecError {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unix_time_millis_preserves_subsecond_precision() {
|
||||||
|
let duration = Duration::new(1_786_449_600, 123_000_000);
|
||||||
|
assert_eq!(
|
||||||
|
duration_to_unix_time_millis(duration),
|
||||||
|
Ok(1_786_449_600_123)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unix_time_millis_rejects_values_outside_u64() {
|
||||||
|
let duration = Duration::new(u64::MAX, 0);
|
||||||
|
assert_eq!(
|
||||||
|
duration_to_unix_time_millis(duration),
|
||||||
|
Err(TimeError::OutOfRange)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_codec_error_display() {
|
fn test_codec_error_display() {
|
||||||
let e = CodecError::InvalidEncoding;
|
let e = CodecError::InvalidEncoding;
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,9 @@ ed25519-dalek = { version = "3.0", optional = true, features = [
|
||||||
hkdf = { version = "0.13", optional = true }
|
hkdf = { version = "0.13", optional = true }
|
||||||
sha2 = { version = "0.11", optional = true }
|
sha2 = { version = "0.11", optional = true }
|
||||||
zeroize = { version = "1.9", features = ["derive"] }
|
zeroize = { version = "1.9", features = ["derive"] }
|
||||||
thiserror = "2"
|
thiserror = "1"
|
||||||
base64 = "0.23"
|
base64 = "0.22"
|
||||||
rand_core = { version = "0.10.1" }
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
rand = "0.10.2"
|
rand = "0.10.2"
|
||||||
getrandom = "0.4.3"
|
getrandom = "0.4.3"
|
||||||
mlkem-tls = { version = "0.2", optional = true }
|
mlkem-tls = { version = "0.2", optional = true }
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,15 @@ use zeroize::Zeroizing;
|
||||||
#[cfg(any(feature = "chacha20poly1305", feature = "aes-gcm"))]
|
#[cfg(any(feature = "chacha20poly1305", feature = "aes-gcm"))]
|
||||||
use getrandom::fill;
|
use getrandom::fill;
|
||||||
|
|
||||||
|
/// Authentication-tag length shared by the supported AEAD constructions.
|
||||||
|
pub const AUTH_TAG_LEN: usize = 16;
|
||||||
|
|
||||||
|
/// Nonce length stored at the front of an XChaCha20-Poly1305 output.
|
||||||
|
pub const XCHACHA20POLY1305_NONCE_LEN: usize = 24;
|
||||||
|
|
||||||
|
/// Nonce length stored at the front of an AES-256-GCM output.
|
||||||
|
pub const AES256GCM_NONCE_LEN: usize = 12;
|
||||||
|
|
||||||
pub trait AeadEncrypt {
|
pub trait AeadEncrypt {
|
||||||
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError>;
|
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError>;
|
||||||
}
|
}
|
||||||
|
|
@ -27,12 +36,12 @@ fn prepend_nonce(nonce: &[u8], ciphertext: &mut Vec<u8>) -> Vec<u8> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
pub struct ChaCha20Poly1305 {
|
pub struct XChaCha20Poly1305 {
|
||||||
key: Zeroizing<[u8; 32]>,
|
key: Zeroizing<[u8; 32]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
impl ChaCha20Poly1305 {
|
impl XChaCha20Poly1305 {
|
||||||
pub fn new(key: [u8; 32]) -> Self {
|
pub fn new(key: [u8; 32]) -> Self {
|
||||||
Self {
|
Self {
|
||||||
key: Zeroizing::new(key),
|
key: Zeroizing::new(key),
|
||||||
|
|
@ -41,7 +50,7 @@ impl ChaCha20Poly1305 {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
impl AeadEncrypt for ChaCha20Poly1305 {
|
impl AeadEncrypt for XChaCha20Poly1305 {
|
||||||
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
fn encrypt(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
use chacha20poly1305::XChaCha20Poly1305;
|
use chacha20poly1305::XChaCha20Poly1305;
|
||||||
use chacha20poly1305::XNonce;
|
use chacha20poly1305::XNonce;
|
||||||
|
|
@ -50,7 +59,7 @@ impl AeadEncrypt for ChaCha20Poly1305 {
|
||||||
let key = chacha20poly1305::Key::from_slice(self.key.as_ref());
|
let key = chacha20poly1305::Key::from_slice(self.key.as_ref());
|
||||||
let cipher = XChaCha20Poly1305::new(key);
|
let cipher = XChaCha20Poly1305::new(key);
|
||||||
|
|
||||||
let mut nonce = [0u8; 24];
|
let mut nonce = [0u8; XCHACHA20POLY1305_NONCE_LEN];
|
||||||
fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?;
|
fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?;
|
||||||
let nonce_ref = XNonce::from_slice(&nonce);
|
let nonce_ref = XNonce::from_slice(&nonce);
|
||||||
|
|
||||||
|
|
@ -68,17 +77,17 @@ impl AeadEncrypt for ChaCha20Poly1305 {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
impl AeadDecrypt for ChaCha20Poly1305 {
|
impl AeadDecrypt for XChaCha20Poly1305 {
|
||||||
fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
fn decrypt(&self, ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
use chacha20poly1305::XChaCha20Poly1305;
|
use chacha20poly1305::XChaCha20Poly1305;
|
||||||
use chacha20poly1305::XNonce;
|
use chacha20poly1305::XNonce;
|
||||||
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
||||||
|
|
||||||
if ciphertext.len() < 24 {
|
if ciphertext.len() < XCHACHA20POLY1305_NONCE_LEN + AUTH_TAG_LEN {
|
||||||
return Err(CryptoError::InvalidNonceLength);
|
return Err(CryptoError::InvalidNonceLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (nonce, ct) = ciphertext.split_at(24);
|
let (nonce, ct) = ciphertext.split_at(XCHACHA20POLY1305_NONCE_LEN);
|
||||||
let key = chacha20poly1305::Key::from_slice(self.key.as_ref());
|
let key = chacha20poly1305::Key::from_slice(self.key.as_ref());
|
||||||
let cipher = XChaCha20Poly1305::new(key);
|
let cipher = XChaCha20Poly1305::new(key);
|
||||||
let nonce_ref = XNonce::from_slice(nonce);
|
let nonce_ref = XNonce::from_slice(nonce);
|
||||||
|
|
@ -92,12 +101,17 @@ impl AeadDecrypt for ChaCha20Poly1305 {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
impl AeadCipher for ChaCha20Poly1305 {
|
impl AeadCipher for XChaCha20Poly1305 {
|
||||||
fn key_size() -> usize {
|
fn key_size() -> usize {
|
||||||
32
|
32
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compatibility alias for the original public name. The implementation is
|
||||||
|
/// XChaCha20-Poly1305, including its 24-byte nonce format.
|
||||||
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
|
pub type ChaCha20Poly1305 = XChaCha20Poly1305;
|
||||||
|
|
||||||
#[cfg(feature = "aes-gcm")]
|
#[cfg(feature = "aes-gcm")]
|
||||||
pub struct Aes256Gcm {
|
pub struct Aes256Gcm {
|
||||||
key: Zeroizing<[u8; 32]>,
|
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 key = aes_gcm::Key::<AesGcmInner>::from_slice(self.key.as_ref());
|
||||||
let cipher = AesGcmInner::new(key);
|
let cipher = AesGcmInner::new(key);
|
||||||
|
|
||||||
let mut nonce = [0u8; 12];
|
let mut nonce = [0u8; AES256GCM_NONCE_LEN];
|
||||||
fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?;
|
fill(&mut nonce).map_err(|_| CryptoError::EncryptionFailed)?;
|
||||||
let nonce_ref = Nonce::from_slice(&nonce);
|
let nonce_ref = Nonce::from_slice(&nonce);
|
||||||
|
|
||||||
|
|
@ -146,11 +160,11 @@ impl AeadDecrypt for Aes256Gcm {
|
||||||
use aes_gcm::Nonce;
|
use aes_gcm::Nonce;
|
||||||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||||||
|
|
||||||
if ciphertext.len() < 12 {
|
if ciphertext.len() < AES256GCM_NONCE_LEN + AUTH_TAG_LEN {
|
||||||
return Err(CryptoError::InvalidNonceLength);
|
return Err(CryptoError::InvalidNonceLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (nonce, ct) = ciphertext.split_at(12);
|
let (nonce, ct) = ciphertext.split_at(AES256GCM_NONCE_LEN);
|
||||||
let key = aes_gcm::Key::<AesGcmInner>::from_slice(self.key.as_ref());
|
let key = aes_gcm::Key::<AesGcmInner>::from_slice(self.key.as_ref());
|
||||||
let cipher = AesGcmInner::new(key);
|
let cipher = AesGcmInner::new(key);
|
||||||
let nonce_ref = Nonce::from_slice(nonce);
|
let nonce_ref = Nonce::from_slice(nonce);
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,15 @@
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
use crate::error::CryptoError;
|
use crate::error::CryptoError;
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
#[cfg(feature = "mlkem-tls")]
|
||||||
use crate::kdf::derive_encryption_key;
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
|
||||||
use crate::kem::HybridKem;
|
use crate::kem::HybridKem;
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
|
||||||
use crate::keypair::{Keyring, PublicKeyBundle};
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Algorithm selector for encrypted containers.
|
* Algorithm selector for encrypted values.
|
||||||
*
|
*
|
||||||
* Mirrors `SigAlgorithm` for signatures: a single marking byte identifies the
|
* Mirrors `SigAlgorithm` for signatures: a single marking byte identifies the
|
||||||
* key-encapsulation mechanism and the AEAD used to seal a container. The byte
|
* key-encapsulation mechanism and the AEAD used to seal a container. The byte
|
||||||
* is stored as the first byte of every encrypted blob so the decryptor can pick
|
* is stored as the first byte of every encrypted envelope so the decryptor can pick
|
||||||
* the matching algorithm (and the matching keypair from a `Keyring`) without
|
* the matching algorithm (and the matching keypair from a `Keyring`) without
|
||||||
* any out-of-band agreement.
|
* any out-of-band agreement.
|
||||||
*
|
*
|
||||||
|
|
@ -34,7 +30,7 @@ impl EncryptionType {
|
||||||
pub const ML_KEM_CHACHA20POLY1305: u8 = 0x01;
|
pub const ML_KEM_CHACHA20POLY1305: u8 = 0x01;
|
||||||
pub const ML_KEM_AES256_GCM: u8 = 0x02;
|
pub const ML_KEM_AES256_GCM: u8 = 0x02;
|
||||||
|
|
||||||
/// The marking byte written at the front of an encrypted blob.
|
/// The marking byte written at the front of an encrypted envelope.
|
||||||
pub const fn to_byte(self) -> u8 {
|
pub const fn to_byte(self) -> u8 {
|
||||||
match self {
|
match self {
|
||||||
Self::MlKemChaCha20Poly1305 => Self::ML_KEM_CHACHA20POLY1305,
|
Self::MlKemChaCha20Poly1305 => Self::ML_KEM_CHACHA20POLY1305,
|
||||||
|
|
@ -50,6 +46,50 @@ impl EncryptionType {
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Size of the content-encryption key wrapped for each recipient.
|
||||||
|
pub const CONTENT_ENCRYPTION_KEY_LEN: usize = 32;
|
||||||
|
|
||||||
|
/// The fixed-size ciphertext emitted by the KEM selected by this suite.
|
||||||
|
pub const fn kem_ciphertext_len(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::MlKemChaCha20Poly1305 | Self::MlKemAes256Gcm => {
|
||||||
|
#[cfg(feature = "mlkem-tls")]
|
||||||
|
{
|
||||||
|
HybridKem::ciphertext_len()
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "mlkem-tls"))]
|
||||||
|
{
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes the selected AEAD prepends/appends to an encrypted payload.
|
||||||
|
pub const fn aead_overhead(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::MlKemChaCha20Poly1305 => {
|
||||||
|
crate::aead::XCHACHA20POLY1305_NONCE_LEN + crate::aead::AUTH_TAG_LEN
|
||||||
|
}
|
||||||
|
Self::MlKemAes256Gcm => crate::aead::AES256GCM_NONCE_LEN + crate::aead::AUTH_TAG_LEN,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total output length for an encrypted plaintext of `plaintext_len` bytes.
|
||||||
|
pub const fn encrypted_len(self, plaintext_len: usize) -> usize {
|
||||||
|
plaintext_len.saturating_add(self.aead_overhead())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimum valid AEAD output length for this suite.
|
||||||
|
pub const fn minimum_ciphertext_len(self) -> usize {
|
||||||
|
self.encrypted_len(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The size of a wrapped 32-byte content key for this suite.
|
||||||
|
pub const fn wrapped_key_len(self) -> usize {
|
||||||
|
self.encrypted_len(Self::CONTENT_ENCRYPTION_KEY_LEN)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -59,7 +99,7 @@ impl EncryptionType {
|
||||||
*/
|
*/
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
fn aead_seal(
|
pub fn seal_with_key(
|
||||||
enc_type: EncryptionType,
|
enc_type: EncryptionType,
|
||||||
key: [u8; 32],
|
key: [u8; 32],
|
||||||
plaintext: &[u8],
|
plaintext: &[u8],
|
||||||
|
|
@ -70,7 +110,7 @@ fn aead_seal(
|
||||||
match enc_type {
|
match enc_type {
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
EncryptionType::MlKemChaCha20Poly1305 => {
|
EncryptionType::MlKemChaCha20Poly1305 => {
|
||||||
crate::aead::ChaCha20Poly1305::new(key).encrypt(plaintext, aad)
|
crate::aead::XChaCha20Poly1305::new(key).encrypt(plaintext, aad)
|
||||||
}
|
}
|
||||||
#[cfg(feature = "aes-gcm")]
|
#[cfg(feature = "aes-gcm")]
|
||||||
EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).encrypt(plaintext, aad),
|
EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).encrypt(plaintext, aad),
|
||||||
|
|
@ -86,7 +126,7 @@ fn aead_seal(
|
||||||
*/
|
*/
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
fn aead_open(
|
pub fn open_with_key(
|
||||||
enc_type: EncryptionType,
|
enc_type: EncryptionType,
|
||||||
key: [u8; 32],
|
key: [u8; 32],
|
||||||
ciphertext: &[u8],
|
ciphertext: &[u8],
|
||||||
|
|
@ -97,7 +137,7 @@ fn aead_open(
|
||||||
match enc_type {
|
match enc_type {
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
EncryptionType::MlKemChaCha20Poly1305 => {
|
EncryptionType::MlKemChaCha20Poly1305 => {
|
||||||
crate::aead::ChaCha20Poly1305::new(key).decrypt(ciphertext, aad)
|
crate::aead::XChaCha20Poly1305::new(key).decrypt(ciphertext, aad)
|
||||||
}
|
}
|
||||||
#[cfg(feature = "aes-gcm")]
|
#[cfg(feature = "aes-gcm")]
|
||||||
EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).decrypt(ciphertext, aad),
|
EncryptionType::MlKemAes256Gcm => crate::aead::Aes256Gcm::new(key).decrypt(ciphertext, aad),
|
||||||
|
|
@ -106,76 +146,51 @@ fn aead_open(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
|
||||||
const ENC_KDF_SALT: &[u8] = b"mtp-container-enc";
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
|
||||||
const ENC_KDF_CONTEXT: &[u8] = b"single-recipient";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Encrypt `plaintext` for a single recipient, selecting the algorithm with
|
|
||||||
* `enc_type` and the recipient's KEM public key from `recipient`.
|
|
||||||
*
|
|
||||||
* The returned, self-describing blob is laid out as:
|
|
||||||
* [1 byte EncryptionType] [2 bytes u16 kem_ct_len] [kem_ciphertext] [aead_payload]
|
|
||||||
* where `aead_payload` is the AEAD output (nonce + ciphertext + tag). The AEAD
|
|
||||||
* key is derived from the KEM shared secret via HKDF, so no separate content key
|
|
||||||
* is transmitted.
|
|
||||||
*
|
|
||||||
* Requires the `mlkem-tls` and `hkdf` features, plus the AEAD feature backing
|
|
||||||
* `enc_type`.
|
|
||||||
*/
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
|
||||||
pub fn encrypt_for(
|
|
||||||
enc_type: EncryptionType,
|
|
||||||
recipient: &PublicKeyBundle,
|
|
||||||
plaintext: &[u8],
|
|
||||||
aad: &[u8],
|
|
||||||
) -> Result<Vec<u8>, CryptoError> {
|
|
||||||
let enc = HybridKem::encapsulate(&recipient.kem_public_key)?;
|
|
||||||
let key = derive_encryption_key(&enc.shared_secret, ENC_KDF_SALT, ENC_KDF_CONTEXT)?;
|
|
||||||
let aead_payload = aead_seal(enc_type, key, plaintext, aad)?;
|
|
||||||
|
|
||||||
let kem_ct = enc.ciphertext;
|
|
||||||
let mut out = Vec::with_capacity(1 + 2 + kem_ct.len() + aead_payload.len());
|
|
||||||
out.push(enc_type.to_byte());
|
|
||||||
out.extend_from_slice(&(kem_ct.len() as u16).to_be_bytes());
|
|
||||||
out.extend_from_slice(&kem_ct);
|
|
||||||
out.extend_from_slice(&aead_payload);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Decrypt a blob produced by [`encrypt_for`] using `keyring`.
|
|
||||||
*
|
|
||||||
* The leading byte selects the `EncryptionType` (and thus which keypair to use
|
|
||||||
* from the keyring); for the current ML-KEM variants that is `kem_secret_key`.
|
|
||||||
* Returns `DecryptionFailed` on any malformed input or authentication failure.
|
|
||||||
*
|
|
||||||
* Requires the `mlkem-tls` and `hkdf` features, plus the AEAD feature backing
|
|
||||||
* the blob's algorithm.
|
|
||||||
*/
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
|
||||||
pub fn decrypt_with(blob: &[u8], keyring: &Keyring, aad: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
|
||||||
if blob.len() < 3 {
|
|
||||||
return Err(CryptoError::DecryptionFailed);
|
|
||||||
}
|
|
||||||
let enc_type = EncryptionType::from_byte(blob[0]).ok_or(CryptoError::DecryptionFailed)?;
|
|
||||||
let kem_ct_len = u16::from_be_bytes([blob[1], blob[2]]) as usize;
|
|
||||||
let kem_end = 3usize
|
|
||||||
.checked_add(kem_ct_len)
|
|
||||||
.ok_or(CryptoError::DecryptionFailed)?;
|
|
||||||
let kem_ct = blob.get(3..kem_end).ok_or(CryptoError::DecryptionFailed)?;
|
|
||||||
let aead_payload = blob.get(kem_end..).ok_or(CryptoError::DecryptionFailed)?;
|
|
||||||
|
|
||||||
let shared_secret = HybridKem::decapsulate(&keyring.kem_secret_key, kem_ct)?;
|
|
||||||
let key = derive_encryption_key(&shared_secret, ENC_KDF_SALT, ENC_KDF_CONTEXT)?;
|
|
||||||
aead_open(enc_type, key, aead_payload, aad)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(feature = "mlkem-tls")]
|
||||||
|
#[test]
|
||||||
|
fn envelope_parser_uses_suite_dependent_fixed_widths() {
|
||||||
|
use crate::helper::{MultiEncryptedMessage, RecipientEntry};
|
||||||
|
|
||||||
|
let suites = [
|
||||||
|
EncryptionType::MlKemChaCha20Poly1305,
|
||||||
|
EncryptionType::MlKemAes256Gcm,
|
||||||
|
];
|
||||||
|
assert_ne!(suites[0].wrapped_key_len(), suites[1].wrapped_key_len());
|
||||||
|
|
||||||
|
for (index, suite) in suites.into_iter().enumerate() {
|
||||||
|
let marker = u8::try_from(index).unwrap();
|
||||||
|
let message = MultiEncryptedMessage {
|
||||||
|
encryption_type: suite,
|
||||||
|
purpose: 0xA5,
|
||||||
|
recipients: vec![RecipientEntry {
|
||||||
|
kem_ciphertext: vec![0x10 + marker; suite.kem_ciphertext_len()],
|
||||||
|
encrypted_key: vec![0x20 + marker; suite.wrapped_key_len()],
|
||||||
|
}],
|
||||||
|
ciphertext: vec![0x30 + marker; suite.minimum_ciphertext_len() + 3],
|
||||||
|
};
|
||||||
|
let encoded = message.to_bytes().expect("synthetic envelope is valid");
|
||||||
|
let kem_end = 4 + suite.kem_ciphertext_len();
|
||||||
|
let wrapped_end = kem_end + suite.wrapped_key_len();
|
||||||
|
|
||||||
|
assert_eq!(&encoded[..4], &[suite.to_byte(), 0xA5, 0, 1]);
|
||||||
|
assert_eq!(&encoded[4..kem_end], message.recipients[0].kem_ciphertext);
|
||||||
|
assert_eq!(
|
||||||
|
&encoded[kem_end..wrapped_end],
|
||||||
|
message.recipients[0].encrypted_key
|
||||||
|
);
|
||||||
|
assert_eq!(&encoded[wrapped_end..], message.ciphertext);
|
||||||
|
assert_eq!(
|
||||||
|
MultiEncryptedMessage::from_bytes(&encoded)
|
||||||
|
.expect("suite-specific envelope should parse"),
|
||||||
|
message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn encryption_type_byte_roundtrip() {
|
fn encryption_type_byte_roundtrip() {
|
||||||
for t in [
|
for t in [
|
||||||
|
|
@ -188,59 +203,19 @@ mod tests {
|
||||||
assert_eq!(EncryptionType::from_byte(0xFF), None);
|
assert_eq!(EncryptionType::from_byte(0xFF), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
|
|
||||||
#[test]
|
#[test]
|
||||||
fn encrypt_for_roundtrip() -> Result<(), CryptoError> {
|
fn suite_lengths_are_derived_from_the_selected_primitives() {
|
||||||
let kr = Keyring::generate();
|
assert_eq!(
|
||||||
let blob = encrypt_for(
|
EncryptionType::MlKemChaCha20Poly1305.wrapped_key_len(),
|
||||||
EncryptionType::MlKemChaCha20Poly1305,
|
EncryptionType::CONTENT_ENCRYPTION_KEY_LEN
|
||||||
&kr.public_key_bundle(),
|
+ crate::aead::XCHACHA20POLY1305_NONCE_LEN
|
||||||
b"secret payload",
|
+ crate::aead::AUTH_TAG_LEN
|
||||||
b"aad",
|
);
|
||||||
)?;
|
assert_eq!(
|
||||||
assert_eq!(blob[0], EncryptionType::ML_KEM_CHACHA20POLY1305);
|
EncryptionType::MlKemAes256Gcm.wrapped_key_len(),
|
||||||
|
EncryptionType::CONTENT_ENCRYPTION_KEY_LEN
|
||||||
let pt = decrypt_with(&blob, &kr, b"aad")?;
|
+ crate::aead::AES256GCM_NONCE_LEN
|
||||||
assert_eq!(pt, b"secret payload");
|
+ crate::aead::AUTH_TAG_LEN
|
||||||
Ok(())
|
);
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
|
|
||||||
#[test]
|
|
||||||
fn decrypt_with_wrong_keyring_fails() -> Result<(), CryptoError> {
|
|
||||||
let kr = Keyring::generate();
|
|
||||||
let other = Keyring::generate();
|
|
||||||
let blob = encrypt_for(
|
|
||||||
EncryptionType::MlKemChaCha20Poly1305,
|
|
||||||
&kr.public_key_bundle(),
|
|
||||||
b"secret",
|
|
||||||
b"aad",
|
|
||||||
)?;
|
|
||||||
assert!(decrypt_with(&blob, &other, b"aad").is_err());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
|
|
||||||
#[test]
|
|
||||||
fn decrypt_with_wrong_aad_fails() -> Result<(), CryptoError> {
|
|
||||||
let kr = Keyring::generate();
|
|
||||||
let blob = encrypt_for(
|
|
||||||
EncryptionType::MlKemChaCha20Poly1305,
|
|
||||||
&kr.public_key_bundle(),
|
|
||||||
b"secret",
|
|
||||||
b"right",
|
|
||||||
)?;
|
|
||||||
assert!(decrypt_with(&blob, &kr, b"wrong").is_err());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
|
|
||||||
#[test]
|
|
||||||
fn decrypt_with_malformed_fails() {
|
|
||||||
let kr = Keyring::generate();
|
|
||||||
assert!(decrypt_with(b"", &kr, b"").is_err());
|
|
||||||
assert!(decrypt_with(&[0x01, 0x00], &kr, b"").is_err());
|
|
||||||
// Unknown algorithm byte.
|
|
||||||
assert!(decrypt_with(&[0x7F, 0x00, 0x00], &kr, b"").is_err());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,16 @@ pub enum CryptoError {
|
||||||
EncryptionFailed,
|
EncryptionFailed,
|
||||||
#[error("decryption failed")]
|
#[error("decryption failed")]
|
||||||
DecryptionFailed,
|
DecryptionFailed,
|
||||||
|
#[error("malformed encryption envelope")]
|
||||||
|
MalformedEnvelope,
|
||||||
|
#[error("no encryption recipients")]
|
||||||
|
NoRecipients,
|
||||||
|
#[error("no matching encryption recipient")]
|
||||||
|
NoMatchingRecipient,
|
||||||
#[error("invalid key length")]
|
#[error("invalid key length")]
|
||||||
InvalidKeyLength,
|
InvalidKeyLength,
|
||||||
|
#[error("public and private key material do not match")]
|
||||||
|
InvalidKeyMaterial,
|
||||||
#[error("invalid nonce length")]
|
#[error("invalid nonce length")]
|
||||||
InvalidNonceLength,
|
InvalidNonceLength,
|
||||||
#[error("invalid signature")]
|
#[error("invalid signature")]
|
||||||
|
|
|
||||||
|
|
@ -1,208 +1,328 @@
|
||||||
|
// Canonical multi-recipient encryption envelopes.
|
||||||
|
|
||||||
|
use crate::enc::EncryptionType;
|
||||||
use crate::error::CryptoError;
|
use crate::error::CryptoError;
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
use crate::aead::{AeadDecrypt, AeadEncrypt, ChaCha20Poly1305};
|
use crate::enc::{open_with_key, seal_with_key};
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
use crate::kdf::derive_encryption_key;
|
use crate::kdf::derive_encryption_key;
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
use crate::kem::HybridKem;
|
use crate::kem::HybridKem;
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
use crate::keypair::{Keyring, PublicKeyBundle};
|
use crate::keypair::{Keyring, PublicKeyBundle};
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
pub const ENCRYPT_DOMAIN: &[u8] = b"MTP-DATA-ENC-1";
|
||||||
|
pub const KEY_WRAP_DOMAIN: &[u8] = b"MTP-DATA-WRAP-1";
|
||||||
|
/// Operational cap for recipient entries accepted in one envelope.
|
||||||
|
///
|
||||||
|
/// The wire count remains a `u16` for format stability, but decapsulation is
|
||||||
|
/// intentionally bounded because each entry can require a KEM operation.
|
||||||
|
pub const MAX_RECIPIENTS: usize = 64;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct RecipientEntry {
|
pub struct RecipientEntry {
|
||||||
pub kem_ciphertext: Vec<u8>,
|
pub kem_ciphertext: Vec<u8>,
|
||||||
pub encrypted_key: Vec<u8>,
|
pub encrypted_key: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/// The envelope body used by `DataValue::Encrypted`.
|
||||||
* A payload encrypted for multiple recipients.
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
*
|
|
||||||
* Any recipient who possesses the corresponding `KemPrivateKey` can decrypt the message.
|
|
||||||
*/
|
|
||||||
pub struct MultiEncryptedMessage {
|
pub struct MultiEncryptedMessage {
|
||||||
|
pub encryption_type: EncryptionType,
|
||||||
|
pub purpose: u8,
|
||||||
pub recipients: Vec<RecipientEntry>,
|
pub recipients: Vec<RecipientEntry>,
|
||||||
pub nonce: [u8; 24],
|
/// The AEAD output, including its nonce as defined by the selected suite.
|
||||||
pub ciphertext: Vec<u8>,
|
pub ciphertext: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MultiEncryptedMessage {
|
impl MultiEncryptedMessage {
|
||||||
/*
|
/// Serialize the envelope body without redundant per-recipient lengths.
|
||||||
* Serialize into a compact byte vector.
|
pub fn to_bytes(&self) -> Result<Vec<u8>, CryptoError> {
|
||||||
*
|
let kem_len = self.encryption_type.kem_ciphertext_len();
|
||||||
* Format:
|
let wrapped_len = self.encryption_type.wrapped_key_len();
|
||||||
* - `num_recipients: u16`
|
let count =
|
||||||
* - for each recipient:
|
u16::try_from(self.recipients.len()).map_err(|_| CryptoError::EncryptionFailed)?;
|
||||||
* - `kem_ct_len: u16` | `kem_ciphertext`
|
if self.recipients.is_empty()
|
||||||
* - `ek_len: u16` | `encrypted_key`
|
|| self.recipients.len() > MAX_RECIPIENTS
|
||||||
* - `nonce: 24 bytes`
|
|| self.ciphertext.len() < self.encryption_type.minimum_ciphertext_len()
|
||||||
* - `ciphertext` (remaining)
|
|| self
|
||||||
*/
|
.recipients
|
||||||
pub fn to_bytes(&self) -> Vec<u8> {
|
.iter()
|
||||||
|
.any(|r| r.kem_ciphertext.len() != kem_len || r.encrypted_key.len() != wrapped_len)
|
||||||
|
{
|
||||||
|
return Err(CryptoError::MalformedEnvelope);
|
||||||
|
}
|
||||||
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
out.extend_from_slice(&(self.recipients.len() as u16).to_be_bytes());
|
out.push(self.encryption_type.to_byte());
|
||||||
for r in &self.recipients {
|
out.push(self.purpose);
|
||||||
out.extend_from_slice(&(r.kem_ciphertext.len() as u16).to_be_bytes());
|
out.extend_from_slice(&count.to_be_bytes());
|
||||||
out.extend_from_slice(&r.kem_ciphertext);
|
for recipient in &self.recipients {
|
||||||
out.extend_from_slice(&(r.encrypted_key.len() as u16).to_be_bytes());
|
out.extend_from_slice(&recipient.kem_ciphertext);
|
||||||
out.extend_from_slice(&r.encrypted_key);
|
out.extend_from_slice(&recipient.encrypted_key);
|
||||||
}
|
}
|
||||||
out.extend_from_slice(&self.nonce);
|
|
||||||
out.extend_from_slice(&self.ciphertext);
|
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> {
|
pub fn from_bytes(bytes: &[u8]) -> Result<Self, CryptoError> {
|
||||||
let mut offset = 0;
|
if bytes.len() < 4 {
|
||||||
let read_u16 = |off: &mut usize| -> Result<u16, CryptoError> {
|
return Err(CryptoError::MalformedEnvelope);
|
||||||
let slice = bytes
|
}
|
||||||
.get(*off..*off + 2)
|
let encryption_type =
|
||||||
.ok_or(CryptoError::DecryptionFailed)?;
|
EncryptionType::from_byte(bytes[0]).ok_or(CryptoError::UnknownAlgorithm)?;
|
||||||
let arr: [u8; 2] = slice
|
let purpose = bytes[1];
|
||||||
.try_into()
|
let count = u16::from_be_bytes([bytes[2], bytes[3]]) as usize;
|
||||||
.map_err(|_| CryptoError::DecryptionFailed)?;
|
if count == 0 || count > MAX_RECIPIENTS {
|
||||||
*off += 2;
|
return Err(CryptoError::MalformedEnvelope);
|
||||||
Ok(u16::from_be_bytes(arr))
|
}
|
||||||
};
|
let entry_len = encryption_type.kem_ciphertext_len() + encryption_type.wrapped_key_len();
|
||||||
|
let entries_len = count
|
||||||
let num = read_u16(&mut offset)? as usize;
|
.checked_mul(entry_len)
|
||||||
let mut recipients = Vec::with_capacity(num);
|
.ok_or(CryptoError::MalformedEnvelope)?;
|
||||||
for _ in 0..num {
|
let start = 4usize;
|
||||||
let klen = read_u16(&mut offset)? as usize;
|
let end = start
|
||||||
let kem_ct = bytes
|
.checked_add(entries_len)
|
||||||
.get(offset..offset + klen)
|
.ok_or(CryptoError::MalformedEnvelope)?;
|
||||||
.ok_or(CryptoError::DecryptionFailed)?
|
let ciphertext_len = bytes
|
||||||
.to_vec();
|
.len()
|
||||||
offset += klen;
|
.checked_sub(end)
|
||||||
|
.ok_or(CryptoError::MalformedEnvelope)?;
|
||||||
let elen = read_u16(&mut offset)? as usize;
|
if ciphertext_len < encryption_type.minimum_ciphertext_len() {
|
||||||
let enc_key = bytes
|
return Err(CryptoError::MalformedEnvelope);
|
||||||
.get(offset..offset + elen)
|
|
||||||
.ok_or(CryptoError::DecryptionFailed)?
|
|
||||||
.to_vec();
|
|
||||||
offset += elen;
|
|
||||||
|
|
||||||
recipients.push(RecipientEntry {
|
|
||||||
kem_ciphertext: kem_ct,
|
|
||||||
encrypted_key: enc_key,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let nonce: [u8; 24] = bytes
|
let mut offset = start;
|
||||||
.get(offset..offset + 24)
|
let mut recipients = Vec::with_capacity(count);
|
||||||
.ok_or(CryptoError::DecryptionFailed)?
|
for _ in 0..count {
|
||||||
.try_into()
|
let kem_end = offset + encryption_type.kem_ciphertext_len();
|
||||||
.map_err(|_| CryptoError::DecryptionFailed)?;
|
let wrapped_end = kem_end + encryption_type.wrapped_key_len();
|
||||||
offset += 24;
|
recipients.push(RecipientEntry {
|
||||||
|
kem_ciphertext: bytes[offset..kem_end].to_vec(),
|
||||||
let ciphertext = bytes
|
encrypted_key: bytes[kem_end..wrapped_end].to_vec(),
|
||||||
.get(offset..)
|
});
|
||||||
.ok_or(CryptoError::DecryptionFailed)?
|
offset = wrapped_end;
|
||||||
.to_vec();
|
}
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
encryption_type,
|
||||||
|
purpose,
|
||||||
recipients,
|
recipients,
|
||||||
nonce,
|
ciphertext: bytes[offset..].to_vec(),
|
||||||
ciphertext,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
* Encrypt `plaintext` for every recipient in `entities`.
|
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());
|
||||||
* Internally generates a fresh content-encryption key, encrypts the payload
|
aad.extend_from_slice(KEY_WRAP_DOMAIN);
|
||||||
* with ChaCha20-Poly1305, then KEM-encapsulates and wraps the key for each
|
aad.push(encryption_type.to_byte());
|
||||||
* recipient. The returned `MultiEncryptedMessage` can be decrypted by any
|
aad.push(purpose);
|
||||||
* entity whose keyring contains the corresponding private KEM key.
|
aad.extend_from_slice(kem_ciphertext);
|
||||||
*
|
aad
|
||||||
* Requires the `pqc` and `chacha20poly1305` features.
|
}
|
||||||
*/
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
pub fn encrypt_multi(
|
fn payload_aad(message: &MultiEncryptedMessage) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let count =
|
||||||
|
u16::try_from(message.recipients.len()).map_err(|_| CryptoError::MalformedEnvelope)?;
|
||||||
|
let mut aad = Vec::new();
|
||||||
|
aad.extend_from_slice(ENCRYPT_DOMAIN);
|
||||||
|
aad.push(message.encryption_type.to_byte());
|
||||||
|
aad.push(message.purpose);
|
||||||
|
aad.extend_from_slice(&count.to_be_bytes());
|
||||||
|
for recipient in &message.recipients {
|
||||||
|
aad.extend_from_slice(&recipient.kem_ciphertext);
|
||||||
|
aad.extend_from_slice(&recipient.encrypted_key);
|
||||||
|
}
|
||||||
|
Ok(aad)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt a value for one or more recipients using the canonical envelope.
|
||||||
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
|
pub fn encrypt_multi_for(
|
||||||
|
encryption_type: EncryptionType,
|
||||||
|
purpose: u8,
|
||||||
plaintext: &[u8],
|
plaintext: &[u8],
|
||||||
aad: &[u8],
|
|
||||||
entities: &[PublicKeyBundle],
|
entities: &[PublicKeyBundle],
|
||||||
) -> Result<MultiEncryptedMessage, CryptoError> {
|
) -> Result<MultiEncryptedMessage, CryptoError> {
|
||||||
|
if entities.is_empty() {
|
||||||
|
return Err(CryptoError::NoRecipients);
|
||||||
|
}
|
||||||
|
if entities.len() > MAX_RECIPIENTS {
|
||||||
|
return Err(CryptoError::EncryptionFailed);
|
||||||
|
}
|
||||||
|
|
||||||
let mut cek = Zeroizing::new([0u8; 32]);
|
let mut cek = Zeroizing::new([0u8; 32]);
|
||||||
rand::rng().fill_bytes(cek.as_mut());
|
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());
|
let mut recipients = Vec::with_capacity(entities.len());
|
||||||
for entity in entities {
|
for entity in entities {
|
||||||
let enc = HybridKem::encapsulate(&entity.kem_public_key)?;
|
let enc = HybridKem::encapsulate(&entity.kem_public_key)?;
|
||||||
let wrap_key = Zeroizing::new(derive_encryption_key(
|
let wrap_key = Zeroizing::new(derive_encryption_key(
|
||||||
&enc.shared_secret,
|
&enc.shared_secret,
|
||||||
b"mtp-multi-key-wrap",
|
KEY_WRAP_DOMAIN,
|
||||||
b"multi-recipient",
|
&[encryption_type.to_byte(), purpose],
|
||||||
)?);
|
)?);
|
||||||
|
let aad = wrap_aad(encryption_type, purpose, &enc.ciphertext);
|
||||||
let wrap_cipher = ChaCha20Poly1305::new(*wrap_key);
|
let encrypted_key = seal_with_key(encryption_type, *wrap_key, cek.as_ref(), &aad)?;
|
||||||
let encrypted_key = wrap_cipher.encrypt(cek.as_ref(), b"")?;
|
|
||||||
|
|
||||||
recipients.push(RecipientEntry {
|
recipients.push(RecipientEntry {
|
||||||
kem_ciphertext: enc.ciphertext,
|
kem_ciphertext: enc.ciphertext,
|
||||||
encrypted_key,
|
encrypted_key,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(MultiEncryptedMessage {
|
let mut message = MultiEncryptedMessage {
|
||||||
|
encryption_type,
|
||||||
|
purpose,
|
||||||
recipients,
|
recipients,
|
||||||
nonce,
|
ciphertext: Vec::new(),
|
||||||
ciphertext,
|
};
|
||||||
})
|
let aad = payload_aad(&message)?;
|
||||||
|
message.ciphertext = seal_with_key(encryption_type, *cek, plaintext, &aad)?;
|
||||||
|
Ok(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/// Decrypt a canonical envelope for a recipient in `keyring`.
|
||||||
* Decrypt a `MultiEncryptedMessage` using the recipient's `Keyring`.
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
*
|
pub fn decrypt_multi_for(
|
||||||
* Tries each `RecipientEntry` until one succeeds with the given keyring's
|
message: &MultiEncryptedMessage,
|
||||||
* KEM secret key. Returns the original plaintext.
|
purpose: u8,
|
||||||
*/
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
|
||||||
pub fn decrypt_multi(
|
|
||||||
msg: &MultiEncryptedMessage,
|
|
||||||
aad: &[u8],
|
|
||||||
keyring: &Keyring,
|
keyring: &Keyring,
|
||||||
) -> Result<Vec<u8>, CryptoError> {
|
) -> Result<Vec<u8>, CryptoError> {
|
||||||
for entry in &msg.recipients {
|
if message.recipients.is_empty()
|
||||||
let ss = match HybridKem::decapsulate(&keyring.kem_secret_key, &entry.kem_ciphertext) {
|
|| message.recipients.len() > MAX_RECIPIENTS
|
||||||
Ok(s) => s,
|
|| message.purpose != purpose
|
||||||
Err(_) => continue,
|
|| 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(
|
let wrap_key = Zeroizing::new(derive_encryption_key(
|
||||||
&ss,
|
&shared_secret,
|
||||||
b"mtp-multi-key-wrap",
|
KEY_WRAP_DOMAIN,
|
||||||
b"multi-recipient",
|
&[message.encryption_type.to_byte(), purpose],
|
||||||
)?);
|
)?);
|
||||||
let wrap_cipher = ChaCha20Poly1305::new(*wrap_key);
|
let aad = wrap_aad(message.encryption_type, purpose, &entry.kem_ciphertext);
|
||||||
let cek = match wrap_cipher.decrypt(&entry.encrypted_key, b"") {
|
let cek = match open_with_key(
|
||||||
Ok(k) => Zeroizing::new(k),
|
message.encryption_type,
|
||||||
|
*wrap_key,
|
||||||
|
&entry.encrypted_key,
|
||||||
|
&aad,
|
||||||
|
) {
|
||||||
|
Ok(key) => key,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
let cek_arr = Zeroizing::new(
|
let cek: [u8; 32] = cek.try_into().map_err(|_| CryptoError::DecryptionFailed)?;
|
||||||
cek.as_slice()
|
return open_with_key(
|
||||||
.try_into()
|
message.encryption_type,
|
||||||
.map_err(|_| CryptoError::DecryptionFailed)?,
|
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());
|
let mut wrong_purpose = message.clone();
|
||||||
full_ct.extend_from_slice(&msg.nonce);
|
wrong_purpose.purpose ^= 1;
|
||||||
full_ct.extend_from_slice(&msg.ciphertext);
|
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);
|
let mut wrong_recipient_table = message.clone();
|
||||||
return data_cipher.decrypt(&full_ct, aad);
|
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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,13 @@ pub struct HybridKem;
|
||||||
|
|
||||||
#[cfg(feature = "mlkem-tls")]
|
#[cfg(feature = "mlkem-tls")]
|
||||||
impl HybridKem {
|
impl HybridKem {
|
||||||
|
/// Fixed wire size of the KEM ciphertext used by MTP envelopes.
|
||||||
|
pub const fn ciphertext_len() -> usize {
|
||||||
|
mlkem_tls::X25519MlKem768::CIPHERTEXT_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
pub fn generate_keypair() -> (KemPrivateKey, KemPublicKey) {
|
pub fn generate_keypair() -> (KemPrivateKey, KemPublicKey) {
|
||||||
/* Obviously: cannot find module or crate rand_core06 in this scope
|
let (ek, dk) = mlkem_tls::X25519MlKem768::keygen(&mut rand_core::OsRng);
|
||||||
use of unresolved module or unlinked crate rand_core06 (rustc E0433) */
|
|
||||||
let (ek, dk) =
|
|
||||||
mlkem_tls::X25519MlKem768::keygen(&mut chacha20poly1305::aead::rand_core::OsRng);
|
|
||||||
(
|
(
|
||||||
KemPrivateKey::new(dk.as_bytes().to_vec()),
|
KemPrivateKey::new(dk.as_bytes().to_vec()),
|
||||||
KemPublicKey::new(ek.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> {
|
pub fn encapsulate(recipient_pk: &KemPublicKey) -> Result<Encapsulated, CryptoError> {
|
||||||
let ek = mlkem_tls::EncapsKey768::try_from(recipient_pk.as_bytes())
|
let ek = mlkem_tls::EncapsKey768::try_from(recipient_pk.as_bytes())
|
||||||
.map_err(|_| CryptoError::KemEncapsulationFailed)?;
|
.map_err(|_| CryptoError::KemEncapsulationFailed)?;
|
||||||
let (ct, ss) = mlkem_tls::X25519MlKem768::encapsulate(
|
let (ct, ss) = mlkem_tls::X25519MlKem768::encapsulate(&ek, &mut rand_core::OsRng);
|
||||||
&ek,
|
|
||||||
&mut chacha20poly1305::aead::rand_core::OsRng,
|
|
||||||
);
|
|
||||||
Ok(Encapsulated {
|
Ok(Encapsulated {
|
||||||
ciphertext: ct.as_bytes().to_vec(),
|
ciphertext: ct.as_bytes().to_vec(),
|
||||||
shared_secret: Zeroizing::new(ss.as_bytes().to_vec()),
|
shared_secret: Zeroizing::new(ss.as_bytes().to_vec()),
|
||||||
|
|
|
||||||
|
|
@ -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]] = &[
|
let fields: &[&[u8]] = &[
|
||||||
self.kem_public_key.as_bytes(),
|
self.kem_public_key.as_bytes(),
|
||||||
self.kem_secret_key.as_bytes(),
|
self.kem_secret_key.as_bytes(),
|
||||||
|
|
@ -248,10 +332,17 @@ impl Keyring {
|
||||||
];
|
];
|
||||||
let mut out = Zeroizing::new(Vec::new());
|
let mut out = Zeroizing::new(Vec::new());
|
||||||
for f in fields {
|
for f in fields {
|
||||||
out.extend_from_slice(&(f.len() as u16).to_be_bytes());
|
let length =
|
||||||
|
u16::try_from(f.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?;
|
||||||
|
out.extend_from_slice(&length.to_be_bytes());
|
||||||
out.extend_from_slice(f);
|
out.extend_from_slice(f);
|
||||||
}
|
}
|
||||||
out
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
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_public_key: SignaturePublicKey::new(read_key(&mut offset)?),
|
||||||
sig_cl_secret_key: SignaturePrivateKey::new(read_key(&mut offset)?),
|
sig_cl_secret_key: SignaturePrivateKey::new(read_key(&mut offset)?),
|
||||||
})
|
})
|
||||||
|
.and_then(|keyring| {
|
||||||
|
if offset == bytes.len() {
|
||||||
|
Ok(keyring)
|
||||||
|
} else {
|
||||||
|
Err(CryptoError::InvalidKeyLength)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_hex(&self) -> String {
|
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> {
|
pub fn validate(&self) -> Result<(), crate::error::CryptoError> {
|
||||||
use crate::error::CryptoError;
|
use crate::error::CryptoError;
|
||||||
if self.sig_cl_public_key.as_bytes().len() != SIG_CL_PUBLIC_KEY_LEN {
|
if self.sig_cl_public_key.as_bytes().len() != SIG_CL_PUBLIC_KEY_LEN {
|
||||||
|
|
@ -364,25 +461,70 @@ impl PublicKeyBundle {
|
||||||
if self.kem_public_key.as_bytes().len() != KEM_PUBLIC_KEY_LEN {
|
if self.kem_public_key.as_bytes().len() != KEM_PUBLIC_KEY_LEN {
|
||||||
return Err(CryptoError::InvalidKeyLength);
|
return Err(CryptoError::InvalidKeyLength);
|
||||||
}
|
}
|
||||||
|
#[cfg(feature = "ed25519-dalek")]
|
||||||
|
{
|
||||||
|
let bytes: [u8; SIG_CL_PUBLIC_KEY_LEN] = self
|
||||||
|
.sig_cl_public_key
|
||||||
|
.as_bytes()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| CryptoError::InvalidKeyLength)?;
|
||||||
|
ed25519_dalek::VerifyingKey::from_bytes(&bytes)
|
||||||
|
.map_err(|_| CryptoError::InvalidKeyMaterial)?;
|
||||||
|
}
|
||||||
|
#[cfg(feature = "ml-dsa")]
|
||||||
|
{
|
||||||
|
let encoded = ml_dsa::EncodedVerifyingKey::<ml_dsa::MlDsa65>::try_from(
|
||||||
|
self.sig_pq_public_key.as_bytes(),
|
||||||
|
)
|
||||||
|
.map_err(|_| CryptoError::InvalidKeyMaterial)?;
|
||||||
|
let _ = ml_dsa::VerifyingKey::<ml_dsa::MlDsa65>::decode(&encoded);
|
||||||
|
}
|
||||||
|
#[cfg(feature = "mlkem-tls")]
|
||||||
|
{
|
||||||
|
crate::kem::HybridKem::encapsulate(&self.kem_public_key)
|
||||||
|
.map_err(|_| CryptoError::InvalidKeyMaterial)?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn as_bytes(&self) -> Vec<u8> {
|
pub fn try_as_bytes(&self) -> Result<Vec<u8>, crate::error::CryptoError> {
|
||||||
let kem = self.kem_public_key.as_bytes();
|
let kem = self.kem_public_key.as_bytes();
|
||||||
let pq = self.sig_pq_public_key.as_bytes();
|
let pq = self.sig_pq_public_key.as_bytes();
|
||||||
let cl = self.sig_cl_public_key.as_bytes();
|
let cl = self.sig_cl_public_key.as_bytes();
|
||||||
|
let kem_len =
|
||||||
|
u16::try_from(kem.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?;
|
||||||
|
let pq_len =
|
||||||
|
u16::try_from(pq.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?;
|
||||||
|
let cl_len =
|
||||||
|
u16::try_from(cl.len()).map_err(|_| crate::error::CryptoError::InvalidKeyLength)?;
|
||||||
let mut out = Vec::with_capacity(kem.len() + pq.len() + cl.len() + 6);
|
let mut out = Vec::with_capacity(kem.len() + pq.len() + cl.len() + 6);
|
||||||
out.extend_from_slice(&(kem.len() as u16).to_be_bytes());
|
out.extend_from_slice(&kem_len.to_be_bytes());
|
||||||
out.extend_from_slice(kem);
|
out.extend_from_slice(kem);
|
||||||
out.extend_from_slice(&(pq.len() as u16).to_be_bytes());
|
out.extend_from_slice(&pq_len.to_be_bytes());
|
||||||
out.extend_from_slice(pq);
|
out.extend_from_slice(pq);
|
||||||
out.extend_from_slice(&(cl.len() as u16).to_be_bytes());
|
out.extend_from_slice(&cl_len.to_be_bytes());
|
||||||
out.extend_from_slice(cl);
|
out.extend_from_slice(cl);
|
||||||
out
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
pub fn from_bytes(bytes: &[u8]) -> Result<Self, crate::error::CryptoError> {
|
||||||
|
let bundle = Self::from_bytes_unvalidated(bytes)?;
|
||||||
|
bundle.validate()?;
|
||||||
|
Ok(bundle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the canonical field layout without requiring all suite fields.
|
||||||
|
///
|
||||||
|
/// This is reserved for explicitly partial development material, such as
|
||||||
|
/// an Ed25519-only browser keyring. Callers that will encrypt or verify
|
||||||
|
/// cryptographic protocol values must use [`Self::from_bytes`].
|
||||||
|
pub fn from_bytes_unvalidated(bytes: &[u8]) -> Result<Self, crate::error::CryptoError> {
|
||||||
use crate::error::CryptoError;
|
use crate::error::CryptoError;
|
||||||
let mut offset = 0;
|
let mut offset = 0;
|
||||||
|
|
||||||
|
|
@ -422,6 +564,11 @@ impl PublicKeyBundle {
|
||||||
.ok_or(CryptoError::InvalidKeyLength)?
|
.ok_or(CryptoError::InvalidKeyLength)?
|
||||||
.to_vec(),
|
.to_vec(),
|
||||||
);
|
);
|
||||||
|
offset += cl_len;
|
||||||
|
|
||||||
|
if offset != bytes.len() {
|
||||||
|
return Err(CryptoError::InvalidKeyLength);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
kem_public_key: kem,
|
kem_public_key: kem,
|
||||||
|
|
@ -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 {
|
pub fn to_base64(&self) -> String {
|
||||||
bytes_to_base64(&self.as_bytes())
|
bytes_to_base64(&self.as_bytes())
|
||||||
}
|
}
|
||||||
|
|
@ -437,6 +589,10 @@ impl PublicKeyBundle {
|
||||||
pub fn from_base64(s: &str) -> Result<Self, crate::error::CryptoError> {
|
pub fn from_base64(s: &str) -> Result<Self, crate::error::CryptoError> {
|
||||||
Self::from_bytes(&base64_to_bytes(s)?)
|
Self::from_bytes(&base64_to_bytes(s)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_base64_unvalidated(s: &str) -> Result<Self, crate::error::CryptoError> {
|
||||||
|
Self::from_bytes_unvalidated(&base64_to_bytes(s)?)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<&[u8]> for PublicKeyBundle {
|
impl TryFrom<&[u8]> for PublicKeyBundle {
|
||||||
|
|
@ -474,7 +630,7 @@ mod tests {
|
||||||
|
|
||||||
let bundle = PublicKeyBundle::new(kem, pq, cl);
|
let bundle = PublicKeyBundle::new(kem, pq, cl);
|
||||||
let bytes = bundle.as_bytes();
|
let bytes = bundle.as_bytes();
|
||||||
let recovered = PublicKeyBundle::from_bytes(&bytes)?;
|
let recovered = PublicKeyBundle::from_bytes_unvalidated(&bytes)?;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bundle.kem_public_key.as_bytes(),
|
bundle.kem_public_key.as_bytes(),
|
||||||
|
|
@ -491,6 +647,24 @@ mod tests {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(feature = "mlkem-tls", feature = "ml-dsa", feature = "ed25519-dalek"))]
|
||||||
|
#[test]
|
||||||
|
fn full_keyring_validation_checks_key_correspondence() {
|
||||||
|
let keyring = Keyring::generate();
|
||||||
|
assert!(keyring.validate_full().is_ok());
|
||||||
|
assert!(keyring.validate_encryption().is_ok());
|
||||||
|
|
||||||
|
let mut invalid = Keyring::generate();
|
||||||
|
invalid.sig_cl_public_key = SignaturePublicKey::new(vec![0; SIG_CL_PUBLIC_KEY_LEN]);
|
||||||
|
assert!(matches!(
|
||||||
|
invalid.validate_full(),
|
||||||
|
Err(crate::error::CryptoError::InvalidKeyMaterial)
|
||||||
|
));
|
||||||
|
|
||||||
|
invalid.kem_secret_key = KemPrivateKey::new(vec![0]);
|
||||||
|
assert!(invalid.validate_encryption().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn public_key_bundle_try_from_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
|
fn public_key_bundle_try_from_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let bundle = PublicKeyBundle::new(
|
let bundle = PublicKeyBundle::new(
|
||||||
|
|
@ -499,7 +673,7 @@ mod tests {
|
||||||
SignaturePublicKey::new(vec![0xEFu8; 32]),
|
SignaturePublicKey::new(vec![0xEFu8; 32]),
|
||||||
);
|
);
|
||||||
let bytes: Vec<u8> = Vec::from(&bundle);
|
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());
|
assert_eq!(bundle.as_bytes(), recovered.as_bytes());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -531,6 +705,53 @@ mod tests {
|
||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn keyring_try_from_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
|
fn keyring_try_from_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let keyring = Keyring::new(
|
let keyring = Keyring::new(
|
||||||
|
|
@ -597,7 +818,7 @@ mod tests {
|
||||||
SignaturePublicKey::new(vec![3u8; 32]),
|
SignaturePublicKey::new(vec![3u8; 32]),
|
||||||
);
|
);
|
||||||
let b64 = bundle.to_base64();
|
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());
|
assert_eq!(bundle.as_bytes(), recovered.as_bytes());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ pub use keypair::{
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(feature = "chacha20poly1305")]
|
#[cfg(feature = "chacha20poly1305")]
|
||||||
pub use aead::ChaCha20Poly1305;
|
pub use aead::{ChaCha20Poly1305, XChaCha20Poly1305};
|
||||||
|
|
||||||
#[cfg(feature = "aes-gcm")]
|
#[cfg(feature = "aes-gcm")]
|
||||||
pub use aead::Aes256Gcm;
|
pub use aead::Aes256Gcm;
|
||||||
|
|
@ -55,7 +55,7 @@ pub use sign::{Ed25519Signer, SignatureScheme, verify_ed25519};
|
||||||
pub use sign::{MlDsaSigner, verify_ml_dsa};
|
pub use sign::{MlDsaSigner, verify_ml_dsa};
|
||||||
|
|
||||||
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||||
pub use sign::{DualSignature, sign_dual};
|
pub use sign::{DualSignature, DualSigner, sign_dual};
|
||||||
|
|
||||||
#[cfg(feature = "sha2")]
|
#[cfg(feature = "sha2")]
|
||||||
pub use hash::{Sha256Hasher, sha256, sha256_double};
|
pub use hash::{Sha256Hasher, sha256, sha256_double};
|
||||||
|
|
@ -79,11 +79,12 @@ pub fn ensure_crypto_provider() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
pub use helper::{ENCRYPT_DOMAIN, KEY_WRAP_DOMAIN};
|
||||||
pub use enc::{decrypt_with, encrypt_for};
|
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
pub use helper::{MultiEncryptedMessage, RecipientEntry, decrypt_multi, encrypt_multi};
|
pub use helper::{
|
||||||
|
MAX_RECIPIENTS, MultiEncryptedMessage, RecipientEntry, decrypt_multi_for, encrypt_multi_for,
|
||||||
|
};
|
||||||
|
|
||||||
/* ================================ TESTS ================================ */
|
/* ================================ TESTS ================================ */
|
||||||
#[cfg(test)]
|
#[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")]
|
#[cfg(feature = "hkdf")]
|
||||||
#[test]
|
#[test]
|
||||||
fn hkdf_expand_produces_key() {
|
fn hkdf_expand_produces_key() {
|
||||||
|
|
@ -343,17 +358,46 @@ mod tests {
|
||||||
assert_eq!(enc.shared_secret, ss);
|
assert_eq!(enc.shared_secret, ss);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "chacha20poly1305", feature = "hkdf"))]
|
#[cfg(all(
|
||||||
#[test]
|
feature = "mlkem-tls",
|
||||||
fn encrypt_multi_roundtrip() {
|
feature = "hkdf",
|
||||||
use crate::helper::{decrypt_multi, encrypt_multi};
|
feature = "ml-dsa",
|
||||||
|
feature = "ed25519-dalek"
|
||||||
|
))]
|
||||||
|
fn multi_envelope_roundtrip(encryption_type: EncryptionType) {
|
||||||
|
use crate::helper::{decrypt_multi_for, encrypt_multi_for};
|
||||||
use crate::keypair::Keyring;
|
use crate::keypair::Keyring;
|
||||||
|
|
||||||
let kr = Keyring::generate();
|
let kr = Keyring::generate();
|
||||||
let entities = vec![kr.public_key_bundle()];
|
let entities = vec![kr.public_key_bundle()];
|
||||||
let msg = b"secret data";
|
let msg = b"secret data";
|
||||||
let ct = encrypt_multi(msg, b"aad", &entities).expect("multi encrypt should succeed");
|
let ct = encrypt_multi_for(encryption_type, 7, msg, &entities)
|
||||||
let pt = decrypt_multi(&ct, b"aad", &kr).expect("multi decrypt should succeed");
|
.expect("multi encrypt should succeed");
|
||||||
|
let pt = decrypt_multi_for(&ct, 7, &kr).expect("multi decrypt should succeed");
|
||||||
assert_eq!(pt, msg);
|
assert_eq!(pt, msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(
|
||||||
|
feature = "mlkem-tls",
|
||||||
|
feature = "hkdf",
|
||||||
|
feature = "ml-dsa",
|
||||||
|
feature = "ed25519-dalek",
|
||||||
|
feature = "chacha20poly1305"
|
||||||
|
))]
|
||||||
|
#[test]
|
||||||
|
fn chacha20_multi_envelope_roundtrip() {
|
||||||
|
multi_envelope_roundtrip(EncryptionType::MlKemChaCha20Poly1305);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(
|
||||||
|
feature = "mlkem-tls",
|
||||||
|
feature = "hkdf",
|
||||||
|
feature = "ml-dsa",
|
||||||
|
feature = "ed25519-dalek",
|
||||||
|
feature = "aes-gcm"
|
||||||
|
))]
|
||||||
|
#[test]
|
||||||
|
fn aes_gcm_multi_envelope_roundtrip() {
|
||||||
|
multi_envelope_roundtrip(EncryptionType::MlKemAes256Gcm);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ impl SigAlgorithm {
|
||||||
use crate::keypair::{SignaturePqPrivateKey, SignaturePqPublicKey};
|
use crate::keypair::{SignaturePqPrivateKey, SignaturePqPublicKey};
|
||||||
|
|
||||||
pub trait SignatureScheme {
|
pub trait SignatureScheme {
|
||||||
|
/// The wire algorithm identifier produced by this signer.
|
||||||
|
fn algorithm(&self) -> u8;
|
||||||
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError>;
|
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError>;
|
||||||
fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError>;
|
fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError>;
|
||||||
}
|
}
|
||||||
|
|
@ -76,6 +78,10 @@ impl Ed25519Signer {
|
||||||
|
|
||||||
#[cfg(feature = "ed25519-dalek")]
|
#[cfg(feature = "ed25519-dalek")]
|
||||||
impl SignatureScheme for Ed25519Signer {
|
impl SignatureScheme for Ed25519Signer {
|
||||||
|
fn algorithm(&self) -> u8 {
|
||||||
|
SigAlgorithm::ED25519
|
||||||
|
}
|
||||||
|
|
||||||
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
use ed25519_dalek::Signer;
|
use ed25519_dalek::Signer;
|
||||||
let signature = self.secret.sign(msg).to_bytes().to_vec();
|
let signature = self.secret.sign(msg).to_bytes().to_vec();
|
||||||
|
|
@ -170,6 +176,10 @@ impl MlDsaSigner {
|
||||||
|
|
||||||
#[cfg(feature = "ml-dsa")]
|
#[cfg(feature = "ml-dsa")]
|
||||||
impl SignatureScheme for MlDsaSigner {
|
impl SignatureScheme for MlDsaSigner {
|
||||||
|
fn algorithm(&self) -> u8 {
|
||||||
|
SigAlgorithm::ML_DSA_65
|
||||||
|
}
|
||||||
|
|
||||||
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
use ml_dsa::Signer;
|
use ml_dsa::Signer;
|
||||||
let signature = self
|
let signature = self
|
||||||
|
|
@ -237,6 +247,75 @@ pub fn sign_dual(
|
||||||
Ok(DualSignature { ed25519, mldsa })
|
Ok(DualSignature { ed25519, mldsa })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A signer that produces the canonical concatenated Ed25519 + ML-DSA-65
|
||||||
|
/// signature represented by [`SigAlgorithm::DUAL`].
|
||||||
|
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||||
|
pub struct DualSigner {
|
||||||
|
ed25519: Ed25519Signer,
|
||||||
|
mldsa: MlDsaSigner,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||||
|
impl DualSigner {
|
||||||
|
pub fn new(
|
||||||
|
ed25519_secret: &SignaturePrivateKey,
|
||||||
|
mldsa_secret: &SignaturePqPrivateKey,
|
||||||
|
mldsa_public: &SignaturePqPublicKey,
|
||||||
|
) -> Result<Self, CryptoError> {
|
||||||
|
Ok(Self {
|
||||||
|
ed25519: Ed25519Signer::new(ed25519_secret)?,
|
||||||
|
mldsa: MlDsaSigner::new(mldsa_secret, mldsa_public)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate() -> (
|
||||||
|
Self,
|
||||||
|
SignaturePrivateKey,
|
||||||
|
SignaturePqPrivateKey,
|
||||||
|
SignaturePublicKey,
|
||||||
|
SignaturePqPublicKey,
|
||||||
|
) {
|
||||||
|
let (ed25519, ed25519_secret, ed25519_public) = Ed25519Signer::generate();
|
||||||
|
let (mldsa, mldsa_secret, mldsa_public) = MlDsaSigner::generate();
|
||||||
|
(
|
||||||
|
Self { ed25519, mldsa },
|
||||||
|
ed25519_secret,
|
||||||
|
mldsa_secret,
|
||||||
|
ed25519_public,
|
||||||
|
mldsa_public,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||||
|
impl SignatureScheme for DualSigner {
|
||||||
|
fn algorithm(&self) -> u8 {
|
||||||
|
SigAlgorithm::DUAL
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let dual = sign_dual(self.ed25519.signing_key(), self.mldsa.signing_key(), msg)?;
|
||||||
|
let mut signature = Vec::with_capacity(
|
||||||
|
SigAlgorithm::length(SigAlgorithm::DUAL).expect("known signature algorithm length"),
|
||||||
|
);
|
||||||
|
signature.extend_from_slice(&dual.ed25519);
|
||||||
|
signature.extend_from_slice(&dual.mldsa);
|
||||||
|
Ok(signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify(&self, msg: &[u8], signature: &[u8]) -> Result<(), CryptoError> {
|
||||||
|
let ed_len =
|
||||||
|
SigAlgorithm::length(SigAlgorithm::ED25519).expect("known signature algorithm length");
|
||||||
|
let mldsa_len = SigAlgorithm::length(SigAlgorithm::ML_DSA_65)
|
||||||
|
.expect("known signature algorithm length");
|
||||||
|
if signature.len() != ed_len + mldsa_len {
|
||||||
|
return Err(CryptoError::InvalidSignature);
|
||||||
|
}
|
||||||
|
verify_ed25519(&self.ed25519.public_key(), msg, &signature[..ed_len])?;
|
||||||
|
verify_ml_dsa(&self.mldsa.public_key(), msg, &signature[ed_len..])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl DualSignature {
|
impl DualSignature {
|
||||||
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
#[cfg(all(feature = "ed25519-dalek", feature = "ml-dsa"))]
|
||||||
pub fn verify(
|
pub fn verify(
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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.
|
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.
|
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-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.
|
`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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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.
|
`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.
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,6 @@ See [Protocol Reference](PROTOCOL-REFERENCE.md#protocol-keepalive).
|
||||||
|
|
||||||
## Protocol Version Changes
|
## 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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ let conn = MTPClient::connect(
|
||||||
let request = CommunicationValue::new(CommunicationType::Ping).with_id(1);
|
let request = CommunicationValue::new(CommunicationType::Ping).with_id(1);
|
||||||
conn.sender.send(&request).await?;
|
conn.sender.send(&request).await?;
|
||||||
let response = conn.receive().await?;
|
let response = conn.receive().await?;
|
||||||
println!("received {}", response.get_id());
|
println!("received {:?}", response.id());
|
||||||
conn.sender.close();
|
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.
|
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
|
```rust
|
||||||
use mtp::crypto::{EncryptionType, Ed25519Signer, SigAlgorithm};
|
use mtp::codec::{ProtectionPurpose, DataTypeId, DataValue};
|
||||||
|
use mtp::crypto::{Ed25519Signer, Keyring};
|
||||||
|
|
||||||
let enc_type = EncryptionType::MlKemChaCha20Poly1305;
|
let sender_keyring = Keyring::generate();
|
||||||
let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)?;
|
let recipient_keyring = Keyring::generate();
|
||||||
|
let signer = Ed25519Signer::new(&sender_keyring.sig_cl_secret_key)?;
|
||||||
// `recipient` is the PublicKeyBundle of whoever should be able to decrypt
|
let recipient = recipient_keyring.public_key_bundle();
|
||||||
// (e.g. the host's bundle, obtained out of band).
|
let sender_public_keys = sender_keyring.public_key_bundle();
|
||||||
|
let value = DataValue::Container(vec![
|
||||||
// Encrypted container
|
(DataTypeId(32), DataValue::Str("secret".into())),
|
||||||
let mut enc = DataValue::Container(vec![
|
|
||||||
(DataTypeId(1), DataValue::Str("secret".into())),
|
|
||||||
]);
|
]);
|
||||||
enc.encrypt_container(enc_type, &recipient, b"aad");
|
|
||||||
|
|
||||||
// Signed container
|
// The outer encrypted wrapper hides the signer metadata.
|
||||||
let mut sig = DataValue::Container(vec![
|
let private_signer = value.clone().sign(7, ProtectionPurpose::from(1), &signer)?;
|
||||||
(DataTypeId(1), DataValue::Str("signed".into())),
|
let sealed = private_signer.encrypt_for(
|
||||||
]);
|
std::slice::from_ref(&recipient),
|
||||||
sig.sign_container(SigAlgorithm::ED25519, &signer);
|
ProtectionPurpose::from(2),
|
||||||
|
)?;
|
||||||
// Signed + encrypted
|
|
||||||
let mut sec = DataValue::Container(vec![
|
|
||||||
(DataTypeId(1), DataValue::Str("both".into())),
|
|
||||||
]);
|
|
||||||
sec.sign_and_encrypt_container(SigAlgorithm::ED25519, &signer, enc_type, &recipient, b"aad");
|
|
||||||
```
|
```
|
||||||
> 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
|
```rust
|
||||||
enc.decrypt_into_container(&keyring, b"aad"); // -> Container
|
let encrypted = value.encrypt_for(
|
||||||
sig.verify_into_container(&verifier); // verifier: impl SignatureScheme
|
std::slice::from_ref(&recipient),
|
||||||
sec.decrypt_signed_encrypted_container(&keyring, b"aad"); // -> SignedContainer, then verify_into_container
|
ProtectionPurpose::from(2),
|
||||||
|
)?;
|
||||||
|
let public_signer = encrypted.sign(7, ProtectionPurpose::from(1), &signer)?;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Opening and verification are explicit and return the inner value without mutating the wrapper:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let signed = sealed.decrypt(&recipient_keyring, ProtectionPurpose::from(2))?;
|
||||||
|
signed.verify(7, &sender_public_keys, ProtectionPurpose::from(1))?;
|
||||||
|
let plain = signed.into_verified(7, &sender_public_keys, ProtectionPurpose::from(1))?;
|
||||||
|
```
|
||||||
|
|
||||||
|
For `public_signer`, call `verify` and `into_verified` before calling `decrypt`; its outer signature is available before the encrypted value is opened.
|
||||||
|
|
||||||
### Policy Configuration
|
### Policy Configuration
|
||||||
|
|
||||||
The `Policy` struct controls transport behaviour:
|
The `Policy` struct controls transport behaviour:
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ let mut server = MTPWebServer::new(host_config, web).await?;
|
||||||
while let Some(connection) = server.accept().await? {
|
while let Some(connection) = server.accept().await? {
|
||||||
// connection: WebMTPConnection
|
// connection: WebMTPConnection
|
||||||
while let Ok(message) = connection.receive().await {
|
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 |
|
| 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. |
|
| `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. |
|
| `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. |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -157,10 +157,9 @@ let get_existing_client = |id: u64, _description: Option<String>| {
|
||||||
|
|
||||||
### guest_id_generator
|
### 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`);
|
Return `Some(id)` to accept the guest with that full-width `u64` ID, or `None` to reject the connection.
|
||||||
values outside that range are rejected automatically and fall back to the built-in generator.
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
|
||||||
146
docs/PIPES.md
146
docs/PIPES.md
|
|
@ -1,6 +1,14 @@
|
||||||
# MTP Pipes
|
# 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.
|
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.
|
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.
|
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
|
## 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.
|
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
|
## 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.
|
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
|
## 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
|
```rust
|
||||||
// Client
|
// Client
|
||||||
use tokio::io::AsyncWriteExt;
|
use mtp_transport::{PipeSessionParameters, initiate_pipe_session};
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
let handle = conn.create_pipe("file-upload").await?;
|
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?;
|
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?;
|
writer.finish().await?;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// Host
|
// Host
|
||||||
use tokio::io::AsyncReadExt;
|
use mtp_transport::{PipeSessionParameters, accept_pipe_session};
|
||||||
|
|
||||||
while let Ok(request) = conn.receive_pipe().await {
|
while let Ok(request) = conn.receive_pipe().await {
|
||||||
if request.description() != "file-upload" {
|
if request.description() != "file-upload" {
|
||||||
|
|
@ -62,16 +174,18 @@ while let Ok(request) = conn.receive_pipe().await {
|
||||||
continue;
|
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(), ¶ms, &own_keyring, &client_public_bundle,
|
||||||
|
).await?;
|
||||||
let mut hasher = sha2::Sha256::new();
|
let mut hasher = sha2::Sha256::new();
|
||||||
let mut buffer = [0u8; 64 * 1024];
|
while let Some(chunk) = reader.read_record().await? {
|
||||||
loop {
|
hasher.update(&chunk);
|
||||||
let count = reader.read(&mut buffer).await?;
|
process_chunk(&chunk).await?;
|
||||||
if count == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
hasher.update(&buffer[..count]);
|
|
||||||
process_chunk(&buffer[..count]).await?;
|
|
||||||
}
|
}
|
||||||
let digest = hasher.finalize();
|
let digest = hasher.finalize();
|
||||||
println!("processed upload with digest {digest:x}");
|
println!("processed upload with digest {digest:x}");
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
## Authentication Flow
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -41,3 +82,5 @@ Login proof binds the protocol version, client ID, host challenge, and client no
|
||||||
## Version Negotiation
|
## 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 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.
|
||||||
|
|
|
||||||
131
docs/SECURITY.md
131
docs/SECURITY.md
|
|
@ -95,9 +95,100 @@ The tags prevent a valid signature for one handshake step from being accepted as
|
||||||
| Classical signatures | Ed25519 | Default |
|
| Classical signatures | Ed25519 | Default |
|
||||||
| Post-quantum signatures | ML-DSA-65 | Default |
|
| Post-quantum signatures | ML-DSA-65 | Default |
|
||||||
| KDF and hashing | HKDF-SHA-256, SHA-256 | 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 |
|
| 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).
|
[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 |
|
| `wasm` | `getrandom` support for WebAssembly |
|
||||||
| `tls` | Development certificate generation |
|
| `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
|
## 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.
|
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.
|
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
|
## 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.
|
`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.
|
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
|
## 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.
|
`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.
|
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.
|
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.
|
- `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.
|
- 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 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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
`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
|
## Frames and Message Limits
|
||||||
|
|
||||||
|
|
|
||||||
119
docs/TYPE-MAP.md
119
docs/TYPE-MAP.md
|
|
@ -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.
|
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
|
```text
|
||||||
u32 length
|
[4 bytes total length]
|
||||||
u16 communication_type
|
[2 bytes communication type]
|
||||||
u8 flags
|
[1 byte flags]
|
||||||
u32 id if flag 0x04 is set
|
bit 0 = has ID
|
||||||
u48 sender if flag 0x01 is set
|
bit 1 = has sender ID
|
||||||
u48 receiver if flag 0x02 is set
|
bit 2 = has receiver ID
|
||||||
u8 signature_type if flag 0x10 is set
|
bits 3-7 must be zero
|
||||||
... signature if flag 0x10 is set, length depends on signature_type
|
[4 bytes ID] if bit 0
|
||||||
... data container or encrypted payload
|
[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
|
## 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)).
|
Rust and manual WASM builds can set `MTP_TYPE_MAPS` directly (see [Customizing Type Maps in Downstream Projects](#customizing-type-maps-in-downstream-projects)).
|
||||||
|
|
||||||
|
For browser builds, `protocol_version` selects the one application map compiled
|
||||||
|
into that WASM client. The Vite-generated `mtp/type-map` module contains the
|
||||||
|
reserved MTP names and the application names from that selected version only;
|
||||||
|
the selected version must be present in `type_maps`. This keeps its TypeScript
|
||||||
|
unions aligned with the client runtime.
|
||||||
|
|
||||||
### Using Generated Enums
|
### Using Generated Enums
|
||||||
|
|
||||||
After editing the config and rebuilding, `CommunicationType` and `DataType` enums are generated automatically. Use them in code:
|
After editing the config and rebuilding, `CommunicationType` and `DataType` enums are generated automatically. Use them in code:
|
||||||
|
|
@ -50,11 +119,17 @@ After editing the config and rebuilding, `CommunicationType` and `DataType` enum
|
||||||
```rust
|
```rust
|
||||||
use mtp::type_map::{CommunicationType, DataType, TypeMap};
|
use mtp::type_map::{CommunicationType, DataType, TypeMap};
|
||||||
|
|
||||||
let tm = TypeMap::v2_0();
|
let tm = TypeMap::v3_0();
|
||||||
let id = tm.data_id_enum(DataType::SomeType).unwrap();
|
let id = tm.data_id_enum(DataType::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:
|
Encoding/decoding uses a `TypeMap` to resolve type names to wire IDs:
|
||||||
|
|
||||||
|
|
@ -70,23 +145,19 @@ let decoded = decode(&bytes, &tm).unwrap();
|
||||||
```
|
```
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
let tm_v2 = TypeMap::v2_0();
|
let tm_v3 = TypeMap::v3_0();
|
||||||
assert!(tm_v2.data_id_enum(DataType::SomeType).is_some()); // defined in v2.0
|
assert!(tm_v3.data_id_enum(DataType::SomeType).is_some());
|
||||||
assert!(tm_v2.data_id_enum(DataType::ExampleType).is_none()); // NOT in v2.0
|
|
||||||
|
|
||||||
let tm_v1 = TypeMap::v1_0();
|
|
||||||
assert!(tm_v1.data_id_enum(DataType::ExampleType).is_some()); // defined in v1.0
|
|
||||||
```
|
```
|
||||||
|
|
||||||
When communicating with a peer on another version, encode only variants that map in the negotiated version. If an incoming frame names a type absent from the selected map, reject it as a protocol or type-map compatibility error; do not reinterpret its wire ID using another version's map. 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
|
### Forward/Backward Compatibility Between Versions
|
||||||
|
|
||||||
Because enums are a union of all types across versions, a variant might exist that has no wire mapping in the *negotiated* version:
|
Because enums are a union of all types across versions, a variant might exist that has no wire mapping in the *negotiated* version:
|
||||||
|
|
||||||
```
|
```
|
||||||
v2.0 client sends DataType::SomeType → host encodes with v2.0 TypeMap → wire ID 32
|
v3.0 client sends DataType::SomeType → host encodes with v3.0 TypeMap → wire ID 32
|
||||||
v2.0 host receives DataType::ExampleType (from v1.0 client) → not in v2.0 TypeMap → None → Error
|
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.
|
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);
|
let codec = VersionedCodec::new(registry);
|
||||||
|
|
||||||
// Encode with a specific version
|
// 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
|
// 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
|
## Customizing Type Maps in Downstream Projects
|
||||||
|
|
|
||||||
|
|
@ -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).
|
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.
|
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.
|
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`. |
|
| `pings` | `false` | Protocol pings, or an object with `intervalMs`. |
|
||||||
| `logger` | No-op | Receives SDK state and error events. |
|
| `logger` | No-op | Receives SDK state and error events. |
|
||||||
| `sessionStorage` | In-memory | E2EE session state storage. |
|
| `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.
|
`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.
|
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
|
### Native and Browser Certificate Checks
|
||||||
|
|
||||||
|
|
@ -262,7 +483,10 @@ Use `pings: true` for the default interval.
|
||||||
|
|
||||||
## Pipes
|
## 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
|
### 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.
|
`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`:
|
The handle and writer expose `pipeId` and `description`:
|
||||||
|
|
||||||
```typescript
|
```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.
|
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 }`.
|
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.
|
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.
|
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`.
|
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.
|
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_generate()`
|
||||||
- `keyring_from_ed25519(secretKey, publicKey)`
|
- `keyring_from_ed25519(secretKey, publicKey)`
|
||||||
- `WasmKeyring.from_bytes(bytes)` and `keyring.to_bytes()`
|
- `WasmKeyring.from_bytes(bytes)` and `keyring.to_bytes()`
|
||||||
|
- `keyring.validate_encryption()` for envelope decryption roles
|
||||||
|
- `keyring.validate_full()` for complete hybrid identities
|
||||||
- `WasmPublicKeyBundle.from_bytes(bytes)` and `bundle.to_bytes()`
|
- `WasmPublicKeyBundle.from_bytes(bytes)` and `bundle.to_bytes()`
|
||||||
- `WasmEd25519Signer`
|
- `WasmEd25519Signer`
|
||||||
- `WasmChaCha20Poly1305`
|
- `WasmChaCha20Poly1305`
|
||||||
|
- `sign_data_value_with_keyring` and `verify_data_value_with_policy` (both require an explicit signature suite), plus `encrypt_data_value`, `encrypt_data_value_for_recipients`, and `decrypt_data_value`
|
||||||
|
- `parse_data_value` and `encode_data_value`
|
||||||
- `wasm_sha256`, `wasm_sha256_double`, `wasm_hkdf_expand`, and `wasm_derive_encryption_key`
|
- `wasm_sha256`, `wasm_sha256_double`, `wasm_hkdf_expand`, and `wasm_derive_encryption_key`
|
||||||
|
|
||||||
Raw authenticated login and registration map directly to the Rust WASM layer:
|
Raw authenticated login and registration map directly to the Rust WASM layer:
|
||||||
|
|
@ -463,4 +726,4 @@ A `WasmClient` manages one active WebTransport session. Create a new instance fo
|
||||||
|
|
||||||
### State Management
|
### 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).
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
#################################################################################
|
||||||
|
# This is an example, overwrite it for your project to register your own types. #
|
||||||
|
#################################################################################
|
||||||
|
|
||||||
# The version a Client should use
|
# The version a Client should use
|
||||||
protocol_version: "0.0"
|
protocol_version: "0.0"
|
||||||
|
|
||||||
|
|
@ -26,6 +30,7 @@ protocol_version: "0.0"
|
||||||
# BadGateway: 20
|
# BadGateway: 20
|
||||||
# ServiceUnavailable: 21
|
# ServiceUnavailable: 21
|
||||||
# GatewayTimeout: 22
|
# GatewayTimeout: 22
|
||||||
|
# Relay: 26
|
||||||
# PipeRequest: 23
|
# PipeRequest: 23
|
||||||
# PipeResponse: 24
|
# PipeResponse: 24
|
||||||
# PipeAbort: 25
|
# PipeAbort: 25
|
||||||
|
|
@ -45,16 +50,29 @@ protocol_version: "0.0"
|
||||||
# ErrorParsing: 11
|
# ErrorParsing: 11
|
||||||
# ErrorMessage: 12
|
# ErrorMessage: 12
|
||||||
# Accepted: 13,
|
# 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.
|
# Types absent from a protocol version cannot be encoded for that version.
|
||||||
|
|
||||||
type_maps:
|
type_maps:
|
||||||
"0.0": # Protocol version 0.0
|
"0.0": # Protocol version 0.0
|
||||||
CommunicationTypes:
|
CommunicationTypes:
|
||||||
|
ProtectedMessage: 32
|
||||||
|
AlternateMessage: 33
|
||||||
DataTypes:
|
DataTypes:
|
||||||
ExampleType: 32
|
ExampleType: 32
|
||||||
"1.0":
|
"1.0":
|
||||||
CommunicationTypes:
|
CommunicationTypes:
|
||||||
|
ProtectedMessage: 32
|
||||||
|
AlternateMessage: 33
|
||||||
DataTypes:
|
DataTypes:
|
||||||
# If a v0.0 client connects
|
# If a v0.0 client connects
|
||||||
# - the server can't use "AnotherType"
|
# - the server can't use "AnotherType"
|
||||||
|
|
@ -64,6 +82,8 @@ type_maps:
|
||||||
SomeType: 34
|
SomeType: 34
|
||||||
"2.0":
|
"2.0":
|
||||||
CommunicationTypes:
|
CommunicationTypes:
|
||||||
|
ProtectedMessage: 32
|
||||||
|
AlternateMessage: 33
|
||||||
DataTypes:
|
DataTypes:
|
||||||
# If a v0.0 client connects
|
# If a v0.0 client connects
|
||||||
# - the server can't use "AnotherType"
|
# - the server can't use "AnotherType"
|
||||||
|
|
|
||||||
39
example/Cargo.lock
generated
39
example/Cargo.lock
generated
|
|
@ -12,6 +12,18 @@ dependencies = [
|
||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "asn1-rs"
|
name = "asn1-rs"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
|
|
@ -131,6 +143,15 @@ version = "2.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
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]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
|
|
@ -433,6 +454,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer 0.10.4",
|
"block-buffer 0.10.4",
|
||||||
"crypto-common 0.1.7",
|
"crypto-common 0.1.7",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1269,6 +1291,7 @@ dependencies = [
|
||||||
"mtp-crypto",
|
"mtp-crypto",
|
||||||
"mtp-type-map",
|
"mtp-type-map",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1293,7 +1316,7 @@ dependencies = [
|
||||||
"ml-dsa",
|
"ml-dsa",
|
||||||
"mlkem-tls",
|
"mlkem-tls",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.6.4",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -1308,6 +1331,7 @@ dependencies = [
|
||||||
name = "mtp-files"
|
name = "mtp-files"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
"mtp-crypto",
|
"mtp-crypto",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
|
|
@ -1336,6 +1360,7 @@ dependencies = [
|
||||||
"mtp-codec",
|
"mtp-codec",
|
||||||
"mtp-common",
|
"mtp-common",
|
||||||
"mtp-crypto",
|
"mtp-crypto",
|
||||||
|
"rand 0.10.2",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-native-certs",
|
"rustls-native-certs",
|
||||||
|
|
@ -1343,6 +1368,7 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"wtransport",
|
"wtransport",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1490,6 +1516,17 @@ dependencies = [
|
||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "pem"
|
name = "pem"
|
||||||
version = "3.0.6"
|
version = "3.0.6"
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ name = "client"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[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"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
rand = "0.10.1"
|
rand = "0.10.1"
|
||||||
tracing-subscriber = "0.3.23"
|
tracing-subscriber = "0.3.23"
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,7 @@ use std::time::{Duration, Instant};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
use mtp::client::{ClientConfig, MTPClient, MTPConnection};
|
use mtp::client::{ClientConfig, MTPClient, MTPConnection};
|
||||||
use mtp::crypto::{
|
use mtp::crypto::{Keyring, PublicKeyBundle};
|
||||||
Ed25519Signer, KemPrivateKey, KemPublicKey, Keyring, MlDsaSigner, PublicKeyBundle,
|
|
||||||
};
|
|
||||||
use mtp::files::{load_keyring_raw, save_keyring_raw};
|
use mtp::files::{load_keyring_raw, save_keyring_raw};
|
||||||
|
|
||||||
pub async fn connect_or_register(
|
pub async fn connect_or_register(
|
||||||
|
|
@ -40,17 +38,8 @@ pub async fn connect_or_register(
|
||||||
|
|
||||||
println!("No existing keys found: registering new client");
|
println!("No existing keys found: registering new client");
|
||||||
|
|
||||||
/* The client authenticates with signatures only, so the KEM slot is empty. */
|
/* Registration publishes a complete MTP identity for later protection. */
|
||||||
let (_ed_signer, sig_sk, sig_pk) = Ed25519Signer::generate();
|
let keyring = Keyring::generate();
|
||||||
let (_pq_signer, sig_pq_sk, sig_pq_pk) = MlDsaSigner::generate();
|
|
||||||
let keyring = Keyring::new(
|
|
||||||
KemPublicKey::new(vec![]),
|
|
||||||
KemPrivateKey::new(vec![]),
|
|
||||||
sig_pq_pk,
|
|
||||||
sig_pq_sk,
|
|
||||||
sig_pk,
|
|
||||||
sig_sk,
|
|
||||||
);
|
|
||||||
|
|
||||||
let reg_started = Instant::now();
|
let reg_started = Instant::now();
|
||||||
let conn = MTPClient::auth_register(config, &keyring, &host_public_key).await?;
|
let conn = MTPClient::auth_register(config, &keyring, &host_public_key).await?;
|
||||||
|
|
@ -63,3 +52,19 @@ pub async fn connect_or_register(
|
||||||
|
|
||||||
Ok((conn, keyring, "register".into(), reg_duration))
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@ mod auth;
|
||||||
mod metrics;
|
mod metrics;
|
||||||
mod messages;
|
mod messages;
|
||||||
mod pipes;
|
mod pipes;
|
||||||
|
mod protected;
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use mtp::client::ClientConfig;
|
use mtp::client::{AuthState, ClientConfig};
|
||||||
use mtp::files::load_public_key_bundle;
|
use mtp::files::load_public_key_bundle;
|
||||||
|
|
||||||
fn dev_cert_path() -> String {
|
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 ...");
|
println!("Connecting to 127.0.0.1:8080 ...");
|
||||||
|
|
||||||
let config = ClientConfig::new("https://127.0.0.1:8080")
|
let config = ClientConfig::new("https://127.0.0.1:8080")
|
||||||
.with_pinned_pem(cert_pem)
|
.with_pinned_pem(cert_pem.clone())
|
||||||
.with_description("MTP example client");
|
.with_description("MTP example client");
|
||||||
|
|
||||||
let server_bundle = host_public_key.clone();
|
let server_bundle = host_public_key.clone();
|
||||||
|
|
@ -62,9 +63,57 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
||||||
let mut builder = metrics::SessionBuilder::new(&auth_method, auth_duration);
|
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?;
|
let roundtrip = messages::send_and_receive(&conn, &keyring, &server_bundle).await?;
|
||||||
builder.set_message_roundtrip(roundtrip);
|
builder.set_message_roundtrip(roundtrip);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Direct protected round-trip: {:.3}ms",
|
||||||
|
direct_roundtrip.as_secs_f64() * 1000.0
|
||||||
|
);
|
||||||
|
|
||||||
println!("\n--- Pipe demo ---");
|
println!("\n--- Pipe demo ---");
|
||||||
let pipe_results = pipes::run_pipe_demo(&conn, 1).await?;
|
let pipe_results = pipes::run_pipe_demo(&conn, 1).await?;
|
||||||
for result in &pipe_results {
|
for result in &pipe_results {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use mtp::client::MTPConnection;
|
use mtp::client::MTPConnection;
|
||||||
|
use mtp::codec::ProtectionPurpose;
|
||||||
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||||
use mtp::crypto::{Ed25519Signer, EncryptionType, Keyring, PublicKeyBundle, SigAlgorithm};
|
use mtp::crypto::{Ed25519Signer, Keyring, PublicKeyBundle};
|
||||||
use mtp::type_map::TypeMap;
|
use mtp::type_map::TypeMap;
|
||||||
|
|
||||||
pub fn build_demo_message(
|
pub fn build_demo_message(
|
||||||
|
|
@ -11,7 +12,6 @@ pub fn build_demo_message(
|
||||||
server_bundle: &PublicKeyBundle,
|
server_bundle: &PublicKeyBundle,
|
||||||
) -> Result<CommunicationValue, Box<dyn std::error::Error>> {
|
) -> Result<CommunicationValue, Box<dyn std::error::Error>> {
|
||||||
// Encrypt to the server's KEM public key; the server decrypts with its keyring.
|
// Encrypt to the server's KEM public key; the server decrypts with its keyring.
|
||||||
let enc_type = EncryptionType::MlKemChaCha20Poly1305;
|
|
||||||
let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)?;
|
let signer = Ed25519Signer::new(&keyring.sig_cl_secret_key)?;
|
||||||
|
|
||||||
let tm = TypeMap::latest();
|
let tm = TypeMap::latest();
|
||||||
|
|
@ -26,15 +26,16 @@ pub fn build_demo_message(
|
||||||
(version_id, DataValue::Str("secret inner data".into())),
|
(version_id, DataValue::Str("secret inner data".into())),
|
||||||
(id_id, DataValue::UnsignedNumber(42)),
|
(id_id, DataValue::UnsignedNumber(42)),
|
||||||
]);
|
]);
|
||||||
let mut dv_enc = inner_enc;
|
let dv_enc = inner_enc.encrypt_for(
|
||||||
dv_enc.encrypt_container(enc_type, server_bundle, b"demo-aad");
|
std::slice::from_ref(server_bundle),
|
||||||
|
ProtectionPurpose::from(1),
|
||||||
|
)?;
|
||||||
|
|
||||||
let inner_sig = DataValue::Container(vec![
|
let inner_sig = DataValue::Container(vec![
|
||||||
(version_id, DataValue::Str("signed by client".into())),
|
(version_id, DataValue::Str("signed by client".into())),
|
||||||
(id_id, DataValue::UnsignedNumber(99)),
|
(id_id, DataValue::UnsignedNumber(99)),
|
||||||
]);
|
]);
|
||||||
let mut dv_sig = inner_sig;
|
let dv_sig = inner_sig.sign(client_id, ProtectionPurpose::from(2), &signer)?;
|
||||||
dv_sig.sign_container(SigAlgorithm::ED25519, &signer);
|
|
||||||
|
|
||||||
let inner_sec = DataValue::Container(vec![
|
let inner_sec = DataValue::Container(vec![
|
||||||
(
|
(
|
||||||
|
|
@ -43,18 +44,16 @@ pub fn build_demo_message(
|
||||||
),
|
),
|
||||||
(id_id, DataValue::UnsignedNumber(7)),
|
(id_id, DataValue::UnsignedNumber(7)),
|
||||||
]);
|
]);
|
||||||
let mut dv_sec = inner_sec;
|
let dv_sec = inner_sec
|
||||||
dv_sec.sign_and_encrypt_container(
|
.sign(client_id, ProtectionPurpose::from(3), &signer)?
|
||||||
SigAlgorithm::ED25519,
|
.encrypt_for(
|
||||||
&signer,
|
std::slice::from_ref(server_bundle),
|
||||||
enc_type,
|
ProtectionPurpose::from(4),
|
||||||
server_bundle,
|
)?;
|
||||||
b"demo-aad",
|
|
||||||
);
|
|
||||||
|
|
||||||
let timestamp = std::time::SystemTime::now()
|
let timestamp = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)?
|
.duration_since(std::time::UNIX_EPOCH)?
|
||||||
.as_secs();
|
.as_millis();
|
||||||
|
|
||||||
let msg = CommunicationValue::new(CommunicationType::Ping)
|
let msg = CommunicationValue::new(CommunicationType::Ping)
|
||||||
.add_typed_default(
|
.add_typed_default(
|
||||||
|
|
@ -101,7 +100,10 @@ pub async fn send_and_receive(
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
let roundtrip = start.elapsed();
|
let roundtrip = start.elapsed();
|
||||||
println!("Received: {resp}");
|
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)
|
Ok(roundtrip)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use mtp::common::unix_time_millis;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
@ -8,6 +9,9 @@ fn now_epoch_secs() -> u64 {
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_secs()
|
.as_secs()
|
||||||
}
|
}
|
||||||
|
fn now_epoch_millis() -> u64 {
|
||||||
|
unix_time_millis().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
fn generate_session_id() -> String {
|
fn generate_session_id() -> String {
|
||||||
let ts = now_epoch_secs();
|
let ts = now_epoch_secs();
|
||||||
|
|
@ -197,7 +201,7 @@ impl SessionBuilder {
|
||||||
pub fn new(auth_method: &str, auth_duration: Duration) -> Self {
|
pub fn new(auth_method: &str, auth_duration: Duration) -> Self {
|
||||||
Self {
|
Self {
|
||||||
session_id: generate_session_id(),
|
session_id: generate_session_id(),
|
||||||
timestamp: now_epoch_secs(),
|
timestamp: now_epoch_millis(),
|
||||||
auth_method: auth_method.to_string(),
|
auth_method: auth_method.to_string(),
|
||||||
auth_duration_ms: auth_duration.as_secs_f64() * 1000.0,
|
auth_duration_ms: auth_duration.as_secs_f64() * 1000.0,
|
||||||
error: None,
|
error: None,
|
||||||
|
|
@ -219,11 +223,7 @@ impl SessionBuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build(self) -> ClientSessionRecord {
|
pub fn build(self) -> ClientSessionRecord {
|
||||||
let total_pipe_bytes: u64 = self
|
let total_pipe_bytes: u64 = self.pipe_results.iter().map(|r| r.size as u64).sum();
|
||||||
.pipe_results
|
|
||||||
.iter()
|
|
||||||
.map(|r| r.size as u64)
|
|
||||||
.sum();
|
|
||||||
|
|
||||||
let overall_pipe_avg_total_ms = if self.pipe_results.is_empty() {
|
let overall_pipe_avg_total_ms = if self.pipe_results.is_empty() {
|
||||||
0.0
|
0.0
|
||||||
|
|
@ -235,7 +235,10 @@ impl SessionBuilder {
|
||||||
let overall_pipe_avg_data_ms = if self.pipe_results.is_empty() {
|
let overall_pipe_avg_data_ms = if self.pipe_results.is_empty() {
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} 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
|
/ self.pipe_results.len() as f64
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
198
example/client/src/protected.rs
Normal file
198
example/client/src/protected.rs
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -4,4 +4,4 @@ version = "0.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
mtp = { version = "0.2.0", path = "../../", features = ["files"] }
|
mtp = { version = "0.2.0", path = "../../", features = ["files", "raw"] }
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ name = "server"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[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"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
http = "1"
|
http = "1"
|
||||||
serde_json = { version = "1" }
|
serde_json = { version = "1" }
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,208 @@
|
||||||
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, TypeMap};
|
use std::collections::HashMap;
|
||||||
use mtp::crypto::{CryptoError, Keyring, SignaturePublicKey, SignatureScheme, verify_ed25519};
|
|
||||||
|
|
||||||
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 {
|
const DIRECT_DESTINATION_ID: u64 = 1;
|
||||||
fn sign(&self, _msg: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
const METADATA_RELAY_ID: u64 = 1;
|
||||||
Err(CryptoError::SigningFailed)
|
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(
|
pub fn process_and_respond(
|
||||||
msg: &CommunicationValue,
|
msg: &CommunicationValue,
|
||||||
tm: &TypeMap,
|
tm: &TypeMap,
|
||||||
client_pk: Option<&mtp::crypto::PublicKeyBundle>,
|
client_pk: Option<&mtp::crypto::PublicKeyBundle>,
|
||||||
|
registered_clients: &HashMap<u64, PublicKeyBundle>,
|
||||||
host_keyring: &Keyring,
|
host_keyring: &Keyring,
|
||||||
|
accepted_direct_messages: &mut InMemoryReplayGuard,
|
||||||
|
accepted_relay_messages: &mut InMemoryReplayGuard,
|
||||||
) -> Result<CommunicationValue, String> {
|
) -> 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(
|
let desc_id = DataTypeId(
|
||||||
tm.data_id_enum(DataType::Description)
|
tm.data_id_enum(DataType::Description)
|
||||||
.ok_or("missing Description type mapping")?,
|
.ok_or("missing Description type mapping")?,
|
||||||
|
|
@ -59,13 +244,34 @@ pub fn process_and_respond(
|
||||||
.ok_or("missing SecurePayload type mapping")?,
|
.ok_or("missing SecurePayload type mapping")?,
|
||||||
);
|
);
|
||||||
|
|
||||||
let description = msg.get_data(DataType::Description);
|
let description = msg
|
||||||
let timestamp = msg.get_data(DataType::Timestamp);
|
.get_data(DataType::Description)
|
||||||
let data = msg.get_data(DataType::Data);
|
.cloned()
|
||||||
let flags = msg.get_data(DataType::Flags);
|
.unwrap_or(DataValue::Null);
|
||||||
let value = msg.get_data(DataType::Value);
|
let timestamp = msg
|
||||||
let binary = msg.get_data(DataType::BinaryData);
|
.get_data(DataType::Timestamp)
|
||||||
let items = msg.get_data(DataType::Items);
|
.cloned()
|
||||||
|
.unwrap_or(DataValue::Null);
|
||||||
|
let data = msg
|
||||||
|
.get_data(DataType::Data)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(DataValue::Null);
|
||||||
|
let flags = msg
|
||||||
|
.get_data(DataType::Flags)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(DataValue::Null);
|
||||||
|
let value = msg
|
||||||
|
.get_data(DataType::Value)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(DataValue::Null);
|
||||||
|
let binary = msg
|
||||||
|
.get_data(DataType::BinaryData)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(DataValue::Null);
|
||||||
|
let items = msg
|
||||||
|
.get_data(DataType::Items)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(DataValue::Null);
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
" Description: {}",
|
" Description: {}",
|
||||||
|
|
@ -82,13 +288,8 @@ pub fn process_and_respond(
|
||||||
let mut sig_status = String::from("SignedPayload: not present");
|
let mut sig_status = String::from("SignedPayload: not present");
|
||||||
let mut secure_status = String::from("SecurePayload: not present");
|
let mut secure_status = String::from("SecurePayload: not present");
|
||||||
|
|
||||||
let enc = msg.get_data(DataType::EncryptedPayload);
|
if let Some(enc @ DataValue::Encrypted(_)) = msg.get_data(DataType::EncryptedPayload) {
|
||||||
if matches!(enc, DataValue::EncryptedContainer(_)) {
|
if let Ok(dv) = enc.decrypt(host_keyring, mtp::codec::ProtectionPurpose::from(1)) {
|
||||||
let mut dv = enc.clone();
|
|
||||||
if dv
|
|
||||||
.decrypt_into_container(host_keyring, b"demo-aad")
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
if let Some(entries) = dv.as_container() {
|
if let Some(entries) = dv.as_container() {
|
||||||
println!(" Decrypted EncryptedPayload: {:?}", entries);
|
println!(" Decrypted EncryptedPayload: {:?}", entries);
|
||||||
enc_status = format!("EncryptedPayload decrypted OK ({} entries)", entries.len());
|
enc_status = format!("EncryptedPayload decrypted OK ({} entries)", entries.len());
|
||||||
|
|
@ -99,13 +300,19 @@ pub fn process_and_respond(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let sig = msg.get_data(DataType::SignedPayload);
|
if let Some(sig @ DataValue::Signed(_)) = msg.get_data(DataType::SignedPayload) {
|
||||||
if matches!(sig, DataValue::SignedContainer(_)) {
|
|
||||||
if let Some(pk_bundle) = client_pk {
|
if let Some(pk_bundle) = client_pk {
|
||||||
let verifier = Ed25519Verifier(pk_bundle.sig_cl_public_key.clone());
|
let signer_id = sig.as_signed().map(|signed| signed.signer_id);
|
||||||
let mut dv = sig.clone();
|
if let Some(signer_id) = signer_id
|
||||||
if dv.verify_into_container(&verifier).is_some() {
|
&& sig
|
||||||
if let Some(entries) = dv.as_container() {
|
.verify(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);
|
println!(" Verified SignedPayload: {:?}", entries);
|
||||||
sig_status = format!("SignedPayload verified OK ({} entries)", entries.len());
|
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 let Some(secure @ DataValue::Encrypted(_)) = msg.get_data(DataType::SecurePayload) {
|
||||||
if matches!(secure, DataValue::SignedEncryptedContainer(_)) {
|
|
||||||
if let Some(pk_bundle) = client_pk {
|
if let Some(pk_bundle) = client_pk {
|
||||||
let verifier = Ed25519Verifier(pk_bundle.sig_cl_public_key.clone());
|
if let Ok(opened) = secure.decrypt(host_keyring, mtp::codec::ProtectionPurpose::from(4))
|
||||||
let mut dv = secure.clone();
|
&& let Some(signed) = opened.as_signed()
|
||||||
if dv
|
&& opened
|
||||||
.decrypt_signed_encrypted_container(host_keyring, b"demo-aad")
|
.verify(
|
||||||
.is_some()
|
signed.signer_id,
|
||||||
&& dv.verify_into_container(&verifier).is_some()
|
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);
|
println!(" Verified SecurePayload: {:?}", entries);
|
||||||
secure_status = format!(
|
secure_status = format!(
|
||||||
"SecurePayload decrypted+verified OK ({} entries)",
|
"SecurePayload decrypted+verified OK ({} entries)",
|
||||||
|
|
@ -149,11 +362,13 @@ pub fn process_and_respond(
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.as_secs();
|
.as_millis();
|
||||||
|
|
||||||
Ok(CommunicationValue::from_comm(CommunicationType::Pong, tm)
|
let response = CommunicationValue::from_comm(CommunicationType::Pong, tm)
|
||||||
.add_data(desc_id, description.clone())
|
.add_data(desc_id, description)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
.add_data(ts_id, DataValue::UnsignedNumber(now as u128))
|
.add_data(ts_id, DataValue::UnsignedNumber(now as u128))
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
.add_data(
|
.add_data(
|
||||||
data_id,
|
data_id,
|
||||||
DataValue::Str(format!(
|
DataValue::Str(format!(
|
||||||
|
|
@ -161,8 +376,14 @@ pub fn process_and_respond(
|
||||||
enc_status, sig_status, secure_status
|
enc_status, sig_status, secure_status
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
.add_data(flags_id, flags.clone())
|
.map_err(|e| e.to_string())?
|
||||||
.add_data(value_id, value.clone())
|
.add_data(flags_id, flags)
|
||||||
.add_data(bin_id, binary.clone())
|
.map_err(|e| e.to_string())?
|
||||||
.add_data(items_id, items.clone()))
|
.add_data(value_id, value)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.add_data(bin_id, binary)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.add_data(items_id, items)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ mod tls;
|
||||||
#[path = "web-server.rs"]
|
#[path = "web-server.rs"]
|
||||||
mod web_server;
|
mod web_server;
|
||||||
|
|
||||||
use mtp::host::HostConfig;
|
use mtp::host::{AuthenticationPolicy, AuthState, HostConfig};
|
||||||
use mtp::type_map::TypeMap;
|
use mtp::type_map::TypeMap;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
@ -136,7 +136,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
host_keyring,
|
host_keyring,
|
||||||
Box::new(get_existing_client),
|
Box::new(get_existing_client),
|
||||||
Box::new(complete_register),
|
Box::new(complete_register),
|
||||||
);
|
)
|
||||||
|
.with_authentication_policy(AuthenticationPolicy::AllowAuthentication);
|
||||||
|
|
||||||
let mut host = mtp::webserver::MTPWebServer::new(config, web_server::config()?).await?;
|
let mut host = mtp::webserver::MTPWebServer::new(config, web_server::config()?).await?;
|
||||||
println!("Server listening on https://{}", host.local_addr());
|
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 decrypt_keyring = Arc::clone(&decrypt_keyring);
|
||||||
let metrics = Arc::clone(&metrics);
|
let metrics = Arc::clone(&metrics);
|
||||||
|
let registered_clients = Arc::clone(&clients);
|
||||||
metrics.record_connection_version(&conn.version.to_string());
|
metrics.record_connection_version(&conn.version.to_string());
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let desc = conn.description.as_deref().unwrap_or("(no description)");
|
let desc = conn.description.as_deref().unwrap_or("(no description)");
|
||||||
|
let connection_state = match &conn.auth_state {
|
||||||
|
AuthState::Authenticated => "authenticated client",
|
||||||
|
AuthState::Unauthenticated => "unauthenticated client",
|
||||||
|
AuthState::Pending => "pending client",
|
||||||
|
AuthState::Failed => "failed client",
|
||||||
|
};
|
||||||
println!(
|
println!(
|
||||||
"\n--- New connection (version {}, remote: {}, description: {desc}) ---",
|
"\n--- New connection (version {}, remote: {}, description: {desc}) ---",
|
||||||
conn.version,
|
conn.version,
|
||||||
|
|
@ -168,7 +176,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
.map(|addr| addr.to_string())
|
.map(|addr| addr.to_string())
|
||||||
.unwrap_or_else(|| "unknown".into())
|
.unwrap_or_else(|| "unknown".into())
|
||||||
);
|
);
|
||||||
println!("Client ID: {}", conn.client_id);
|
println!("Connection state: {connection_state}; MTP ID: {}", conn.client_id);
|
||||||
|
|
||||||
let mut session = metrics.start_session(conn.client_id, desc.to_string());
|
let 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 ...");
|
println!("Waiting for messages / pipe requests ...");
|
||||||
let mut pipe_open = true;
|
let mut pipe_open = true;
|
||||||
let mut message_open = true;
|
let mut message_open = true;
|
||||||
|
let mut accepted_direct_messages = mtp::codec::InMemoryReplayGuard::default();
|
||||||
|
let mut accepted_relay_messages = mtp::codec::InMemoryReplayGuard::default();
|
||||||
let mut exit_reason = "normal".to_string();
|
let mut exit_reason = "normal".to_string();
|
||||||
|
|
||||||
while pipe_open || message_open {
|
while pipe_open || message_open {
|
||||||
|
|
@ -215,11 +225,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
Ok(message) => {
|
Ok(message) => {
|
||||||
println!("Received: {message}");
|
println!("Received: {message}");
|
||||||
let msg_start = std::time::Instant::now();
|
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(
|
let result = handlers::process_and_respond(
|
||||||
&message,
|
&message,
|
||||||
tm,
|
tm,
|
||||||
conn.client_public_key.as_ref(),
|
conn.client_public_key.as_ref(),
|
||||||
|
®istered_clients,
|
||||||
&decrypt_keyring,
|
&decrypt_keyring,
|
||||||
|
&mut accepted_direct_messages,
|
||||||
|
&mut accepted_relay_messages,
|
||||||
);
|
);
|
||||||
let latency = msg_start.elapsed();
|
let latency = msg_start.elapsed();
|
||||||
let ok = result.is_ok();
|
let ok = result.is_ok();
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
pub struct SessionHandle<'a> {
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,13 @@
|
||||||
protocol_version: "1.0"
|
protocol_version: "3.0"
|
||||||
|
|
||||||
type_maps:
|
type_maps:
|
||||||
"0.0":
|
"3.0":
|
||||||
CommunicationTypes:
|
CommunicationTypes:
|
||||||
|
ProtectedMessage: 32
|
||||||
|
AlternateMessage: 33
|
||||||
DataTypes:
|
DataTypes:
|
||||||
"1.0":
|
|
||||||
CommunicationTypes:
|
|
||||||
CommunicationType: 32
|
|
||||||
DataTypes:
|
|
||||||
Data: 32
|
|
||||||
Flags: 33
|
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
|
Data: 34
|
||||||
Flags: 33
|
|
||||||
Value: 35
|
Value: 35
|
||||||
BinaryData: 36
|
BinaryData: 36
|
||||||
Items: 37
|
Items: 37
|
||||||
|
|
@ -32,3 +16,7 @@ type_maps:
|
||||||
SecurePayload: 40
|
SecurePayload: 40
|
||||||
CommunicationType: 41
|
CommunicationType: 41
|
||||||
DataType: 42
|
DataType: 42
|
||||||
|
ExampleText: 43
|
||||||
|
ExampleNumber: 44
|
||||||
|
ExampleRole: 45
|
||||||
|
ExampleMetadata: 46
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,9 @@
|
||||||
Use new credentials
|
Use new credentials
|
||||||
</button>
|
</button>
|
||||||
<button id="connect" type="button" disabled>Connect</button>
|
<button id="connect" type="button" disabled>Connect</button>
|
||||||
|
<button id="connect-unauthenticated" type="button" disabled>
|
||||||
|
Connect Unauthenticated
|
||||||
|
</button>
|
||||||
<button id="clear-keys" type="button">Clear saved keys</button>
|
<button id="clear-keys" type="button">Clear saved keys</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ const GENERATE_KEYPAIR = document.getElementById(
|
||||||
"generate-keypair",
|
"generate-keypair",
|
||||||
) as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
const CONNECT = document.getElementById("connect") as HTMLButtonElement;
|
const CONNECT = document.getElementById("connect") as HTMLButtonElement;
|
||||||
|
const CONNECT_UNAUTHENTICATED = document.getElementById(
|
||||||
|
"connect-unauthenticated",
|
||||||
|
) as HTMLButtonElement;
|
||||||
const CLEAR_KEYS = document.getElementById("clear-keys") as HTMLButtonElement;
|
const CLEAR_KEYS = document.getElementById("clear-keys") as HTMLButtonElement;
|
||||||
const STREAM_MIC = document.getElementById("stream-mic") as HTMLButtonElement;
|
const STREAM_MIC = document.getElementById("stream-mic") as HTMLButtonElement;
|
||||||
const STOP_MIC = document.getElementById("stop-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 = {
|
type SavedKeys = {
|
||||||
clientId: string | null;
|
clientId: string | null;
|
||||||
keyring?: number[];
|
keyring?: number[];
|
||||||
keyringBytes?: number[];
|
|
||||||
hostPublicKey?: number[];
|
hostPublicKey?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -290,7 +292,7 @@ function loadKeys() {
|
||||||
|
|
||||||
const data = JSON.parse(raw) as SavedKeys;
|
const data = JSON.parse(raw) as SavedKeys;
|
||||||
clientId = data.clientId ? BigInt(data.clientId) : null;
|
clientId = data.clientId ? BigInt(data.clientId) : null;
|
||||||
const keyringLength = (data.keyring ?? data.keyringBytes ?? []).length;
|
const keyringLength = (data.keyring ?? []).length;
|
||||||
CLIENT_CREDENTIALS.value = renderStructured({
|
CLIENT_CREDENTIALS.value = renderStructured({
|
||||||
clientId: data.clientId,
|
clientId: data.clientId,
|
||||||
keyringBytes: keyringLength,
|
keyringBytes: keyringLength,
|
||||||
|
|
@ -352,6 +354,7 @@ async function initWasm() {
|
||||||
const supported = MTPClient.isSupported();
|
const supported = MTPClient.isSupported();
|
||||||
log(`WASM loaded. WebTransport supported: ${supported}`);
|
log(`WASM loaded. WebTransport supported: ${supported}`);
|
||||||
CONNECT.disabled = !supported;
|
CONNECT.disabled = !supported;
|
||||||
|
CONNECT_UNAUTHENTICATED.disabled = !supported;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createClient() {
|
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() {
|
async function startMicStreaming() {
|
||||||
if (!activeClient) {
|
if (!activeClient) {
|
||||||
pipeLog("No active client connection.", "error");
|
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", () => {
|
CLEAR_KEYS.addEventListener("click", () => {
|
||||||
clientId = null;
|
clientId = null;
|
||||||
CLIENT_CREDENTIALS.value = "";
|
CLIENT_CREDENTIALS.value = "";
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,12 @@ edition = "2024"
|
||||||
# Only the plain key types (`Keyring`, `PublicKeyBundle`, `CryptoError`) are
|
# Only the plain key types (`Keyring`, `PublicKeyBundle`, `CryptoError`) are
|
||||||
# needed here; those are always compiled, so no crypto features are required.
|
# 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"] }
|
mtp-crypto = { version = "0.2.0", path = "../crypto", default-features = false, features = ["chacha20poly1305", "hkdf"] }
|
||||||
|
argon2 = "0.5"
|
||||||
rand = "0.10.2"
|
rand = "0.10.2"
|
||||||
|
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
zeroize = "1.9"
|
zeroize = "1.9"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Plain private-key files are only needed by migration tooling and tests.
|
||||||
|
raw = []
|
||||||
|
|
|
||||||
125
files/src/lib.rs
125
files/src/lib.rs
|
|
@ -13,7 +13,7 @@ use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
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 rand::RngExt;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
@ -29,11 +29,15 @@ pub const BUNDLE_EXTENSION: &str = "mpkb";
|
||||||
const KEYRING_MAGIC: [u8; 4] = *b"MTMK"; /* Methanium Keyring */
|
const KEYRING_MAGIC: [u8; 4] = *b"MTMK"; /* Methanium Keyring */
|
||||||
const BUNDLE_MAGIC: [u8; 4] = *b"MPKB"; /* Methanium Public Key Bundle */
|
const BUNDLE_MAGIC: [u8; 4] = *b"MPKB"; /* Methanium Public Key Bundle */
|
||||||
const RAW_FORMAT_VERSION: u8 = 1;
|
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 BUNDLE_FORMAT_VERSION: u8 = 1;
|
||||||
const HEADER_LEN: usize = 4 + 1;
|
const HEADER_LEN: usize = 4 + 1;
|
||||||
const SALT_LEN: usize = 32;
|
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)]
|
#[derive(Error, Debug)]
|
||||||
pub enum FileError {
|
pub enum FileError {
|
||||||
|
|
@ -145,7 +149,39 @@ fn write_secret_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||||
Ok(())
|
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(
|
pub fn save_keyring(
|
||||||
keyring: &Keyring,
|
keyring: &Keyring,
|
||||||
path: impl AsRef<Path>,
|
path: impl AsRef<Path>,
|
||||||
|
|
@ -156,16 +192,24 @@ pub fn save_keyring(
|
||||||
}
|
}
|
||||||
let mut salt = [0u8; SALT_LEN];
|
let mut salt = [0u8; SALT_LEN];
|
||||||
rand::rng().fill(&mut salt);
|
rand::rng().fill(&mut salt);
|
||||||
let key = Zeroizing::new(derive_encryption_key(
|
let key = derive_key(
|
||||||
passphrase,
|
passphrase,
|
||||||
&salt,
|
&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 cipher = ChaCha20Poly1305::new(*key);
|
||||||
let plaintext = keyring.to_bytes();
|
let plaintext = keyring.to_bytes();
|
||||||
let encrypted = cipher.encrypt(&plaintext, &KEYRING_MAGIC)?;
|
let encrypted = cipher.encrypt(&plaintext, &protected_header_aad(¶meters))?;
|
||||||
let mut payload = Vec::with_capacity(SALT_LEN + encrypted.len());
|
let mut payload = Vec::with_capacity(PROTECTED_PARAMS_LEN + encrypted.len());
|
||||||
payload.extend_from_slice(&salt);
|
payload.extend_from_slice(¶meters);
|
||||||
payload.extend_from_slice(&encrypted);
|
payload.extend_from_slice(&encrypted);
|
||||||
let bytes = encode(KEYRING_MAGIC, PROTECTED_FORMAT_VERSION, &payload);
|
let bytes = encode(KEYRING_MAGIC, PROTECTED_FORMAT_VERSION, &payload);
|
||||||
write_secret_atomic(path.as_ref(), &bytes)?;
|
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,
|
found: version,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let salt = payload
|
if payload.len() < PROTECTED_PARAMS_LEN {
|
||||||
.get(..SALT_LEN)
|
return Err(FileError::Truncated(bytes.len()));
|
||||||
.ok_or(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
|
let encrypted = payload
|
||||||
.get(SALT_LEN..)
|
.get(PROTECTED_PARAMS_LEN..)
|
||||||
.ok_or(FileError::Truncated(bytes.len()))?;
|
.ok_or(FileError::Truncated(bytes.len()))?;
|
||||||
let key = Zeroizing::new(derive_encryption_key(
|
let key = derive_key(passphrase, salt, memory_kib, iterations, lanes)?;
|
||||||
passphrase,
|
|
||||||
salt,
|
|
||||||
KEYRING_KDF_CONTEXT,
|
|
||||||
)?);
|
|
||||||
let cipher = ChaCha20Poly1305::new(*key);
|
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)?)
|
Ok(Keyring::from_bytes(&plaintext)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Explicitly save the legacy plaintext format for tests and development.
|
/// 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> {
|
pub fn save_keyring_raw(keyring: &Keyring, path: impl AsRef<Path>) -> Result<(), FileError> {
|
||||||
let payload = keyring.to_bytes();
|
let payload = keyring.to_bytes();
|
||||||
let bytes = Zeroizing::new(encode(KEYRING_MAGIC, RAW_FORMAT_VERSION, &payload));
|
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.
|
/// 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> {
|
pub fn load_keyring_raw(path: impl AsRef<Path>) -> Result<Keyring, FileError> {
|
||||||
let bytes = Zeroizing::new(fs::read(path)?);
|
let bytes = Zeroizing::new(fs::read(path)?);
|
||||||
let (version, payload) = decode(&bytes, KEYRING_MAGIC, "keyring")?;
|
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,
|
found: version,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(PublicKeyBundle::from_bytes(payload)?)
|
Ok(PublicKeyBundle::from_bytes_validated(payload)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use mtp_crypto::keypair::{
|
use mtp_crypto::keypair::{
|
||||||
KemPrivateKey, KemPublicKey, SignaturePqPrivateKey, SignaturePqPublicKey,
|
KEM_PUBLIC_KEY_LEN, KemPrivateKey, KemPublicKey, SIG_CL_PUBLIC_KEY_LEN,
|
||||||
SignaturePrivateKey, SignaturePublicKey,
|
SIG_PQ_PUBLIC_KEY_LEN, SignaturePqPrivateKey, SignaturePqPublicKey, SignaturePrivateKey,
|
||||||
|
SignaturePublicKey,
|
||||||
};
|
};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
|
@ -268,11 +324,11 @@ mod tests {
|
||||||
|
|
||||||
fn sample_keyring() -> Keyring {
|
fn sample_keyring() -> Keyring {
|
||||||
Keyring::new(
|
Keyring::new(
|
||||||
KemPublicKey::new(vec![1u8; 32]),
|
KemPublicKey::new(vec![1u8; KEM_PUBLIC_KEY_LEN]),
|
||||||
KemPrivateKey::new(vec![2u8; 32]),
|
KemPrivateKey::new(vec![2u8; 32]),
|
||||||
SignaturePqPublicKey::new(vec![3u8; 64]),
|
SignaturePqPublicKey::new(vec![3u8; SIG_PQ_PUBLIC_KEY_LEN]),
|
||||||
SignaturePqPrivateKey::new(vec![4u8; 64]),
|
SignaturePqPrivateKey::new(vec![4u8; 64]),
|
||||||
SignaturePublicKey::new(vec![5u8; 32]),
|
SignaturePublicKey::new(vec![5u8; SIG_CL_PUBLIC_KEY_LEN]),
|
||||||
SignaturePrivateKey::new(vec![6u8; 32]),
|
SignaturePrivateKey::new(vec![6u8; 32]),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -377,4 +433,21 @@ mod tests {
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
flake.nix
11
flake.nix
|
|
@ -40,7 +40,7 @@
|
||||||
name = "mtp-clippy";
|
name = "mtp-clippy";
|
||||||
runtimeInputs = [rustToolchain];
|
runtimeInputs = [rustToolchain];
|
||||||
text = ''
|
text = ''
|
||||||
export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example-type-maps.yaml}"
|
export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}"
|
||||||
cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings -W unreachable-pub
|
cargo clippy --workspace --exclude mtp-wasm --all-targets --all-features -- -D warnings -W unreachable-pub
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
@ -57,7 +57,7 @@
|
||||||
name = "mtp-build-all";
|
name = "mtp-build-all";
|
||||||
runtimeInputs = [rustToolchain pkgs.cargo-deny pkgs.wasm-pack pkgs.pnpm pkgs.coreutils clippyCheck macheteCheck];
|
runtimeInputs = [rustToolchain pkgs.cargo-deny pkgs.wasm-pack pkgs.pnpm pkgs.coreutils clippyCheck macheteCheck];
|
||||||
text = ''
|
text = ''
|
||||||
export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example-type-maps.yaml}"
|
export MTP_TYPE_MAPS="''${MTP_TYPE_MAPS:-$PWD/example/type-maps.yaml}"
|
||||||
|
|
||||||
timeout 60s pnpm install --frozen-lockfile
|
timeout 60s pnpm install --frozen-lockfile
|
||||||
cargo fmt --all --check
|
cargo fmt --all --check
|
||||||
|
|
@ -69,6 +69,11 @@
|
||||||
mtp-machete
|
mtp-machete
|
||||||
pnpm run dup
|
pnpm run dup
|
||||||
pnpm run build
|
pnpm run build
|
||||||
|
RUSTFLAGS="--cfg web_sys_unstable_apis" wasm-pack test --node wasm
|
||||||
|
pnpm run test:e2e
|
||||||
|
pnpm run test:secrets
|
||||||
|
pnpm run test:types
|
||||||
|
pnpm run check:boundary
|
||||||
pnpm --filter mtp-web-client run build
|
pnpm --filter mtp-web-client run build
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
@ -96,7 +101,7 @@
|
||||||
openssl
|
openssl
|
||||||
];
|
];
|
||||||
|
|
||||||
MTP_TYPE_MAPS = "${toString ./example-type-maps.yaml}";
|
MTP_TYPE_MAPS = "${toString ./example/type-maps.yaml}";
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
use std::collections::HashMap;
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
use std::collections::HashSet;
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
use tokio::time::Duration;
|
use tokio::time::Duration;
|
||||||
|
|
||||||
pub use mtp_transport::Policy;
|
pub use mtp_transport::Policy;
|
||||||
|
|
@ -26,18 +32,23 @@ pub type GetExistingClient = Box<
|
||||||
|
|
||||||
/// Callback that assigns a guest (unauthenticated) client ID.
|
/// Callback that assigns a guest (unauthenticated) client ID.
|
||||||
///
|
///
|
||||||
/// Return `Some(id)` to accept the guest with the given ID, or `None` to reject
|
/// Return `Some(id)` to accept the guest with the given full-width `u64` ID, or
|
||||||
/// the connection. The returned ID must fit in 48 bits
|
/// `None` to reject the connection.
|
||||||
/// (`id <= mtp_codec::MAX_WIRE_ID`); values outside that range are rejected
|
|
||||||
/// automatically.
|
|
||||||
///
|
///
|
||||||
/// When set to `None` on `HostConfig`, the built-in generator produces a random
|
/// 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")]
|
#[cfg(feature = "crypto")]
|
||||||
pub type GuestIdGenerator =
|
pub type GuestIdGenerator =
|
||||||
Box<dyn Fn() -> Pin<Box<dyn std::future::Future<Output = Option<u64>> + Send>> + Send + Sync>;
|
Box<dyn Fn() -> Pin<Box<dyn std::future::Future<Output = Option<u64>> + Send>> + Send + Sync>;
|
||||||
|
|
||||||
#[cfg(feature = "crypto")]
|
#[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<
|
pub type CompleteRegister = Box<
|
||||||
dyn Fn(
|
dyn Fn(
|
||||||
mtp_crypto::PublicKeyBundle,
|
mtp_crypto::PublicKeyBundle,
|
||||||
|
|
@ -47,6 +58,22 @@ pub type CompleteRegister = Box<
|
||||||
+ Sync,
|
+ 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")]
|
#[cfg(feature = "crypto")]
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AuthenticationPolicy {
|
pub enum AuthenticationPolicy {
|
||||||
|
|
@ -75,9 +102,17 @@ pub struct HostConfig {
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
pub get_existing_client: GetExistingClient,
|
pub get_existing_client: GetExistingClient,
|
||||||
#[cfg(feature = "crypto")]
|
#[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>,
|
pub guest_id_generator: Option<GuestIdGenerator>,
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
pub complete_register: CompleteRegister,
|
pub complete_register: CompleteRegister,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub find_registered_client: Option<FindRegisteredClient>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HostConfig {
|
impl HostConfig {
|
||||||
|
|
@ -107,9 +142,17 @@ impl HostConfig {
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
get_existing_client: Box::new(|_, _| Box::pin(async { None })),
|
get_existing_client: Box::new(|_, _| Box::pin(async { None })),
|
||||||
#[cfg(feature = "crypto")]
|
#[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,
|
guest_id_generator: None,
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
complete_register: Box::new(|_, _| Box::pin(async { 0 })),
|
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.guest_id_generator = Some(generator);
|
||||||
self
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,12 +77,30 @@ pub struct MTPConnection<
|
||||||
pub(crate) _pipe_stream: std::marker::PhantomData<P>,
|
pub(crate) _pipe_stream: std::marker::PhantomData<P>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub(crate) _dispatcher_task: tokio::task::JoinHandle<()>,
|
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")]
|
#[cfg(feature = "crypto")]
|
||||||
pub auth_state: crate::error::AuthState,
|
pub auth_state: crate::error::AuthState,
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
pub client_id: u64,
|
pub client_id: u64,
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
pub client_public_key: Option<mtp_crypto::PublicKeyBundle>,
|
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")]
|
#[cfg(feature = "pipes")]
|
||||||
|
|
@ -133,6 +151,7 @@ where
|
||||||
pending_creations: Mutex::new(std::collections::HashMap::new()),
|
pending_creations: Mutex::new(std::collections::HashMap::new()),
|
||||||
pending_pipes: Mutex::new(std::collections::HashMap::new()),
|
pending_pipes: Mutex::new(std::collections::HashMap::new()),
|
||||||
policy,
|
policy,
|
||||||
|
type_map: codec.type_map().clone(),
|
||||||
});
|
});
|
||||||
let task = tokio::spawn(run_dispatcher(
|
let task = tokio::spawn(run_dispatcher(
|
||||||
receiver.clone(),
|
receiver.clone(),
|
||||||
|
|
@ -153,12 +172,15 @@ where
|
||||||
pipe_dispatcher: dispatcher,
|
pipe_dispatcher: dispatcher,
|
||||||
description,
|
description,
|
||||||
_dispatcher_task: task,
|
_dispatcher_task: task,
|
||||||
|
_connection_guard: None,
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
auth_state: crate::error::AuthState::Unauthenticated,
|
auth_state: crate::error::AuthState::Unauthenticated,
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
client_id: random_client_id(),
|
client_id: random_client_id(),
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
client_public_key: None,
|
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_creations: Mutex::new(std::collections::HashMap::new()),
|
||||||
pending_pipes: Mutex::new(std::collections::HashMap::new()),
|
pending_pipes: Mutex::new(std::collections::HashMap::new()),
|
||||||
policy,
|
policy,
|
||||||
|
type_map: codec.type_map().clone(),
|
||||||
});
|
});
|
||||||
let task = tokio::spawn(run_dispatcher(
|
let task = tokio::spawn(run_dispatcher(
|
||||||
receiver.clone(),
|
receiver.clone(),
|
||||||
|
|
@ -202,12 +225,15 @@ where
|
||||||
pipe_dispatcher: dispatcher,
|
pipe_dispatcher: dispatcher,
|
||||||
description,
|
description,
|
||||||
_dispatcher_task: task,
|
_dispatcher_task: task,
|
||||||
|
_connection_guard: None,
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
auth_state: crate::error::AuthState::Unauthenticated,
|
auth_state: crate::error::AuthState::Unauthenticated,
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
client_id: random_client_id(),
|
client_id: random_client_id(),
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
client_public_key: None,
|
client_public_key: None,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
guest_id_lease: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -252,12 +278,15 @@ impl<S, R, P> MTPConnection<S, R, P> {
|
||||||
description,
|
description,
|
||||||
_pipe_stream: std::marker::PhantomData,
|
_pipe_stream: std::marker::PhantomData,
|
||||||
_dispatcher_task: tokio::spawn(async {}),
|
_dispatcher_task: tokio::spawn(async {}),
|
||||||
|
_connection_guard: None,
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
auth_state: crate::error::AuthState::Unauthenticated,
|
auth_state: crate::error::AuthState::Unauthenticated,
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
client_id: random_client_id(),
|
client_id: random_client_id(),
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
client_public_key: None,
|
client_public_key: None,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
guest_id_lease: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -293,21 +322,33 @@ where
|
||||||
&self,
|
&self,
|
||||||
description: &str,
|
description: &str,
|
||||||
) -> Result<crate::pipe::PipeHandle<S>, mtp_common::PipeError> {
|
) -> Result<crate::pipe::PipeHandle<S>, mtp_common::PipeError> {
|
||||||
let pipe_id = rand::random::<u32>();
|
|
||||||
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
|
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
|
||||||
self.pipe_dispatcher
|
let pipe_id = {
|
||||||
.pending_creations
|
let mut pending = self.pipe_dispatcher.pending_creations.lock().await;
|
||||||
.lock()
|
let pipe_id = loop {
|
||||||
.await
|
let candidate = rand::random::<u32>();
|
||||||
.insert(pipe_id, response_tx);
|
if candidate != 0 && !pending.contains_key(&candidate) {
|
||||||
|
break candidate;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pending.insert(pipe_id, response_tx);
|
||||||
|
pipe_id
|
||||||
|
};
|
||||||
|
|
||||||
let request = CommunicationValue::new(CommunicationType::PipeRequest)
|
let request = CommunicationValue::new_with_type_map(
|
||||||
.with_id(pipe_id)
|
CommunicationType::PipeRequest,
|
||||||
.add_typed_default(DataType::Description, DataValue::Str(description.into()));
|
self.codec.type_map(),
|
||||||
self.sender
|
)
|
||||||
.send_pipe_message(&request)
|
.with_id(pipe_id)
|
||||||
.await
|
.add_typed_default(DataType::Description, DataValue::Str(description.into()));
|
||||||
.map_err(mtp_common::PipeError::from)?;
|
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 {
|
Ok(crate::pipe::PipeHandle {
|
||||||
pipe_id,
|
pipe_id,
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,15 @@
|
||||||
use crate::config::HostConfig;
|
use crate::config::HostConfig;
|
||||||
use crate::error::AcceptError;
|
use crate::error::AcceptError;
|
||||||
use mtp_codec::{
|
use mtp_codec::{
|
||||||
CommunicationType, CommunicationValue, DataType, DataValue, Version,
|
CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, Version,
|
||||||
registry::{Registry, VersionedCodec},
|
registry::{Registry, VersionedCodec},
|
||||||
};
|
};
|
||||||
use mtp_common::{CommunicationError, RejectionReason};
|
use mtp_common::{CommunicationError, RejectionReason};
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
/// Trait for sending handshake messages during the opening exchange.
|
/// Trait for sending handshake messages during the opening exchange.
|
||||||
///
|
///
|
||||||
|
|
@ -24,6 +28,9 @@ pub trait HandshakeSender: Send + Sync {
|
||||||
fn finish_stream(
|
fn finish_stream(
|
||||||
&self,
|
&self,
|
||||||
) -> impl std::future::Future<Output = Result<(), CommunicationError>> + Send;
|
) -> 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);
|
fn close(&self);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,6 +41,37 @@ pub trait HandshakeReceiver: Send + Sync {
|
||||||
fn receive(
|
fn receive(
|
||||||
&self,
|
&self,
|
||||||
) -> impl std::future::Future<Output = Result<CommunicationValue, CommunicationError>> + Send;
|
) -> 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
|
/// The result of a successful handshake, containing everything needed to
|
||||||
|
|
@ -49,6 +87,8 @@ pub struct HandshakeResult {
|
||||||
pub client_id: u64,
|
pub client_id: u64,
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
pub client_public_key: Option<mtp_crypto::PublicKeyBundle>,
|
pub client_public_key: Option<mtp_crypto::PublicKeyBundle>,
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub guest_id_lease: Option<GuestIdLease>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transport-independent handshake state machine.
|
/// Transport-independent handshake state machine.
|
||||||
|
|
@ -90,13 +130,56 @@ impl HandshakeEngine {
|
||||||
) -> Result<HandshakeResult, AcceptError> {
|
) -> Result<HandshakeResult, AcceptError> {
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
{
|
{
|
||||||
let timeout = self.config.auth_timeout;
|
self.accept_until(
|
||||||
tokio::time::timeout(timeout, self.accept_inner(sender, receiver))
|
sender,
|
||||||
.await
|
receiver,
|
||||||
.unwrap_or(Err(AcceptError::AuthenticationTimedOut))
|
tokio::time::Instant::now() + self.config.auth_timeout,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "crypto"))]
|
#[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>(
|
async fn accept_inner<S: HandshakeSender, R: HandshakeReceiver>(
|
||||||
|
|
@ -104,16 +187,17 @@ impl HandshakeEngine {
|
||||||
sender: &S,
|
sender: &S,
|
||||||
receiver: &R,
|
receiver: &R,
|
||||||
) -> Result<HandshakeResult, AcceptError> {
|
) -> 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) {
|
let version_str = match first_msg.get_data(DataType::Version) {
|
||||||
DataValue::Str(s) => s.clone(),
|
Some(DataValue::Str(s)) => s.clone(),
|
||||||
_ => {
|
_ => {
|
||||||
send_rejection_generic(
|
send_rejection_generic(
|
||||||
sender,
|
sender,
|
||||||
RejectionReason::AuthenticationFailed {
|
RejectionReason::AuthenticationFailed {
|
||||||
detail: "opening message omitted a valid protocol version".into(),
|
detail: "opening message omitted a valid protocol version".into(),
|
||||||
},
|
},
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
sender.close();
|
sender.close();
|
||||||
|
|
@ -128,6 +212,7 @@ impl HandshakeEngine {
|
||||||
RejectionReason::AuthenticationFailed {
|
RejectionReason::AuthenticationFailed {
|
||||||
detail: "opening message omitted a valid protocol version".into(),
|
detail: "opening message omitted a valid protocol version".into(),
|
||||||
},
|
},
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
sender.close();
|
sender.close();
|
||||||
|
|
@ -150,6 +235,7 @@ impl HandshakeEngine {
|
||||||
.map(|v| v.to_string())
|
.map(|v| v.to_string())
|
||||||
.collect(),
|
.collect(),
|
||||||
},
|
},
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
sender.close();
|
sender.close();
|
||||||
|
|
@ -160,8 +246,12 @@ impl HandshakeEngine {
|
||||||
let codec = VersionedCodec::for_version(self.registry.clone(), negotiated.clone())
|
let codec = VersionedCodec::for_version(self.registry.clone(), negotiated.clone())
|
||||||
.ok_or_else(|| AcceptError::UnsupportedVersion(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) {
|
let description = match first_msg.get_data(DataType::Description) {
|
||||||
DataValue::Str(s) => Some(s.clone()),
|
Some(DataValue::Str(s)) => Some(s.clone()),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -209,9 +299,28 @@ impl HandshakeEngine {
|
||||||
|
|
||||||
#[cfg(not(feature = "crypto"))]
|
#[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 _ = receiver;
|
||||||
let _ = first_msg;
|
send_accepted_generic(sender, &negotiated, codec.type_map(), Some(0))
|
||||||
|
.await
|
||||||
|
.map_err(AcceptError::Send)?;
|
||||||
Ok(HandshakeResult {
|
Ok(HandshakeResult {
|
||||||
negotiated_version: negotiated,
|
negotiated_version: negotiated,
|
||||||
codec,
|
codec,
|
||||||
|
|
@ -229,15 +338,21 @@ impl HandshakeEngine {
|
||||||
codec: VersionedCodec,
|
codec: VersionedCodec,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
) -> Result<HandshakeResult, AcceptError> {
|
) -> Result<HandshakeResult, AcceptError> {
|
||||||
let tm = mtp_codec::TypeMap::latest();
|
let tm = codec.type_map();
|
||||||
|
|
||||||
// Reject Register frames on unauthenticated hosts
|
// Reject explicit authentication attempts on unauthenticated hosts.
|
||||||
if Some(first_msg.get_type()) == CommunicationType::Register.try_to_id(&tm) {
|
// 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(
|
send_rejection_generic(
|
||||||
sender,
|
sender,
|
||||||
RejectionReason::AuthenticationFailed {
|
RejectionReason::AuthenticationFailed {
|
||||||
detail: "authentication not allowed on this host".into(),
|
detail: "authentication not allowed on this host".into(),
|
||||||
},
|
},
|
||||||
|
Some(tm),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
sender.close();
|
sender.close();
|
||||||
|
|
@ -246,8 +361,15 @@ impl HandshakeEngine {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let guest_id = self.assign_guest_id().await?;
|
let guest_id_lease = match self.assign_guest_id().await {
|
||||||
send_accepted_generic(sender, &negotiated, Some(guest_id))
|
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
|
.await
|
||||||
.map_err(AcceptError::Send)?;
|
.map_err(AcceptError::Send)?;
|
||||||
|
|
||||||
|
|
@ -258,6 +380,7 @@ impl HandshakeEngine {
|
||||||
auth_state: crate::error::AuthState::Unauthenticated,
|
auth_state: crate::error::AuthState::Unauthenticated,
|
||||||
client_id: guest_id,
|
client_id: guest_id,
|
||||||
client_public_key: None,
|
client_public_key: None,
|
||||||
|
guest_id_lease: Some(guest_id_lease),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -274,11 +397,17 @@ impl HandshakeEngine {
|
||||||
version_str: &str,
|
version_str: &str,
|
||||||
client_version: Version,
|
client_version: Version,
|
||||||
) -> Result<HandshakeResult, AcceptError> {
|
) -> Result<HandshakeResult, AcceptError> {
|
||||||
let tm = mtp_codec::TypeMap::latest();
|
let tm = codec.type_map();
|
||||||
|
|
||||||
// Register frames always go through full authentication
|
// Register frames always go through full authentication
|
||||||
if Some(first_msg.get_type()) == CommunicationType::Register.try_to_id(&tm) {
|
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();
|
let pk_bytes = bundle.as_bytes();
|
||||||
return self
|
return self
|
||||||
.complete_auth_handshake(
|
.complete_auth_handshake(
|
||||||
|
|
@ -298,7 +427,7 @@ impl HandshakeEngine {
|
||||||
// Identification: try lookup, fall back to guest
|
// Identification: try lookup, fall back to guest
|
||||||
if Some(first_msg.get_type()) == CommunicationType::Identification.try_to_id(&tm) {
|
if Some(first_msg.get_type()) == CommunicationType::Identification.try_to_id(&tm) {
|
||||||
let cid = match first_msg.get_data(DataType::Id) {
|
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,
|
_ => 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -321,9 +450,26 @@ impl HandshakeEngine {
|
||||||
.await;
|
.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
|
// Unknown or zero ID: fall back to guest
|
||||||
let guest_id = self.assign_guest_id().await?;
|
let guest_id_lease = match self.assign_guest_id().await {
|
||||||
send_accepted_generic(sender, &negotiated, Some(guest_id))
|
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
|
.await
|
||||||
.map_err(AcceptError::Send)?;
|
.map_err(AcceptError::Send)?;
|
||||||
return Ok(HandshakeResult {
|
return Ok(HandshakeResult {
|
||||||
|
|
@ -333,13 +479,13 @@ impl HandshakeEngine {
|
||||||
auth_state: crate::error::AuthState::Unauthenticated,
|
auth_state: crate::error::AuthState::Unauthenticated,
|
||||||
client_id: guest_id,
|
client_id: guest_id,
|
||||||
client_public_key: None,
|
client_public_key: None,
|
||||||
|
guest_id_lease: Some(guest_id_lease),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
sender.close();
|
let error = AcceptError::AuthenticationFailed("unexpected message type".into());
|
||||||
Err(AcceptError::AuthenticationFailed(
|
reject_error_generic(sender, &error, tm).await;
|
||||||
"unexpected message type".into(),
|
Err(error)
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
|
|
@ -355,30 +501,39 @@ impl HandshakeEngine {
|
||||||
version_str: &str,
|
version_str: &str,
|
||||||
client_version: Version,
|
client_version: Version,
|
||||||
) -> Result<HandshakeResult, AcceptError> {
|
) -> Result<HandshakeResult, AcceptError> {
|
||||||
let tm = mtp_codec::TypeMap::latest();
|
let tm = codec.type_map();
|
||||||
|
|
||||||
let (flow, response_type) = if Some(first_msg.get_type())
|
let (flow, response_type) = if Some(first_msg.get_type())
|
||||||
== CommunicationType::Identification.try_to_id(&tm)
|
== CommunicationType::Identification.try_to_id(&tm)
|
||||||
{
|
{
|
||||||
let cid = match first_msg.get_data(DataType::Id) {
|
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();
|
let error = AcceptError::AuthenticationFailed("missing client id".into());
|
||||||
return Err(AcceptError::AuthenticationFailed(
|
reject_error_generic(sender, &error, tm).await;
|
||||||
"missing client id".into(),
|
return Err(error);
|
||||||
));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let bundle = match (self.config.get_existing_client)(cid, description.clone()).await {
|
let bundle = match (self.config.get_existing_client)(cid, description.clone()).await {
|
||||||
Some(b) => b,
|
Some(b) => b,
|
||||||
None => {
|
None => {
|
||||||
let rejection =
|
let rejection = CommunicationValue::new_with_type_map(
|
||||||
CommunicationValue::new(CommunicationType::IdentificationResponse)
|
CommunicationType::IdentificationResponse,
|
||||||
.add_typed_default(DataType::Connected, DataValue::BoolFalse)
|
tm,
|
||||||
.add_typed_default(
|
)
|
||||||
DataType::ErrorMessage,
|
.add_typed_default(DataType::Connected, DataValue::BoolFalse)
|
||||||
DataValue::Str("unknown client id".into()),
|
.add_typed_default(
|
||||||
);
|
DataType::ErrorMessage,
|
||||||
|
DataValue::Str("unknown client id".into()),
|
||||||
|
);
|
||||||
let _ = sender.send(&rejection).await;
|
let _ = sender.send(&rejection).await;
|
||||||
sender.close();
|
sender.close();
|
||||||
return Err(AcceptError::AuthenticationFailed(
|
return Err(AcceptError::AuthenticationFailed(
|
||||||
|
|
@ -391,17 +546,23 @@ impl HandshakeEngine {
|
||||||
CommunicationType::IdentificationResponse,
|
CommunicationType::IdentificationResponse,
|
||||||
)
|
)
|
||||||
} else if Some(first_msg.get_type()) == CommunicationType::Register.try_to_id(&tm) {
|
} 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();
|
let pk_bytes = bundle.as_bytes();
|
||||||
(
|
(
|
||||||
Flow::Register { bundle, pk_bytes },
|
Flow::Register { bundle, pk_bytes },
|
||||||
CommunicationType::RegisterResponse,
|
CommunicationType::RegisterResponse,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
sender.close();
|
let error =
|
||||||
return Err(AcceptError::AuthenticationFailed(
|
AcceptError::AuthenticationFailed("unexpected authentication message".into());
|
||||||
"unexpected authentication message".into(),
|
reject_error_generic(sender, &error, tm).await;
|
||||||
));
|
return Err(error);
|
||||||
};
|
};
|
||||||
|
|
||||||
self.complete_auth_handshake(
|
self.complete_auth_handshake(
|
||||||
|
|
@ -434,7 +595,7 @@ impl HandshakeEngine {
|
||||||
) -> Result<HandshakeResult, AcceptError> {
|
) -> Result<HandshakeResult, AcceptError> {
|
||||||
use mtp_crypto::{Ed25519Signer, MlDsaSigner, SignatureScheme, auth, verify_ed25519};
|
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
|
// PQ preflight: host requiring PQ must have a PQ key
|
||||||
let pq_enabled = !self
|
let pq_enabled = !self
|
||||||
|
|
@ -457,6 +618,7 @@ impl HandshakeEngine {
|
||||||
RejectionReason::AuthenticationFailed {
|
RejectionReason::AuthenticationFailed {
|
||||||
detail: "host requires PQ authentication but has no PQ signing key".into(),
|
detail: "host requires PQ authentication but has no PQ signing key".into(),
|
||||||
},
|
},
|
||||||
|
Some(tm),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
sender.close();
|
sender.close();
|
||||||
|
|
@ -468,11 +630,17 @@ impl HandshakeEngine {
|
||||||
// Initialize host signers
|
// Initialize host signers
|
||||||
let host_pq_signer = if pq_enabled {
|
let host_pq_signer = if pq_enabled {
|
||||||
Some(Arc::new(
|
Some(Arc::new(
|
||||||
MlDsaSigner::new(
|
match MlDsaSigner::new(
|
||||||
&self.config.host_keyring.sig_pq_secret_key,
|
&self.config.host_keyring.sig_pq_secret_key,
|
||||||
&self.config.host_keyring.sig_pq_public_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 {
|
} else {
|
||||||
None
|
None
|
||||||
|
|
@ -505,14 +673,21 @@ impl HandshakeEngine {
|
||||||
|
|
||||||
let server_challenge: u128 = rand::random();
|
let server_challenge: u128 = rand::random();
|
||||||
let (chal_sig, chal_pq_sig) =
|
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)
|
let mut challenge_msg =
|
||||||
.add_typed_default(
|
CommunicationValue::new_with_type_map(CommunicationType::Challenge, tm)
|
||||||
DataType::ServerNonce,
|
.add_typed_default(
|
||||||
DataValue::UnsignedNumber(server_challenge),
|
DataType::ServerNonce,
|
||||||
)
|
DataValue::UnsignedNumber(server_challenge),
|
||||||
.add_typed_default(DataType::Signature, DataValue::Bytes(chal_sig));
|
)
|
||||||
|
.add_typed_default(DataType::Signature, DataValue::Bytes(chal_sig));
|
||||||
challenge_msg = challenge_msg.add_typed_default(
|
challenge_msg = challenge_msg.add_typed_default(
|
||||||
DataType::RequirePq,
|
DataType::RequirePq,
|
||||||
if self.config.require_pq {
|
if self.config.require_pq {
|
||||||
|
|
@ -536,31 +711,28 @@ impl HandshakeEngine {
|
||||||
AcceptError::Receive(e)
|
AcceptError::Receive(e)
|
||||||
})?;
|
})?;
|
||||||
if Some(proof.get_type()) != CommunicationType::ChallengeResponse.try_to_id(&tm) {
|
if Some(proof.get_type()) != CommunicationType::ChallengeResponse.try_to_id(&tm) {
|
||||||
sender.close();
|
let error = AcceptError::AuthenticationFailed("missing challenge response".into());
|
||||||
return Err(AcceptError::AuthenticationFailed(
|
reject_error_generic(sender, &error, tm).await;
|
||||||
"missing challenge response".into(),
|
return Err(error);
|
||||||
));
|
|
||||||
}
|
}
|
||||||
let client_nonce = match proof.get_data(DataType::ClientNonce) {
|
let client_nonce = match proof.get_data(DataType::ClientNonce) {
|
||||||
DataValue::UnsignedNumber(n) => *n,
|
Some(DataValue::UnsignedNumber(n)) => *n,
|
||||||
_ => {
|
_ => {
|
||||||
sender.close();
|
let error = AcceptError::AuthenticationFailed("missing client nonce".into());
|
||||||
return Err(AcceptError::AuthenticationFailed(
|
reject_error_generic(sender, &error, tm).await;
|
||||||
"missing client nonce".into(),
|
return Err(error);
|
||||||
));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let sig_bytes = match proof.get_data(DataType::Signature) {
|
let sig_bytes = match proof.get_data(DataType::Signature) {
|
||||||
DataValue::Bytes(b) => b.clone(),
|
Some(DataValue::Bytes(b)) => b.clone(),
|
||||||
_ => {
|
_ => {
|
||||||
sender.close();
|
let error = AcceptError::AuthenticationFailed("missing challenge signature".into());
|
||||||
return Err(AcceptError::AuthenticationFailed(
|
reject_error_generic(sender, &error, tm).await;
|
||||||
"missing challenge signature".into(),
|
return Err(error);
|
||||||
));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let pq_sig_bytes: Vec<u8> = match proof.get_data(DataType::PqSignature) {
|
let pq_sig_bytes: Vec<u8> = match proof.get_data(DataType::PqSignature) {
|
||||||
DataValue::Bytes(b) => b.clone(),
|
Some(DataValue::Bytes(b)) => b.clone(),
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -601,6 +773,7 @@ impl HandshakeEngine {
|
||||||
RejectionReason::AuthenticationFailed {
|
RejectionReason::AuthenticationFailed {
|
||||||
detail: "client proof signature invalid".into(),
|
detail: "client proof signature invalid".into(),
|
||||||
},
|
},
|
||||||
|
Some(tm),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
sender.close();
|
sender.close();
|
||||||
|
|
@ -613,21 +786,66 @@ impl HandshakeEngine {
|
||||||
let (assigned_id, client_bundle) = match flow {
|
let (assigned_id, client_bundle) = match flow {
|
||||||
Flow::Login { id, bundle } => (id, bundle),
|
Flow::Login { id, bundle } => (id, bundle),
|
||||||
Flow::Register { bundle, .. } => {
|
Flow::Register { bundle, .. } => {
|
||||||
let new_id =
|
let _registration_guard = self.config.registration_lock.lock().await;
|
||||||
(self.config.complete_register)(bundle.clone(), description.clone()).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)
|
(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
|
// 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,
|
assigned_id,
|
||||||
client_nonce,
|
client_nonce,
|
||||||
server_challenge,
|
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::Connected, DataValue::BoolTrue)
|
||||||
.add_typed_default(DataType::Id, DataValue::UnsignedNumber(assigned_id as u128))
|
.add_typed_default(DataType::Id, DataValue::UnsignedNumber(assigned_id as u128))
|
||||||
.add_typed_default(
|
.add_typed_default(
|
||||||
|
|
@ -658,6 +876,7 @@ impl HandshakeEngine {
|
||||||
auth_state: crate::error::AuthState::Authenticated,
|
auth_state: crate::error::AuthState::Authenticated,
|
||||||
client_id: assigned_id,
|
client_id: assigned_id,
|
||||||
client_public_key: Some(client_bundle),
|
client_public_key: Some(client_bundle),
|
||||||
|
guest_id_lease: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -682,36 +901,52 @@ enum Flow {
|
||||||
impl HandshakeEngine {
|
impl HandshakeEngine {
|
||||||
const GUEST_ID_MAX_RETRIES: u32 = 100;
|
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 {
|
if let Some(ref generator) = self.config.guest_id_generator {
|
||||||
let id = generator().await.ok_or_else(|| {
|
let id = generator().await.ok_or_else(|| {
|
||||||
AcceptError::AuthenticationFailed(
|
AcceptError::AuthenticationFailed(
|
||||||
"guest id generator rejected the connection".into(),
|
"guest id generator rejected the connection".into(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
if id > mtp_codec::MAX_WIRE_ID {
|
if id == 0 {
|
||||||
return Err(AcceptError::AuthenticationFailed(
|
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() {
|
if let Some(lease) = self.try_reserve_guest_id(id).await? {
|
||||||
return Ok(id);
|
return Ok(lease);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.random_guest_id().await
|
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 {
|
for _ in 0..Self::GUEST_ID_MAX_RETRIES {
|
||||||
let id = rand::random::<u64>() & mtp_codec::MAX_WIRE_ID;
|
let id = rand::random::<u64>();
|
||||||
if (self.config.get_existing_client)(id, None).await.is_none() {
|
if let Some(lease) = self.try_reserve_guest_id(id).await? {
|
||||||
return Ok(id);
|
return Ok(lease);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(AcceptError::AuthenticationFailed(
|
Err(AcceptError::AuthenticationFailed(
|
||||||
"failed to allocate a unique guest id after retries".into(),
|
"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,
|
msg: &CommunicationValue,
|
||||||
) -> Result<mtp_crypto::PublicKeyBundle, AcceptError> {
|
) -> Result<mtp_crypto::PublicKeyBundle, AcceptError> {
|
||||||
match msg.get_data(DataType::PublicKeys) {
|
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())),
|
.map_err(|_| AcceptError::AuthenticationFailed("invalid public key bundle".into())),
|
||||||
_ => Err(AcceptError::AuthenticationFailed(
|
_ => Err(AcceptError::AuthenticationFailed(
|
||||||
"missing public keys".into(),
|
"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 {
|
let response = match &reason {
|
||||||
RejectionReason::BadVersion { supported_versions } => {
|
RejectionReason::BadVersion { supported_versions } => {
|
||||||
CommunicationValue::new(CommunicationType::ErrorBadVersion)
|
CommunicationValue::new_with_type_map(CommunicationType::ErrorBadVersion, &type_map)
|
||||||
.add_typed_default(
|
.add_typed_default(
|
||||||
DataType::Version,
|
DataType::Version,
|
||||||
DataValue::Str(supported_versions.join(",")),
|
DataValue::Str(supported_versions.join(",")),
|
||||||
)
|
)
|
||||||
.add_typed_default(DataType::ErrorMessage, DataValue::Str(reason.to_string()))
|
.add_typed_default(DataType::ErrorMessage, DataValue::Str(reason.to_string()))
|
||||||
}
|
}
|
||||||
_ => CommunicationValue::new(CommunicationType::IdentificationResponse)
|
_ => CommunicationValue::new_with_type_map(
|
||||||
.add_typed_default(DataType::Connected, DataValue::BoolFalse)
|
CommunicationType::IdentificationResponse,
|
||||||
.add_typed_default(DataType::ErrorMessage, DataValue::Str(reason.to_string())),
|
&type_map,
|
||||||
|
)
|
||||||
|
.add_typed_default(DataType::Connected, DataValue::BoolFalse)
|
||||||
|
.add_typed_default(DataType::ErrorMessage, DataValue::Str(reason.to_string())),
|
||||||
};
|
};
|
||||||
let _ = sender.send(&response).await;
|
let _ = sender.send(&response).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "crypto")]
|
#[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>(
|
async fn send_accepted_generic<S: HandshakeSender>(
|
||||||
sender: &S,
|
sender: &S,
|
||||||
version: &Version,
|
version: &Version,
|
||||||
|
type_map: &TypeMap,
|
||||||
assigned_id: Option<u64>,
|
assigned_id: Option<u64>,
|
||||||
) -> Result<(), CommunicationError> {
|
) -> Result<(), CommunicationError> {
|
||||||
let mut response = CommunicationValue::new(CommunicationType::IdentificationResponse)
|
let mut response =
|
||||||
.add_typed_default(DataType::Connected, DataValue::BoolTrue)
|
CommunicationValue::new_with_type_map(CommunicationType::IdentificationResponse, type_map)
|
||||||
.add_typed_default(DataType::Version, DataValue::Str(version.to_string()));
|
.add_typed_default(DataType::Connected, DataValue::BoolTrue)
|
||||||
|
.add_typed_default(DataType::Version, DataValue::Str(version.to_string()));
|
||||||
if let Some(id) = assigned_id {
|
if let Some(id) = assigned_id {
|
||||||
response = response.add_typed_default(DataType::Id, DataValue::UnsignedNumber(id as u128));
|
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 {
|
) -> impl std::future::Future<Output = Result<(), CommunicationError>> + Send {
|
||||||
mtp_transport::Sender::finish_stream(self)
|
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) {
|
fn close(&self) {
|
||||||
let sender = self.clone();
|
let sender = self.clone();
|
||||||
tokio::spawn(async move { sender.close().await });
|
tokio::spawn(async move { sender.close().await });
|
||||||
|
|
@ -793,6 +1057,10 @@ impl HandshakeReceiver for mtp_transport::Receiver {
|
||||||
{
|
{
|
||||||
mtp_transport::Receiver::receive(self)
|
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> {
|
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 {
|
) -> impl std::future::Future<Output = Result<(), CommunicationError>> + Send {
|
||||||
mtp_transport::GenericSender::finish_stream(self)
|
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) {
|
fn close(&self) {
|
||||||
mtp_transport::GenericSender::close(self);
|
mtp_transport::GenericSender::close(self);
|
||||||
}
|
}
|
||||||
|
|
@ -821,4 +1092,68 @@ impl<C: mtp_transport::TransportConnection> HandshakeReceiver
|
||||||
{
|
{
|
||||||
mtp_transport::GenericReceiver::receive(self)
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,13 @@ use mtp_codec::{CommunicationValue, DataType, DataValue};
|
||||||
|
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
pub(crate) fn random_client_id() -> u64 {
|
pub(crate) fn random_client_id() -> u64 {
|
||||||
rand::random::<u64>() & mtp_codec::MAX_WIRE_ID
|
rand::random::<u64>()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn extract_version(msg: &CommunicationValue) -> Option<Version> {
|
pub(crate) fn extract_version(msg: &CommunicationValue) -> Option<Version> {
|
||||||
let value = msg.get_data(DataType::Version);
|
match msg.get_data(DataType::Version) {
|
||||||
match value {
|
Some(DataValue::Str(s)) => Version::parse(s.as_str()),
|
||||||
DataValue::Str(s) => Version::parse(s.as_str()),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,8 @@ impl HandshakeContext {
|
||||||
let remote_addr = sender.handle().remote_addr();
|
let remote_addr = sender.handle().remote_addr();
|
||||||
receiver.set_max_message_size(self.config.policy.max_message_size);
|
receiver.set_max_message_size(self.config.policy.max_message_size);
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
|
let type_map = result.codec.type_map().clone();
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
{
|
{
|
||||||
if self.config.send_pongs {
|
if self.config.send_pongs {
|
||||||
receiver.respond_to_pings(sender.clone());
|
receiver.respond_to_pings(sender.clone());
|
||||||
|
|
@ -177,6 +179,7 @@ impl HandshakeContext {
|
||||||
pending_creations: tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
pending_creations: tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
pending_pipes: tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
pending_pipes: tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
policy: Arc::new(self.config.policy),
|
policy: Arc::new(self.config.policy),
|
||||||
|
type_map: type_map.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let dispatcher_clone = dispatcher.clone();
|
let dispatcher_clone = dispatcher.clone();
|
||||||
|
|
@ -202,9 +205,11 @@ impl HandshakeContext {
|
||||||
pipe_dispatcher: dispatcher,
|
pipe_dispatcher: dispatcher,
|
||||||
description: result.description,
|
description: result.description,
|
||||||
_dispatcher_task: task,
|
_dispatcher_task: task,
|
||||||
|
_connection_guard: None,
|
||||||
auth_state: result.auth_state,
|
auth_state: result.auth_state,
|
||||||
client_id: result.client_id,
|
client_id: result.client_id,
|
||||||
client_public_key: result.client_public_key,
|
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,
|
_pipe_stream: std::marker::PhantomData,
|
||||||
description: result.description,
|
description: result.description,
|
||||||
_dispatcher_task: task,
|
_dispatcher_task: task,
|
||||||
|
_connection_guard: None,
|
||||||
auth_state: result.auth_state,
|
auth_state: result.auth_state,
|
||||||
client_id: result.client_id,
|
client_id: result.client_id,
|
||||||
client_public_key: result.client_public_key,
|
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();
|
let remote_addr = sender.handle().remote_addr();
|
||||||
receiver.set_max_message_size(self.config.policy.max_message_size);
|
receiver.set_max_message_size(self.config.policy.max_message_size);
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
|
let type_map = codec.type_map().clone();
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
{
|
{
|
||||||
if self.config.send_pongs {
|
if self.config.send_pongs {
|
||||||
receiver.respond_to_pings(sender.clone());
|
receiver.respond_to_pings(sender.clone());
|
||||||
|
|
@ -259,6 +268,7 @@ impl HandshakeContext {
|
||||||
pending_creations: tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
pending_creations: tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
pending_pipes: tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
pending_pipes: tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
policy: Arc::new(self.config.policy),
|
policy: Arc::new(self.config.policy),
|
||||||
|
type_map,
|
||||||
});
|
});
|
||||||
|
|
||||||
let dispatcher_clone = dispatcher.clone();
|
let dispatcher_clone = dispatcher.clone();
|
||||||
|
|
@ -284,6 +294,7 @@ impl HandshakeContext {
|
||||||
pipe_dispatcher: dispatcher,
|
pipe_dispatcher: dispatcher,
|
||||||
description,
|
description,
|
||||||
_dispatcher_task: task,
|
_dispatcher_task: task,
|
||||||
|
_connection_guard: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -305,6 +316,7 @@ impl HandshakeContext {
|
||||||
_pipe_stream: std::marker::PhantomData,
|
_pipe_stream: std::marker::PhantomData,
|
||||||
description,
|
description,
|
||||||
_dispatcher_task: task,
|
_dispatcher_task: task,
|
||||||
|
_connection_guard: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,10 @@ pub use pipe::PipeRequest;
|
||||||
pub use mtp_codec::registry::Registry;
|
pub use mtp_codec::registry::Registry;
|
||||||
|
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
pub use config::{AuthenticationPolicy, CompleteRegister, GetExistingClient, GuestIdGenerator};
|
pub use config::{
|
||||||
|
AuthenticationPolicy, CompleteRegister, FindRegisteredClient, GetExistingClient,
|
||||||
|
GuestIdGenerator,
|
||||||
|
};
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
pub use error::AuthState;
|
pub use error::AuthState;
|
||||||
|
|
||||||
|
|
@ -51,9 +54,9 @@ mod tests {
|
||||||
fn version_extraction() {
|
fn version_extraction() {
|
||||||
let tm = mtp_codec::TypeMap::latest();
|
let tm = mtp_codec::TypeMap::latest();
|
||||||
let msg = mtp_codec::CommunicationValue::from_comm(CommunicationType::Identification, &tm)
|
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);
|
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]
|
#[test]
|
||||||
|
|
@ -92,7 +95,7 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn alternative_transports_use_the_shared_connection_type() {
|
async fn alternative_transports_use_the_shared_connection_type() {
|
||||||
let registry = Registry::builtin();
|
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 codec = VersionedCodec::for_version(registry, version.clone()).unwrap();
|
||||||
let connection: MTPConnection<AlternateSender, AlternateReceiver> =
|
let connection: MTPConnection<AlternateSender, AlternateReceiver> =
|
||||||
MTPConnection::from_transport_parts(
|
MTPConnection::from_transport_parts(
|
||||||
|
|
|
||||||
|
|
@ -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_common::{CommunicationError, PipeError};
|
||||||
use mtp_transport::{PipeReader, PipeWriter, Policy, TransportEvent};
|
use mtp_transport::{PipeReader, PipeWriter, Policy, TransportEvent};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
@ -152,24 +152,49 @@ where
|
||||||
.await
|
.await
|
||||||
.insert(self.pipe_id, pipe_tx);
|
.insert(self.pipe_id, pipe_tx);
|
||||||
|
|
||||||
let response = CommunicationValue::new(CommunicationType::PipeResponse)
|
let response = CommunicationValue::new_with_type_map(
|
||||||
.with_id(self.pipe_id)
|
CommunicationType::PipeResponse,
|
||||||
.add_typed_default(DataType::Accepted, DataValue::BoolTrue);
|
&self.dispatcher.type_map,
|
||||||
self.sender
|
)
|
||||||
.send_pipe_message(&response)
|
.with_id(self.pipe_id)
|
||||||
.await
|
.add_typed_default(DataType::Accepted, DataValue::BoolTrue);
|
||||||
.map_err(PipeError::from)?;
|
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)
|
match tokio::time::timeout(self.dispatcher.policy.read_timeout, pipe_rx).await {
|
||||||
.await
|
Ok(Ok(reader)) => Ok(reader),
|
||||||
.map_err(|_| PipeError::HandshakeTimeout)?
|
Ok(Err(_)) => {
|
||||||
.map_err(|_| PipeError::StreamClosed)
|
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> {
|
pub async fn deny(self) -> Result<(), PipeError> {
|
||||||
let response = CommunicationValue::new(CommunicationType::PipeResponse)
|
let response = CommunicationValue::new_with_type_map(
|
||||||
.with_id(self.pipe_id)
|
CommunicationType::PipeResponse,
|
||||||
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
|
&self.dispatcher.type_map,
|
||||||
|
)
|
||||||
|
.with_id(self.pipe_id)
|
||||||
|
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
|
||||||
self.sender
|
self.sender
|
||||||
.send_pipe_message(&response)
|
.send_pipe_message(&response)
|
||||||
.await
|
.await
|
||||||
|
|
@ -182,6 +207,7 @@ pub(crate) struct PipeDispatcher<P> {
|
||||||
Mutex<HashMap<u32, tokio::sync::oneshot::Sender<Result<bool, PipeError>>>>,
|
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) pending_pipes: Mutex<HashMap<u32, tokio::sync::oneshot::Sender<PipeReader<P>>>>,
|
||||||
pub(crate) policy: Arc<Policy>,
|
pub(crate) policy: Arc<Policy>,
|
||||||
|
pub(crate) type_map: TypeMap,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn run_dispatcher<S, R, P>(
|
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>,
|
R: PipeReceiver<P>,
|
||||||
P: tokio::io::AsyncRead + Send + Unpin + 'static,
|
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 {
|
loop {
|
||||||
match receiver.receive_pipe_event().await {
|
match receiver.receive_pipe_event().await {
|
||||||
Ok(TransportEvent::Message(message)) => {
|
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 {
|
let request = PipeRequest {
|
||||||
pipe_id: message.get_id(),
|
pipe_id,
|
||||||
description: message
|
description: message
|
||||||
.get_str(DataType::Description)
|
.get_str(DataType::Description)
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
|
|
@ -214,14 +246,36 @@ pub(crate) async fn run_dispatcher<S, R, P>(
|
||||||
let _ = pipe_req_tx.send(request).await;
|
let _ = pipe_req_tx.send(request).await;
|
||||||
continue;
|
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;
|
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 _ =
|
let _ =
|
||||||
reply.send(Ok(message.get_bool(DataType::Accepted).unwrap_or(false)));
|
reply.send(Ok(message.get_bool(DataType::Accepted).unwrap_or(false)));
|
||||||
}
|
}
|
||||||
continue;
|
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() {
|
if app_tx.send(Ok(message)).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,10 @@ pub(crate) async fn run_driver(
|
||||||
let host_config = host_config.clone();
|
let host_config = host_config.clone();
|
||||||
let auth_semaphore = auth_semaphore.clone();
|
let auth_semaphore = auth_semaphore.clone();
|
||||||
connection_tasks.spawn(async move {
|
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 connect_start = std::time::Instant::now();
|
||||||
let connection = match incoming.await {
|
let connection = match incoming.await {
|
||||||
Ok(connection) => connection,
|
Ok(connection) => connection,
|
||||||
|
|
@ -149,27 +152,41 @@ pub(crate) async fn run_driver(
|
||||||
remote_addr,
|
remote_addr,
|
||||||
));
|
));
|
||||||
let mtp_tx = mtp_tx.clone();
|
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 auth_semaphore = auth_semaphore.clone();
|
||||||
let host_config = host_config.clone();
|
let host_config = host_config.clone();
|
||||||
|
let connection_guard = connection_permit.take();
|
||||||
let connection = connection.clone();
|
let connection = connection.clone();
|
||||||
|
let close_connection = connection.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let result =
|
let result = accept_web_connection(
|
||||||
accept_web_connection(session, mtp_path, connection, send_pongs, policy, host_config, auth_semaphore)
|
session,
|
||||||
.await;
|
mtp_path,
|
||||||
match mtp_tx.try_send(result) {
|
connection,
|
||||||
Ok(()) => {}
|
send_pongs,
|
||||||
Err(tokio::sync::mpsc::error::TrySendError::Full(result)) => {
|
policy,
|
||||||
tracing::warn!("MTP connection backlog is full; dropping connection");
|
host_config,
|
||||||
if let Ok(connection) = result {
|
auth_semaphore,
|
||||||
connection.sender.close();
|
connection_guard,
|
||||||
}
|
)
|
||||||
}
|
.await;
|
||||||
Err(tokio::sync::mpsc::error::TrySendError::Closed(result)) => {
|
if result.is_err() {
|
||||||
if let Ok(connection) = result {
|
close_connection
|
||||||
connection.sender.close();
|
.close(quinn::VarInt::from_u32(0), b"mtp handshake failed");
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
mtp_queue_permit.send(result);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use mtp_codec::{
|
use mtp_codec::registry::Registry;
|
||||||
DataType, DataValue, Version,
|
|
||||||
registry::{Registry, VersionedCodec},
|
|
||||||
};
|
|
||||||
use mtp_common::CommunicationError;
|
use mtp_common::CommunicationError;
|
||||||
use mtp_host::AcceptError;
|
use mtp_host::AcceptError;
|
||||||
use mtp_host::HostConfig;
|
use mtp_host::HostConfig;
|
||||||
|
|
@ -11,14 +8,9 @@ use mtp_transport::{
|
||||||
TransportSendStream,
|
TransportSendStream,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
use std::time::Instant;
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
const GUEST_ID_MAX_RETRIES: u32 = 100;
|
|
||||||
|
|
||||||
type Session = h3_webtransport::server::WebTransportSession<h3_quinn::Connection, Bytes>;
|
type Session = h3_webtransport::server::WebTransportSession<h3_quinn::Connection, Bytes>;
|
||||||
type H3SendStream = h3_webtransport::stream::SendStream<h3_quinn::SendStream<Bytes>, Bytes>;
|
type H3SendStream = h3_webtransport::stream::SendStream<h3_quinn::SendStream<Bytes>, Bytes>;
|
||||||
type H3RecvStream = h3_webtransport::stream::RecvStream<h3_quinn::RecvStream, Bytes>;
|
type H3RecvStream = h3_webtransport::stream::RecvStream<h3_quinn::RecvStream, Bytes>;
|
||||||
|
|
@ -228,38 +220,6 @@ pub type WebMtpReceiver = GenericReceiver<H3TransportConnection>;
|
||||||
pub type WebMTPConnection =
|
pub type WebMTPConnection =
|
||||||
mtp_host::MTPConnection<WebMtpSender, WebMtpReceiver, H3TransportReceiver>;
|
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(
|
pub(crate) async fn accept_web_connection(
|
||||||
session: Arc<Session>,
|
session: Arc<Session>,
|
||||||
path: String,
|
path: String,
|
||||||
|
|
@ -267,25 +227,45 @@ pub(crate) async fn accept_web_connection(
|
||||||
send_pongs: bool,
|
send_pongs: bool,
|
||||||
policy: Policy,
|
policy: Policy,
|
||||||
host_config: Arc<HostConfig>,
|
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> {
|
) -> Result<WebMTPConnection, AcceptError> {
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
{
|
{
|
||||||
let permit = _auth_semaphore.clone().acquire_owned().await.map_err(|_| {
|
let deadline = tokio::time::Instant::now() + host_config.auth_timeout;
|
||||||
AcceptError::AuthenticationFailed("authentication service stopped".into())
|
let permit = tokio::time::timeout_at(deadline, auth_semaphore.clone().acquire_owned())
|
||||||
})?;
|
.await
|
||||||
let result = tokio::time::timeout(
|
.map_err(|_| AcceptError::AuthenticationTimedOut)?
|
||||||
host_config.auth_timeout,
|
.map_err(|_| {
|
||||||
accept_web_connection_inner(session, path, quinn, send_pongs, policy, host_config),
|
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
|
.await;
|
||||||
.unwrap_or(Err(AcceptError::AuthenticationTimedOut));
|
|
||||||
drop(permit);
|
drop(permit);
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "crypto"))]
|
#[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(
|
async fn accept_web_connection_inner(
|
||||||
|
|
@ -294,434 +274,74 @@ async fn accept_web_connection_inner(
|
||||||
quinn: quinn::Connection,
|
quinn: quinn::Connection,
|
||||||
send_pongs: bool,
|
send_pongs: bool,
|
||||||
policy: Policy,
|
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> {
|
) -> Result<WebMTPConnection, AcceptError> {
|
||||||
#[cfg(feature = "crypto")]
|
|
||||||
let auth_handshake_started = Instant::now();
|
|
||||||
let max_message_size = policy.max_message_size;
|
let max_message_size = policy.max_message_size;
|
||||||
let transport = H3TransportConnection::new(session, quinn);
|
let transport = H3TransportConnection::new(session, quinn);
|
||||||
let remote_addr = transport.remote_addr();
|
let remote_addr = transport.remote_addr();
|
||||||
let policy = Arc::new(policy);
|
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 engine = mtp_host::HandshakeEngine::new(Registry::builtin(), host_config);
|
||||||
let version = match first.get_data(DataType::Version) {
|
#[cfg(feature = "crypto")]
|
||||||
DataValue::Str(value) => Version::parse(value).ok_or(AcceptError::MissingVersion)?,
|
let result = engine
|
||||||
_ => return Err(AcceptError::MissingVersion),
|
.accept_until(
|
||||||
};
|
&sender,
|
||||||
let registry = Registry::builtin();
|
&receiver,
|
||||||
let negotiated = registry
|
deadline.expect("crypto WebTransport handshakes have a deadline"),
|
||||||
.negotiate(std::slice::from_ref(&version))
|
)
|
||||||
.ok_or_else(|| AcceptError::UnsupportedVersion(version.clone()))?;
|
.await?;
|
||||||
let codec = VersionedCodec::for_version(registry, negotiated.clone())
|
#[cfg(not(feature = "crypto"))]
|
||||||
.ok_or_else(|| AcceptError::UnsupportedVersion(negotiated.clone()))?;
|
let result = engine.accept(&sender, &receiver).await?;
|
||||||
let description = match first.get_data(DataType::Description) {
|
|
||||||
DataValue::Str(value) => Some(value.clone()),
|
let version = result.negotiated_version.clone();
|
||||||
_ => None,
|
let codec = result.codec.clone();
|
||||||
};
|
let description = result.description.clone();
|
||||||
let sender = WebMtpSender::new(transport, policy.clone());
|
|
||||||
if send_pongs {
|
|
||||||
receiver.respond_to_pings(sender.clone()).await;
|
|
||||||
}
|
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
let connection: WebMTPConnection = mtp_host::MTPConnection::from_transport_parts_with_policy(
|
let connection: WebMTPConnection = mtp_host::MTPConnection::from_transport_parts_with_policy(
|
||||||
negotiated,
|
version,
|
||||||
codec,
|
codec,
|
||||||
sender,
|
sender,
|
||||||
receiver,
|
receiver,
|
||||||
path,
|
path,
|
||||||
description.clone(),
|
description,
|
||||||
Some(remote_addr),
|
Some(remote_addr),
|
||||||
policy,
|
policy,
|
||||||
);
|
);
|
||||||
#[cfg(not(feature = "pipes"))]
|
#[cfg(not(feature = "pipes"))]
|
||||||
let connection: WebMTPConnection =
|
let connection: WebMTPConnection =
|
||||||
mtp_host::MTPConnection::from_transport_parts_with_remote_addr(
|
mtp_host::MTPConnection::from_transport_parts_with_remote_addr(
|
||||||
negotiated,
|
version,
|
||||||
codec,
|
codec,
|
||||||
sender,
|
sender,
|
||||||
receiver,
|
receiver,
|
||||||
path,
|
path,
|
||||||
description.clone(),
|
description,
|
||||||
Some(remote_addr),
|
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;
|
let mut connection = connection;
|
||||||
|
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
{
|
{
|
||||||
use mtp_codec::{CommunicationType, DataType, DataValue};
|
connection.auth_state = result.auth_state;
|
||||||
use mtp_crypto::{
|
connection.client_id = result.client_id;
|
||||||
Ed25519Signer, MlDsaSigner, PublicKeyBundle, SignatureScheme, auth, verify_ed25519,
|
connection.client_public_key = result.client_public_key;
|
||||||
};
|
connection.set_guest_id_lease(result.guest_id_lease);
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@
|
||||||
"crypto/src/",
|
"crypto/src/",
|
||||||
"type-map/Cargo.toml",
|
"type-map/Cargo.toml",
|
||||||
"type-map/build.rs",
|
"type-map/build.rs",
|
||||||
|
"type-map/reserved.json",
|
||||||
"type-map/src/",
|
"type-map/src/",
|
||||||
"wasm/.cargo/",
|
"wasm/.cargo/",
|
||||||
"wasm/Cargo.toml",
|
"wasm/Cargo.toml",
|
||||||
|
|
@ -51,7 +52,13 @@
|
||||||
"example": "pnpm install && pnpm run build:all && nix develop .#autoStart",
|
"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": "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",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/node": "^26.0.1",
|
"@types/node": "^26.0.1",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -14,8 +14,8 @@ const HEADER_FIXED_LEN = 1 + 1 + 8 + 8 + 4 + 2 + 4;
|
||||||
export interface ParsedEncryptedMessage {
|
export interface ParsedEncryptedMessage {
|
||||||
version: 1;
|
version: 1;
|
||||||
flags: number;
|
flags: number;
|
||||||
senderClientId: bigint;
|
senderId: bigint;
|
||||||
recipientClientId: bigint;
|
recipientId: bigint;
|
||||||
messageNumber: number;
|
messageNumber: number;
|
||||||
kemCiphertext?: Uint8Array;
|
kemCiphertext?: Uint8Array;
|
||||||
ciphertext: Uint8Array;
|
ciphertext: Uint8Array;
|
||||||
|
|
@ -28,8 +28,8 @@ export interface ParsedEncryptedMessage {
|
||||||
export interface EncryptedMessageHeader {
|
export interface EncryptedMessageHeader {
|
||||||
version: 1;
|
version: 1;
|
||||||
flags: number;
|
flags: number;
|
||||||
senderClientId: bigint;
|
senderId: bigint;
|
||||||
recipientClientId: bigint;
|
recipientId: bigint;
|
||||||
messageNumber: number;
|
messageNumber: number;
|
||||||
kemCiphertext?: Uint8Array;
|
kemCiphertext?: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
@ -105,8 +105,8 @@ export function serializeEncryptedMessage(
|
||||||
return concatBytes([
|
return concatBytes([
|
||||||
new Uint8Array([normalized.version]),
|
new Uint8Array([normalized.version]),
|
||||||
new Uint8Array([normalized.flags]),
|
new Uint8Array([normalized.flags]),
|
||||||
writeU64BE(normalized.senderClientId),
|
writeU64BE(normalized.senderId),
|
||||||
writeU64BE(normalized.recipientClientId),
|
writeU64BE(normalized.recipientId),
|
||||||
writeU32BE(normalized.messageNumber),
|
writeU32BE(normalized.messageNumber),
|
||||||
new Uint8Array([
|
new Uint8Array([
|
||||||
(kemCiphertext.length >>> 8) & 0xff,
|
(kemCiphertext.length >>> 8) & 0xff,
|
||||||
|
|
@ -131,9 +131,9 @@ export function parseEncryptedMessage(
|
||||||
|
|
||||||
const version = bytes[offset++];
|
const version = bytes[offset++];
|
||||||
const flags = bytes[offset++];
|
const flags = bytes[offset++];
|
||||||
const senderClientId = readU64BE(bytes, offset);
|
const senderId = readU64BE(bytes, offset);
|
||||||
offset += 8;
|
offset += 8;
|
||||||
const recipientClientId = readU64BE(bytes, offset);
|
const recipientId = readU64BE(bytes, offset);
|
||||||
offset += 8;
|
offset += 8;
|
||||||
const messageNumber =
|
const messageNumber =
|
||||||
((bytes[offset] << 24) |
|
((bytes[offset] << 24) |
|
||||||
|
|
@ -176,8 +176,8 @@ export function parseEncryptedMessage(
|
||||||
const parsed: ParsedEncryptedMessage = {
|
const parsed: ParsedEncryptedMessage = {
|
||||||
version: version as 1,
|
version: version as 1,
|
||||||
flags,
|
flags,
|
||||||
senderClientId,
|
senderId,
|
||||||
recipientClientId,
|
recipientId,
|
||||||
messageNumber,
|
messageNumber,
|
||||||
kemCiphertext,
|
kemCiphertext,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
|
|
@ -185,8 +185,8 @@ export function parseEncryptedMessage(
|
||||||
parsed.header = {
|
parsed.header = {
|
||||||
version: parsed.version,
|
version: parsed.version,
|
||||||
flags: parsed.flags,
|
flags: parsed.flags,
|
||||||
senderClientId: parsed.senderClientId,
|
senderId: parsed.senderId,
|
||||||
recipientClientId: parsed.recipientClientId,
|
recipientId: parsed.recipientId,
|
||||||
messageNumber: parsed.messageNumber,
|
messageNumber: parsed.messageNumber,
|
||||||
kemCiphertext: parsed.kemCiphertext,
|
kemCiphertext: parsed.kemCiphertext,
|
||||||
};
|
};
|
||||||
|
|
@ -199,8 +199,8 @@ function buildAAD(header: EncryptedMessageHeader): Uint8Array {
|
||||||
return concatBytes([
|
return concatBytes([
|
||||||
new Uint8Array([header.version]),
|
new Uint8Array([header.version]),
|
||||||
new Uint8Array([header.flags]),
|
new Uint8Array([header.flags]),
|
||||||
writeU64BE(header.senderClientId),
|
writeU64BE(header.senderId),
|
||||||
writeU64BE(header.recipientClientId),
|
writeU64BE(header.recipientId),
|
||||||
writeU32BE(header.messageNumber),
|
writeU32BE(header.messageNumber),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -227,8 +227,8 @@ export async function encryptPayload(args: {
|
||||||
const header: EncryptedMessageHeader = {
|
const header: EncryptedMessageHeader = {
|
||||||
version: 1,
|
version: 1,
|
||||||
flags: args.kemCiphertext ? FLAG_INIT : 0,
|
flags: args.kemCiphertext ? FLAG_INIT : 0,
|
||||||
senderClientId: args.session.ownClientId,
|
senderId: args.session.localId,
|
||||||
recipientClientId: args.session.peerClientId,
|
recipientId: args.session.remoteId,
|
||||||
messageNumber: args.session.sendCount,
|
messageNumber: args.session.sendCount,
|
||||||
kemCiphertext: args.kemCiphertext,
|
kemCiphertext: args.kemCiphertext,
|
||||||
};
|
};
|
||||||
|
|
@ -257,19 +257,18 @@ export async function encryptPayload(args: {
|
||||||
export async function decryptPayload(args: {
|
export async function decryptPayload(args: {
|
||||||
payload: Uint8Array;
|
payload: Uint8Array;
|
||||||
session: MTPSessionState;
|
session: MTPSessionState;
|
||||||
expectedRecipientClientId?: bigint;
|
expectedRecipientId?: bigint;
|
||||||
aad?: Uint8Array;
|
aad?: Uint8Array;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
plaintext: Uint8Array;
|
plaintext: Uint8Array;
|
||||||
session: MTPSessionState;
|
session: MTPSessionState;
|
||||||
}> {
|
}> {
|
||||||
const parsed = parseEncryptedMessage(args.payload);
|
const parsed = parseEncryptedMessage(args.payload);
|
||||||
const expectedRecipientClientId =
|
const expectedRecipientId = args.expectedRecipientId ?? args.session.localId;
|
||||||
args.expectedRecipientClientId ?? args.session.ownClientId;
|
if (parsed.recipientId !== expectedRecipientId) {
|
||||||
if (parsed.recipientClientId !== expectedRecipientClientId) {
|
|
||||||
throw new Error("Encrypted message recipient mismatch");
|
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");
|
throw new Error("Encrypted message sender mismatch");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -322,8 +321,8 @@ export async function decryptPayload(args: {
|
||||||
const header: EncryptedMessageHeader = {
|
const header: EncryptedMessageHeader = {
|
||||||
version: parsed.version,
|
version: parsed.version,
|
||||||
flags: parsed.flags,
|
flags: parsed.flags,
|
||||||
senderClientId: parsed.senderClientId,
|
senderId: parsed.senderId,
|
||||||
recipientClientId: parsed.recipientClientId,
|
recipientId: parsed.recipientId,
|
||||||
messageNumber: parsed.messageNumber,
|
messageNumber: parsed.messageNumber,
|
||||||
kemCiphertext: parsed.kemCiphertext,
|
kemCiphertext: parsed.kemCiphertext,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
1523
src/sdk/encrypted-pipe.ts
Normal file
1523
src/sdk/encrypted-pipe.ts
Normal file
File diff suppressed because it is too large
Load diff
90
src/sdk/encrypted-secret.ts
Normal file
90
src/sdk/encrypted-secret.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
2334
src/sdk/index.ts
2334
src/sdk/index.ts
File diff suppressed because it is too large
Load diff
|
|
@ -4,23 +4,29 @@ import { concatBytes, utf8Encode, writeU64BE } from "./utils.js";
|
||||||
export const HKDF_SALT_ROOT = "mtp-e2ee-v1-root";
|
export const HKDF_SALT_ROOT = "mtp-e2ee-v1-root";
|
||||||
const HKDF_INITIATOR_SEND = "mtp-e2ee-v1-initiator-send";
|
const HKDF_INITIATOR_SEND = "mtp-e2ee-v1-initiator-send";
|
||||||
const HKDF_INITIATOR_RECV = "mtp-e2ee-v1-initiator-recv";
|
const HKDF_INITIATOR_RECV = "mtp-e2ee-v1-initiator-recv";
|
||||||
|
const SESSION_TRANSCRIPT_DOMAIN = "mtp-e2ee-session-transcript-v1";
|
||||||
|
|
||||||
export interface MTPSessionTranscriptContext {
|
export interface MTPSessionTranscriptContext {
|
||||||
senderUserId?: string;
|
/** Application-selected identity for this stateful MTP session. */
|
||||||
senderClientId: bigint;
|
sessionId: string;
|
||||||
recipientUserId?: string;
|
/** MTP identity that initiated key establishment. */
|
||||||
recipientClientId: bigint;
|
initiatorId: bigint;
|
||||||
|
/** MTP identity that receives the key-establishment message. */
|
||||||
|
recipientId: bigint;
|
||||||
|
/** Public key used by the recipient for this key establishment. */
|
||||||
recipientPublicKey: Uint8Array;
|
recipientPublicKey: Uint8Array;
|
||||||
|
/** KEM ciphertext used by the key establishment. */
|
||||||
kemCiphertext: Uint8Array;
|
kemCiphertext: Uint8Array;
|
||||||
conversationId: string;
|
/** Opaque, application-owned context included by hash only. */
|
||||||
|
applicationContext?: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MTPSessionState {
|
export interface MTPSessionState {
|
||||||
version: 1;
|
version: 1;
|
||||||
conversationId: string;
|
sessionId: string;
|
||||||
ownClientId: bigint;
|
localId: bigint;
|
||||||
peerClientId: bigint;
|
remoteId: bigint;
|
||||||
peerPublicKey: Uint8Array;
|
remotePublicKey: Uint8Array;
|
||||||
sendChainKey: Uint8Array;
|
sendChainKey: Uint8Array;
|
||||||
recvChainKey: Uint8Array;
|
recvChainKey: Uint8Array;
|
||||||
sendCount: number;
|
sendCount: number;
|
||||||
|
|
@ -37,9 +43,26 @@ export interface SkippedMessageKey {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MTPSessionStorage {
|
export interface MTPSessionStorage {
|
||||||
getSession(conversationId: string): Promise<MTPSessionState | null>;
|
getSession(sessionId: string): Promise<MTPSessionState | null>;
|
||||||
setSession(state: MTPSessionState): Promise<void>;
|
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 {
|
export class InMemorySessionStorage implements MTPSessionStorage {
|
||||||
|
|
@ -48,7 +71,7 @@ export class InMemorySessionStorage implements MTPSessionStorage {
|
||||||
private cloneSession(state: MTPSessionState): MTPSessionState {
|
private cloneSession(state: MTPSessionState): MTPSessionState {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
peerPublicKey: state.peerPublicKey.slice(),
|
remotePublicKey: state.remotePublicKey.slice(),
|
||||||
sendChainKey: state.sendChainKey.slice(),
|
sendChainKey: state.sendChainKey.slice(),
|
||||||
recvChainKey: state.recvChainKey.slice(),
|
recvChainKey: state.recvChainKey.slice(),
|
||||||
skippedMessageKeys: (state.skippedMessageKeys ?? []).map((skipped) => ({
|
skippedMessageKeys: (state.skippedMessageKeys ?? []).map((skipped) => ({
|
||||||
|
|
@ -64,26 +87,31 @@ export class InMemorySessionStorage implements MTPSessionStorage {
|
||||||
for (const skipped of state.skippedMessageKeys ?? []) skipped.key.fill(0);
|
for (const skipped of state.skippedMessageKeys ?? []) skipped.key.fill(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSession(conversationId: string): Promise<MTPSessionState | null> {
|
async getSession(sessionId: string): Promise<MTPSessionState | null> {
|
||||||
const state = this.store.get(conversationId);
|
const state = this.store.get(requireSessionId(sessionId));
|
||||||
return state ? this.cloneSession(state) : null;
|
return state ? this.cloneSession(state) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setSession(state: MTPSessionState): Promise<void> {
|
async setSession(state: MTPSessionState): Promise<void> {
|
||||||
|
const sessionId = requireSessionId(state.sessionId);
|
||||||
const replacement = this.cloneSession(state);
|
const replacement = this.cloneSession(state);
|
||||||
const previous = this.store.get(state.conversationId);
|
const previous = this.store.get(sessionId);
|
||||||
if (previous) this.zeroizeSession(previous);
|
if (previous) this.zeroizeSession(previous);
|
||||||
this.store.set(state.conversationId, replacement);
|
this.store.set(sessionId, replacement);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSession(conversationId: string): Promise<void> {
|
async deleteSession(sessionId: string): Promise<void> {
|
||||||
const previous = this.store.get(conversationId);
|
const key = requireSessionId(sessionId);
|
||||||
|
const previous = this.store.get(key);
|
||||||
if (previous) this.zeroizeSession(previous);
|
if (previous) this.zeroizeSession(previous);
|
||||||
this.store.delete(conversationId);
|
this.store.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeU32BE(value: number): Uint8Array {
|
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([
|
return new Uint8Array([
|
||||||
(value >>> 24) & 0xff,
|
(value >>> 24) & 0xff,
|
||||||
(value >>> 16) & 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(
|
export function buildSessionTranscript(
|
||||||
args: MTPSessionTranscriptContext,
|
args: MTPSessionTranscriptContext,
|
||||||
): Uint8Array {
|
): 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 recipientPublicKeyHash = bindings.wasm_sha256(args.recipientPublicKey);
|
||||||
const kemHash = bindings.wasm_sha256(args.kemCiphertext);
|
const kemHash = bindings.wasm_sha256(args.kemCiphertext);
|
||||||
|
const applicationContextHash = bindings.wasm_sha256(
|
||||||
|
args.applicationContext ?? new Uint8Array(0),
|
||||||
|
);
|
||||||
|
|
||||||
return concatBytes([
|
return concatBytes([
|
||||||
transcriptField("domain", utf8Encode("mtp-e2ee-session-transcript-v1")),
|
transcriptField("domain", utf8Encode(SESSION_TRANSCRIPT_DOMAIN)),
|
||||||
transcriptField("version", utf8Encode("1")),
|
transcriptField("version", utf8Encode("1")),
|
||||||
transcriptField("senderUserId", utf8Encode(args.senderUserId ?? "")),
|
transcriptField("sessionId", utf8Encode(sessionId)),
|
||||||
transcriptField("senderClientId", writeU64BE(args.senderClientId)),
|
transcriptField("initiatorId", writeU64BE(initiatorId)),
|
||||||
transcriptField("recipientUserId", utf8Encode(args.recipientUserId ?? "")),
|
transcriptField("recipientId", writeU64BE(recipientId)),
|
||||||
transcriptField("recipientClientId", writeU64BE(args.recipientClientId)),
|
|
||||||
transcriptField("recipientPublicKeyHash", recipientPublicKeyHash),
|
transcriptField("recipientPublicKeyHash", recipientPublicKeyHash),
|
||||||
transcriptField("kemCiphertextHash", kemHash),
|
transcriptField("kemCiphertextHash", kemHash),
|
||||||
transcriptField("conversationId", utf8Encode(args.conversationId)),
|
transcriptField("applicationContextHash", applicationContextHash),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,62 +191,65 @@ export async function deriveSessionKeys(
|
||||||
return { root, initiatorSend, initiatorRecv };
|
return { root, initiatorSend, initiatorRecv };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConversationId(
|
/**
|
||||||
ownClientId: bigint,
|
* Derive a stable ID for the unordered pair of MTP identities.
|
||||||
peerClientId: bigint,
|
*
|
||||||
|
* 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 {
|
): string {
|
||||||
const ids = [ownClientId, peerClientId].sort((a, b) =>
|
const ids = [
|
||||||
a < b ? -1 : a > b ? 1 : 0,
|
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)}`;
|
return `${ids[0].toString(16)}:${ids[1].toString(16)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MTPSessionManager {
|
export class MTPSessionManager {
|
||||||
constructor(private storage: MTPSessionStorage) {}
|
constructor(private storage: MTPSessionStorage) {}
|
||||||
|
|
||||||
getConversationId(
|
getSession(sessionId: string): Promise<MTPSessionState | null> {
|
||||||
ownClientId: bigint,
|
return this.storage.getSession(requireSessionId(sessionId));
|
||||||
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),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveSession(state: MTPSessionState): Promise<void> {
|
async saveSession(state: MTPSessionState): Promise<void> {
|
||||||
await this.storage.setSession({ ...state, updatedAt: Date.now() });
|
await this.storage.setSession({
|
||||||
|
...state,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSession(
|
async deleteSession(sessionId: string): Promise<void> {
|
||||||
ownClientId: bigint,
|
await this.storage.deleteSession(requireSessionId(sessionId));
|
||||||
peerClientId: bigint,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.storage.deleteSession(
|
|
||||||
getConversationId(ownClientId, peerClientId),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(args: {
|
async createSession(args: {
|
||||||
ownClientId: bigint;
|
sessionId: string;
|
||||||
peerClientId: bigint;
|
localId: bigint;
|
||||||
peerPublicKey: Uint8Array;
|
remoteId: bigint;
|
||||||
|
remotePublicKey: Uint8Array;
|
||||||
sharedSecret: Uint8Array;
|
sharedSecret: Uint8Array;
|
||||||
role: "initiator" | "receiver";
|
role: "initiator" | "receiver";
|
||||||
transcript?: Uint8Array;
|
transcript?: Uint8Array;
|
||||||
transcriptContext?: MTPSessionTranscriptContext;
|
transcriptContext?: MTPSessionTranscriptContext;
|
||||||
}): Promise<MTPSessionState> {
|
}): Promise<MTPSessionState> {
|
||||||
const transcript =
|
const sessionId = requireSessionId(args.sessionId);
|
||||||
args.transcript ??
|
const localId = requireMtpId(args.localId, "localId");
|
||||||
(args.transcriptContext
|
const remoteId = requireMtpId(args.remoteId, "remoteId");
|
||||||
|
const transcript = args.transcript
|
||||||
|
? args.transcript.slice()
|
||||||
|
: args.transcriptContext
|
||||||
? buildSessionTranscript(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(
|
const { root, initiatorSend, initiatorRecv } = await deriveSessionKeys(
|
||||||
args.sharedSecret,
|
args.sharedSecret,
|
||||||
transcript,
|
transcript,
|
||||||
|
|
@ -213,10 +257,10 @@ export class MTPSessionManager {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const state: MTPSessionState = {
|
const state: MTPSessionState = {
|
||||||
version: 1,
|
version: 1,
|
||||||
conversationId: getConversationId(args.ownClientId, args.peerClientId),
|
sessionId,
|
||||||
ownClientId: args.ownClientId,
|
localId,
|
||||||
peerClientId: args.peerClientId,
|
remoteId,
|
||||||
peerPublicKey: args.peerPublicKey,
|
remotePublicKey: args.remotePublicKey.slice(),
|
||||||
sendChainKey: args.role === "initiator" ? initiatorSend : initiatorRecv,
|
sendChainKey: args.role === "initiator" ? initiatorSend : initiatorRecv,
|
||||||
recvChainKey: args.role === "initiator" ? initiatorRecv : initiatorSend,
|
recvChainKey: args.role === "initiator" ? initiatorRecv : initiatorSend,
|
||||||
sendCount: 0,
|
sendCount: 0,
|
||||||
|
|
|
||||||
170
src/sdk/signature-policy.ts
Normal file
170
src/sdk/signature-policy.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,11 @@ export function utf8Encode(text: string): Uint8Array {
|
||||||
return bytes.subarray(0, len);
|
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 {
|
export function writeU64BE(value: bigint): Uint8Array {
|
||||||
if (value < 0n || value > 0xffff_ffff_ffff_ffffn) throw new Error("u64 value out of range");
|
if (value < 0n || value > 0xffff_ffff_ffff_ffffn) throw new Error("u64 value out of range");
|
||||||
const out = new Uint8Array(8);
|
const out = new Uint8Array(8);
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,13 @@
|
||||||
export const RESERVED_COMMUNICATION_TYPES = [
|
import reserved from "../../type-map/reserved.json" with { type: "json" };
|
||||||
"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;
|
|
||||||
|
|
||||||
export const RESERVED_DATA_TYPES = [
|
export const FIRST_USER_TYPE_ID = reserved.firstUserTypeId;
|
||||||
"Version", "Id", "ClientNonce", "ServerNonce", "PublicKeys", "Signature",
|
export const RESERVED_COMMUNICATION_TYPES = reserved.communication.map(
|
||||||
"PqSignature", "Description", "Connected", "Timestamp", "Error", "ErrorParsing",
|
({ name }) => name,
|
||||||
"ErrorMessage", "Accepted", "RequirePq",
|
);
|
||||||
] as const;
|
export const RESERVED_DATA_TYPES = reserved.data.map(({ name }) => name);
|
||||||
|
export const RESERVED_COMMUNICATION_TYPE_IDS = Object.fromEntries(
|
||||||
export const FIRST_USER_TYPE_ID = 32;
|
reserved.communication.map(({ name, id }) => [name, id]),
|
||||||
|
);
|
||||||
|
export const RESERVED_DATA_TYPE_IDS = Object.fromEntries(
|
||||||
|
reserved.data.map(({ name, id }) => [name, id]),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ function devServerPath(root: string, filePath: string) {
|
||||||
return `/${relativePath.split(path.sep).join("/")}`;
|
return `/${relativePath.split(path.sep).join("/")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hashPackageInputs() {
|
export async function hashPackageInputs(root = packageRoot) {
|
||||||
const hash = crypto.createHash("sha256");
|
const hash = crypto.createHash("sha256");
|
||||||
const inputs = [
|
const inputs = [
|
||||||
"wasm/Cargo.toml",
|
"wasm/Cargo.toml",
|
||||||
|
|
@ -74,11 +74,12 @@ async function hashPackageInputs() {
|
||||||
"crypto/src",
|
"crypto/src",
|
||||||
"type-map/Cargo.toml",
|
"type-map/Cargo.toml",
|
||||||
"type-map/build.rs",
|
"type-map/build.rs",
|
||||||
|
"type-map/reserved.json",
|
||||||
"type-map/src",
|
"type-map/src",
|
||||||
];
|
];
|
||||||
|
|
||||||
async function addPath(relativePath) {
|
async function addPath(relativePath) {
|
||||||
const absolutePath = path.join(packageRoot, relativePath);
|
const absolutePath = path.join(root, relativePath);
|
||||||
const stat = await fs.stat(absolutePath).catch(() => null);
|
const stat = await fs.stat(absolutePath).catch(() => null);
|
||||||
if (!stat) {
|
if (!stat) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -109,7 +110,7 @@ function quoteList(values: string[]): string {
|
||||||
: values.map((value) => JSON.stringify(value)).join(" | ");
|
: 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 });
|
const document = YAML.parseDocument(source, { prettyErrors: false });
|
||||||
if (document.errors.length) {
|
if (document.errors.length) {
|
||||||
const error = document.errors[0];
|
const error = document.errors[0];
|
||||||
|
|
@ -120,27 +121,80 @@ function parseTypeMapYaml(source: string, filePath: string) {
|
||||||
throw new Error(`${filePath}:${line}: ${error.message}`);
|
throw new Error(`${filePath}:${line}: ${error.message}`);
|
||||||
}
|
}
|
||||||
const root = document.toJS() as {
|
const root = document.toJS() as {
|
||||||
type_maps?: Record<
|
protocol_version?: unknown;
|
||||||
string,
|
type_maps?: unknown;
|
||||||
{
|
|
||||||
CommunicationTypes?: Record<string, unknown>;
|
|
||||||
DataTypes?: Record<string, 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 communicationTypes = new Set<string>(RESERVED_COMMUNICATION_TYPES);
|
||||||
const dataTypes = new Set<string>(RESERVED_DATA_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))
|
if (!/^\d+\.\d+$/.test(version))
|
||||||
throw new Error(`${filePath}: unparseable type-map version '${version}'`);
|
throw new Error(`${filePath}: unparseable type-map version '${version}'`);
|
||||||
for (const [section, target] of [
|
if (
|
||||||
["CommunicationTypes", communicationTypes],
|
rawMap === null ||
|
||||||
["DataTypes", dataTypes],
|
typeof rawMap !== "object" ||
|
||||||
] as const) {
|
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>();
|
const ids = new Map<number, string>();
|
||||||
for (const [name, value] of Object.entries(
|
for (const [name, value] of Object.entries(sectionValue ?? {})) {
|
||||||
map[section as "CommunicationTypes" | "DataTypes"] ?? {},
|
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)
|
if (!Number.isInteger(value) || (value as number) < FIRST_USER_TYPE_ID)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${filePath}: ${version}.${section}.${name} must use an integer id >= ${FIRST_USER_TYPE_ID}`,
|
`${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})`,
|
`${filePath}: duplicate type id ${id} in ${section} (${previous} and ${name})`,
|
||||||
);
|
);
|
||||||
ids.set(id, 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[];
|
communicationTypes: string[];
|
||||||
dataTypes: string[];
|
dataTypes: string[];
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -171,7 +227,7 @@ function generateTypeMapModule(metadata: {
|
||||||
return { js, dts };
|
return { js, dts };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeTypeMapModule(outDir, typeMapsPath) {
|
export async function writeTypeMapModule(outDir, typeMapsPath) {
|
||||||
const source = await fs.readFile(typeMapsPath, "utf8").catch((error) => {
|
const source = await fs.readFile(typeMapsPath, "utf8").catch((error) => {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to read type map '${typeMapsPath}': ${error.message}`,
|
`Failed to read type map '${typeMapsPath}': ${error.message}`,
|
||||||
|
|
@ -186,7 +242,7 @@ async function writeTypeMapModule(outDir, typeMapsPath) {
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyWasmBuildInputs(buildRoot) {
|
export async function copyWasmBuildInputs(buildRoot) {
|
||||||
const inputs = [
|
const inputs = [
|
||||||
"Cargo.lock",
|
"Cargo.lock",
|
||||||
"wasm",
|
"wasm",
|
||||||
|
|
@ -416,18 +472,19 @@ export function mtp(options: MTPVitePluginOptions): VitePlugin {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceWatchDirs = [
|
const sourceWatchPaths = [
|
||||||
"wasm/src",
|
"wasm/src",
|
||||||
"common/src",
|
"common/src",
|
||||||
"codec/src",
|
"codec/src",
|
||||||
"crypto/src",
|
"crypto/src",
|
||||||
"type-map/src",
|
"type-map/src",
|
||||||
|
"type-map/reserved.json",
|
||||||
].map((rel) => path.join(packageRoot, rel));
|
].map((rel) => path.join(packageRoot, rel));
|
||||||
|
|
||||||
server.watcher.add(state.typeMapsPath);
|
server.watcher.add(state.typeMapsPath);
|
||||||
for (const dir of sourceWatchDirs) {
|
for (const sourcePath of sourceWatchPaths) {
|
||||||
if (await pathExists(dir)) {
|
if (await pathExists(sourcePath)) {
|
||||||
server.watcher.add(dir);
|
server.watcher.add(sourcePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -435,8 +492,10 @@ export function mtp(options: MTPVitePluginOptions): VitePlugin {
|
||||||
const scheduleRebuild = (changedPath: string) => {
|
const scheduleRebuild = (changedPath: string) => {
|
||||||
const resolved = path.resolve(changedPath);
|
const resolved = path.resolve(changedPath);
|
||||||
const isTypeMap = resolved === state.typeMapsPath;
|
const isTypeMap = resolved === state.typeMapsPath;
|
||||||
const isSource = sourceWatchDirs.some((dir) =>
|
const isSource = sourceWatchPaths.some(
|
||||||
resolved.startsWith(`${dir}${path.sep}`),
|
(sourcePath) =>
|
||||||
|
resolved === sourcePath ||
|
||||||
|
resolved.startsWith(`${sourcePath}${path.sep}`),
|
||||||
);
|
);
|
||||||
if (!isTypeMap && !isSource) {
|
if (!isTypeMap && !isSource) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
2315
test/e2ee.mjs
2315
test/e2ee.mjs
File diff suppressed because it is too large
Load diff
95
test/encrypted-secret.mjs
Normal file
95
test/encrypted-secret.mjs
Normal 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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
78
test/task8-options.type-test.ts
Normal file
78
test/task8-options.type-test.ts
Normal 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
243
test/vite-type-map.mjs
Normal 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");
|
||||||
|
|
@ -19,6 +19,8 @@ rcgen = "0.14"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
|
rand = "0.10.2"
|
||||||
|
zeroize = "1.9"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
||||||
|
|
@ -30,7 +32,7 @@ required-features = ["host", "insecure-tls"]
|
||||||
# Enables hosting a MTP server
|
# Enables hosting a MTP server
|
||||||
host = []
|
host = []
|
||||||
|
|
||||||
pipes = ["mtp-codec/pipes"]
|
pipes = ["mtp-codec/pipes", "mtp-codec/crypto"]
|
||||||
|
|
||||||
# Compiles the insecure certificate verifier (NoopCertVerifier).
|
# Compiles the insecure certificate verifier (NoopCertVerifier).
|
||||||
# Even with this feature enabled, the verifier requires the environment
|
# Even with this feature enabled, the verifier requires the environment
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use crate::ConnectionHandle;
|
use crate::ConnectionHandle;
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
use crate::pipe::PipeReader;
|
use crate::pipe::PipeReader;
|
||||||
use mtp_codec::CommunicationValue;
|
use mtp_codec::{CommunicationValue, DecodeLimits, TypeMap};
|
||||||
use mtp_common::CommunicationError;
|
use mtp_common::CommunicationError;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
@ -148,6 +148,7 @@ pub struct Sender {
|
||||||
handle: Arc<ConnectionHandle>,
|
handle: Arc<ConnectionHandle>,
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
policy: Arc<Policy>,
|
policy: Arc<Policy>,
|
||||||
|
type_map: Arc<RwLock<TypeMap>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sender {
|
impl Sender {
|
||||||
|
|
@ -158,9 +159,15 @@ impl Sender {
|
||||||
handle,
|
handle,
|
||||||
connection,
|
connection,
|
||||||
policy,
|
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")]
|
#[instrument(skip(stream, data, policy), level = "trace")]
|
||||||
async fn write_frame(
|
async fn write_frame(
|
||||||
stream: &mut wtransport::SendStream,
|
stream: &mut wtransport::SendStream,
|
||||||
|
|
@ -174,9 +181,7 @@ impl Sender {
|
||||||
return Err(CommunicationError::MessageTooLarge);
|
return Err(CommunicationError::MessageTooLarge);
|
||||||
}
|
}
|
||||||
|
|
||||||
let len_bytes = (bytes.len() as u32).to_be_bytes();
|
|
||||||
let write_result = async {
|
let write_result = async {
|
||||||
stream.write_all(&len_bytes).await?;
|
|
||||||
stream.write_all(&bytes).await?;
|
stream.write_all(&bytes).await?;
|
||||||
Ok::<(), wtransport::error::StreamWriteError>(())
|
Ok::<(), wtransport::error::StreamWriteError>(())
|
||||||
};
|
};
|
||||||
|
|
@ -458,12 +463,16 @@ impl Sender {
|
||||||
|
|
||||||
let mut stream = Self::open_uni_stream(&self.connection, &self.policy).await?;
|
let mut stream = Self::open_uni_stream(&self.connection, &self.policy).await?;
|
||||||
|
|
||||||
let request = CommunicationValue::new(mtp_codec::CommunicationType::PipeRequest)
|
let type_map = self.type_map.read().await.clone();
|
||||||
.with_id(pipe_id)
|
let request = CommunicationValue::new_with_type_map(
|
||||||
.add_typed_default(
|
mtp_codec::CommunicationType::PipeRequest,
|
||||||
mtp_codec::DataType::Description,
|
&type_map,
|
||||||
mtp_codec::DataValue::Str(description.to_string()),
|
)
|
||||||
);
|
.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?;
|
Self::write_frame(&mut stream, &request, &self.policy).await?;
|
||||||
|
|
||||||
|
|
@ -595,6 +604,7 @@ struct ReceiverInner {
|
||||||
ping_control: Arc<RwLock<PingControl>>,
|
ping_control: Arc<RwLock<PingControl>>,
|
||||||
queue_notify: Arc<Notify>,
|
queue_notify: Arc<Notify>,
|
||||||
max_message_size: Arc<AtomicU64>,
|
max_message_size: Arc<AtomicU64>,
|
||||||
|
type_map: Arc<RwLock<TypeMap>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Clone for Receiver {
|
impl Clone for Receiver {
|
||||||
|
|
@ -624,6 +634,7 @@ impl Receiver {
|
||||||
Self::new_with_max_message_size(connection, handle, policy.clone(), policy.max_message_size)
|
Self::new_with_max_message_size(connection, handle, policy.clone(), policy.max_message_size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "host")]
|
||||||
pub(crate) fn new_for_handshake(
|
pub(crate) fn new_for_handshake(
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
handle: Arc<ConnectionHandle>,
|
handle: Arc<ConnectionHandle>,
|
||||||
|
|
@ -661,6 +672,8 @@ impl Receiver {
|
||||||
let accept_queue_notify = queue_notify.clone();
|
let accept_queue_notify = queue_notify.clone();
|
||||||
let max_message_size = Arc::new(AtomicU64::new(initial_max_message_size));
|
let max_message_size = Arc::new(AtomicU64::new(initial_max_message_size));
|
||||||
let accept_max_message_size = max_message_size.clone();
|
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 stream_limit = Arc::new(Semaphore::new(policy.max_concurrent_stream_tasks.max(1)));
|
||||||
let accept_stream_limit = stream_limit.clone();
|
let accept_stream_limit = stream_limit.clone();
|
||||||
debug!(
|
debug!(
|
||||||
|
|
@ -728,6 +741,7 @@ impl Receiver {
|
||||||
let stream_policy = accept_policy.clone();
|
let stream_policy = accept_policy.clone();
|
||||||
let stream_ping_control = accept_ping_control.clone();
|
let stream_ping_control = accept_ping_control.clone();
|
||||||
let stream_max_message_size = accept_max_message_size.clone();
|
let stream_max_message_size = accept_max_message_size.clone();
|
||||||
|
let stream_type_map = accept_type_map.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
|
|
@ -748,18 +762,25 @@ impl Receiver {
|
||||||
|
|
||||||
let frame_limit = stream_max_message_size.load(Ordering::Relaxed);
|
let frame_limit = stream_max_message_size.load(Ordering::Relaxed);
|
||||||
match Self::read_one_frame(&mut s, &stream_policy, frame_limit).await {
|
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;
|
frame_count += 1;
|
||||||
|
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
{
|
{
|
||||||
let pipe_request_type =
|
if msg.is_type(mtp_codec::CommunicationType::PipeRequest)
|
||||||
mtp_codec::CommunicationType::PipeRequest
|
|
||||||
.try_to_id(&mtp_codec::TypeMap::latest());
|
|
||||||
if Some(msg.get_type()) == pipe_request_type
|
|
||||||
&& frame_count == 1
|
&& 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
|
let description = msg
|
||||||
.get_str(mtp_codec::DataType::Description)
|
.get_str(mtp_codec::DataType::Description)
|
||||||
.unwrap_or("")
|
.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 = {
|
||||||
let control = stream_ping_control.read().await;
|
let control = stream_ping_control.read().await;
|
||||||
if Some(msg.get_type()) == ping_type {
|
if msg.is_type(mtp_codec::CommunicationType::Ping) {
|
||||||
control
|
control
|
||||||
.pong_sender
|
.pong_sender
|
||||||
.clone()
|
.clone()
|
||||||
.map(|sender| (Some(sender), None))
|
.map(|sender| (Some(sender), None))
|
||||||
} else if Some(msg.get_type()) == pong_type {
|
} else if msg.is_type(mtp_codec::CommunicationType::Pong) {
|
||||||
control
|
control
|
||||||
.pong_observer
|
.pong_observer
|
||||||
.clone()
|
.clone()
|
||||||
|
|
@ -806,9 +823,16 @@ impl Receiver {
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some((Some(sender), _)) = control {
|
if let Some((Some(sender), _)) = control {
|
||||||
let mut pong = CommunicationValue::new(mtp_codec::CommunicationType::Pong)
|
let mut pong = CommunicationValue::new_with_type_map(
|
||||||
.with_id(msg.get_id());
|
mtp_codec::CommunicationType::Pong,
|
||||||
if let Some(timestamp) = msg.get_data_opt(mtp_codec::DataType::Timestamp) {
|
&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(
|
pong = pong.add_typed_default(
|
||||||
mtp_codec::DataType::Timestamp,
|
mtp_codec::DataType::Timestamp,
|
||||||
timestamp.clone(),
|
timestamp.clone(),
|
||||||
|
|
@ -917,6 +941,7 @@ impl Receiver {
|
||||||
ping_control,
|
ping_control,
|
||||||
queue_notify,
|
queue_notify,
|
||||||
max_message_size,
|
max_message_size,
|
||||||
|
type_map,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -928,6 +953,11 @@ impl Receiver {
|
||||||
.store(max_message_size, Ordering::Relaxed);
|
.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. */
|
/* Respond to reserved Ping frames without exposing them to application I/O. */
|
||||||
pub fn respond_to_pings(&self, sender: Sender) {
|
pub fn respond_to_pings(&self, sender: Sender) {
|
||||||
if let Ok(mut control) = self.inner.ping_control.try_write() {
|
if let Ok(mut control) = self.inner.ping_control.try_write() {
|
||||||
|
|
@ -982,18 +1012,21 @@ impl Receiver {
|
||||||
return Ok(ReceivedFrame::ClosedByPeer);
|
return Ok(ReceivedFrame::ClosedByPeer);
|
||||||
}
|
}
|
||||||
|
|
||||||
let len_usize = len as usize;
|
let body_len = len as usize;
|
||||||
if len as u64 > max_message_size {
|
let frame_len = body_len
|
||||||
|
.checked_add(4)
|
||||||
|
.ok_or(CommunicationError::MessageTooLarge)?;
|
||||||
|
if frame_len as u64 > max_message_size {
|
||||||
return Err(CommunicationError::MessageTooLarge);
|
return Err(CommunicationError::MessageTooLarge);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grow in bounded chunks instead of trusting the peer's length prefix
|
// Grow in bounded chunks instead of trusting the peer's length prefix
|
||||||
// enough to allocate the complete frame up front.
|
// enough to allocate the complete frame up front.
|
||||||
let mut buf = Vec::new();
|
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)?;
|
.map_err(|_| CommunicationError::MessageTooLarge)?;
|
||||||
while buf.len() < len_usize {
|
while buf.len() < body_len {
|
||||||
let chunk_len = (len_usize - buf.len()).min(16 * 1024);
|
let chunk_len = (body_len - buf.len()).min(16 * 1024);
|
||||||
let mut chunk = [0u8; 16 * 1024];
|
let mut chunk = [0u8; 16 * 1024];
|
||||||
match timeout(
|
match timeout(
|
||||||
policy.read_timeout,
|
policy.read_timeout,
|
||||||
|
|
@ -1008,7 +1041,7 @@ impl Receiver {
|
||||||
}
|
}
|
||||||
Ok(Err(StreamReadExactError::FinishedEarly(n))) => {
|
Ok(Err(StreamReadExactError::FinishedEarly(n))) => {
|
||||||
warn!(
|
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
|
buf.len() + n
|
||||||
);
|
);
|
||||||
return Err(CommunicationError::StreamError);
|
return Err(CommunicationError::StreamError);
|
||||||
|
|
@ -1024,14 +1057,20 @@ impl Receiver {
|
||||||
return Err(CommunicationError::StreamError);
|
return Err(CommunicationError::StreamError);
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
warn!("[Receiver] body read timed out (len={len_usize})");
|
warn!("[Receiver] body read timed out (len={body_len})");
|
||||||
return Err(CommunicationError::StreamError);
|
return Err(CommunicationError::StreamError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = CommunicationValue::from_bytes(&buf)
|
let mut frame = Vec::with_capacity(frame_len);
|
||||||
.map_err(|_| CommunicationError::ParseCommunicationValue)?;
|
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))
|
Ok(ReceivedFrame::Message(message))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1512
transport/src/encrypted_pipe.rs
Normal file
1512
transport/src/encrypted_pipe.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,11 @@ use crate::{Policy, TransportSendStream};
|
||||||
use mtp_codec::CommunicationValue;
|
use mtp_codec::CommunicationValue;
|
||||||
use mtp_common::CommunicationError;
|
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>(
|
pub(crate) async fn write_frame<S: TransportSendStream>(
|
||||||
stream: &mut S,
|
stream: &mut S,
|
||||||
value: &CommunicationValue,
|
value: &CommunicationValue,
|
||||||
|
|
@ -14,8 +18,70 @@ pub(crate) async fn write_frame<S: TransportSendStream>(
|
||||||
{
|
{
|
||||||
return Err(CommunicationError::MessageTooLarge);
|
return Err(CommunicationError::MessageTooLarge);
|
||||||
}
|
}
|
||||||
stream
|
|
||||||
.write_all(&(bytes.len() as u32).to_be_bytes())
|
|
||||||
.await?;
|
|
||||||
stream.write_all(&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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
use crate::{
|
use crate::{
|
||||||
Policy, TransportConnection, TransportRecvStream, TransportSendStream, framing::write_frame,
|
Policy, TransportConnection, TransportRecvStream, TransportSendStream, framing::write_frame,
|
||||||
};
|
};
|
||||||
use mtp_codec::CommunicationValue;
|
use mtp_codec::{CommunicationValue, DecodeLimits, TypeMap};
|
||||||
use mtp_common::CommunicationError;
|
use mtp_common::CommunicationError;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
@ -22,6 +22,7 @@ pub struct GenericSender<C: TransportConnection> {
|
||||||
policy: Arc<Policy>,
|
policy: Arc<Policy>,
|
||||||
persistent: Arc<Mutex<Option<C::SendStream>>>,
|
persistent: Arc<Mutex<Option<C::SendStream>>>,
|
||||||
send_lock: Arc<Mutex<()>>,
|
send_lock: Arc<Mutex<()>>,
|
||||||
|
type_map: Arc<RwLock<TypeMap>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<C: TransportConnection> Clone for GenericSender<C> {
|
impl<C: TransportConnection> Clone for GenericSender<C> {
|
||||||
|
|
@ -31,6 +32,7 @@ impl<C: TransportConnection> Clone for GenericSender<C> {
|
||||||
policy: self.policy.clone(),
|
policy: self.policy.clone(),
|
||||||
persistent: self.persistent.clone(),
|
persistent: self.persistent.clone(),
|
||||||
send_lock: self.send_lock.clone(),
|
send_lock: self.send_lock.clone(),
|
||||||
|
type_map: self.type_map.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -42,9 +44,15 @@ impl<C: TransportConnection> GenericSender<C> {
|
||||||
policy,
|
policy,
|
||||||
persistent: Arc::new(Mutex::new(None)),
|
persistent: Arc::new(Mutex::new(None)),
|
||||||
send_lock: Arc::new(Mutex::new(())),
|
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> {
|
async fn open(&self) -> Result<C::SendStream, CommunicationError> {
|
||||||
timeout(self.policy.open_stream_timeout, self.connection.open_uni())
|
timeout(self.policy.open_stream_timeout, self.connection.open_uni())
|
||||||
.await
|
.await
|
||||||
|
|
@ -112,12 +120,16 @@ impl<C: TransportConnection> GenericSender<C> {
|
||||||
|
|
||||||
let mut stream = self.open().await?;
|
let mut stream = self.open().await?;
|
||||||
|
|
||||||
let request = CommunicationValue::new(mtp_codec::CommunicationType::PipeRequest)
|
let type_map = self.type_map.read().await.clone();
|
||||||
.with_id(pipe_id)
|
let request = CommunicationValue::new_with_type_map(
|
||||||
.add_typed_default(
|
mtp_codec::CommunicationType::PipeRequest,
|
||||||
mtp_codec::DataType::Description,
|
&type_map,
|
||||||
mtp_codec::DataValue::Str(description.to_string()),
|
)
|
||||||
);
|
.with_id(pipe_id)
|
||||||
|
.add_typed_default(
|
||||||
|
mtp_codec::DataType::Description,
|
||||||
|
mtp_codec::DataValue::Str(description.to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
timeout(
|
timeout(
|
||||||
self.policy.write_timeout,
|
self.policy.write_timeout,
|
||||||
|
|
@ -166,6 +178,7 @@ pub struct GenericReceiver<C: TransportConnection> {
|
||||||
connection: C,
|
connection: C,
|
||||||
ping_sender: Arc<RwLock<Option<GenericSender<C>>>>,
|
ping_sender: Arc<RwLock<Option<GenericSender<C>>>>,
|
||||||
max_message_size: Arc<AtomicU64>,
|
max_message_size: Arc<AtomicU64>,
|
||||||
|
type_map: Arc<RwLock<TypeMap>>,
|
||||||
_accept_task: Arc<tokio::task::JoinHandle<()>>,
|
_accept_task: Arc<tokio::task::JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,6 +191,7 @@ impl<C: TransportConnection> Clone for GenericReceiver<C> {
|
||||||
connection: self.connection.clone(),
|
connection: self.connection.clone(),
|
||||||
ping_sender: self.ping_sender.clone(),
|
ping_sender: self.ping_sender.clone(),
|
||||||
max_message_size: self.max_message_size.clone(),
|
max_message_size: self.max_message_size.clone(),
|
||||||
|
type_map: self.type_map.clone(),
|
||||||
_accept_task: self._accept_task.clone(),
|
_accept_task: self._accept_task.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -206,6 +220,8 @@ impl<C: TransportConnection> GenericReceiver<C> {
|
||||||
let task_connection = connection.clone();
|
let task_connection = connection.clone();
|
||||||
let task_policy = policy.clone();
|
let task_policy = policy.clone();
|
||||||
let task_max_message_size = max_message_size.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();
|
let task_accept_task_tx = tx.clone();
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
let task_accept_task_pipe_tx = pipe_tx.clone();
|
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 max_message_size = task_max_message_size.clone();
|
||||||
let ping_sender = task_ping_sender.clone();
|
let ping_sender = task_ping_sender.clone();
|
||||||
let connection = task_connection.clone();
|
let connection = task_connection.clone();
|
||||||
|
let type_map = task_type_map.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
let mut stream = stream;
|
let mut stream = stream;
|
||||||
|
|
@ -298,13 +315,22 @@ impl<C: TransportConnection> GenericReceiver<C> {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let frame_limit = max_message_size.load(Ordering::Relaxed);
|
let frame_limit = max_message_size.load(Ordering::Relaxed);
|
||||||
if len as u64 > frame_limit {
|
let body_len = len as usize;
|
||||||
tracing::warn!(len, "MTP receive stream frame is too large");
|
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;
|
let _ = tx.send(Err(CommunicationError::MessageTooLarge)).await;
|
||||||
connection.close(policy.application_close_code, b"frame too large");
|
connection.close(policy.application_close_code, b"frame too large");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let target_len = len as usize;
|
let target_len = body_len;
|
||||||
let mut body = Vec::new();
|
let mut body = Vec::new();
|
||||||
if body.try_reserve(target_len.min(16 * 1024)).is_err() {
|
if body.try_reserve(target_len.min(16 * 1024)).is_err() {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
|
|
@ -343,7 +369,13 @@ impl<C: TransportConnection> GenericReceiver<C> {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
frames += 1;
|
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,
|
Ok(message) => message,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
tracing::warn!("MTP receive stream contained an invalid frame");
|
tracing::warn!("MTP receive stream contained an invalid frame");
|
||||||
|
|
@ -354,13 +386,25 @@ impl<C: TransportConnection> GenericReceiver<C> {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let negotiated_type_map = type_map.read().await.clone();
|
||||||
|
message.set_type_map(&negotiated_type_map);
|
||||||
|
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
{
|
{
|
||||||
let pipe_request_type = mtp_codec::CommunicationType::PipeRequest
|
if message.is_type(mtp_codec::CommunicationType::PipeRequest)
|
||||||
.try_to_id(&mtp_codec::TypeMap::latest());
|
&& frames == 1
|
||||||
if Some(message.get_type()) == pipe_request_type && frames == 1 {
|
{
|
||||||
let pipe_id = message.get_id();
|
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
|
let description = message
|
||||||
.get_str(mtp_codec::DataType::Description)
|
.get_str(mtp_codec::DataType::Description)
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
|
|
@ -383,11 +427,17 @@ impl<C: TransportConnection> GenericReceiver<C> {
|
||||||
|
|
||||||
if message.is_type(mtp_codec::CommunicationType::Ping) {
|
if message.is_type(mtp_codec::CommunicationType::Ping) {
|
||||||
if let Some(sender) = ping_sender.read().await.clone() {
|
if let Some(sender) = ping_sender.read().await.clone() {
|
||||||
let mut pong =
|
let mut pong = CommunicationValue::new_with_type_map(
|
||||||
CommunicationValue::new(mtp_codec::CommunicationType::Pong)
|
mtp_codec::CommunicationType::Pong,
|
||||||
.with_id(message.get_id());
|
&negotiated_type_map,
|
||||||
|
);
|
||||||
|
if let Some(id) = message.id() {
|
||||||
|
pong = pong.with_id(id);
|
||||||
|
} else {
|
||||||
|
pong = pong.without_id();
|
||||||
|
}
|
||||||
if let Some(timestamp) =
|
if let Some(timestamp) =
|
||||||
message.get_data_opt(mtp_codec::DataType::Timestamp)
|
message.get_data(mtp_codec::DataType::Timestamp)
|
||||||
{
|
{
|
||||||
pong = pong.add_typed_default(
|
pong = pong.add_typed_default(
|
||||||
mtp_codec::DataType::Timestamp,
|
mtp_codec::DataType::Timestamp,
|
||||||
|
|
@ -412,6 +462,7 @@ impl<C: TransportConnection> GenericReceiver<C> {
|
||||||
connection,
|
connection,
|
||||||
ping_sender,
|
ping_sender,
|
||||||
max_message_size,
|
max_message_size,
|
||||||
|
type_map,
|
||||||
_accept_task: Arc::new(accept_task),
|
_accept_task: Arc::new(accept_task),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -424,6 +475,11 @@ impl<C: TransportConnection> GenericReceiver<C> {
|
||||||
self.max_message_size
|
self.max_message_size
|
||||||
.store(max_message_size, Ordering::Relaxed);
|
.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> {
|
pub async fn receive(&self) -> Result<CommunicationValue, CommunicationError> {
|
||||||
self.incoming
|
self.incoming
|
||||||
.lock()
|
.lock()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ pub mod generic_connection;
|
||||||
pub mod pinning;
|
pub mod pinning;
|
||||||
pub mod transport_traits;
|
pub mod transport_traits;
|
||||||
|
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
pub mod encrypted_pipe;
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
pub mod pipe;
|
pub mod pipe;
|
||||||
|
|
||||||
|
|
@ -15,6 +17,15 @@ pub use generic_connection::{GenericReceiver, GenericSender};
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
pub use connection::TransportEvent;
|
pub use connection::TransportEvent;
|
||||||
#[cfg(feature = "pipes")]
|
#[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 pipe::{PipeReader, PipeWriter};
|
||||||
|
|
||||||
pub use client::{ClientConfig, connect, connect_with_config};
|
pub use client::{ClientConfig, connect, connect_with_config};
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ impl PipeWriter {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S: tokio::io::AsyncWrite + Send + Unpin> PipeWriter<S> {
|
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> {
|
pub async fn finish_async(mut self) -> Result<(), mtp_common::CommunicationError> {
|
||||||
tokio::io::AsyncWriteExt::shutdown(&mut self)
|
tokio::io::AsyncWriteExt::shutdown(&mut self)
|
||||||
.await
|
.await
|
||||||
|
|
@ -58,6 +62,10 @@ pub struct PipeReader<R = wtransport::RecvStream> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R> PipeReader<R> {
|
impl<R> PipeReader<R> {
|
||||||
|
pub fn into_inner(self) -> R {
|
||||||
|
self.stream
|
||||||
|
}
|
||||||
|
|
||||||
pub fn description(&self) -> &str {
|
pub fn description(&self) -> &str {
|
||||||
&self.description
|
&self.description
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,12 +50,14 @@ async fn connected_pair()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn numbered_message(comm_type: CommunicationType, value: u128, tm: &TypeMap) -> CommunicationValue {
|
fn numbered_message(comm_type: CommunicationType, value: u128, tm: &TypeMap) -> CommunicationValue {
|
||||||
CommunicationValue::new(comm_type).add_data(
|
CommunicationValue::new(comm_type)
|
||||||
DataType::PqSignature
|
.add_data(
|
||||||
.try_to_id(tm)
|
DataType::PqSignature
|
||||||
.expect("test type must be mapped"),
|
.try_to_id(tm)
|
||||||
DataValue::UnsignedNumber(value),
|
.expect("test type must be mapped"),
|
||||||
)
|
DataValue::UnsignedNumber(value),
|
||||||
|
)
|
||||||
|
.expect("numbered message must have a container payload")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_numbered_message(
|
fn assert_numbered_message(
|
||||||
|
|
@ -69,8 +71,8 @@ fn assert_numbered_message(
|
||||||
comm_type.try_to_id(tm).expect("test type must be mapped")
|
comm_type.try_to_id(tm).expect("test type must be mapped")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
message.get_data(DataType::PqSignature).clone(),
|
message.get_data(DataType::PqSignature),
|
||||||
DataValue::UnsignedNumber(value)
|
Some(&DataValue::UnsignedNumber(value))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,6 +140,25 @@ async fn test_send_receive_roundtrip() -> Result<(), Box<dyn std::error::Error>>
|
||||||
Ok(())
|
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]
|
#[tokio::test]
|
||||||
async fn test_concurrent_messages() -> Result<(), Box<dyn std::error::Error>> {
|
async fn test_concurrent_messages() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let (_h, client_tx, _client_rx, _host_tx, host_rx) = connected_pair().await?;
|
let (_h, client_tx, _client_rx, _host_tx, host_rx) = connected_pair().await?;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/raw/**/*.ts",
|
"src/raw/**/*.ts",
|
||||||
|
|
|
||||||
8
tsconfig.type-tests.json
Normal file
8
tsconfig.type-tests.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "test/**/*.type-test.ts"]
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,9 @@ use serde::Deserialize;
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
const DEFAULT_TYPE_MAPS_PATH: &str = "./type-maps.yaml";
|
const DEFAULT_TYPE_MAPS_PATH: &str = "./type-maps.yaml";
|
||||||
const FIRST_USER_TYPE_ID: u16 = 32;
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Config {
|
struct Config {
|
||||||
|
|
@ -22,186 +22,54 @@ struct TypeMapConfig {
|
||||||
data_types: BTreeMap<String, u16>,
|
data_types: BTreeMap<String, u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ReservedEntry {
|
#[derive(Deserialize)]
|
||||||
name: &'static str,
|
struct ReservedManifest {
|
||||||
|
#[serde(rename = "firstUserTypeId")]
|
||||||
|
first_user_type_id: u16,
|
||||||
|
communication: Vec<ManifestEntry>,
|
||||||
|
data: Vec<ManifestEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ManifestEntry {
|
||||||
|
name: String,
|
||||||
id: u16,
|
id: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESERVED_COMM_TYPES: &[ReservedEntry] = &[
|
fn reserved_manifest() -> &'static ReservedManifest {
|
||||||
ReservedEntry {
|
static MANIFEST: OnceLock<ReservedManifest> = OnceLock::new();
|
||||||
name: "Identification",
|
MANIFEST.get_or_init(|| {
|
||||||
id: 0,
|
serde_yaml::from_str(include_str!("reserved.json"))
|
||||||
},
|
.expect("type-map/reserved.json must be valid JSON/YAML")
|
||||||
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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const RESERVED_DATA_TYPES: &[ReservedEntry] = &[
|
fn first_user_type_id() -> u16 {
|
||||||
ReservedEntry {
|
reserved_manifest().first_user_type_id
|
||||||
name: "Version",
|
}
|
||||||
id: 0,
|
|
||||||
},
|
fn all_reserved_comm_types() -> &'static [ManifestEntry] {
|
||||||
ReservedEntry { name: "Id", id: 1 },
|
&reserved_manifest().communication
|
||||||
ReservedEntry {
|
}
|
||||||
name: "ClientNonce",
|
|
||||||
id: 2,
|
fn generated_reserved_comm_types() -> Vec<&'static ManifestEntry> {
|
||||||
},
|
all_reserved_comm_types()
|
||||||
ReservedEntry {
|
.iter()
|
||||||
name: "ServerNonce",
|
.filter(|entry| cfg!(feature = "pipes") || !entry.name.starts_with("Pipe"))
|
||||||
id: 3,
|
.collect()
|
||||||
},
|
}
|
||||||
ReservedEntry {
|
|
||||||
name: "PublicKeys",
|
fn all_reserved_data_types() -> &'static [ManifestEntry] {
|
||||||
id: 4,
|
&reserved_manifest().data
|
||||||
},
|
}
|
||||||
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 main() {
|
fn main() {
|
||||||
let out = PathBuf::from(std::env::var("OUT_DIR").unwrap());
|
let out = PathBuf::from(std::env::var("OUT_DIR").unwrap());
|
||||||
let multi_version = std::env::var("CARGO_FEATURE_REGISTRY").is_ok();
|
let multi_version = std::env::var("CARGO_FEATURE_REGISTRY").is_ok();
|
||||||
|
|
||||||
println!("cargo:rerun-if-env-changed=MTP_TYPE_MAPS");
|
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") {
|
let loaded = match std::env::var_os("MTP_TYPE_MAPS") {
|
||||||
Some(config_path) => load_config(&PathBuf::from(config_path)),
|
Some(config_path) => load_config(&PathBuf::from(config_path)),
|
||||||
|
|
@ -231,6 +99,35 @@ fn main() {
|
||||||
std::fs::write(out.join("types.rs"), code).unwrap();
|
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 {
|
struct LoadedConfig {
|
||||||
config: Config,
|
config: Config,
|
||||||
path: Option<PathBuf>,
|
path: Option<PathBuf>,
|
||||||
|
|
@ -292,8 +189,15 @@ fn validate_config(loaded: &LoadedConfig) {
|
||||||
version,
|
version,
|
||||||
"CommunicationTypes",
|
"CommunicationTypes",
|
||||||
&type_map.communication_types,
|
&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,
|
version: &str,
|
||||||
category: &str,
|
category: &str,
|
||||||
entries: &BTreeMap<String, u16>,
|
entries: &BTreeMap<String, u16>,
|
||||||
|
reserved: &[ManifestEntry],
|
||||||
) {
|
) {
|
||||||
let mut names_by_id = BTreeMap::new();
|
let mut names_by_id = BTreeMap::new();
|
||||||
for (name, id) in entries {
|
for (name, id) in entries {
|
||||||
if *id < FIRST_USER_TYPE_ID {
|
if reserved.iter().any(|entry| entry.name == *name) {
|
||||||
validation_error(
|
validation_error(
|
||||||
loaded,
|
loaded,
|
||||||
&["type_maps", version, category],
|
&["type_maps", version, category],
|
||||||
name,
|
name,
|
||||||
format!(
|
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, "#[allow(clippy::enum_variant_names)]").unwrap();
|
||||||
writeln!(out, "pub enum CommunicationType {{").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();
|
writeln!(out, " {},", entry.name).unwrap();
|
||||||
}
|
}
|
||||||
for name in user_names {
|
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, " pub fn name(self) -> &'static str {{").unwrap();
|
||||||
writeln!(out, " match self {{").unwrap();
|
writeln!(out, " match self {{").unwrap();
|
||||||
|
|
||||||
for entry in RESERVED_COMM_TYPES {
|
for entry in generated_reserved_comm_types() {
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" CommunicationType::{} => \"{}\",",
|
" 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, " pub fn from_name(s: &str) -> Option<Self> {{").unwrap();
|
||||||
writeln!(out, " match s {{").unwrap();
|
writeln!(out, " match s {{").unwrap();
|
||||||
|
|
||||||
for entry in RESERVED_COMM_TYPES {
|
for entry in generated_reserved_comm_types() {
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" \"{}\" => Some(CommunicationType::{}),",
|
" \"{}\" => 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, "#[allow(clippy::enum_variant_names)]").unwrap();
|
||||||
writeln!(out, "pub enum DataType {{").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();
|
writeln!(out, " {},", entry.name).unwrap();
|
||||||
}
|
}
|
||||||
for name in user_names {
|
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, " pub fn name(self) -> &'static str {{").unwrap();
|
||||||
writeln!(out, " match self {{").unwrap();
|
writeln!(out, " match self {{").unwrap();
|
||||||
|
|
||||||
for entry in RESERVED_DATA_TYPES {
|
for entry in all_reserved_data_types() {
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" DataType::{} => \"{}\",",
|
" 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, " pub fn from_name(s: &str) -> Option<Self> {{").unwrap();
|
||||||
writeln!(out, " match s {{").unwrap();
|
writeln!(out, " match s {{").unwrap();
|
||||||
|
|
||||||
for entry in RESERVED_DATA_TYPES {
|
for entry in all_reserved_data_types() {
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" \"{}\" => Some(DataType::{}),",
|
" \"{}\" => Some(DataType::{}),",
|
||||||
|
|
@ -733,7 +649,7 @@ fn generate_lookup_methods(
|
||||||
major, minor
|
major, minor
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
for entry in RESERVED_COMM_TYPES {
|
for entry in generated_reserved_comm_types() {
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" CommunicationType::{} => Some({}),",
|
" CommunicationType::{} => Some({}),",
|
||||||
|
|
@ -772,7 +688,7 @@ fn generate_lookup_methods(
|
||||||
major, minor
|
major, minor
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
for entry in RESERVED_DATA_TYPES {
|
for entry in all_reserved_data_types() {
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" DataType::{} => Some({}),",
|
" DataType::{} => Some({}),",
|
||||||
|
|
@ -806,7 +722,7 @@ fn generate_lookup_methods(
|
||||||
major, minor
|
major, minor
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
for entry in RESERVED_COMM_TYPES {
|
for entry in generated_reserved_comm_types() {
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" {} => Some(CommunicationType::{}),",
|
" {} => Some(CommunicationType::{}),",
|
||||||
|
|
@ -844,7 +760,7 @@ fn generate_lookup_methods(
|
||||||
major, minor
|
major, minor
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
for entry in RESERVED_DATA_TYPES {
|
for entry in all_reserved_data_types() {
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" {} => Some(DataType::{}),",
|
" {} => Some(DataType::{}),",
|
||||||
|
|
@ -877,7 +793,7 @@ fn generate_single_version_lookup(out: &mut String, config: &Config) {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
writeln!(out, " match self.version {{").unwrap();
|
writeln!(out, " match self.version {{").unwrap();
|
||||||
writeln!(out, " PROTOCOL_VERSION => match ct {{").unwrap();
|
writeln!(out, " PROTOCOL_VERSION => match ct {{").unwrap();
|
||||||
for entry in RESERVED_COMM_TYPES {
|
for entry in generated_reserved_comm_types() {
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" CommunicationType::{} => Some({}),",
|
" CommunicationType::{} => Some({}),",
|
||||||
|
|
@ -910,7 +826,7 @@ fn generate_single_version_lookup(out: &mut String, config: &Config) {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
writeln!(out, " match self.version {{").unwrap();
|
writeln!(out, " match self.version {{").unwrap();
|
||||||
writeln!(out, " PROTOCOL_VERSION => match dt {{").unwrap();
|
writeln!(out, " PROTOCOL_VERSION => match dt {{").unwrap();
|
||||||
for entry in RESERVED_DATA_TYPES {
|
for entry in all_reserved_data_types() {
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" DataType::{} => Some({}),",
|
" DataType::{} => Some({}),",
|
||||||
|
|
@ -938,7 +854,7 @@ fn generate_single_version_lookup(out: &mut String, config: &Config) {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
writeln!(out, " match self.version {{").unwrap();
|
writeln!(out, " match self.version {{").unwrap();
|
||||||
writeln!(out, " PROTOCOL_VERSION => match id {{").unwrap();
|
writeln!(out, " PROTOCOL_VERSION => match id {{").unwrap();
|
||||||
for entry in RESERVED_COMM_TYPES {
|
for entry in generated_reserved_comm_types() {
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" {} => Some(CommunicationType::{}),",
|
" {} => Some(CommunicationType::{}),",
|
||||||
|
|
@ -970,7 +886,7 @@ fn generate_single_version_lookup(out: &mut String, config: &Config) {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
writeln!(out, " match self.version {{").unwrap();
|
writeln!(out, " match self.version {{").unwrap();
|
||||||
writeln!(out, " PROTOCOL_VERSION => match id {{").unwrap();
|
writeln!(out, " PROTOCOL_VERSION => match id {{").unwrap();
|
||||||
for entry in RESERVED_DATA_TYPES {
|
for entry in all_reserved_data_types() {
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" {} => Some(DataType::{}),",
|
" {} => Some(DataType::{}),",
|
||||||
|
|
@ -1077,7 +993,7 @@ fn generate_all_types_methods(
|
||||||
let tm_cfg = &config.type_maps[version_key];
|
let tm_cfg = &config.type_maps[version_key];
|
||||||
write!(out, " Version({}, {}) => &[", major, minor).unwrap();
|
write!(out, " Version({}, {}) => &[", major, minor).unwrap();
|
||||||
let mut first = true;
|
let mut first = true;
|
||||||
for entry in RESERVED_COMM_TYPES {
|
for entry in generated_reserved_comm_types() {
|
||||||
if !first {
|
if !first {
|
||||||
write!(out, ", ").unwrap();
|
write!(out, ", ").unwrap();
|
||||||
}
|
}
|
||||||
|
|
@ -1108,7 +1024,7 @@ fn generate_all_types_methods(
|
||||||
let tm_cfg = &config.type_maps[version_key];
|
let tm_cfg = &config.type_maps[version_key];
|
||||||
write!(out, " Version({}, {}) => &[", major, minor).unwrap();
|
write!(out, " Version({}, {}) => &[", major, minor).unwrap();
|
||||||
let mut first = true;
|
let mut first = true;
|
||||||
for entry in RESERVED_DATA_TYPES {
|
for entry in all_reserved_data_types() {
|
||||||
if !first {
|
if !first {
|
||||||
write!(out, ", ").unwrap();
|
write!(out, ", ").unwrap();
|
||||||
}
|
}
|
||||||
|
|
@ -1144,7 +1060,7 @@ fn generate_all_types_methods_single(out: &mut String, config: &Config) {
|
||||||
writeln!(out, " match self.version {{").unwrap();
|
writeln!(out, " match self.version {{").unwrap();
|
||||||
write!(out, " PROTOCOL_VERSION => &[").unwrap();
|
write!(out, " PROTOCOL_VERSION => &[").unwrap();
|
||||||
let mut first = true;
|
let mut first = true;
|
||||||
for entry in RESERVED_COMM_TYPES {
|
for entry in generated_reserved_comm_types() {
|
||||||
if !first {
|
if !first {
|
||||||
write!(out, ", ").unwrap();
|
write!(out, ", ").unwrap();
|
||||||
}
|
}
|
||||||
|
|
@ -1174,7 +1090,7 @@ fn generate_all_types_methods_single(out: &mut String, config: &Config) {
|
||||||
writeln!(out, " match self.version {{").unwrap();
|
writeln!(out, " match self.version {{").unwrap();
|
||||||
write!(out, " PROTOCOL_VERSION => &[").unwrap();
|
write!(out, " PROTOCOL_VERSION => &[").unwrap();
|
||||||
let mut first = true;
|
let mut first = true;
|
||||||
for entry in RESERVED_DATA_TYPES {
|
for entry in all_reserved_data_types() {
|
||||||
if !first {
|
if !first {
|
||||||
write!(out, ", ").unwrap();
|
write!(out, ", ").unwrap();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
type-map/reserved.json
Normal file
57
type-map/reserved.json
Normal 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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -92,6 +92,51 @@ impl TypeMap {
|
||||||
|
|
||||||
include!(concat!(env!("OUT_DIR"), "/types.rs"));
|
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 ============================= */
|
/* ============================= REGISTRY ============================= */
|
||||||
#[cfg(feature = "registry")]
|
#[cfg(feature = "registry")]
|
||||||
pub use registry::*;
|
pub use registry::*;
|
||||||
|
|
@ -169,9 +214,17 @@ mod registry {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_contains_versions() {
|
fn builtin_registers_only_the_current_codec_version() {
|
||||||
let r = Registry::builtin();
|
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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ getrandom-v04 = { package = "getrandom", version = "0.4.3", features = ["wasm_js
|
||||||
|
|
||||||
mtp-common = { version = "0.2.0", path = "../common" }
|
mtp-common = { version = "0.2.0", path = "../common" }
|
||||||
mtp-type-map = { version = "0.2.0", path = "../type-map" }
|
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"] }
|
mtp-crypto = { version = "0.2.0", path = "../crypto", features = ["wasm"] }
|
||||||
zeroize = "1.9"
|
zeroize = "1.9"
|
||||||
wasm-bindgen-test = "0.3.76"
|
wasm-bindgen-test = "0.3.76"
|
||||||
|
|
|
||||||
|
|
@ -38,15 +38,15 @@ pub(crate) fn verify_host_challenge(
|
||||||
require_pq: bool,
|
require_pq: bool,
|
||||||
) -> Result<(), JsValue> {
|
) -> Result<(), JsValue> {
|
||||||
let sig = match challenge.get_data(DataType::Signature) {
|
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")),
|
_ => return Err(js_error("missing host challenge signature")),
|
||||||
};
|
};
|
||||||
let pq_sig = match challenge.get_data(DataType::PqSignature) {
|
let pq_sig = match challenge.get_data(DataType::PqSignature) {
|
||||||
DataValue::Bytes(b) => b.clone(),
|
Some(DataValue::Bytes(b)) => b.clone(),
|
||||||
_ => vec![],
|
_ => 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() {
|
if host_requires_pq && host_pk.sig_pq_public_key.as_bytes().is_empty() {
|
||||||
return Err(js_error(
|
return Err(js_error(
|
||||||
"host requires post-quantum authentication but its PQ public key is absent",
|
"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,
|
server_challenge: u128,
|
||||||
require_pq: bool,
|
require_pq: bool,
|
||||||
) -> Result<(), JsValue> {
|
) -> 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"));
|
return Err(js_error("nonce mismatch"));
|
||||||
}
|
}
|
||||||
let host_sig = match resp.get_data(DataType::Signature) {
|
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")),
|
_ => return Err(js_error("missing host signature")),
|
||||||
};
|
};
|
||||||
let host_pq_sig = match resp.get_data(DataType::PqSignature) {
|
let host_pq_sig = match resp.get_data(DataType::PqSignature) {
|
||||||
DataValue::Bytes(b) => b.clone(),
|
Some(DataValue::Bytes(b)) => b.clone(),
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
};
|
};
|
||||||
if require_pq && host_pq_sig.is_empty() {
|
if require_pq && host_pq_sig.is_empty() {
|
||||||
|
|
@ -114,6 +114,7 @@ pub(crate) fn signed_challenge_response_bytes(
|
||||||
keyring: &mtp_crypto::Keyring,
|
keyring: &mtp_crypto::Keyring,
|
||||||
proof_payload: &[u8],
|
proof_payload: &[u8],
|
||||||
client_nonce: u128,
|
client_nonce: u128,
|
||||||
|
type_map: &mtp_codec::TypeMap,
|
||||||
) -> Result<Vec<u8>, JsValue> {
|
) -> Result<Vec<u8>, JsValue> {
|
||||||
use mtp_crypto::SignatureScheme;
|
use mtp_crypto::SignatureScheme;
|
||||||
|
|
||||||
|
|
@ -123,12 +124,13 @@ pub(crate) fn signed_challenge_response_bytes(
|
||||||
.sign(proof_payload)
|
.sign(proof_payload)
|
||||||
.map_err(|e| js_error(format!("signature failed: {}", e)))?;
|
.map_err(|e| js_error(format!("signature failed: {}", e)))?;
|
||||||
|
|
||||||
let mut proof = CommunicationValue::new(CommunicationType::ChallengeResponse)
|
let mut proof =
|
||||||
.add_typed_default(
|
CommunicationValue::new_with_type_map(CommunicationType::ChallengeResponse, type_map)
|
||||||
DataType::ClientNonce,
|
.add_typed_default(
|
||||||
DataValue::UnsignedNumber(client_nonce),
|
DataType::ClientNonce,
|
||||||
)
|
DataValue::UnsignedNumber(client_nonce),
|
||||||
.add_typed_default(DataType::Signature, DataValue::Bytes(signature));
|
)
|
||||||
|
.add_typed_default(DataType::Signature, DataValue::Bytes(signature));
|
||||||
|
|
||||||
if !keyring.sig_pq_secret_key.as_bytes().is_empty() {
|
if !keyring.sig_pq_secret_key.as_bytes().is_empty() {
|
||||||
let pq_signer =
|
let pq_signer =
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -13,11 +13,27 @@ use crate::pipe::PipeReader;
|
||||||
use crate::transport::WasmTransport;
|
use crate::transport::WasmTransport;
|
||||||
|
|
||||||
pub(crate) struct PendingRequest {
|
pub(crate) struct PendingRequest {
|
||||||
|
pub(crate) generation: u32,
|
||||||
pub(crate) token: Rc<()>,
|
pub(crate) token: Rc<()>,
|
||||||
pub(crate) response_type: Option<String>,
|
pub(crate) response_type: Option<String>,
|
||||||
pub(crate) sender: oneshot::Sender<Result<JsValue, JsValue>>,
|
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(
|
pub(crate) fn remove_pending_request(
|
||||||
pending_requests: &Rc<RefCell<HashMap<u32, PendingRequest>>>,
|
pending_requests: &Rc<RefCell<HashMap<u32, PendingRequest>>>,
|
||||||
request_id: u32,
|
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)]
|
#[wasm_bindgen(typescript_custom_section)]
|
||||||
const PIPE_HANDLE_TS: &str = r#"
|
const PIPE_HANDLE_TS: &str = r#"
|
||||||
export interface WasmPipeHandle {
|
export interface WasmPipeHandle {
|
||||||
|
|
@ -46,7 +113,7 @@ pub struct WasmPipeHandle {
|
||||||
pipe_id: u32,
|
pipe_id: u32,
|
||||||
description: String,
|
description: String,
|
||||||
transport: WasmTransport,
|
transport: WasmTransport,
|
||||||
response_rx: Rc<RefCell<Option<oneshot::Receiver<Result<bool, JsValue>>>>>,
|
response_rx: PipeResponseCell,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
|
|
@ -92,13 +159,17 @@ pub(crate) fn random_pipe_id() -> Result<u32, JsValue> {
|
||||||
Ok(u32::from_be_bytes(bytes))
|
Ok(u32::from_be_bytes(bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn reject_pending_pipe_creations(
|
pub(crate) fn reject_pending_pipe_creations(pending: &PendingPipeCreations, message: &str) {
|
||||||
pending: &Rc<RefCell<HashMap<u32, oneshot::Sender<Result<bool, JsValue>>>>>,
|
|
||||||
message: &str,
|
|
||||||
) {
|
|
||||||
let pending = std::mem::take(&mut *pending.borrow_mut());
|
let pending = std::mem::take(&mut *pending.borrow_mut());
|
||||||
for (_, tx) in pending {
|
for (_, entry) in pending {
|
||||||
let _ = tx.send(Err(js_error(message)));
|
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,
|
transport: &WasmTransport,
|
||||||
description: &str,
|
description: &str,
|
||||||
pipe_id: u32,
|
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> {
|
) -> Result<WasmPipeHandle, JsValue> {
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
pending_pipe_creations.borrow_mut().insert(pipe_id, tx);
|
let mut pipe_id = pipe_id;
|
||||||
|
for _ in 0..128 {
|
||||||
let request = CommunicationValue::new(CommunicationType::PipeRequest)
|
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)
|
.with_id(pipe_id)
|
||||||
.add_typed_default(
|
.add_typed_default(
|
||||||
DataType::Description,
|
DataType::Description,
|
||||||
|
|
@ -120,6 +203,13 @@ pub(crate) async fn wasm_create_pipe(
|
||||||
let request_bytes = request
|
let request_bytes = request
|
||||||
.to_bytes()
|
.to_bytes()
|
||||||
.map_err(|e| js_error(format!("encode failed: {}", e)))?;
|
.map_err(|e| js_error(format!("encode failed: {}", e)))?;
|
||||||
|
pending_pipe_creations.borrow_mut().insert(
|
||||||
|
pipe_id,
|
||||||
|
PendingPipeCreation {
|
||||||
|
generation,
|
||||||
|
sender: tx,
|
||||||
|
},
|
||||||
|
);
|
||||||
debug!(
|
debug!(
|
||||||
target = "mtp.wasm",
|
target = "mtp.wasm",
|
||||||
pipe_id,
|
pipe_id,
|
||||||
|
|
@ -127,7 +217,26 @@ pub(crate) async fn wasm_create_pipe(
|
||||||
frame_len = request_bytes.len(),
|
frame_len = request_bytes.len(),
|
||||||
"sending pipe request"
|
"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 {
|
Ok(WasmPipeHandle {
|
||||||
pipe_id,
|
pipe_id,
|
||||||
|
|
@ -140,14 +249,40 @@ pub(crate) async fn wasm_create_pipe(
|
||||||
pub(crate) async fn wasm_accept_pipe(
|
pub(crate) async fn wasm_accept_pipe(
|
||||||
transport: &WasmTransport,
|
transport: &WasmTransport,
|
||||||
pipe_id: u32,
|
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> {
|
) -> 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)
|
.with_id(pipe_id)
|
||||||
.add_typed_default(DataType::Accepted, DataValue::BoolTrue);
|
.add_typed_default(DataType::Accepted, DataValue::BoolTrue);
|
||||||
let resp_bytes = resp
|
let resp_bytes = resp
|
||||||
.to_bytes()
|
.to_bytes()
|
||||||
.map_err(|e| js_error(format!("encode failed: {}", e)))?;
|
.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!(
|
debug!(
|
||||||
target = "mtp.wasm",
|
target = "mtp.wasm",
|
||||||
pipe_id,
|
pipe_id,
|
||||||
|
|
@ -155,17 +290,37 @@ pub(crate) async fn wasm_accept_pipe(
|
||||||
frame_len = resp_bytes.len(),
|
frame_len = resp_bytes.len(),
|
||||||
"sending pipe response"
|
"sending pipe response"
|
||||||
);
|
);
|
||||||
transport.send_frame(&resp_bytes).await?;
|
if let Err(error) = transport.send_frame(&resp_bytes).await {
|
||||||
|
let mut pending = pending_pipes.borrow_mut();
|
||||||
let (tx, rx) = oneshot::channel();
|
if pending
|
||||||
pending_pipes.borrow_mut().insert(pipe_id, tx);
|
.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
|
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> {
|
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)
|
.with_id(pipe_id)
|
||||||
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
|
.add_typed_default(DataType::Accepted, DataValue::BoolFalse);
|
||||||
let resp_bytes = resp
|
let resp_bytes = resp
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,67 @@
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use mtp_codec::{
|
||||||
|
DataValue, MtpProtectionPurpose, PROTOCOL_VERSION, ProtectionPolicy, ProtectionPurpose,
|
||||||
|
SealedRelayBuilder, SignaturePolicy, TypeMap,
|
||||||
|
};
|
||||||
use mtp_crypto::{
|
use mtp_crypto::{
|
||||||
AeadDecrypt, AeadEncrypt, ChaCha20Poly1305, Ed25519Signer, HybridKem, KemPrivateKey,
|
AeadDecrypt, AeadEncrypt, DualSigner, Ed25519Signer, HybridKem, KemPrivateKey, KemPublicKey,
|
||||||
KemPublicKey, Keyring, PublicKeyBundle, SignaturePqPrivateKey, SignaturePqPublicKey,
|
Keyring, PublicKeyBundle, SignaturePqPrivateKey, SignaturePqPublicKey, SignaturePrivateKey,
|
||||||
SignaturePrivateKey, SignaturePublicKey, SignatureScheme, sha256, sha256_double,
|
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
|
// Keyring
|
||||||
|
|
@ -41,6 +95,25 @@ impl WasmKeyring {
|
||||||
inner: self.inner.public_key_bundle(),
|
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.
|
/// 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)))?;
|
.map_err(|e| js_error(format!("PublicKeyBundle::from_bytes: {}", e)))?;
|
||||||
Ok(Self { inner })
|
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>,
|
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]
|
#[wasm_bindgen]
|
||||||
impl WasmEncapsulated {
|
impl WasmEncapsulated {
|
||||||
/// Symmetric secret derived during encapsulation.
|
/// Symmetric secret derived during encapsulation.
|
||||||
|
|
@ -178,7 +291,7 @@ pub fn wasm_kem_decapsulate(
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub struct WasmChaCha20Poly1305 {
|
pub struct WasmChaCha20Poly1305 {
|
||||||
inner: ChaCha20Poly1305,
|
inner: XChaCha20Poly1305,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
|
|
@ -192,7 +305,7 @@ impl WasmChaCha20Poly1305 {
|
||||||
let mut k = [0u8; 32];
|
let mut k = [0u8; 32];
|
||||||
k.copy_from_slice(&key);
|
k.copy_from_slice(&key);
|
||||||
Ok(Self {
|
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)))
|
.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(test)]
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
@ -384,7 +892,8 @@ mod tests {
|
||||||
};
|
};
|
||||||
|
|
||||||
let bytes = bundle.to_bytes();
|
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);
|
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");
|
wasm_derive_encryption_key(b"pass2", b"salt", b"context").expect("derive failed");
|
||||||
assert_ne!(key, key2);
|
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
Loading…
Reference in a new issue