Compare commits

...
Author SHA1 Message Date
252800b1bf Update Rust crate aes-gcm to 0.11
Some checks failed
renovate/stability-days Updates have met minimum release age requirement
CI / checks (pull_request) Failing after 2m57s
2026-08-18 22:00:55 +03:00
Alex Emmet
3395b91ad1
Merge remote-tracking branch 'refs/remotes/origin/master'
Some checks failed
CI / checks (push) Failing after 2m20s
2026-08-18 20:59:06 +02:00
Alex Emmet
a7e804c603
[Fix] Harden MTP codec, transport, and SDK security 2026-08-18 20:58:01 +02:00
73 changed files with 11893 additions and 5757 deletions

3
Cargo.lock generated
View file

@ -1372,6 +1372,7 @@ name = "mtp-crypto"
version = "0.3.0"
dependencies = [
"aes-gcm",
"argon2",
"base64 0.22.1",
"chacha20poly1305",
"ed25519-dalek",
@ -1395,7 +1396,6 @@ dependencies = [
name = "mtp-files"
version = "0.3.0"
dependencies = [
"argon2",
"mtp-crypto",
"rand",
"thiserror 2.0.20",
@ -1411,6 +1411,7 @@ dependencies = [
"mtp-crypto",
"mtp-transport",
"rand",
"thiserror 2.0.20",
"tokio",
"tracing",
"wtransport",

View file

@ -13,6 +13,10 @@ use crate::error::AuthState;
use crate::ping::{PingSession, start_ping_session};
#[cfg(feature = "pipes")]
use crate::pipe::PipeRequest;
#[cfg(feature = "pipes")]
use crate::pipe::is_expired_creation;
#[cfg(feature = "pipes")]
use crate::pipe::{PendingCreation, PendingCreationGuard};
use crate::pipe::{PendingRequest, PipeDispatcher, run_dispatcher};
pub struct MTPConnection {
@ -134,17 +138,33 @@ impl MTPConnection {
description: &str,
) -> Result<crate::pipe::PipeHandle, mtp_common::PipeError> {
let (tx, rx) = tokio::sync::oneshot::channel();
let token = Arc::new(());
let pipe_id = {
let mut pending = self.pipe_dispatcher.pending_creations.lock().await;
let mut pending = self
.pipe_dispatcher
.pending_creations
.lock()
.map_err(|_| mtp_common::PipeError::ConnectionClosed)?;
let pipe_id = loop {
let candidate = rand::random::<u32>();
if candidate != 0 && !pending.contains_key(&candidate) {
if candidate != 0
&& !pending.contains_key(&candidate)
&& !is_expired_creation(&self.pipe_dispatcher, candidate)
{
break candidate;
}
};
pending.insert(pipe_id, tx);
pending.insert(
pipe_id,
PendingCreation {
token: token.clone(),
sender: tx,
},
);
pipe_id
};
let mut creation_guard =
PendingCreationGuard::new(self.pipe_dispatcher.clone(), pipe_id, token.clone());
let request = CommunicationValue::new_with_type_map(
mtp_codec::CommunicationType::PipeRequest,
@ -154,19 +174,17 @@ impl MTPConnection {
.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));
}
creation_guard.disarm();
Ok(crate::pipe::PipeHandle {
pipe_id,
description: description.to_string(),
sender: self.sender.clone(),
response_rx: rx,
dispatcher: self.pipe_dispatcher.clone(),
token,
})
}
@ -207,18 +225,19 @@ pub(crate) async fn connection_from_parts(
#[cfg(feature = "pipes")]
{
let receiver_queue_capacity = config.policy.receiver_queue_capacity.max(1);
let (app_tx, app_rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(
config.policy.receiver_queue_capacity,
receiver_queue_capacity,
);
let (pipe_req_tx, pipe_req_rx) =
mpsc::channel::<PipeRequest>(config.policy.receiver_queue_capacity);
let (pipe_req_tx, pipe_req_rx) = mpsc::channel::<PipeRequest>(receiver_queue_capacity);
let dispatcher = Arc::new(PipeDispatcher {
pending_requests: Mutex::new(std::collections::HashMap::new()),
expired_requests: Mutex::new(std::collections::HashMap::new()),
#[cfg(feature = "pipes")]
type_map: type_map.clone(),
pending_creations: Mutex::new(std::collections::HashMap::new()),
pending_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
expired_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
pending_pipes: Mutex::new(std::collections::HashMap::new()),
policy: Arc::new(config.policy),
});
@ -255,8 +274,9 @@ pub(crate) async fn connection_from_parts(
#[cfg(not(feature = "pipes"))]
{
let receiver_queue_capacity = config.policy.receiver_queue_capacity.max(1);
let (app_tx, app_rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(
config.policy.receiver_queue_capacity,
receiver_queue_capacity,
);
let dispatcher = Arc::new(PipeDispatcher {
pending_requests: Mutex::new(std::collections::HashMap::new()),

View file

@ -222,6 +222,10 @@ impl MTPClient {
sender.set_type_map(&tm).await;
receiver.set_type_map(&tm).await;
let version_str = format!("{}", PROTOCOL_VERSION);
let public_key_bytes = keys
.public_key_bundle()
.try_as_bytes()
.map_err(|error| CommunicationError::ParseError(error.to_string()))?;
let mut ident =
CommunicationValue::new_with_type_map(CommunicationType::Identification, &tm)
@ -233,10 +237,7 @@ impl MTPClient {
// 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()),
);
.add_typed_default(DataType::PublicKeys, DataValue::Bytes(public_key_bytes));
if let Some(desc) = &config.description {
ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone()));
}
@ -415,7 +416,9 @@ impl MTPClient {
receiver.set_type_map(&tm).await;
let version_str = format!("{}", PROTOCOL_VERSION);
let pk_bundle = keys.public_key_bundle();
let pk_bytes = pk_bundle.as_bytes();
let pk_bytes = pk_bundle
.try_as_bytes()
.map_err(|error| CommunicationError::ParseError(error.to_string()))?;
let mut register = CommunicationValue::new_with_type_map(CommunicationType::Register, &tm)
.add_typed_default(DataType::Version, DataValue::Str(version_str.clone()))
@ -601,7 +604,9 @@ mod tests {
#[cfg(feature = "pipes")]
type_map: mtp_codec::TypeMap::latest(),
#[cfg(feature = "pipes")]
pending_creations: Mutex::new(HashMap::new()),
pending_creations: std::sync::Mutex::new(HashMap::new()),
#[cfg(feature = "pipes")]
expired_creations: std::sync::Mutex::new(HashMap::new()),
#[cfg(feature = "pipes")]
pending_pipes: Mutex::new(HashMap::new()),
#[cfg(feature = "pipes")]
@ -639,7 +644,9 @@ mod tests {
#[cfg(feature = "pipes")]
type_map: mtp_codec::TypeMap::latest(),
#[cfg(feature = "pipes")]
pending_creations: Mutex::new(HashMap::new()),
pending_creations: std::sync::Mutex::new(HashMap::new()),
#[cfg(feature = "pipes")]
expired_creations: std::sync::Mutex::new(HashMap::new()),
#[cfg(feature = "pipes")]
pending_pipes: Mutex::new(HashMap::new()),
#[cfg(feature = "pipes")]

View file

@ -66,8 +66,8 @@ pub(crate) async fn start_ping_session(
return None;
}
let (pong_tx, mut pong_rx) = mpsc::unbounded_channel();
receiver.observe_pongs(pong_tx).await;
let (pong_tx, mut pong_rx) = mpsc::channel(1);
receiver.observe_pongs_bounded(pong_tx).await;
let last_ping = Arc::new(Mutex::new(None));
let ping_state = last_ping.clone();
let interval = config.ping_interval;
@ -75,6 +75,7 @@ pub(crate) async fn start_ping_session(
let max_missed_pings = config.max_missed_pings;
let ping_timestamp = config.ping_timestamp;
let type_map = type_map.clone();
let ping_receiver = receiver.clone();
let mut close_rx = receiver.handle().subscribe_close();
let task = tokio::spawn(async move {
@ -91,6 +92,7 @@ pub(crate) async fn start_ping_session(
}
_ = ticker.tick() => {
let missed_pings = tracker.begin_round();
ping_receiver.set_expected_pong_id(None).await;
if max_missed_pings > 0 && missed_pings >= max_missed_pings {
sender.close().await;
break;
@ -121,7 +123,9 @@ pub(crate) async fn start_ping_session(
sender.close().await;
break;
};
ping_receiver.set_expected_pong_id(Some(id)).await;
if sender.send(&ping).await.is_err() {
ping_receiver.set_expected_pong_id(None).await;
sender.close().await;
break;
}

View file

@ -5,6 +5,8 @@ use mtp_common::CommunicationError;
use mtp_transport::Receiver;
use std::collections::HashMap;
use std::sync::Arc;
#[cfg(feature = "pipes")]
use std::sync::Mutex as StdMutex;
use tokio::sync::{Mutex, mpsc};
use tokio::time::{Duration, Instant};
@ -21,6 +23,8 @@ pub struct PipeHandle {
pub(crate) description: String,
pub(crate) sender: Sender,
pub(crate) response_rx: tokio::sync::oneshot::Receiver<Result<bool, PipeError>>,
pub(crate) dispatcher: Arc<PipeDispatcher>,
pub(crate) token: Arc<()>,
}
#[cfg(feature = "pipes")]
@ -33,9 +37,11 @@ impl PipeHandle {
&self.description
}
pub async fn wait(self) -> Result<Option<mtp_transport::PipeWriter>, PipeError> {
match self.response_rx.await {
Ok(Ok(true)) => {
pub async fn wait(mut self) -> Result<Option<mtp_transport::PipeWriter>, PipeError> {
let response =
tokio::time::timeout(self.dispatcher.policy.read_timeout, &mut self.response_rx).await;
match response {
Ok(Ok(Ok(true))) => {
let writer = self
.sender
.open_pipe(self.pipe_id, &self.description)
@ -43,13 +49,30 @@ impl PipeHandle {
.map_err(PipeError::from)?;
Ok(Some(writer))
}
Ok(Ok(false)) => Ok(None),
Ok(Err(e)) => Err(e),
Err(_) => Err(PipeError::StreamClosed),
Ok(Ok(Ok(false))) => Ok(None),
Ok(Ok(Err(error))) => {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
Err(error)
}
Ok(Err(_)) => {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
Err(PipeError::StreamClosed)
}
Err(_) => {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
Err(PipeError::HandshakeTimeout)
}
}
}
}
#[cfg(feature = "pipes")]
impl Drop for PipeHandle {
fn drop(&mut self) {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
}
}
#[cfg(feature = "pipes")]
pub struct PipeRequest {
pub(crate) pipe_id: u32,
@ -129,14 +152,54 @@ pub(crate) struct PendingRequest {
pub(crate) sender: tokio::sync::oneshot::Sender<Result<CommunicationValue, CommunicationError>>,
}
#[cfg(feature = "pipes")]
pub(crate) struct PendingCreation {
pub(crate) token: Arc<()>,
pub(crate) sender: tokio::sync::oneshot::Sender<Result<bool, PipeError>>,
}
#[cfg(feature = "pipes")]
pub(crate) struct PendingCreationGuard {
dispatcher: Arc<PipeDispatcher>,
pipe_id: u32,
token: Arc<()>,
armed: bool,
}
#[cfg(feature = "pipes")]
impl PendingCreationGuard {
pub(crate) fn new(dispatcher: Arc<PipeDispatcher>, pipe_id: u32, token: Arc<()>) -> Self {
Self {
dispatcher,
pipe_id,
token,
armed: true,
}
}
pub(crate) fn disarm(&mut self) {
self.armed = false;
}
}
#[cfg(feature = "pipes")]
impl Drop for PendingCreationGuard {
fn drop(&mut self) {
if self.armed {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
}
}
}
pub(crate) struct PipeDispatcher {
pub(crate) pending_requests: Mutex<HashMap<u32, PendingRequest>>,
pub(crate) expired_requests: Mutex<HashMap<u32, Instant>>,
#[cfg(feature = "pipes")]
pub(crate) type_map: TypeMap,
#[cfg(feature = "pipes")]
pub(crate) pending_creations:
Mutex<HashMap<u32, tokio::sync::oneshot::Sender<Result<bool, PipeError>>>>,
pub(crate) pending_creations: StdMutex<HashMap<u32, PendingCreation>>,
#[cfg(feature = "pipes")]
pub(crate) expired_creations: StdMutex<HashMap<u32, Instant>>,
#[cfg(feature = "pipes")]
pub(crate) pending_pipes:
Mutex<HashMap<u32, tokio::sync::oneshot::Sender<mtp_transport::PipeReader>>>,
@ -144,6 +207,90 @@ pub(crate) struct PipeDispatcher {
pub(crate) policy: Arc<Policy>,
}
#[cfg(feature = "pipes")]
const EXPIRED_CREATION_TOMBSTONE_TTL: Duration = Duration::from_secs(60);
#[cfg(feature = "pipes")]
const MAX_EXPIRED_CREATION_TOMBSTONES: usize = 1024;
#[cfg(feature = "pipes")]
pub(crate) fn expire_pending_creation(dispatcher: &PipeDispatcher, pipe_id: u32, token: &Arc<()>) {
let removed = dispatcher
.pending_creations
.lock()
.ok()
.and_then(|mut pending| {
if pending
.get(&pipe_id)
.is_some_and(|entry| Arc::ptr_eq(&entry.token, token))
{
pending.remove(&pipe_id);
Some(())
} else {
None
}
});
if removed.is_none() {
return;
}
let Ok(mut expired) = dispatcher.expired_creations.lock() else {
return;
};
let now = Instant::now();
expired.retain(|_, expires_at| *expires_at > now);
if expired.len() >= MAX_EXPIRED_CREATION_TOMBSTONES
&& let Some(oldest) = expired
.iter()
.min_by_key(|(_, expires_at)| **expires_at)
.map(|(id, _)| *id)
{
expired.remove(&oldest);
}
expired.insert(pipe_id, now + EXPIRED_CREATION_TOMBSTONE_TTL);
}
#[cfg(feature = "pipes")]
fn consume_expired_creation(dispatcher: &PipeDispatcher, pipe_id: u32) -> bool {
let Ok(mut expired) = dispatcher.expired_creations.lock() else {
return false;
};
let now = Instant::now();
expired.retain(|_, expires_at| *expires_at > now);
expired.remove(&pipe_id).is_some()
}
#[cfg(feature = "pipes")]
pub(crate) fn is_expired_creation(dispatcher: &PipeDispatcher, pipe_id: u32) -> bool {
let Ok(mut expired) = dispatcher.expired_creations.lock() else {
return true;
};
let now = Instant::now();
expired.retain(|_, expires_at| *expires_at > now);
expired.contains_key(&pipe_id)
}
#[cfg(feature = "pipes")]
pub(crate) fn fail_pending_creations(dispatcher: &PipeDispatcher, error: &CommunicationError) {
let pending = dispatcher
.pending_creations
.lock()
.ok()
.map(|mut pending| std::mem::take(&mut *pending));
if let Some(pending) = pending {
let error = PipeError::from(error.clone());
for (_, pending) in pending {
let _ = pending.sender.send(Err(error.clone()));
}
}
if let Ok(mut expired) = dispatcher.expired_creations.lock() {
expired.clear();
}
}
#[cfg(feature = "pipes")]
pub(crate) async fn fail_pending_pipes(dispatcher: &PipeDispatcher) {
dispatcher.pending_pipes.lock().await.clear();
}
pub(crate) async fn route_message(
msg: CommunicationValue,
app_tx: &mpsc::Sender<Result<CommunicationValue, CommunicationError>>,
@ -283,9 +430,15 @@ pub(crate) async fn run_dispatcher(
continue;
};
let accepted = msg.get_bool(DataType::Accepted).unwrap_or(false);
let mut pending = dispatcher.pending_creations.lock().await;
if let Some(tx) = pending.remove(&pipe_id) {
let _ = tx.send(Ok(accepted));
let pending = dispatcher
.pending_creations
.lock()
.ok()
.and_then(|mut pending| pending.remove(&pipe_id));
if let Some(entry) = pending {
let _ = entry.sender.send(Ok(accepted));
} else {
let _ = consume_expired_creation(&dispatcher, pipe_id);
}
continue;
}
@ -303,6 +456,10 @@ pub(crate) async fn run_dispatcher(
}
Err(e) => {
fail_pending_requests(&dispatcher, e.clone()).await;
#[cfg(feature = "pipes")]
fail_pending_creations(&dispatcher, &e);
#[cfg(feature = "pipes")]
fail_pending_pipes(&dispatcher).await;
let _ = app_tx.send(Err(e)).await;
break;
}
@ -325,6 +482,10 @@ pub(crate) async fn run_dispatcher(
}
Err(e) => {
fail_pending_requests(&dispatcher, e.clone()).await;
#[cfg(feature = "pipes")]
fail_pending_creations(&dispatcher, &e);
#[cfg(feature = "pipes")]
fail_pending_pipes(&dispatcher).await;
let _ = app_tx.send(Err(e)).await;
break;
}

View file

@ -2,7 +2,7 @@ use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
use std::fmt;
use std::io::Cursor;
use crate::data_value::{DataKind, DataValue, DecodeLimits};
use crate::data_value::{DataKind, DataValue, DecodeError, DecodeLimits, EncodeLimits};
use crate::rand_u32;
use mtp_common::CodecError;
use mtp_type_map::{
@ -260,23 +260,50 @@ impl CommunicationValue {
#[must_use]
pub fn reply_to(&self, comm_type: CommunicationType) -> Self {
let mut response = Self::new(comm_type);
let type_map = self
.type_map
.as_ref()
.cloned()
.unwrap_or_else(TypeMap::latest);
let mut response = Self::new_with_type_map(comm_type, &type_map);
response.sender = self.receiver;
response.receiver = self.sender;
response
}
pub fn merge(&mut self, other: &Self) {
if self.mapping_error.is_none() {
self.mapping_error.clone_from(&other.mapping_error);
/// Merge clear container fields after confirming both values use the same
/// negotiated type map.
pub fn try_merge(&mut self, other: &Self) -> Result<(), CodecError> {
if let Some(error) = &self.mapping_error {
return Err(error.clone());
}
let Some(other_entries) = other.payload.container_entries() else {
self.mapping_error
.get_or_insert(CodecError::InvalidEncoding);
return;
};
let left = self.type_map().ok_or(CodecError::MissingTypeMap)?;
let right = other.type_map().ok_or(CodecError::MissingTypeMap)?;
if left.version != right.version {
return Err(CodecError::TypeMapMismatch {
expected: left.version.to_string(),
actual: right.version.to_string(),
});
}
if let Some(error) = &other.mapping_error {
return Err(error.clone());
}
let other_entries = other
.payload
.container_entries()
.ok_or(CodecError::InvalidEncoding)?;
for (id, value) in other_entries {
let _ = self.insert_data(*id, value.clone());
self.insert_data(*id, value.clone())?;
}
Ok(())
}
// Migrate to `try_merge` so a map mismatch cannot be silently recorded in
// a frame that is later sent over the wire.
#[deprecated(note = "migrate to try_merge to handle negotiated type-map mismatches")]
pub fn merge(&mut self, other: &Self) {
if let Err(error) = self.try_merge(other) {
self.mapping_error.get_or_insert(error);
}
}
@ -315,9 +342,22 @@ impl CommunicationValue {
}
pub fn to_bytes(&self) -> Result<Vec<u8>, CodecError> {
self.to_bytes_with_limits(EncodeLimits::default())
}
pub fn to_bytes_with_limits(&self, limits: EncodeLimits) -> Result<Vec<u8>, CodecError> {
if let Some(error) = &self.mapping_error {
return Err(error.clone());
}
let header_len = self.frame_header_len();
let payload_limit = limits
.max_output_size
.checked_sub(header_len)
.ok_or(CodecError::TooManyEntries)?;
let payload = self.payload.to_bytes_with_limits(EncodeLimits {
max_output_size: payload_limit,
..limits
})?;
let mut body = Vec::new();
body.write_u16::<BigEndian>(self.comm_type.0)
.map_err(|_| CodecError::InvalidEncoding)?;
@ -344,44 +384,71 @@ impl CommunicationValue {
body.write_u64::<BigEndian>(receiver)
.map_err(|_| CodecError::InvalidEncoding)?;
}
body.extend_from_slice(&self.payload.to_bytes()?);
body.extend_from_slice(&payload);
let length = u32::try_from(body.len()).map_err(|_| CodecError::TooManyEntries)?;
let mut out = Vec::with_capacity(4 + body.len());
let total_len = 4usize
.checked_add(body.len())
.ok_or(CodecError::TooManyEntries)?;
if total_len > limits.max_output_size {
return Err(CodecError::TooManyEntries);
}
let mut out = Vec::with_capacity(total_len);
out.write_u32::<BigEndian>(length)
.map_err(|_| CodecError::InvalidEncoding)?;
out.extend_from_slice(&body);
Ok(out)
}
fn frame_header_len(&self) -> usize {
4 + 2
+ 1
+ self.id.is_some() as usize * 4
+ self.sender.is_some() as usize * 8
+ self.receiver.is_some() as usize * 8
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, CodecError> {
Self::from_bytes_with_limits(bytes, DecodeLimits::default())
}
pub fn from_bytes_with_limits(bytes: &[u8], limits: DecodeLimits) -> Result<Self, CodecError> {
Self::try_from_bytes_with_limits(bytes, limits).map_err(|_| CodecError::InvalidEncoding)
}
pub fn try_from_bytes(bytes: &[u8]) -> Result<Self, DecodeError> {
Self::try_from_bytes_with_limits(bytes, DecodeLimits::default())
}
pub fn try_from_bytes_with_limits(
bytes: &[u8],
limits: DecodeLimits,
) -> Result<Self, DecodeError> {
let mut cursor = Cursor::new(bytes);
let length = cursor
.read_u32::<BigEndian>()
.map_err(|_| CodecError::InvalidEncoding)? as usize;
.map_err(|_| DecodeError::MalformedEncoding)? as usize;
let end = 4usize
.checked_add(length)
.ok_or(CodecError::InvalidEncoding)?;
.ok_or(DecodeError::MalformedEncoding)?;
if end != bytes.len() {
return Err(CodecError::InvalidEncoding);
return Err(DecodeError::MalformedEncoding);
}
let comm_type = CommunicationTypeId(
cursor
.read_u16::<BigEndian>()
.map_err(|_| CodecError::InvalidEncoding)?,
.map_err(|_| DecodeError::MalformedEncoding)?,
);
let flags = cursor.read_u8().map_err(|_| CodecError::InvalidEncoding)?;
let flags = cursor
.read_u8()
.map_err(|_| DecodeError::MalformedEncoding)?;
if flags & !FLAG_KNOWN != 0 {
return Err(CodecError::InvalidEncoding);
return Err(DecodeError::MalformedEncoding);
}
let id = if flags & FLAG_HAS_ID != 0 {
Some(
cursor
.read_u32::<BigEndian>()
.map_err(|_| CodecError::InvalidEncoding)?,
.map_err(|_| DecodeError::MalformedEncoding)?,
)
} else {
None
@ -390,7 +457,7 @@ impl CommunicationValue {
Some(
cursor
.read_u64::<BigEndian>()
.map_err(|_| CodecError::InvalidEncoding)?,
.map_err(|_| DecodeError::MalformedEncoding)?,
)
} else {
None
@ -399,14 +466,14 @@ impl CommunicationValue {
Some(
cursor
.read_u64::<BigEndian>()
.map_err(|_| CodecError::InvalidEncoding)?,
.map_err(|_| DecodeError::MalformedEncoding)?,
)
} else {
None
};
let payload = DataValue::read_from_with_limits(&mut cursor, limits)?;
let payload = DataValue::read_from_with_diagnostics(&mut cursor, limits)?;
if cursor.position() as usize != end {
return Err(CodecError::InvalidEncoding);
return Err(DecodeError::MalformedEncoding);
}
Ok(Self {
id,
@ -420,13 +487,36 @@ impl CommunicationValue {
}
pub fn from_bytes_with(bytes: &[u8], type_map: &TypeMap) -> Result<Self, CodecError> {
let mut value = Self::from_bytes(bytes)?;
Self::try_from_bytes_with(bytes, type_map).map_err(|_| CodecError::InvalidEncoding)
}
pub fn try_from_bytes_with(bytes: &[u8], type_map: &TypeMap) -> Result<Self, DecodeError> {
Self::try_from_bytes_with_type_map_and_limits(bytes, type_map, DecodeLimits::default())
}
pub fn try_from_bytes_with_type_map_and_limits(
bytes: &[u8],
type_map: &TypeMap,
limits: DecodeLimits,
) -> Result<Self, DecodeError> {
let mut value = Self::try_from_bytes_with_limits(bytes, limits)?;
value.set_type_map(type_map);
Ok(value)
}
#[cfg(feature = "registry")]
pub fn migrate(&self, target: &TypeMap) -> Result<Self, CodecError> {
self.migrate_with_limits(target, EncodeLimits::default())
}
/// Migrate a clear frame while bounding the recursive traversal used to
/// translate its type IDs.
#[cfg(feature = "registry")]
pub fn migrate_with_limits(
&self,
target: &TypeMap,
limits: EncodeLimits,
) -> Result<Self, CodecError> {
if let Some(error) = &self.mapping_error {
return Err(error.clone());
}
@ -441,7 +531,8 @@ impl CommunicationValue {
.comm_id_enum(comm)
.ok_or_else(|| CodecError::UnknownCommunicationType(comm_name.to_string()))?,
);
let payload = migrate_data_value(&self.payload, source, target)?;
let mut context = MigrationContext::new(limits);
let payload = migrate_data_value(&self.payload, source, target, &mut context)?;
Ok(Self {
id: self.id,
comm_type,
@ -454,15 +545,63 @@ impl CommunicationValue {
}
}
#[cfg(feature = "registry")]
struct MigrationContext {
limits: EncodeLimits,
depth: usize,
values: usize,
}
#[cfg(feature = "registry")]
impl MigrationContext {
fn new(limits: EncodeLimits) -> Self {
Self {
limits,
depth: 0,
values: 0,
}
}
fn value(&mut self) -> Result<(), CodecError> {
self.values = self
.values
.checked_add(1)
.ok_or(CodecError::TooManyEntries)?;
if self.values > self.limits.max_values {
return Err(CodecError::TooManyEntries);
}
Ok(())
}
fn enter(&mut self) -> Result<(), CodecError> {
self.depth = self
.depth
.checked_add(1)
.ok_or(CodecError::TooManyEntries)?;
if self.depth > self.limits.max_depth {
return Err(CodecError::TooManyEntries);
}
Ok(())
}
fn leave(&mut self) {
self.depth = self.depth.saturating_sub(1);
}
}
#[cfg(feature = "registry")]
fn migrate_data_value(
value: &DataValue,
source: &TypeMap,
target: &TypeMap,
context: &mut MigrationContext,
) -> Result<DataValue, CodecError> {
context.value()?;
match value {
DataValue::Container(entries) => {
let mut migrated = Vec::with_capacity(entries.len());
context.enter()?;
let count = u16::try_from(entries.len()).map_err(|_| CodecError::TooManyEntries)?;
let mut migrated = Vec::with_capacity(usize::from(count));
for (old_id, value) in entries {
let name = source
.data_type_name(old_id.0)
@ -474,16 +613,20 @@ fn migrate_data_value(
.data_id_enum(data)
.ok_or_else(|| CodecError::UnknownDataType(name.to_string()))?,
);
migrated.push((new_id, migrate_data_value(value, source, target)?));
migrated.push((new_id, migrate_data_value(value, source, target, context)?));
}
context.leave();
Ok(DataValue::Container(migrated))
}
DataValue::Array(values) => Ok(DataValue::Array(
values
.iter()
.map(|value| migrate_data_value(value, source, target))
.collect::<Result<Vec<_>, _>>()?,
)),
DataValue::Array(values) => {
context.enter()?;
let mut migrated = Vec::with_capacity(values.len());
for value in values {
migrated.push(migrate_data_value(value, source, target, context)?);
}
context.leave();
Ok(DataValue::Array(migrated))
}
#[cfg(feature = "crypto")]
DataValue::Signed(_) | DataValue::Encrypted(_) => Err(CodecError::InvalidEncoding),
scalar => Ok(scalar.clone()),
@ -641,6 +784,39 @@ mod tests {
assert_eq!(frame.get_data(DataType::Version), None);
}
#[test]
fn replies_retain_the_request_type_map() {
let type_map = TypeMap::new(mtp_type_map::Version::new(3, 0));
let request = CommunicationValue::new_with_type_map(CommunicationType::Ping, &type_map)
.with_sender(7)
.with_receiver(9);
let reply = request.reply_to(CommunicationType::Pong);
assert_eq!(
reply.type_map().map(|map| &map.version),
Some(&type_map.version)
);
assert_eq!(reply.sender(), Some(9));
assert_eq!(reply.receiver(), Some(7));
}
#[test]
fn try_merge_rejects_frames_from_different_type_maps() {
let left_map = TypeMap::new(mtp_type_map::Version::new(3, 0));
let right_map = TypeMap::new(mtp_type_map::Version::new(4, 0));
let mut left = CommunicationValue::new_with_type_map(CommunicationType::Ping, &left_map);
let right = CommunicationValue::new_with_type_map(CommunicationType::Ping, &right_map);
assert_eq!(
left.try_merge(&right),
Err(CodecError::TypeMapMismatch {
expected: "3.0".into(),
actual: "4.0".into(),
})
);
assert_eq!(left.data_len(), 0);
}
#[test]
fn generic_payload_roundtrips_without_becoming_a_container() {
let payload = DataValue::Array(vec![

File diff suppressed because it is too large Load diff

View file

@ -11,20 +11,33 @@ pub use data_value::{
ApplicationProtectionPurpose, EncryptedValue, MtpProtectionPurpose, ProtectionError,
ProtectionPolicy, ProtectionPurpose, ProtectionPurposeError, SignaturePolicy, SignedValue,
};
pub use data_value::{DataKind, DataValue, DecodeLimits};
pub use data_value::{
DEFAULT_TRANSPORT_ALLOCATION_FACTOR, DataKind, DataValue, DecodeError, DecodeLimits,
EncodeLimits,
};
pub use mtp_common::{CodecError, TimeError, unix_time_millis};
#[cfg(feature = "crypto")]
#[allow(deprecated)]
pub use protected::{
CURRENT_PROTECTED_VERSION, InMemoryReplayGuard, ProtectedError, ProtectedMessageBuilder,
ProtectedOpenOptions, ReplayError, ReplayGuard, VerifiedProtectedMessage, open_protected,
open_protected_with, open_protected_with_keys, protected_claimed_signer_id,
CURRENT_PROTECTED_VERSION, InMemoryReplayGuard, ProtectedError, ProtectedLimits,
ProtectedMessageBuilder, ProtectedOpenOptions, ReplayError, ReplayGuard,
VerifiedProtectedMessage, open_protected_checked, open_protected_with_checked,
open_protected_with_keys_checked, open_protected_with_keys_without_replay,
open_protected_with_without_replay, open_protected_without_replay, protected_claimed_signer_id,
protected_claimed_signer_id_with_limits, protected_claimed_signer_id_with_options,
};
#[cfg(feature = "crypto")]
#[allow(deprecated)]
pub use relay::{
CURRENT_RELAY_VERSION, RelayError, SealedRelayBuilder, VerifiedRelayContent,
CURRENT_RELAY_VERSION, RelayError, RelayOpenOptions, 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,
open_relay_content_with_keyrings, open_relay_content_with_keyrings_and_limits,
open_relay_content_with_keys, open_relay_content_with_limits,
open_relay_content_with_limits_without_replay, open_relay_metadata_checked,
open_relay_metadata_with_checked, open_relay_metadata_with_limits_checked,
open_relay_metadata_with_limits_without_replay, open_relay_metadata_with_without_replay,
open_relay_metadata_without_replay, relay_metadata_claimed_signer_id,
relay_metadata_claimed_signer_id_with_limits, relay_metadata_claimed_signer_id_with_options,
};
pub use mtp_type_map::{

View file

@ -7,15 +7,39 @@
#![cfg(feature = "crypto")]
use mtp_crypto::{Keyring, PublicKeyBundle, SignatureScheme};
use mtp_type_map::{CommunicationType, DataType, TypeMap};
use std::collections::HashSet;
use mtp_type_map::{CommunicationType, DataType, DataTypeId, TypeMap};
use std::collections::{HashSet, VecDeque};
use crate::{CommunicationValue, DataValue, ProtectionError, ProtectionPolicy, ProtectionPurpose};
use crate::{
CommunicationValue, DataValue, DecodeLimits, EncodeLimits, ProtectionError, ProtectionPolicy,
ProtectionPurpose,
};
/// The direct protected-message envelope schema version emitted by this
/// codec.
pub const CURRENT_PROTECTED_VERSION: u64 = 1;
/// Semantic limits for fields that are retained after a protected message is
/// opened. These are intentionally separate from generic transport blobs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ProtectedLimits {
pub max_message_id_bytes: usize,
pub max_metadata_encoded_bytes: usize,
pub max_signer_key_history: usize,
pub max_decryption_key_history: usize,
}
impl Default for ProtectedLimits {
fn default() -> Self {
Self {
max_message_id_bytes: 256,
max_metadata_encoded_bytes: 1024 * 1024,
max_signer_key_history: 8,
max_decryption_key_history: 8,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ProtectedError {
#[error("value is not an application communication frame")]
@ -46,6 +70,8 @@ pub enum ProtectedError {
ReservedApplicationType(String),
#[error("protected message was already accepted")]
Replay,
#[error("protected resource limit exceeded: {0}")]
ResourceLimit(&'static str),
#[error("protection error: {0}")]
Protection(#[from] ProtectionError),
#[error("replay guard error: {0}")]
@ -78,9 +104,35 @@ pub enum ReplayError {
/// Small in-memory guard useful for tests and short-lived clients. Production
/// consumers should implement [`ReplayGuard`] over persistent storage.
#[derive(Debug, Default)]
#[derive(Debug)]
pub struct InMemoryReplayGuard {
accepted: HashSet<(u64, String)>,
order: VecDeque<(u64, String)>,
capacity: usize,
}
impl Default for InMemoryReplayGuard {
fn default() -> Self {
Self::with_capacity(10_000)
}
}
impl InMemoryReplayGuard {
pub fn new(capacity: usize) -> Self {
Self::with_capacity(capacity)
}
pub fn with_capacity(capacity: usize) -> Self {
Self {
accepted: HashSet::new(),
order: VecDeque::new(),
capacity,
}
}
pub fn len(&self) -> usize {
self.accepted.len()
}
}
impl ReplayGuard for InMemoryReplayGuard {
@ -90,7 +142,21 @@ impl ReplayGuard for InMemoryReplayGuard {
message_id: &str,
_created_at: u64,
) -> Result<bool, ReplayError> {
Ok(self.accepted.insert((signer_id, message_id.to_owned())))
let key = (signer_id, message_id.to_owned());
if self.accepted.contains(&key) {
return Ok(false);
}
if self.capacity == 0 {
return Ok(false);
}
self.accepted.insert(key.clone());
self.order.push_back(key);
while self.accepted.len() > self.capacity {
if let Some(oldest) = self.order.pop_front() {
self.accepted.remove(&oldest);
}
}
Ok(true)
}
}
@ -110,6 +176,8 @@ pub struct ProtectedMessageBuilder<'a> {
type_map: Option<TypeMap>,
frame_id: Option<u32>,
expose_sender: bool,
limits: ProtectedLimits,
encode_limits: EncodeLimits,
}
impl<'a> ProtectedMessageBuilder<'a> {
@ -136,6 +204,8 @@ impl<'a> ProtectedMessageBuilder<'a> {
type_map: None,
frame_id: None,
expose_sender: false,
limits: ProtectedLimits::default(),
encode_limits: EncodeLimits::default(),
}
}
@ -176,6 +246,16 @@ impl<'a> ProtectedMessageBuilder<'a> {
self
}
pub fn protected_limits(mut self, limits: ProtectedLimits) -> Self {
self.limits = limits;
self
}
pub fn encode_limits(mut self, limits: EncodeLimits) -> Self {
self.encode_limits = limits;
self
}
pub fn build(self) -> Result<CommunicationValue, ProtectedError> {
let message_id = self.message_id.ok_or(ProtectedError::InvalidLayout(
"protected builder requires a message ID",
@ -188,6 +268,9 @@ impl<'a> ProtectedMessageBuilder<'a> {
"protected identifiers must be non-empty",
));
}
if message_id.len() > self.limits.max_message_id_bytes {
return Err(ProtectedError::ResourceLimit("message ID"));
}
if self.recipients.is_empty() {
return Err(ProtectedError::InvalidLayout(
"protected builder requires at least one recipient",
@ -217,8 +300,17 @@ impl<'a> ProtectedMessageBuilder<'a> {
(created_at_id, DataValue::UnsignedNumber(created_at as u128)),
(content_id, self.content),
]);
let signed = envelope.sign(self.signer_id, self.signature_purpose, self.signer)?;
let encrypted = signed.encrypt_for(&self.recipients, self.encryption_purpose)?;
let signed = envelope.sign_with_limits(
self.signer_id,
self.signature_purpose,
self.signer,
self.encode_limits,
)?;
let encrypted = signed.encrypt_for_with_limits(
&self.recipients,
self.encryption_purpose,
self.encode_limits,
)?;
let mut frame = CommunicationValue::new_with_type_map(application_type, &type_map)
.with_receiver(self.final_recipient_id)
@ -258,6 +350,12 @@ pub struct ProtectedOpenOptions {
pub encryption_purpose: ProtectionPurpose,
/// Signature algorithms accepted by the receiver.
pub policy: ProtectionPolicy,
/// Recursive and cumulative allocation policy used while opening.
pub decode_limits: DecodeLimits,
/// Bound used when reconstructing signed bytes for verification.
pub encode_limits: EncodeLimits,
/// Semantic limits for retained protected fields and key histories.
pub protected_limits: ProtectedLimits,
}
impl ProtectedOpenOptions {
@ -272,8 +370,41 @@ impl ProtectedOpenOptions {
signature_purpose,
encryption_purpose,
policy,
decode_limits: DecodeLimits {
max_depth: 64,
max_values: 65_536,
max_blob_size: 16 * 1024 * 1024,
max_recipients: 64,
max_allocated_bytes: 64 * 1024 * 1024,
},
encode_limits: EncodeLimits {
max_depth: 64,
max_values: 65_536,
max_output_size: 16 * 1024 * 1024,
},
protected_limits: ProtectedLimits {
max_message_id_bytes: 256,
max_metadata_encoded_bytes: 1024 * 1024,
max_signer_key_history: 8,
max_decryption_key_history: 8,
},
}
}
pub const fn with_limits(
mut self,
decode_limits: DecodeLimits,
protected_limits: ProtectedLimits,
) -> Self {
self.decode_limits = decode_limits;
self.protected_limits = protected_limits;
self
}
pub const fn with_encode_limits(mut self, encode_limits: EncodeLimits) -> Self {
self.encode_limits = encode_limits;
self
}
}
fn protected_field_id(
@ -328,23 +459,26 @@ fn validate_protected_frame(frame: &CommunicationValue) -> Result<TypeMap, Prote
}
fn field<'a>(
value: &'a DataValue,
entries: &'a [(DataTypeId, DataValue)],
data_type: DataType,
type_map: &TypeMap,
) -> Result<&'a DataValue, ProtectedError> {
value
.get_field(protected_field_id(data_type, type_map)?)
let field_id = protected_field_id(data_type, type_map)?;
entries
.iter()
.find(|(id, _)| *id == field_id)
.map(|(_, value)| value)
.ok_or(ProtectedError::InvalidLayout(
"required protected field is missing",
))
}
fn unsigned_field(
value: &DataValue,
entries: &[(DataTypeId, DataValue)],
data_type: DataType,
type_map: &TypeMap,
) -> Result<u128, ProtectedError> {
field(value, data_type, type_map)?
field(entries, data_type, type_map)?
.as_unsigned_number()
.ok_or(ProtectedError::InvalidLayout(
"protected field is not unsigned",
@ -352,11 +486,11 @@ fn unsigned_field(
}
fn string_field(
value: &DataValue,
entries: &[(DataTypeId, DataValue)],
data_type: DataType,
type_map: &TypeMap,
) -> Result<String, ProtectedError> {
field(value, data_type, type_map)?
field(entries, data_type, type_map)?
.as_string()
.filter(|value| !value.is_empty())
.ok_or(ProtectedError::InvalidLayout(
@ -364,10 +498,28 @@ fn string_field(
))
}
fn protected_version(value: &DataValue, type_map: &TypeMap) -> Result<u64, ProtectedError> {
fn string_field_ref<'a>(
entries: &'a [(DataTypeId, DataValue)],
data_type: DataType,
type_map: &TypeMap,
) -> Result<&'a str, ProtectedError> {
field(entries, data_type, type_map)?
.as_str()
.filter(|value| !value.is_empty())
.ok_or(ProtectedError::InvalidLayout(
"protected field is not a non-empty string",
))
}
fn protected_version(
entries: &[(DataTypeId, DataValue)],
type_map: &TypeMap,
) -> Result<u64, ProtectedError> {
let version_id = protected_field_id(DataType::ProtectedVersion, type_map)?;
let version = value
.get_field(version_id)
let version = entries
.iter()
.find(|(id, _)| *id == version_id)
.map(|(_, value)| value)
.ok_or(ProtectedError::MissingProtectedVersion)?
.as_unsigned_number()
.ok_or(ProtectedError::InvalidLayout(
@ -381,10 +533,15 @@ fn decrypt_protected_payload(
frame: &CommunicationValue,
keyrings: &[&Keyring],
encryption_purpose: ProtectionPurpose,
decode_limits: DecodeLimits,
max_decryption_key_history: usize,
) -> Result<DataValue, ProtectedError> {
if keyrings.len() > max_decryption_key_history {
return Err(ProtectedError::ResourceLimit("decryption key history"));
}
frame
.payload()
.decrypt_with_keyrings(keyrings, encryption_purpose)
.decrypt_with_keyrings_and_limits(keyrings, encryption_purpose, decode_limits)
.map_err(|error| match error {
ProtectionError::NotEncrypted => ProtectedError::PayloadNotEncrypted,
other => ProtectedError::Protection(other),
@ -394,22 +551,63 @@ fn decrypt_protected_payload(
/// Return the claimed signer ID after decryption, without verifying its
/// signature. The value is untrusted and may only select the key history that
/// is then bound to the same signer ID during the subsequent open.
#[deprecated(note = "use protected_claimed_signer_id_with_limits; pass the receive DecodeLimits")]
pub fn protected_claimed_signer_id(
frame: &CommunicationValue,
keyrings: &[&Keyring],
encryption_purpose: ProtectionPurpose,
) -> Result<u64, ProtectedError> {
// Migrate to `protected_claimed_signer_id_with_limits` at receive boundaries.
protected_claimed_signer_id_with_limits(
frame,
keyrings,
encryption_purpose,
DecodeLimits::default(),
)
}
pub fn protected_claimed_signer_id_with_limits(
frame: &CommunicationValue,
keyrings: &[&Keyring],
encryption_purpose: ProtectionPurpose,
decode_limits: DecodeLimits,
) -> Result<u64, ProtectedError> {
protected_claimed_signer_id_with_options(
frame,
keyrings,
encryption_purpose,
decode_limits,
ProtectedLimits::default(),
)
}
/// Return the claimed signer ID while applying the complete receive policy.
///
/// This is deliberately separate from the compatibility decoder above: the
/// claimed ID is used to select a signer-key history, so the decryption-key
/// history bound must be the same bound used by the eventual open operation.
pub fn protected_claimed_signer_id_with_options(
frame: &CommunicationValue,
keyrings: &[&Keyring],
encryption_purpose: ProtectionPurpose,
decode_limits: DecodeLimits,
protected_limits: ProtectedLimits,
) -> Result<u64, ProtectedError> {
validate_protected_frame(frame)?;
let decrypted = decrypt_protected_payload(frame, keyrings, encryption_purpose)?;
let decrypted = decrypt_protected_payload(
frame,
keyrings,
encryption_purpose,
decode_limits,
protected_limits.max_decryption_key_history,
)?;
let signed = decrypted
.as_signed()
.ok_or(ProtectedError::PayloadNotSigned)?;
Ok(signed.signer_id)
}
/// Open a direct protected message using a resolver for trusted signer keys.
/// The resolver receives a claimed, unverified signer ID only as a lookup key.
pub fn open_protected_with<F>(
fn open_protected_with_impl<F>(
frame: &CommunicationValue,
keyrings: &[&Keyring],
expected_signer_id: Option<u64>,
@ -422,7 +620,13 @@ where
{
validate_protected_frame(frame)?;
let type_map = frame.type_map().cloned().unwrap_or_else(TypeMap::latest);
let decrypted = decrypt_protected_payload(frame, keyrings, options.encryption_purpose)?;
let decrypted = decrypt_protected_payload(
frame,
keyrings,
options.encryption_purpose,
options.decode_limits,
options.protected_limits.max_decryption_key_history,
)?;
let signed = decrypted
.as_signed()
.ok_or(ProtectedError::PayloadNotSigned)?;
@ -437,13 +641,54 @@ where
}
let signer_keys = resolve_signer_keys(signed.signer_id)
.ok_or(ProtectionError::SignerKeyNotFound(signed.signer_id))?;
if signer_keys.len() > options.protected_limits.max_signer_key_history {
return Err(ProtectedError::ResourceLimit("signer key history"));
}
open_decrypted_protected(frame, type_map, signed, &signer_keys, options, replay_guard)
}
/// Open a direct protected message against already resolved trusted signer
/// keys. The signer ID is mandatory so a key history cannot be applied to a
/// different claimed identity.
pub fn open_protected_with_keys(
pub fn open_protected_with_checked<F>(
frame: &CommunicationValue,
keyrings: &[&Keyring],
expected_signer_id: Option<u64>,
resolve_signer_keys: F,
options: ProtectedOpenOptions,
replay_guard: &mut dyn ReplayGuard,
) -> Result<VerifiedProtectedMessage, ProtectedError>
where
F: FnOnce(u64) -> Option<Vec<PublicKeyBundle>>,
{
open_protected_with_impl(
frame,
keyrings,
expected_signer_id,
resolve_signer_keys,
options,
Some(replay_guard),
)
}
pub fn open_protected_with_without_replay<F>(
frame: &CommunicationValue,
keyrings: &[&Keyring],
expected_signer_id: Option<u64>,
resolve_signer_keys: F,
options: ProtectedOpenOptions,
) -> Result<VerifiedProtectedMessage, ProtectedError>
where
F: FnOnce(u64) -> Option<Vec<PublicKeyBundle>>,
{
open_protected_with_impl(
frame,
keyrings,
expected_signer_id,
resolve_signer_keys,
options,
None,
)
}
fn open_protected_with_keys_impl(
frame: &CommunicationValue,
keyrings: &[&Keyring],
expected_signer_id: u64,
@ -452,7 +697,13 @@ pub fn open_protected_with_keys(
replay_guard: Option<&mut dyn ReplayGuard>,
) -> Result<VerifiedProtectedMessage, ProtectedError> {
let type_map = validate_protected_frame(frame)?;
let decrypted = decrypt_protected_payload(frame, keyrings, options.encryption_purpose)?;
let decrypted = decrypt_protected_payload(
frame,
keyrings,
options.encryption_purpose,
options.decode_limits,
options.protected_limits.max_decryption_key_history,
)?;
let signed = decrypted
.as_signed()
.ok_or(ProtectedError::PayloadNotSigned)?;
@ -473,23 +724,73 @@ pub fn open_protected_with_keys(
)
}
/// Open a direct protected message when the expected signer and one trusted
/// public key are already known.
pub fn open_protected(
pub fn open_protected_with_keys_checked(
frame: &CommunicationValue,
keyrings: &[&Keyring],
expected_signer_id: u64,
signer_public_keys: &[PublicKeyBundle],
options: ProtectedOpenOptions,
replay_guard: &mut dyn ReplayGuard,
) -> Result<VerifiedProtectedMessage, ProtectedError> {
open_protected_with_keys_impl(
frame,
keyrings,
expected_signer_id,
signer_public_keys,
options,
Some(replay_guard),
)
}
pub fn open_protected_with_keys_without_replay(
frame: &CommunicationValue,
keyrings: &[&Keyring],
expected_signer_id: u64,
signer_public_keys: &[PublicKeyBundle],
options: ProtectedOpenOptions,
) -> Result<VerifiedProtectedMessage, ProtectedError> {
open_protected_with_keys_impl(
frame,
keyrings,
expected_signer_id,
signer_public_keys,
options,
None,
)
}
pub fn open_protected_checked(
frame: &CommunicationValue,
keyring: &Keyring,
expected_signer_id: u64,
signer_public_key: &PublicKeyBundle,
options: ProtectedOpenOptions,
replay_guard: Option<&mut dyn ReplayGuard>,
replay_guard: &mut dyn ReplayGuard,
) -> Result<VerifiedProtectedMessage, ProtectedError> {
open_protected_with_keys(
open_protected_with_keys_impl(
frame,
std::slice::from_ref(&keyring),
expected_signer_id,
std::slice::from_ref(signer_public_key),
options,
replay_guard,
Some(replay_guard),
)
}
pub fn open_protected_without_replay(
frame: &CommunicationValue,
keyring: &Keyring,
expected_signer_id: u64,
signer_public_key: &PublicKeyBundle,
options: ProtectedOpenOptions,
) -> Result<VerifiedProtectedMessage, ProtectedError> {
open_protected_with_keys_impl(
frame,
std::slice::from_ref(&keyring),
expected_signer_id,
std::slice::from_ref(signer_public_key),
options,
None,
)
}
@ -501,11 +802,15 @@ fn open_decrypted_protected(
options: ProtectedOpenOptions,
mut replay_guard: Option<&mut dyn ReplayGuard>,
) -> Result<VerifiedProtectedMessage, ProtectedError> {
let matched_signer_key_index = signed.verify_with_key_history_index(
if signer_public_keys.len() > options.protected_limits.max_signer_key_history {
return Err(ProtectedError::ResourceLimit("signer key history"));
}
let matched_signer_key_index = signed.verify_with_key_history_index_and_limits(
signed.signer_id,
signer_public_keys,
options.signature_purpose,
options.policy,
options.encode_limits,
)?;
let receiver_id = frame.receiver().ok_or(ProtectedError::MissingReceiver)?;
if options
@ -520,22 +825,23 @@ fn open_decrypted_protected(
{
return Err(ProtectedError::SenderMismatch);
}
/* The authenticated value is already owned by the decoder. Keep this
inspection borrowed so opening a large envelope does not clone it. */
let envelope = signed
.value
.as_container()
.container_entries()
.ok_or(ProtectedError::MissingEnvelope)?;
let envelope = DataValue::Container(envelope);
let version = protected_version(&envelope, &type_map)?;
let version = protected_version(envelope, &type_map)?;
if version != CURRENT_PROTECTED_VERSION {
return Err(ProtectedError::UnsupportedProtectedVersion(version));
}
let message_type = string_field(&envelope, DataType::MessageType, &type_map)?;
let message_type = string_field(envelope, DataType::MessageType, &type_map)?;
let application_type = validate_application_message_type(&message_type, &type_map)?;
if frame.get_comm_type_enum() != Some(application_type) {
return Err(ProtectedError::MessageTypeMismatch);
}
let final_recipient_id = u64::try_from(unsigned_field(
&envelope,
envelope,
DataType::FinalRecipientId,
&type_map,
)?)
@ -543,10 +849,14 @@ fn open_decrypted_protected(
if final_recipient_id != receiver_id {
return Err(ProtectedError::FinalRecipientMismatch);
}
let message_id = string_field(&envelope, DataType::MessageId, &type_map)?;
let created_at = u64::try_from(unsigned_field(&envelope, DataType::CreatedAt, &type_map)?)
let message_id = string_field_ref(envelope, DataType::MessageId, &type_map)?;
if message_id.len() > options.protected_limits.max_message_id_bytes {
return Err(ProtectedError::ResourceLimit("message ID"));
}
let message_id = message_id.to_owned();
let created_at = u64::try_from(unsigned_field(envelope, DataType::CreatedAt, &type_map)?)
.map_err(|_| ProtectedError::InvalidLayout("created-at value is out of range"))?;
let content = field(&envelope, DataType::Content, &type_map)?.clone();
let content = field(envelope, DataType::Content, &type_map)?.clone();
if let Some(guard) = replay_guard.as_mut()
&& !guard.accept(signed.signer_id, &message_id, created_at)?
@ -569,7 +879,7 @@ fn open_decrypted_protected(
#[cfg(test)]
mod tests {
use super::*;
use mtp_crypto::{Ed25519Signer, Keyring};
use mtp_crypto::{Ed25519Signer, Keyring, PublicKeyBundle};
use mtp_type_map::{DataType, DataTypeId};
const SIGNATURE_PURPOSE: ProtectionPurpose = ProtectionPurpose(0x40);
@ -584,10 +894,98 @@ mod tests {
)
}
// Keep the existing test cases concise while making the production API
// choice explicit: every call below is routed to either the checked or
// the named without-replay entry point.
fn open_protected(
frame: &CommunicationValue,
keyring: &Keyring,
expected_signer_id: u64,
signer_public_key: &PublicKeyBundle,
options: ProtectedOpenOptions,
replay_guard: Option<&mut dyn ReplayGuard>,
) -> Result<VerifiedProtectedMessage, ProtectedError> {
match replay_guard {
Some(replay_guard) => super::open_protected_checked(
frame,
keyring,
expected_signer_id,
signer_public_key,
options,
replay_guard,
),
None => super::open_protected_without_replay(
frame,
keyring,
expected_signer_id,
signer_public_key,
options,
),
}
}
fn open_protected_with<F>(
frame: &CommunicationValue,
keyrings: &[&Keyring],
expected_signer_id: Option<u64>,
resolve_signer_keys: F,
options: ProtectedOpenOptions,
replay_guard: Option<&mut dyn ReplayGuard>,
) -> Result<VerifiedProtectedMessage, ProtectedError>
where
F: FnOnce(u64) -> Option<Vec<PublicKeyBundle>>,
{
match replay_guard {
Some(replay_guard) => super::open_protected_with_checked(
frame,
keyrings,
expected_signer_id,
resolve_signer_keys,
options,
replay_guard,
),
None => super::open_protected_with_without_replay(
frame,
keyrings,
expected_signer_id,
resolve_signer_keys,
options,
),
}
}
fn open_protected_with_keys(
frame: &CommunicationValue,
keyrings: &[&Keyring],
expected_signer_id: u64,
signer_public_keys: &[PublicKeyBundle],
options: ProtectedOpenOptions,
replay_guard: Option<&mut dyn ReplayGuard>,
) -> Result<VerifiedProtectedMessage, ProtectedError> {
match replay_guard {
Some(replay_guard) => super::open_protected_with_keys_checked(
frame,
keyrings,
expected_signer_id,
signer_public_keys,
options,
replay_guard,
),
None => super::open_protected_with_keys_without_replay(
frame,
keyrings,
expected_signer_id,
signer_public_keys,
options,
),
}
}
#[derive(Default)]
struct RecordingReplayGuard {
created_at: Option<u64>,
accepted: bool,
calls: usize,
}
impl ReplayGuard for RecordingReplayGuard {
@ -597,6 +995,7 @@ mod tests {
_message_id: &str,
created_at: u64,
) -> Result<bool, ReplayError> {
self.calls += 1;
self.created_at = Some(created_at);
if self.accepted {
Ok(false)
@ -607,6 +1006,38 @@ mod tests {
}
}
#[test]
fn in_memory_replay_guard_is_bounded_and_deduplicates() {
let mut guard = InMemoryReplayGuard::with_capacity(2);
assert!(guard.accept(7, "first", 1).expect("first replay decision"));
assert!(
guard
.accept(7, "second", 2)
.expect("second replay decision")
);
assert!(
!guard
.accept(7, "first", 3)
.expect("duplicate replay decision")
);
assert_eq!(guard.len(), 2);
assert!(guard.accept(7, "third", 4).expect("third replay decision"));
assert_eq!(guard.len(), 2);
assert!(
guard
.accept(7, "first", 5)
.expect("evicted replay decision")
);
let mut disabled = InMemoryReplayGuard::with_capacity(0);
assert!(
!disabled
.accept(7, "disabled", 1)
.expect("disabled replay decision")
);
}
fn protected_field(data_type: DataType, type_map: &TypeMap) -> DataTypeId {
data_type
.try_to_id(type_map)
@ -743,6 +1174,7 @@ mod tests {
assert_eq!(opened.message_type, "ProtectedMessage");
assert_eq!(opened.message_id, "protected-test");
assert_eq!(guard.created_at, Some(1_700_000_000_000));
assert_eq!(guard.calls, 1);
assert_eq!(opened.content, DataValue::Str("hello".into()));
assert!(matches!(
open_protected(
@ -757,6 +1189,29 @@ mod tests {
));
}
#[test]
fn oversized_message_id_is_rejected_before_replay_guard() {
let sender = Keyring::generate();
let recipient = Keyring::generate();
let frame = valid_frame(&sender, &recipient, DataValue::Str("hello".into()));
let mut options = open_options(Some(42));
options.protected_limits.max_message_id_bytes = 3;
let mut guard = RecordingReplayGuard::default();
assert!(matches!(
open_protected_checked(
&frame,
&recipient,
7,
&sender.public_key_bundle(),
options,
&mut guard,
),
Err(ProtectedError::ResourceLimit("message ID"))
));
assert_eq!(guard.calls, 0);
}
#[test]
fn builder_owns_outer_sender_and_frame_id() {
let sender = Keyring::generate();

View file

@ -2,6 +2,7 @@ use mtp_common::CodecError;
use mtp_type_map::{PROTOCOL_VERSION, TypeMap, Version};
use crate::CommunicationValue;
use crate::EncodeLimits;
pub use mtp_type_map::Registry;
@ -42,7 +43,41 @@ impl VersionedCodec {
/// Encode a value using the codec's negotiated framing rules.
pub fn encode(&self, value: &CommunicationValue) -> Result<Vec<u8>, CodecError> {
value.to_bytes()
self.encode_with_limits(value, EncodeLimits::default())
}
/// Encode using an explicit output/resource limit after verifying the
/// value belongs to this codec's negotiated type map.
pub fn encode_with_limits(
&self,
value: &CommunicationValue,
limits: EncodeLimits,
) -> Result<Vec<u8>, CodecError> {
let value_map = value.type_map().ok_or(CodecError::MissingTypeMap)?;
if value_map.version != self.type_map.version {
return Err(CodecError::TypeMapMismatch {
expected: self.type_map.version.to_string(),
actual: value_map.version.to_string(),
});
}
value.to_bytes_with_limits(limits)
}
/// Explicitly migrate a clear frame to this codec's negotiated type map
/// before encoding it.
pub fn encode_migrating(&self, value: &CommunicationValue) -> Result<Vec<u8>, CodecError> {
self.encode_migrating_with_limits(value, EncodeLimits::default())
}
/// Explicitly migrate and encode with bounded traversal/output.
pub fn encode_migrating_with_limits(
&self,
value: &CommunicationValue,
limits: EncodeLimits,
) -> Result<Vec<u8>, CodecError> {
value
.migrate_with_limits(&self.type_map, limits)?
.to_bytes_with_limits(limits)
}
/// Decode a frame and retain the negotiated type map for typed access.
@ -58,3 +93,34 @@ impl VersionedCodec {
&self.registry
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DataValue;
use mtp_type_map::{CommunicationType, Version};
#[test]
fn encode_rejects_a_value_from_another_negotiated_map() {
let mut registry = Registry::new();
let version_a = Version::new(3, 0);
let version_b = Version::new(4, 0);
registry.register(TypeMap::new(version_a.clone()));
registry.register(TypeMap::new(version_b.clone()));
let codec = VersionedCodec::for_version(registry, version_b).expect("codec version");
let value = CommunicationValue::new_with_type_map(
CommunicationType::Ping,
&TypeMap::new(version_a.clone()),
)
.with_payload(DataValue::Null);
assert_eq!(
codec.encode(&value),
Err(CodecError::TypeMapMismatch {
expected: "4.0".into(),
actual: "3.0".into(),
})
);
}
}

View file

@ -9,11 +9,11 @@
#![cfg(feature = "crypto")]
use mtp_crypto::{Keyring, PublicKeyBundle, SignatureScheme};
use mtp_type_map::{CommunicationType, DataType, TypeMap};
use mtp_type_map::{CommunicationType, DataType, DataTypeId, TypeMap};
use crate::{
CommunicationValue, DataValue, MtpProtectionPurpose, ProtectionError, ProtectionPolicy,
ReplayError, ReplayGuard,
CommunicationValue, DataValue, DecodeLimits, EncodeLimits, MtpProtectionPurpose,
ProtectedLimits, ProtectionError, ProtectionPolicy, ReplayError, ReplayGuard,
};
/// The relay metadata schema emitted by [`SealedRelayBuilder`].
@ -37,6 +37,8 @@ pub enum RelayError {
NotFinalRecipient,
#[error("relay message was already accepted")]
Replay,
#[error("relay resource limit exceeded: {0}")]
ResourceLimit(&'static str),
#[error("relay application message type is reserved: {0}")]
ReservedApplicationType(String),
#[error("protection error: {0}")]
@ -62,6 +64,9 @@ pub struct VerifiedRelayMetadata {
encrypted_content: DataValue,
type_map: TypeMap,
matched_signer_key_index: usize,
decode_limits: DecodeLimits,
encode_limits: EncodeLimits,
protected_limits: ProtectedLimits,
// There is intentionally no public constructor. This marker documents
// that the fields originate from a successful authenticated open.
_verified: VerifiedMarker,
@ -84,6 +89,18 @@ impl VerifiedRelayMetadata {
self.final_recipient_id
}
pub fn decode_limits(&self) -> DecodeLimits {
self.decode_limits
}
pub fn protected_limits(&self) -> ProtectedLimits {
self.protected_limits
}
pub fn encode_limits(&self) -> EncodeLimits {
self.encode_limits
}
pub fn message_id(&self) -> &str {
&self.message_id
}
@ -120,6 +137,40 @@ pub struct VerifiedRelayContent {
pub content: DataValue,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RelayOpenOptions {
pub policy: ProtectionPolicy,
pub decode_limits: DecodeLimits,
pub encode_limits: EncodeLimits,
pub protected_limits: ProtectedLimits,
}
impl RelayOpenOptions {
pub fn new(policy: ProtectionPolicy) -> Self {
Self {
policy,
decode_limits: DecodeLimits::default(),
encode_limits: EncodeLimits::default(),
protected_limits: ProtectedLimits::default(),
}
}
pub const fn with_limits(
mut self,
decode_limits: DecodeLimits,
protected_limits: ProtectedLimits,
) -> Self {
self.decode_limits = decode_limits;
self.protected_limits = protected_limits;
self
}
pub const fn with_encode_limits(mut self, encode_limits: EncodeLimits) -> Self {
self.encode_limits = encode_limits;
self
}
}
/// Native sealed-relay builder shared by non-WASM applications.
///
/// The browser SDK and this builder intentionally produce the same reserved
@ -139,6 +190,8 @@ pub struct SealedRelayBuilder<'a> {
metadata_recipients: Vec<PublicKeyBundle>,
content_recipients: Vec<PublicKeyBundle>,
type_map: Option<TypeMap>,
limits: ProtectedLimits,
encode_limits: EncodeLimits,
}
impl<'a> SealedRelayBuilder<'a> {
@ -163,6 +216,8 @@ impl<'a> SealedRelayBuilder<'a> {
metadata_recipients: Vec::new(),
content_recipients: Vec::new(),
type_map: None,
limits: ProtectedLimits::default(),
encode_limits: EncodeLimits::default(),
}
}
@ -192,6 +247,16 @@ impl<'a> SealedRelayBuilder<'a> {
self
}
pub fn protected_limits(mut self, limits: ProtectedLimits) -> Self {
self.limits = limits;
self
}
pub fn encode_limits(mut self, limits: EncodeLimits) -> Self {
self.encode_limits = limits;
self
}
/// Build the reserved relay fields against a negotiated type map. The
/// default is the current map, but native callers handling an older
/// negotiated frame should pass that map explicitly.
@ -212,11 +277,17 @@ impl<'a> SealedRelayBuilder<'a> {
"relay builder identifiers must be non-empty",
));
}
if message_id.len() > self.limits.max_message_id_bytes {
return Err(RelayError::ResourceLimit("message ID"));
}
if self.metadata_recipients.is_empty() || self.content_recipients.is_empty() {
return Err(RelayError::InvalidLayout(
"relay builder requires metadata and content recipients",
));
}
if let Some(metadata) = self.metadata.as_ref() {
validate_metadata_size(metadata, &self.limits)?;
}
let type_map = self.type_map.unwrap_or_else(TypeMap::latest);
validate_application_message_type(&self.message_type, &type_map)?;
@ -232,14 +303,16 @@ impl<'a> SealedRelayBuilder<'a> {
(message_type_id, DataValue::Str(self.message_type)),
(content_id, self.content),
]);
let signed_content = content.sign(
let signed_content = content.sign_with_limits(
self.signer_id,
MtpProtectionPurpose::RelayContentSignature.into(),
self.signer,
self.encode_limits,
)?;
let encrypted_content = signed_content.encrypt_for(
let encrypted_content = signed_content.encrypt_for_with_limits(
&self.content_recipients,
MtpProtectionPurpose::RelayContentEncryption.into(),
self.encode_limits,
)?;
let mut metadata_fields = vec![
(
@ -258,14 +331,16 @@ impl<'a> SealedRelayBuilder<'a> {
metadata_fields.push((metadata_id, application_metadata));
}
let metadata = DataValue::Container(metadata_fields);
let signed_metadata = metadata.sign(
let signed_metadata = metadata.sign_with_limits(
self.signer_id,
MtpProtectionPurpose::RelayMetadataSignature.into(),
self.signer,
self.encode_limits,
)?;
let encrypted_metadata = signed_metadata.encrypt_for(
let encrypted_metadata = signed_metadata.encrypt_for_with_limits(
&self.metadata_recipients,
MtpProtectionPurpose::RelayMetadataEncryption.into(),
self.encode_limits,
)?;
Ok(
CommunicationValue::new_with_type_map(CommunicationType::Relay, &type_map)
@ -305,22 +380,44 @@ fn validate_application_message_type(
Ok(())
}
fn validate_metadata_size(
metadata: &DataValue,
limits: &ProtectedLimits,
) -> Result<(), RelayError> {
let encoded = metadata
.to_bytes_with_limits(EncodeLimits {
max_output_size: limits.max_metadata_encoded_bytes,
..EncodeLimits::default()
})
.map_err(|error| match error {
mtp_common::CodecError::TooManyEntries => RelayError::ResourceLimit("metadata"),
_ => RelayError::InvalidLayout("metadata cannot be encoded"),
})?;
if encoded.len() > limits.max_metadata_encoded_bytes {
return Err(RelayError::ResourceLimit("metadata"));
}
Ok(())
}
fn field<'a>(
value: &'a DataValue,
entries: &'a [(DataTypeId, DataValue)],
data_type: DataType,
type_map: &TypeMap,
) -> Result<&'a DataValue, RelayError> {
value
.get_field(relay_field(data_type, type_map)?)
let field_id = relay_field(data_type, type_map)?;
entries
.iter()
.find(|(id, _)| *id == field_id)
.map(|(_, value)| value)
.ok_or(RelayError::InvalidLayout("required relay field is missing"))
}
fn string_field(
value: &DataValue,
entries: &[(DataTypeId, DataValue)],
data_type: DataType,
type_map: &TypeMap,
) -> Result<String, RelayError> {
field(value, data_type, type_map)?
field(entries, data_type, type_map)?
.as_string()
.filter(|value| !value.is_empty())
.ok_or(RelayError::InvalidLayout(
@ -328,21 +425,36 @@ fn string_field(
))
}
fn optional_metadata_field(
value: &DataValue,
fn string_field_ref<'a>(
entries: &'a [(DataTypeId, DataValue)],
data_type: DataType,
type_map: &TypeMap,
) -> Result<Option<DataValue>, RelayError> {
Ok(value
.get_field(relay_field(DataType::Metadata, type_map)?)
.cloned())
) -> Result<&'a str, RelayError> {
field(entries, data_type, type_map)?
.as_str()
.filter(|value| !value.is_empty())
.ok_or(RelayError::InvalidLayout(
"relay field is not a non-empty string",
))
}
fn optional_metadata_field<'a>(
entries: &'a [(DataTypeId, DataValue)],
type_map: &TypeMap,
) -> Result<Option<&'a DataValue>, RelayError> {
let field_id = relay_field(DataType::Metadata, type_map)?;
Ok(entries
.iter()
.find(|(id, _)| *id == field_id)
.map(|(_, value)| value))
}
fn unsigned_field(
value: &DataValue,
entries: &[(DataTypeId, DataValue)],
data_type: DataType,
type_map: &TypeMap,
) -> Result<u128, RelayError> {
field(value, data_type, type_map)?
field(entries, data_type, type_map)?
.as_unsigned_number()
.ok_or(RelayError::InvalidLayout("relay field is not unsigned"))
}
@ -356,55 +468,86 @@ struct RelayMetadataV1 {
encrypted_content: DataValue,
}
fn relay_version(value: &DataValue, type_map: &TypeMap) -> Result<u64, RelayError> {
fn relay_version(
entries: &[(DataTypeId, DataValue)],
type_map: &TypeMap,
) -> Result<u64, RelayError> {
let version_id = relay_field(DataType::RelayVersion, type_map)?;
let version = value
.get_field(version_id)
let version = entries
.iter()
.find(|(id, _)| *id == version_id)
.map(|(_, value)| value)
.ok_or(RelayError::MissingRelayVersion)?
.as_unsigned_number()
.ok_or(RelayError::InvalidLayout("relay version is not unsigned"))?;
u64::try_from(version).map_err(|_| RelayError::InvalidLayout("relay version is out of range"))
}
fn parse_relay_v1(value: &DataValue, type_map: &TypeMap) -> Result<RelayMetadataV1, RelayError> {
let final_recipient_id =
u64::try_from(unsigned_field(value, DataType::FinalRecipientId, type_map)?)
.map_err(|_| RelayError::InvalidLayout("final recipient ID is out of range"))?;
let created_at = u64::try_from(unsigned_field(value, DataType::CreatedAt, type_map)?)
fn parse_relay_v1(
entries: &[(DataTypeId, DataValue)],
type_map: &TypeMap,
limits: &ProtectedLimits,
) -> Result<RelayMetadataV1, RelayError> {
let final_recipient_id = u64::try_from(unsigned_field(
entries,
DataType::FinalRecipientId,
type_map,
)?)
.map_err(|_| RelayError::InvalidLayout("final recipient ID is out of range"))?;
let created_at = u64::try_from(unsigned_field(entries, DataType::CreatedAt, type_map)?)
.map_err(|_| RelayError::InvalidLayout("created-at value is out of range"))?;
let encrypted_content = field(value, DataType::Content, type_map)?.clone();
let encrypted_content = field(entries, DataType::Content, type_map)?.clone();
if encrypted_content.as_encrypted().is_none() {
return Err(RelayError::InvalidLayout("content is not encrypted"));
}
let message_id = string_field_ref(entries, DataType::MessageId, type_map)?;
if message_id.len() > limits.max_message_id_bytes {
return Err(RelayError::ResourceLimit("message ID"));
}
let metadata = optional_metadata_field(entries, type_map)?;
if let Some(metadata) = metadata {
validate_metadata_size(metadata, limits)?;
}
Ok(RelayMetadataV1 {
final_recipient_id,
message_id: string_field(value, DataType::MessageId, type_map)?,
message_id: message_id.to_owned(),
created_at,
metadata: optional_metadata_field(value, type_map)?,
metadata: metadata.cloned(),
encrypted_content,
})
}
/// Decrypt and verify relay metadata, without opening its content.
///
/// `expected_signer_id` is required when the caller already knows the sender.
/// For sealed-sender operation use [`open_relay_metadata_with`] and resolve a
/// trusted key by the claimed, unverified signer ID. The ID is authenticated
/// only after the returned key history verifies the signature.
pub fn open_relay_metadata(
pub fn open_relay_metadata_checked(
frame: &CommunicationValue,
keyring: &Keyring,
expected_signer_id: u64,
signer_public_key: &PublicKeyBundle,
policy: ProtectionPolicy,
options: RelayOpenOptions,
replay_guard: &mut dyn ReplayGuard,
) -> Result<VerifiedRelayMetadata, RelayError> {
open_relay_metadata_with(
open_relay_metadata_with_limits_checked(
frame,
std::slice::from_ref(&keyring),
Some(expected_signer_id),
|_| Some(vec![signer_public_key.clone()]),
policy,
None,
options,
replay_guard,
)
}
pub fn open_relay_metadata_without_replay(
frame: &CommunicationValue,
keyring: &Keyring,
expected_signer_id: u64,
signer_public_key: &PublicKeyBundle,
options: RelayOpenOptions,
) -> Result<VerifiedRelayMetadata, RelayError> {
open_relay_metadata_with_limits_without_replay(
frame,
std::slice::from_ref(&keyring),
Some(expected_signer_id),
|_| Some(vec![signer_public_key.clone()]),
options,
)
}
@ -425,15 +568,45 @@ fn validate_relay_frame(frame: &CommunicationValue) -> Result<(), RelayError> {
/// signature or interpreting the versioned relay schema. The result is
/// untrusted and may only select the key history that is then bound to the
/// same signer ID during [`open_relay_metadata_with_keys`].
#[deprecated(note = "use relay_metadata_claimed_signer_id_with_limits")]
pub fn relay_metadata_claimed_signer_id(
frame: &CommunicationValue,
keyrings: &[&Keyring],
) -> Result<u64, RelayError> {
validate_relay_frame(frame)?;
// Migrate to `relay_metadata_claimed_signer_id_with_limits` at receive boundaries.
relay_metadata_claimed_signer_id_with_limits(frame, keyrings, DecodeLimits::default())
}
let decrypted = frame.payload().decrypt_with_keyrings(
pub fn relay_metadata_claimed_signer_id_with_limits(
frame: &CommunicationValue,
keyrings: &[&Keyring],
decode_limits: DecodeLimits,
) -> Result<u64, RelayError> {
relay_metadata_claimed_signer_id_with_options(
frame,
keyrings,
decode_limits,
ProtectedLimits::default(),
)
}
/// Return the claimed relay signer ID while applying the complete receive
/// policy, including the caller's decryption-key history bound.
pub fn relay_metadata_claimed_signer_id_with_options(
frame: &CommunicationValue,
keyrings: &[&Keyring],
decode_limits: DecodeLimits,
protected_limits: ProtectedLimits,
) -> Result<u64, RelayError> {
validate_relay_frame(frame)?;
if keyrings.len() > protected_limits.max_decryption_key_history {
return Err(RelayError::ResourceLimit("decryption key history"));
}
let decrypted = frame.payload().decrypt_with_keyrings_and_limits(
keyrings,
MtpProtectionPurpose::RelayMetadataEncryption.into(),
decode_limits,
)?;
let signed = decrypted
.as_signed()
@ -441,49 +614,118 @@ pub fn relay_metadata_claimed_signer_id(
Ok(signed.signer_id)
}
/// Decrypt and verify relay metadata against an already resolved signing-key
/// history. All relay version and field interpretation remains in the native
/// codec rather than being duplicated by language bindings.
pub fn open_relay_metadata_with_keys(
frame: &CommunicationValue,
keyrings: &[&Keyring],
expected_signer_id: u64,
signer_public_keys: &[PublicKeyBundle],
policy: ProtectionPolicy,
) -> Result<VerifiedRelayMetadata, RelayError> {
let signer_public_keys = signer_public_keys.to_vec();
open_relay_metadata_with(
frame,
keyrings,
Some(expected_signer_id),
move |_| Some(signer_public_keys.clone()),
policy,
None,
)
}
/// Decrypt and verify relay metadata using recipient-key history and a
/// signer-key resolver. The resolver receives a claimed, unverified signer
/// ID used only as a trusted-key lookup key. The ID becomes authenticated
/// only after signature verification. This is the native counterpart of the
/// browser relay API and supports sealed sender plus signing-key rotation.
pub fn open_relay_metadata_with<F>(
pub fn open_relay_metadata_with_checked<F>(
frame: &CommunicationValue,
keyrings: &[&Keyring],
expected_signer_id: Option<u64>,
resolve_signer_keys: F,
policy: ProtectionPolicy,
options: RelayOpenOptions,
replay_guard: &mut dyn ReplayGuard,
) -> Result<VerifiedRelayMetadata, RelayError>
where
F: FnOnce(u64) -> Option<Vec<PublicKeyBundle>>,
{
open_relay_metadata_with_limits_checked(
frame,
keyrings,
expected_signer_id,
resolve_signer_keys,
options,
replay_guard,
)
}
/// Open relay metadata for message processing with replay protection required
/// by the type system.
pub fn open_relay_metadata_with_limits_checked<F>(
frame: &CommunicationValue,
keyrings: &[&Keyring],
expected_signer_id: Option<u64>,
resolve_signer_keys: F,
options: RelayOpenOptions,
replay_guard: &mut dyn ReplayGuard,
) -> Result<VerifiedRelayMetadata, RelayError>
where
F: FnOnce(u64) -> Option<Vec<PublicKeyBundle>>,
{
open_relay_metadata_impl(
frame,
keyrings,
expected_signer_id,
resolve_signer_keys,
options,
Some(replay_guard),
)
}
pub fn open_relay_metadata_with_without_replay<F>(
frame: &CommunicationValue,
keyrings: &[&Keyring],
expected_signer_id: Option<u64>,
resolve_signer_keys: F,
options: RelayOpenOptions,
) -> Result<VerifiedRelayMetadata, RelayError>
where
F: FnOnce(u64) -> Option<Vec<PublicKeyBundle>>,
{
open_relay_metadata_with_limits_without_replay(
frame,
keyrings,
expected_signer_id,
resolve_signer_keys,
options,
)
}
/// Open relay metadata for stored/forensic use without replay protection.
/// The name makes the security trade-off explicit at the call site.
pub fn open_relay_metadata_with_limits_without_replay<F>(
frame: &CommunicationValue,
keyrings: &[&Keyring],
expected_signer_id: Option<u64>,
resolve_signer_keys: F,
options: RelayOpenOptions,
) -> Result<VerifiedRelayMetadata, RelayError>
where
F: FnOnce(u64) -> Option<Vec<PublicKeyBundle>>,
{
open_relay_metadata_impl(
frame,
keyrings,
expected_signer_id,
resolve_signer_keys,
options,
None,
)
}
fn open_relay_metadata_impl<F>(
frame: &CommunicationValue,
keyrings: &[&Keyring],
expected_signer_id: Option<u64>,
resolve_signer_keys: F,
options: RelayOpenOptions,
mut replay_guard: Option<&mut dyn ReplayGuard>,
) -> Result<VerifiedRelayMetadata, RelayError>
where
F: Fn(u64) -> Option<Vec<PublicKeyBundle>>,
F: FnOnce(u64) -> Option<Vec<PublicKeyBundle>>,
{
validate_relay_frame(frame)?;
if keyrings.len() > options.protected_limits.max_decryption_key_history {
return Err(RelayError::ResourceLimit("decryption key history"));
}
let type_map = frame.type_map().cloned().unwrap_or_else(TypeMap::latest);
let decrypted = frame.payload().decrypt_with_keyrings(
let decrypted = frame.payload().decrypt_with_keyrings_and_limits(
keyrings,
MtpProtectionPurpose::RelayMetadataEncryption.into(),
options.decode_limits,
)?;
let signed = decrypted
.as_signed()
@ -499,23 +741,30 @@ where
}
let signer_keys = resolve_signer_keys(signed.signer_id)
.ok_or(ProtectionError::SignerKeyNotFound(signed.signer_id))?;
let matched_signer_key_index = signed.verify_with_key_history_index(
if signer_keys.len() > options.protected_limits.max_signer_key_history {
return Err(RelayError::ResourceLimit("signer key history"));
}
let matched_signer_key_index = signed.verify_with_key_history_index_and_limits(
signed.signer_id,
&signer_keys,
MtpProtectionPurpose::RelayMetadataSignature.into(),
policy,
options.policy,
options.encode_limits,
)?;
/* Verification leaves the signed envelope owned by `decrypted`; inspect
its entries in place to avoid a second attacker-controlled clone. */
let metadata = signed
.value
.as_container()
.container_entries()
.ok_or(RelayError::InvalidLayout("metadata is not a container"))?;
let metadata = DataValue::Container(metadata);
let relay_version = relay_version(&metadata, &type_map)?;
let relay_version = relay_version(metadata, &type_map)?;
let parsed = match relay_version {
1 => parse_relay_v1(&metadata, &type_map)?,
1 => parse_relay_v1(metadata, &type_map, &options.protected_limits)?,
other => return Err(RelayError::UnsupportedRelayVersion(other)),
};
if parsed.message_id.len() > options.protected_limits.max_message_id_bytes {
return Err(RelayError::ResourceLimit("message ID"));
}
let result = VerifiedRelayMetadata {
relay_version,
signer_id: signed.signer_id,
@ -526,6 +775,9 @@ where
encrypted_content: parsed.encrypted_content,
type_map,
matched_signer_key_index,
decode_limits: options.decode_limits,
encode_limits: options.encode_limits,
protected_limits: options.protected_limits,
_verified: VerifiedMarker,
};
if let Some(guard) = replay_guard.as_mut()
@ -537,6 +789,7 @@ where
}
/// Open and verify content after metadata has been authenticated.
#[deprecated(note = "use open_relay_content_with_limits_without_replay")]
pub fn open_relay_content(
metadata: &VerifiedRelayMetadata,
keyring: &Keyring,
@ -544,17 +797,23 @@ pub fn open_relay_content(
expected_recipient_id: u64,
policy: ProtectionPolicy,
) -> Result<VerifiedRelayContent, RelayError> {
open_relay_content_with_keys(
open_relay_content_with_limits_without_replay(
metadata,
keyring,
std::slice::from_ref(&keyring),
std::slice::from_ref(signer_public_key),
expected_recipient_id,
policy,
Some(expected_recipient_id),
RelayOpenOptions {
policy,
decode_limits: metadata.decode_limits,
encode_limits: metadata.encode_limits,
protected_limits: metadata.protected_limits,
},
)
}
/// Open relay content against trusted signing-key history for the metadata's
/// authenticated signer ID.
#[deprecated(note = "use open_relay_content_with_limits_without_replay")]
pub fn open_relay_content_with_keys(
metadata: &VerifiedRelayMetadata,
keyring: &Keyring,
@ -562,18 +821,24 @@ pub fn open_relay_content_with_keys(
expected_recipient_id: u64,
policy: ProtectionPolicy,
) -> Result<VerifiedRelayContent, RelayError> {
open_relay_content_with_keyrings(
open_relay_content_with_limits_without_replay(
metadata,
std::slice::from_ref(&keyring),
signer_public_keys,
Some(expected_recipient_id),
policy,
RelayOpenOptions {
policy,
decode_limits: metadata.decode_limits,
encode_limits: metadata.encode_limits,
protected_limits: metadata.protected_limits,
},
)
}
/// Open relay content against recipient-key history and trusted signing-key
/// history. The expected final recipient is optional for callers that only
/// have decryption material and do not have a local identity ID.
#[deprecated(note = "use open_relay_content_with_limits_without_replay")]
pub fn open_relay_content_with_keyrings(
metadata: &VerifiedRelayMetadata,
keyrings: &[&Keyring],
@ -581,15 +846,96 @@ pub fn open_relay_content_with_keyrings(
expected_recipient_id: Option<u64>,
policy: ProtectionPolicy,
) -> Result<VerifiedRelayContent, RelayError> {
// Migrate to `open_relay_content_with_limits_without_replay` to keep the decode policy
// explicit across metadata and content opening.
open_relay_content_with_limits_without_replay(
metadata,
keyrings,
signer_public_keys,
expected_recipient_id,
RelayOpenOptions {
policy,
decode_limits: metadata.decode_limits,
encode_limits: metadata.encode_limits,
protected_limits: metadata.protected_limits,
},
)
}
#[deprecated(note = "use open_relay_content_with_limits_without_replay")]
pub fn open_relay_content_with_keyrings_and_limits(
metadata: &VerifiedRelayMetadata,
keyrings: &[&Keyring],
signer_public_keys: &[PublicKeyBundle],
expected_recipient_id: Option<u64>,
options: RelayOpenOptions,
) -> Result<VerifiedRelayContent, RelayError> {
open_relay_content_with_limits_without_replay(
metadata,
keyrings,
signer_public_keys,
expected_recipient_id,
options,
)
}
/// Compatibility alias for callers that already hold authenticated relay
/// metadata. New code should use the explicit `_without_replay` name.
#[deprecated(note = "use open_relay_content_with_limits_without_replay")]
pub fn open_relay_content_with_limits(
metadata: &VerifiedRelayMetadata,
keyrings: &[&Keyring],
signer_public_keys: &[PublicKeyBundle],
expected_recipient_id: Option<u64>,
options: RelayOpenOptions,
) -> Result<VerifiedRelayContent, RelayError> {
open_relay_content_with_limits_without_replay(
metadata,
keyrings,
signer_public_keys,
expected_recipient_id,
options,
)
}
/// Open relay content after the authenticated metadata operation without
/// making a second replay decision. Replay is consumed by the metadata
/// processing boundary; this explicit name prevents callers from mistaking
/// content opening for an independent replay check.
pub fn open_relay_content_with_limits_without_replay(
metadata: &VerifiedRelayMetadata,
keyrings: &[&Keyring],
signer_public_keys: &[PublicKeyBundle],
expected_recipient_id: Option<u64>,
options: RelayOpenOptions,
) -> Result<VerifiedRelayContent, RelayError> {
let options = RelayOpenOptions {
policy: options.policy,
decode_limits: restrict_decode_limits(options.decode_limits, metadata.decode_limits),
encode_limits: restrict_encode_limits(options.encode_limits, metadata.encode_limits),
protected_limits: restrict_protected_limits(
options.protected_limits,
metadata.protected_limits,
),
};
if expected_recipient_id.is_some_and(|id| metadata.final_recipient_id != id) {
return Err(RelayError::NotFinalRecipient);
}
if keyrings.len() > options.protected_limits.max_decryption_key_history {
return Err(RelayError::ResourceLimit("decryption key history"));
}
if signer_public_keys.len() > options.protected_limits.max_signer_key_history {
return Err(RelayError::ResourceLimit("signer key history"));
}
let type_map = &metadata.type_map;
let decrypted = metadata.encrypted_content.decrypt_with_keyrings(
keyrings,
MtpProtectionPurpose::RelayContentEncryption.into(),
)?;
let decrypted = metadata
.encrypted_content
.decrypt_with_keyrings_and_limits(
keyrings,
MtpProtectionPurpose::RelayContentEncryption.into(),
options.decode_limits,
)?;
let signed = decrypted
.as_signed()
.ok_or(RelayError::InvalidLayout("content is not signed"))?;
@ -600,27 +946,60 @@ pub fn open_relay_content_with_keyrings(
}
// Content is a separately signed value and must not inherit a weaker
// metadata policy.
signed.verify_with_key_history(
signed.verify_with_key_history_and_limits(
metadata.signer_id,
signer_public_keys,
MtpProtectionPurpose::RelayContentSignature.into(),
policy,
options.policy,
options.encode_limits,
)?;
let content = signed
.value
.as_container()
.container_entries()
.ok_or(RelayError::InvalidLayout("content is not a container"))?;
let content = DataValue::Container(content);
let message_type = string_field(&content, DataType::MessageType, type_map)?;
let message_type = string_field(content, DataType::MessageType, type_map)?;
validate_application_message_type(&message_type, type_map)?;
Ok(VerifiedRelayContent {
signer_id: signed.signer_id,
final_recipient_id: metadata.final_recipient_id,
message_type,
content: field(&content, DataType::Content, type_map)?.clone(),
content: field(content, DataType::Content, type_map)?.clone(),
})
}
fn restrict_decode_limits(left: DecodeLimits, right: DecodeLimits) -> DecodeLimits {
DecodeLimits {
max_depth: left.max_depth.min(right.max_depth),
max_values: left.max_values.min(right.max_values),
max_blob_size: left.max_blob_size.min(right.max_blob_size),
max_recipients: left.max_recipients.min(right.max_recipients),
max_allocated_bytes: left.max_allocated_bytes.min(right.max_allocated_bytes),
}
}
fn restrict_encode_limits(left: EncodeLimits, right: EncodeLimits) -> EncodeLimits {
EncodeLimits {
max_depth: left.max_depth.min(right.max_depth),
max_values: left.max_values.min(right.max_values),
max_output_size: left.max_output_size.min(right.max_output_size),
}
}
fn restrict_protected_limits(left: ProtectedLimits, right: ProtectedLimits) -> ProtectedLimits {
ProtectedLimits {
max_message_id_bytes: left.max_message_id_bytes.min(right.max_message_id_bytes),
max_metadata_encoded_bytes: left
.max_metadata_encoded_bytes
.min(right.max_metadata_encoded_bytes),
max_signer_key_history: left
.max_signer_key_history
.min(right.max_signer_key_history),
max_decryption_key_history: left
.max_decryption_key_history
.min(right.max_decryption_key_history),
}
}
/// Change only the clear next-hop routing field of a sealed relay frame.
/// The authenticated encrypted payload is cloned byte-for-byte, so a relay
/// cannot alter the final recipient or message metadata while forwarding.
@ -636,13 +1015,78 @@ pub fn forward_relay_frame(
mod tests {
use super::*;
use crate::InMemoryReplayGuard;
use mtp_crypto::{Ed25519Signer, Keyring};
use mtp_crypto::{Ed25519Signer, Keyring, PublicKeyBundle};
use mtp_type_map::DataTypeId;
fn ed_signer(keyring: &Keyring) -> Ed25519Signer {
Ed25519Signer::new(&keyring.sig_cl_secret_key).expect("Ed25519 signer")
}
// Test-only compatibility shims keep older fixture setup readable while
// routing every invocation to an explicit replay choice in production.
fn open_relay_metadata(
frame: &CommunicationValue,
keyring: &Keyring,
expected_signer_id: u64,
signer_public_key: &PublicKeyBundle,
policy: ProtectionPolicy,
) -> Result<VerifiedRelayMetadata, RelayError> {
super::open_relay_metadata_without_replay(
frame,
keyring,
expected_signer_id,
signer_public_key,
RelayOpenOptions::new(policy),
)
}
fn open_relay_metadata_with<F>(
frame: &CommunicationValue,
keyrings: &[&Keyring],
expected_signer_id: Option<u64>,
resolve_signer_keys: F,
policy: ProtectionPolicy,
replay_guard: Option<&mut dyn ReplayGuard>,
) -> Result<VerifiedRelayMetadata, RelayError>
where
F: Fn(u64) -> Option<Vec<PublicKeyBundle>>,
{
match replay_guard {
Some(replay_guard) => super::open_relay_metadata_with_checked(
frame,
keyrings,
expected_signer_id,
resolve_signer_keys,
RelayOpenOptions::new(policy),
replay_guard,
),
None => super::open_relay_metadata_with_without_replay(
frame,
keyrings,
expected_signer_id,
resolve_signer_keys,
RelayOpenOptions::new(policy),
),
}
}
fn open_relay_metadata_with_keys(
frame: &CommunicationValue,
keyrings: &[&Keyring],
expected_signer_id: u64,
signer_public_keys: &[PublicKeyBundle],
policy: ProtectionPolicy,
) -> Result<VerifiedRelayMetadata, RelayError> {
let signer_public_keys = signer_public_keys.to_vec();
super::open_relay_metadata_with_limits_without_replay(
frame,
keyrings,
Some(expected_signer_id),
move |_| Some(signer_public_keys),
RelayOpenOptions::new(policy),
)
}
fn relay_frame_with_version(
version: Option<u64>,
include_v1_fields: bool,
@ -874,6 +1318,22 @@ mod tests {
assert_eq!(metadata.created_at(), 123);
assert_eq!(metadata.metadata(), Some(&application_metadata));
let mut limited_options = RelayOpenOptions::new(policy);
limited_options.protected_limits.max_message_id_bytes = 1;
let mut limited_guard = InMemoryReplayGuard::default();
assert!(matches!(
open_relay_metadata_with_limits_checked(
&frame,
&[&metadata_recipient],
None,
|signer_id| (signer_id == 7).then(|| vec![sender.public_key_bundle()]),
limited_options,
&mut limited_guard,
),
Err(RelayError::ResourceLimit("message ID"))
));
assert_eq!(limited_guard.len(), 0);
// A final recipient may be included in the metadata recipient set and
// therefore open both authenticated layers directly.
let final_metadata = open_relay_metadata(

View file

@ -41,6 +41,10 @@ pub enum CodecError {
InvalidEncoding,
#[error("Too many entries to encode")]
TooManyEntries,
#[error("Missing negotiated type map")]
MissingTypeMap,
#[error("Type-map mismatch: expected {expected}, actual {actual}")]
TypeMapMismatch { expected: String, actual: String },
#[error("Crypto failed: {0}")]
CryptoFailed(String),
#[error("Missing required field: {0}")]

View file

@ -8,7 +8,7 @@ ignored = ["rand_core"]
[dependencies]
chacha20poly1305 = { version = "0.10", optional = true }
aes-gcm = { version = "0.10", optional = true }
aes-gcm = { version = "0.11", optional = true }
ed25519-dalek = { version = "3.0", optional = true, features = [
"pkcs8",
"pem",
@ -23,6 +23,7 @@ rand = "0.10.2"
getrandom = "0.4.3"
mlkem-tls = { version = "0.2", optional = true }
ml-dsa = { version = "0.1.1", optional = true }
argon2 = { version = "0.5", optional = true }
serde = { version = "1", optional = true, features = ["derive"] }
rcgen = { version = "0.14", optional = true }
time = { version = "0.3", optional = true }
@ -43,3 +44,4 @@ hkdf = ["dep:hkdf", "dep:sha2"]
sha2 = ["dep:sha2"]
tls = ["dep:rcgen", "dep:time"]
parallel = ["dep:tokio"]
password-kdf = ["dep:argon2"]

View file

@ -6,6 +6,8 @@ pub enum CryptoError {
EncryptionFailed,
#[error("decryption failed")]
DecryptionFailed,
#[error("decryption output exceeds the caller's allocation limit")]
AllocationLimit,
#[error("malformed encryption envelope")]
MalformedEnvelope,
#[error("no encryption recipients")]

View file

@ -40,6 +40,114 @@ pub struct MultiEncryptedMessage {
pub ciphertext: Vec<u8>,
}
/// Borrowed view of a canonical encrypted envelope.
///
/// The codec uses this view while validating an attacker-controlled envelope
/// so parsing it does not first create a complete temporary copy of every
/// recipient entry and the ciphertext.
#[derive(Debug, Clone, Copy)]
pub struct MultiEncryptedMessageRef<'a> {
encryption_type: EncryptionType,
purpose: u8,
bytes: &'a [u8],
entries_start: usize,
entry_len: usize,
count: usize,
ciphertext_start: usize,
}
impl<'a> MultiEncryptedMessageRef<'a> {
pub fn from_bytes(bytes: &'a [u8]) -> Result<Self, CryptoError> {
if bytes.len() < 4 {
return Err(CryptoError::MalformedEnvelope);
}
let encryption_type =
EncryptionType::from_byte(bytes[0]).ok_or(CryptoError::UnknownAlgorithm)?;
let purpose = bytes[1];
let count = u16::from_be_bytes([bytes[2], bytes[3]]) as usize;
if count == 0 || count > MAX_RECIPIENTS {
return Err(CryptoError::MalformedEnvelope);
}
let entry_len = encryption_type
.kem_ciphertext_len()
.checked_add(encryption_type.wrapped_key_len())
.ok_or(CryptoError::MalformedEnvelope)?;
let entries_len = count
.checked_mul(entry_len)
.ok_or(CryptoError::MalformedEnvelope)?;
let entries_start = 4usize;
let ciphertext_start = entries_start
.checked_add(entries_len)
.ok_or(CryptoError::MalformedEnvelope)?;
let ciphertext_len = bytes
.len()
.checked_sub(ciphertext_start)
.ok_or(CryptoError::MalformedEnvelope)?;
if ciphertext_len < encryption_type.minimum_ciphertext_len() {
return Err(CryptoError::MalformedEnvelope);
}
Ok(Self {
encryption_type,
purpose,
bytes,
entries_start,
entry_len,
count,
ciphertext_start,
})
}
pub const fn encryption_type(&self) -> EncryptionType {
self.encryption_type
}
pub const fn purpose(&self) -> u8 {
self.purpose
}
pub const fn recipient_count(&self) -> usize {
self.count
}
pub fn recipient(&self, index: usize) -> Option<(&'a [u8], &'a [u8])> {
if index >= self.count {
return None;
}
let offset = self
.entries_start
.checked_add(index.checked_mul(self.entry_len)?)?;
let kem_len = self.encryption_type.kem_ciphertext_len();
let kem_end = offset.checked_add(kem_len)?;
let end = offset.checked_add(self.entry_len)?;
Some((
self.bytes.get(offset..kem_end)?,
self.bytes.get(kem_end..end)?,
))
}
pub fn ciphertext(&self) -> &'a [u8] {
&self.bytes[self.ciphertext_start..]
}
pub fn to_owned(&self) -> MultiEncryptedMessage {
let recipients = (0..self.count)
.filter_map(|index| {
let (kem_ciphertext, encrypted_key) = self.recipient(index)?;
Some(RecipientEntry {
kem_ciphertext: kem_ciphertext.to_vec(),
encrypted_key: encrypted_key.to_vec(),
})
})
.collect();
MultiEncryptedMessage {
encryption_type: self.encryption_type,
purpose: self.purpose,
recipients,
ciphertext: self.ciphertext().to_vec(),
}
}
}
impl MultiEncryptedMessage {
/// Serialize the envelope body without redundant per-recipient lengths.
pub fn to_bytes(&self) -> Result<Vec<u8>, CryptoError> {
@ -72,50 +180,7 @@ impl MultiEncryptedMessage {
/// Parse the canonical envelope body.
pub fn from_bytes(bytes: &[u8]) -> Result<Self, CryptoError> {
if bytes.len() < 4 {
return Err(CryptoError::MalformedEnvelope);
}
let encryption_type =
EncryptionType::from_byte(bytes[0]).ok_or(CryptoError::UnknownAlgorithm)?;
let purpose = bytes[1];
let count = u16::from_be_bytes([bytes[2], bytes[3]]) as usize;
if count == 0 || count > MAX_RECIPIENTS {
return Err(CryptoError::MalformedEnvelope);
}
let entry_len = encryption_type.kem_ciphertext_len() + encryption_type.wrapped_key_len();
let entries_len = count
.checked_mul(entry_len)
.ok_or(CryptoError::MalformedEnvelope)?;
let start = 4usize;
let end = start
.checked_add(entries_len)
.ok_or(CryptoError::MalformedEnvelope)?;
let ciphertext_len = bytes
.len()
.checked_sub(end)
.ok_or(CryptoError::MalformedEnvelope)?;
if ciphertext_len < encryption_type.minimum_ciphertext_len() {
return Err(CryptoError::MalformedEnvelope);
}
let mut offset = start;
let mut recipients = Vec::with_capacity(count);
for _ in 0..count {
let kem_end = offset + encryption_type.kem_ciphertext_len();
let wrapped_end = kem_end + encryption_type.wrapped_key_len();
recipients.push(RecipientEntry {
kem_ciphertext: bytes[offset..kem_end].to_vec(),
encrypted_key: bytes[kem_end..wrapped_end].to_vec(),
});
offset = wrapped_end;
}
Ok(Self {
encryption_type,
purpose,
recipients,
ciphertext: bytes[offset..].to_vec(),
})
Ok(MultiEncryptedMessageRef::from_bytes(bytes)?.to_owned())
}
}
@ -197,20 +262,51 @@ pub fn decrypt_multi_for(
purpose: u8,
keyring: &Keyring,
) -> Result<Vec<u8>, CryptoError> {
if message.recipients.is_empty()
|| message.recipients.len() > MAX_RECIPIENTS
|| message.purpose != purpose
|| message.ciphertext.len() < message.encryption_type.minimum_ciphertext_len()
|| message.recipients.iter().any(|recipient| {
recipient.kem_ciphertext.len() != message.encryption_type.kem_ciphertext_len()
|| recipient.encrypted_key.len() != message.encryption_type.wrapped_key_len()
decrypt_multi_for_parts(
message.encryption_type,
message.purpose,
&message.recipients,
&message.ciphertext,
purpose,
keyring,
)
}
/// Decrypt an envelope represented by borrowed recipient and ciphertext
/// slices. This keeps protected-value opening from cloning an already-owned
/// envelope solely to call the cryptographic primitive.
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub fn decrypt_multi_for_parts(
encryption_type: EncryptionType,
envelope_purpose: u8,
recipients: &[RecipientEntry],
ciphertext: &[u8],
purpose: u8,
keyring: &Keyring,
) -> Result<Vec<u8>, CryptoError> {
if recipients.is_empty()
|| recipients.len() > MAX_RECIPIENTS
|| envelope_purpose != purpose
|| ciphertext.len() < encryption_type.minimum_ciphertext_len()
|| recipients.iter().any(|recipient| {
recipient.kem_ciphertext.len() != encryption_type.kem_ciphertext_len()
|| recipient.encrypted_key.len() != encryption_type.wrapped_key_len()
})
{
return Err(CryptoError::MalformedEnvelope);
}
let payload_aad = payload_aad(message)?;
for entry in &message.recipients {
let count = u16::try_from(recipients.len()).map_err(|_| CryptoError::MalformedEnvelope)?;
let mut payload_aad = Vec::new();
payload_aad.extend_from_slice(ENCRYPT_DOMAIN);
payload_aad.push(encryption_type.to_byte());
payload_aad.push(envelope_purpose);
payload_aad.extend_from_slice(&count.to_be_bytes());
for entry in recipients {
payload_aad.extend_from_slice(&entry.kem_ciphertext);
payload_aad.extend_from_slice(&entry.encrypted_key);
}
for entry in recipients {
let shared_secret =
match HybridKem::decapsulate(&keyring.kem_secret_key, &entry.kem_ciphertext) {
Ok(secret) => secret,
@ -219,30 +315,51 @@ pub fn decrypt_multi_for(
let wrap_key = Zeroizing::new(derive_encryption_key(
&shared_secret,
KEY_WRAP_DOMAIN,
&[message.encryption_type.to_byte(), purpose],
&[encryption_type.to_byte(), purpose],
)?);
let aad = wrap_aad(message.encryption_type, purpose, &entry.kem_ciphertext);
let cek = match open_with_key(
message.encryption_type,
*wrap_key,
&entry.encrypted_key,
&aad,
) {
let aad = wrap_aad(encryption_type, purpose, &entry.kem_ciphertext);
let cek = match open_with_key(encryption_type, *wrap_key, &entry.encrypted_key, &aad) {
Ok(key) => key,
Err(_) => continue,
};
let cek: [u8; 32] = cek.try_into().map_err(|_| CryptoError::DecryptionFailed)?;
return open_with_key(
message.encryption_type,
cek,
&message.ciphertext,
&payload_aad,
);
return open_with_key(encryption_type, cek, ciphertext, &payload_aad);
}
Err(CryptoError::NoMatchingRecipient)
}
/// Decrypt a canonical envelope only when its plaintext can fit inside the
/// caller's allocation budget.
///
/// The AEAD implementation allocates its output buffer internally. Checking
/// the ciphertext upper bound before entering that implementation makes the
/// codec's reservation meaningful instead of merely checking the result
/// after the allocation has already happened.
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub fn decrypt_multi_for_parts_with_limit(
encryption_type: EncryptionType,
envelope_purpose: u8,
recipients: &[RecipientEntry],
ciphertext: &[u8],
purpose: u8,
keyring: &Keyring,
max_plaintext_len: usize,
) -> Result<Vec<u8>, CryptoError> {
if ciphertext.len() > max_plaintext_len {
return Err(CryptoError::AllocationLimit);
}
decrypt_multi_for_parts(
encryption_type,
envelope_purpose,
recipients,
ciphertext,
purpose,
keyring,
)
}
#[cfg(test)]
mod tests {
use super::*;
@ -325,4 +442,30 @@ mod tests {
Err(CryptoError::MalformedEnvelope)
));
}
#[cfg(all(feature = "mlkem-tls", feature = "hkdf", feature = "chacha20poly1305"))]
#[test]
fn bounded_decryption_rejects_before_plaintext_allocation() -> Result<(), CryptoError> {
let recipient = Keyring::generate();
let message = encrypt_multi_for(
EncryptionType::MlKemChaCha20Poly1305,
1,
b"bounded plaintext",
&[recipient.public_key_bundle()],
)?;
assert!(matches!(
decrypt_multi_for_parts_with_limit(
message.encryption_type,
message.purpose,
&message.recipients,
&message.ciphertext,
message.purpose,
&recipient,
message.ciphertext.len() - 1,
),
Err(CryptoError::AllocationLimit)
));
Ok(())
}
}

View file

@ -36,3 +36,29 @@ pub fn derive_encryption_key(
out.copy_from_slice(&key);
Ok(out)
}
#[cfg(feature = "password-kdf")]
pub fn derive_password_key(
passphrase: &[u8],
salt: &[u8],
memory_kib: u32,
iterations: u32,
lanes: u32,
) -> Result<[u8; 32], CryptoError> {
if passphrase.is_empty()
|| salt.len() < 16
|| !(8 * 1024..=256 * 1024).contains(&memory_kib)
|| !(1..=10).contains(&iterations)
|| !(1..=8).contains(&lanes)
{
return Err(CryptoError::KdfError);
}
let params = argon2::Params::new(memory_kib, iterations, lanes, Some(32))
.map_err(|_| CryptoError::KdfError)?;
let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = [0u8; 32];
argon
.hash_password_into(passphrase, salt, &mut key)
.map_err(|_| CryptoError::KdfError)?;
Ok(key)
}

View file

@ -340,9 +340,9 @@ impl Keyring {
Ok(out)
}
pub fn to_bytes(&self) -> Zeroizing<Vec<u8>> {
#[deprecated(note = "use try_to_bytes for the primary fallible serializer")]
pub fn to_bytes(&self) -> Result<Zeroizing<Vec<u8>>, crate::error::CryptoError> {
self.try_to_bytes()
.expect("key material length exceeds wire limit")
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, crate::error::CryptoError> {
@ -383,16 +383,26 @@ impl Keyring {
})
}
pub fn to_hex(&self) -> String {
bytes_to_hex(&self.to_bytes())
#[deprecated(note = "use try_to_hex for the primary fallible serializer")]
pub fn to_hex(&self) -> Result<String, crate::error::CryptoError> {
self.try_to_hex()
}
pub fn try_to_hex(&self) -> Result<String, crate::error::CryptoError> {
Ok(bytes_to_hex(&self.try_to_bytes()?))
}
pub fn from_hex(s: &str) -> Result<Self, crate::error::CryptoError> {
Self::from_bytes(&hex_to_bytes(s)?)
}
pub fn to_base64(&self) -> String {
bytes_to_base64(&self.to_bytes())
#[deprecated(note = "use try_to_base64 for the primary fallible serializer")]
pub fn to_base64(&self) -> Result<String, crate::error::CryptoError> {
self.try_to_base64()
}
pub fn try_to_base64(&self) -> Result<String, crate::error::CryptoError> {
Ok(bytes_to_base64(&self.try_to_bytes()?))
}
pub fn from_base64(s: &str) -> Result<Self, crate::error::CryptoError> {
@ -507,9 +517,9 @@ impl PublicKeyBundle {
Ok(out)
}
pub fn as_bytes(&self) -> Vec<u8> {
#[deprecated(note = "use try_as_bytes for the primary fallible serializer")]
pub fn as_bytes(&self) -> Result<Vec<u8>, crate::error::CryptoError> {
self.try_as_bytes()
.expect("public key bundle field exceeds wire limit")
}
/// Parse a complete suite-compatible public bundle.
@ -582,8 +592,13 @@ impl PublicKeyBundle {
Self::from_bytes(bytes)
}
pub fn to_base64(&self) -> String {
bytes_to_base64(&self.as_bytes())
#[deprecated(note = "use try_to_base64 for the primary fallible serializer")]
pub fn to_base64(&self) -> Result<String, crate::error::CryptoError> {
self.try_to_base64()
}
pub fn try_to_base64(&self) -> Result<String, crate::error::CryptoError> {
Ok(bytes_to_base64(&self.try_as_bytes()?))
}
pub fn from_base64(s: &str) -> Result<Self, crate::error::CryptoError> {
@ -602,12 +617,6 @@ impl TryFrom<&[u8]> for PublicKeyBundle {
}
}
impl From<&PublicKeyBundle> for Vec<u8> {
fn from(bundle: &PublicKeyBundle) -> Vec<u8> {
bundle.as_bytes()
}
}
impl fmt::Debug for PublicKeyBundle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PublicKeyBundle")
@ -629,7 +638,7 @@ mod tests {
let cl = SignaturePublicKey::new(vec![3u8; 32]);
let bundle = PublicKeyBundle::new(kem, pq, cl);
let bytes = bundle.as_bytes();
let bytes = bundle.try_as_bytes()?;
let recovered = PublicKeyBundle::from_bytes_unvalidated(&bytes)?;
assert_eq!(
@ -672,9 +681,9 @@ mod tests {
SignaturePqPublicKey::new(vec![0xCDu8; 96]),
SignaturePublicKey::new(vec![0xEFu8; 32]),
);
let bytes: Vec<u8> = Vec::from(&bundle);
let bytes = bundle.try_as_bytes()?;
let recovered = PublicKeyBundle::from_bytes_unvalidated(bytes.as_slice())?;
assert_eq!(bundle.as_bytes(), recovered.as_bytes());
assert_eq!(bundle.try_as_bytes()?, recovered.try_as_bytes()?);
Ok(())
}
@ -688,8 +697,8 @@ mod tests {
SignaturePublicKey::new(vec![5u8; 32]),
SignaturePrivateKey::new(vec![6u8; 32]),
);
let bytes = keyring.to_bytes();
let recovered = Keyring::from_bytes(&bytes)?;
let bytes = keyring.try_to_bytes()?;
let recovered = Keyring::from_bytes(bytes.as_slice())?;
assert_eq!(
keyring.kem_public_key.as_bytes(),
recovered.kem_public_key.as_bytes()
@ -722,7 +731,7 @@ mod tests {
}
#[test]
fn canonical_key_parsers_reject_trailing_bytes() {
fn canonical_key_parsers_reject_trailing_bytes() -> Result<(), Box<dyn std::error::Error>> {
let keyring = Keyring::new(
KemPublicKey::new(vec![1u8; 16]),
KemPrivateKey::new(vec![2u8; 16]),
@ -731,25 +740,40 @@ mod tests {
SignaturePublicKey::new(vec![5u8; 16]),
SignaturePrivateKey::new(vec![6u8; 16]),
);
let mut keyring_bytes = keyring.to_bytes().to_vec();
let mut keyring_bytes = keyring.try_to_bytes()?.to_vec();
keyring_bytes.push(0xAA);
assert!(Keyring::from_bytes(&keyring_bytes).is_err());
let bundle = keyring.public_key_bundle();
let mut bundle_bytes = bundle.as_bytes();
let mut bundle_bytes = bundle.try_as_bytes()?;
bundle_bytes.push(0xBB);
assert!(PublicKeyBundle::from_bytes(&bundle_bytes).is_err());
Ok(())
}
#[test]
fn validated_bundle_rejects_partial_suite_keys() {
fn public_key_bundle_try_as_bytes_rejects_fields_larger_than_wire_length() {
let bundle = PublicKeyBundle::new(
KemPublicKey::new(vec![0u8; 65_536]),
SignaturePqPublicKey::new(Vec::new()),
SignaturePublicKey::new(Vec::new()),
);
assert!(matches!(
bundle.try_as_bytes(),
Err(crate::error::CryptoError::InvalidKeyLength)
));
}
#[test]
fn validated_bundle_rejects_partial_suite_keys() -> Result<(), Box<dyn std::error::Error>> {
let bundle = PublicKeyBundle::new(
KemPublicKey::new(vec![1u8; 32]),
SignaturePqPublicKey::new(vec![2u8; 64]),
SignaturePublicKey::new(vec![3u8; 32]),
);
assert!(bundle.validate().is_err());
assert!(PublicKeyBundle::from_bytes_validated(&bundle.as_bytes()).is_err());
assert!(PublicKeyBundle::from_bytes_validated(&bundle.try_as_bytes()?).is_err());
Ok(())
}
#[test]
@ -762,9 +786,9 @@ mod tests {
SignaturePublicKey::new(vec![4u8; 16]),
SignaturePrivateKey::new(vec![5u8; 16]),
);
let bytes = keyring.to_bytes();
let bytes = keyring.try_to_bytes()?;
let recovered = Keyring::try_from(bytes.as_slice())?;
assert_eq!(keyring.to_bytes(), recovered.to_bytes());
assert_eq!(keyring.try_to_bytes()?, recovered.try_to_bytes()?);
Ok(())
}
@ -788,9 +812,9 @@ mod tests {
SignaturePublicKey::new(vec![5u8; 16]),
SignaturePrivateKey::new(vec![6u8; 16]),
);
let hex = keyring.to_hex();
let hex = keyring.try_to_hex()?;
let recovered = Keyring::from_hex(&hex)?;
assert_eq!(keyring.to_bytes(), recovered.to_bytes());
assert_eq!(keyring.try_to_bytes()?, recovered.try_to_bytes()?);
Ok(())
}
@ -804,9 +828,9 @@ mod tests {
SignaturePublicKey::new(vec![5u8; 16]),
SignaturePrivateKey::new(vec![6u8; 16]),
);
let b64 = keyring.to_base64();
let b64 = keyring.try_to_base64()?;
let recovered = Keyring::from_base64(&b64)?;
assert_eq!(keyring.to_bytes(), recovered.to_bytes());
assert_eq!(keyring.try_to_bytes()?, recovered.try_to_bytes()?);
Ok(())
}
@ -817,9 +841,9 @@ mod tests {
SignaturePqPublicKey::new(vec![2u8; 64]),
SignaturePublicKey::new(vec![3u8; 32]),
);
let b64 = bundle.to_base64();
let b64 = bundle.try_to_base64()?;
let recovered = PublicKeyBundle::from_base64_unvalidated(&b64)?;
assert_eq!(bundle.as_bytes(), recovered.as_bytes());
assert_eq!(bundle.try_as_bytes()?, recovered.try_as_bytes()?);
Ok(())
}

View file

@ -60,6 +60,8 @@ pub use sign::{DualSignature, DualSigner, sign_dual};
#[cfg(feature = "sha2")]
pub use hash::{Sha256Hasher, sha256, sha256_double};
#[cfg(feature = "password-kdf")]
pub use kdf::derive_password_key;
#[cfg(feature = "hkdf")]
pub use kdf::{derive_encryption_key, hkdf_expand, hkdf_extract};
@ -83,7 +85,9 @@ pub use helper::{ENCRYPT_DOMAIN, KEY_WRAP_DOMAIN};
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
pub use helper::{
MAX_RECIPIENTS, MultiEncryptedMessage, RecipientEntry, decrypt_multi_for, encrypt_multi_for,
MAX_RECIPIENTS, MultiEncryptedMessage, MultiEncryptedMessageRef, RecipientEntry,
decrypt_multi_for, decrypt_multi_for_parts, decrypt_multi_for_parts_with_limit,
encrypt_multi_for,
};
/* ================================ TESTS ================================ */
@ -311,7 +315,9 @@ mod tests {
#[test]
fn keyring_serialize_roundtrip() {
let kr = Keyring::generate();
let bytes = kr.to_bytes();
let bytes = kr
.try_to_bytes()
.expect("keyring serialization should succeed");
let loaded = Keyring::from_bytes(&bytes).expect("keyring roundtrip should succeed");
assert_eq!(
kr.kem_public_key.as_bytes(),
@ -332,7 +338,9 @@ mod tests {
fn public_key_bundle_serialize_roundtrip() {
let kr = Keyring::generate();
let bundle = kr.public_key_bundle();
let bytes = bundle.as_bytes();
let bytes = bundle
.try_as_bytes()
.expect("bundle serialization should succeed");
let loaded = PublicKeyBundle::from_bytes(&bytes).expect("bundle roundtrip should succeed");
assert_eq!(
bundle.kem_public_key.as_bytes(),

View file

@ -142,7 +142,7 @@ let conn = MTPClient::auth_register(config, &keyring, &host_pk).await?;
// Save for next session
let id = conn.client_id;
let keyring_bytes = keyring.to_bytes();
let keyring_bytes = keyring.try_to_bytes()?;
```
When callers already know whether a saved client ID exists, the convenience helper uses `Some(id)` for login and `None` for registration:
@ -175,7 +175,7 @@ pub struct Keyring {
}
```
- Serialise: `keyring.to_bytes()` -> `Vec<u8>`
- Serialise: `keyring.try_to_bytes()` -> `Result<Zeroizing<Vec<u8>>, CryptoError>`
- Deserialise: `Keyring::from_bytes(&bytes)` -> `Result<Keyring, CryptoError>`
- Get public half: `keyring.public_key_bundle()` -> `PublicKeyBundle`

View file

@ -212,7 +212,7 @@ let (kem_sk, kem_pk) = HybridKem::generate_keypair();
let host_keyring = Keyring::new(kem_pk, kem_sk, sig_pq_pk, sig_pq_sk, sig_pk, sig_sk);
// Save to disk
let bytes = host_keyring.to_bytes();
let bytes = host_keyring.try_to_bytes()?;
std::fs::write("host_keys.bin", bytes)?;
```

View file

@ -55,8 +55,28 @@ 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.
the replay-explicit `open_protected_checked` or `open_protected_without_replay`
APIs; language bindings delegate envelope construction and opening to this
codec boundary.
Message processing uses the replay-required native APIs
`open_protected_checked` and `open_relay_metadata_checked` (or the equivalent
browser client path). Stored-message or forensic tooling must opt into the
explicit `*_without_replay` APIs. Native in-memory guards are bounded and
configurable; durable guards must perform an atomic insert-if-absent on
`(signer ID, MessageId)`.
Protected identifiers have semantic limits separate from the generic codec
blob limit. The default maximum `MessageId` is 256 UTF-8 bytes and relay
metadata is limited to 1 MiB of encoded metadata. Deployments can provide
stricter limits through the receive policy. Limits are checked after
authentication and before retained values enter replay or application state.
Transport-derived resource policies use a conservative decoder allocation
factor of `4 * max_message_size`, in addition to the frame-size output limit.
This factor accounts for owned wrapper, recipient, ciphertext, and decoded
value copies; it is an implementation admission policy rather than a wire
field.
## Authentication Flow
@ -77,6 +97,15 @@ Client Host
Login proof binds the protocol version, client ID, host challenge, and client nonce. Registration proof binds the protocol version, public key bundle, host challenge, and client nonce. The host challenge is generated per connection.
Authentication attempts pass through a deployment-configurable limiter before
client lookup, key validation, challenge signing, or registration callbacks.
The default host configuration uses a bounded in-memory window. Hosts may key
limits by connection, peer identity, claimed client ID, or registration flow.
When identity concealment is enabled, an unknown client ID follows a dummy
challenge/proof path and receives the same generic authentication failure as a
known client with an invalid proof; disabling concealment restores the legacy
identity-specific response for deployments where IDs are public.
`ForceAuthentication` requires login or registration. `AllowAuthentication` accepts authenticated and unauthenticated clients. `Unauthenticated` rejects authentication attempts. The connection states are `Pending`, `Authenticated`, `Unauthenticated`, and `Failed`.
## Version Negotiation

View file

@ -160,6 +160,14 @@ process boundaries. A guard should atomically record a new ID before
dispatching application content. Transport frame IDs must not be used for
this purpose.
Native message-processing boundaries require a replay guard through the
checked opening APIs. Reopening stored or forensic frames without a guard is
available only through an explicitly named `without_replay` API. The reference
in-memory guard is bounded and FIFO-evicts old entries, so it is a duplicate
suppression cache rather than durable replay protection. A durable deployment
must use an atomic insert-if-absent operation keyed by `(signer ID, MessageId)`;
a separate read followed by insert is race-prone.
`VerifiedRelayMetadata` is an authenticated capability rather than a caller
constructed data transfer object. Rust fields are private and the browser
implementation keeps authenticated state behind a branded class. Content
@ -174,9 +182,11 @@ fallback.
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
an explicit `ed25519` default and permits an operation or client override. Its
`MTPSecurityProfile` resolves protected-message sender/receiver suites,
encrypted-pipe suites, and the authentication PQ requirement together;
`any-supported` remains an explicit compatibility value. It never derives
receive policy from the recipient keyring. Signature policy must be applied
independently to relay metadata, relay content, and pipe session establishment.
### Key history and rotation
@ -261,16 +271,43 @@ to proceed with an invalid local decryption key.
Applications remain responsible for storage at rest. The `files` feature writes passphrase-protected keyrings to `.mk` files and public bundles to `.mpkb` files. Protected `.mk` files store the Argon2id identifier, parameters, salt, and AEAD ciphertext; they do not derive their key with HKDF. On Unix, keyring files are created with owner-only `0600` permissions.
Restrict those files to the owning account and protect backups. Browser applications should treat the configured credential storage as sensitive application data.
Key-material parsing is explicit in the SDK: use the hex, Base64, or byte
helpers for encoded key material. Arbitrary strings are no longer treated as
passphrases by the compatibility `secretKeyFromString` helper. Applications
migrating data written by the old implicit-HKDF behavior can use the explicitly
named, deprecated `legacySecretKeyFromStringV1` helper only for that migration;
new data must not use it. Passwords must use the explicit Argon2id passphrase
API with a stored per-record salt and versioned parameters. The SDK's
`deriveKeyFromPassphrase` uses a worker when browser workers are available;
the explicitly named `deriveKeyFromPassphraseSync` form is for workers and
command-line migrations. HKDF helpers are for high-entropy key material and
are not password-hardening functions.
## Resource Limits and Operational Controls
`Policy::default()` sets a 16 MiB application message limit and a 64 KiB handshake message limit. It also sets a 30 second read timeout, a 30 second maximum idle timeout, a receiver queue capacity of 1000, and a maximum of 128 concurrent stream tasks. Tune these values for the deployment and peer trust level.
The recursive codec applies additional defaults while parsing untrusted values:
maximum nesting depth 64, 65,536 value nodes, 16 MiB per blob or envelope,
and 64 encrypted recipients. Decrypted values are parsed with the same limits.
64 encrypted recipients, and a 64 MiB cumulative decoder allocation budget.
Decrypted values are parsed with the same limits. Transport derives the blob,
allocation, and encoder output budgets from its admitted frame size rather than
serializing an unrestricted recursive value first. The default transport
allocation budget is four times the admitted frame size to cover conservative
owned-copy and crypto-buffer accounting; deployments may choose another
factor with `DecodeLimits::for_transport_message_size_with_allocation_factor`.
The host 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.
The host applies an authentication-attempt limiter before storage lookups,
public-key validation, challenge signing, and registration callbacks. The
default limiter is a bounded in-memory sliding window; configure a durable or
distributed limiter when limits must coordinate across host instances. Unknown
client IDs are sent through a fixed dummy challenge/proof path by default, so
they receive a generic authentication failure instead of an enumeration hint.
Deployments that intentionally publish client IDs can disable this concealment.
Keepalive Pong observation is bounded and accepts only the currently pending
ping ID. Unsolicited Pongs are dropped before they can consume application
receiver capacity.
## Security Limitations

View file

@ -1,6 +1,8 @@
# Type Map
This file documents the Type Map & Registry configuration used by the MTP protocol. It will assume you are working with the [example-type-maps.yaml](./../example-type-maps.yaml).
A type musn't be the version of MTP, it stays independant.
MTP version defines the codec. The Type-Map version defines the available Types.
## Binary Frame Format
@ -85,6 +87,15 @@ The envelope length counts the bytes after the length field. A recipient entry i
Protection nesting directly represents both signer-visibility choices: `Encrypted(Signed(Container))` keeps signer metadata private, while `Signed(Encrypted(Container))` exposes it. A frame with no outer sender and an `Encrypted(Signed(Container))` payload uses sealed sender. Sealed sender adds no flag or distinct wire type.
### Container ordering and signatures
Container entries are ordered sequences in the current format. Insertion order
is therefore semantic: two containers with the same field/value pairs in a
different order have different serialized bytes and different signatures. The
decoder rejects duplicate field IDs. Applications that need map semantics must
canonicalize their own input before signing; a future canonical map encoding
requires a protocol-format version and cannot be inferred by a receiver.
## TypeMap & Compile-Time Type Safety
A `TypeMap` maps Communication-Types and Data-Types to their wire IDs. Each protocol version has its own `TypeMap` because the same type name may use different wire IDs in different versions.
@ -175,17 +186,33 @@ mtp = { path = "..", features = ["host"] }
```rust
use mtp::codec::registry::{Registry, VersionedCodec};
use mtp::codec::{CommunicationType, CommunicationValue, DataValue};
use mtp_type_map::Version;
let registry = Registry::builtin();
let codec = VersionedCodec::new(registry);
let codec = VersionedCodec::for_version(registry, Version(3, 0)).unwrap();
let value = CommunicationValue::new_with_type_map(
CommunicationType::Ping,
codec.type_map(),
).with_payload(DataValue::Null);
// Encode with a specific version
let bytes = codec.encode(&value, Version(3, 0)).unwrap();
// The value must retain the negotiated map used to construct it.
let bytes = codec.encode(&value).unwrap();
// Decode with a specific version
let decoded = codec.decode(&bytes, Version(3, 0)).unwrap();
let decoded = codec.decode(&bytes).unwrap();
// A clear value can be migrated explicitly when the application has chosen
// that behavior. Protected values are not silently remapped.
let migrated = codec.encode_migrating(&value).unwrap();
```
`VersionedCodec::encode` compares the retained map identity (its protocol
version) and returns `CodecError::MissingTypeMap` or
`CodecError::TypeMapMismatch` on failure. `reply_to` retains the request's
map, while `try_merge` rejects frames from different maps before copying any
fields. The deprecated `merge` method records the error for compatibility; new
code should migrate to `try_merge` and handle the result.
## Customizing Type Maps in Downstream Projects
External projects must provide their own type map configuration. Browser projects use the Vite plugin from [Defining Type Maps](#defining-type-maps) and do not need to publish, fork, or copy a generated WASM package.

View file

@ -3,8 +3,9 @@ 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,
ProtectionPurpose, SealedRelayBuilder, SignaturePolicy, TypeMap,
open_relay_content_with_limits_without_replay,
open_relay_metadata_without_replay,
};
use mtp::common::unix_time_millis;
use mtp::crypto::{Ed25519Signer, Keyring, PublicKeyBundle};
@ -159,7 +160,7 @@ pub async fn send_sealed_relay(
return Err("relay forwarding changed the sealed-sender boundary".into());
}
let metadata = open_relay_metadata(
let metadata = open_relay_metadata_without_replay(
&forwarded,
&final_recipient_keyring,
signer_id,
@ -169,7 +170,7 @@ pub async fn send_sealed_relay(
let application_metadata = metadata
.metadata()
.ok_or("forwarded relay metadata was missing")?;
let content = open_relay_content(
let content = open_relay_content_with_limits_without_replay(
&metadata,
&final_recipient_keyring,
&signer_keyring.public_key_bundle(),

View file

@ -17,14 +17,22 @@ fn main() -> Result<(), files::FileError> {
/* Read both back to confirm the files round-trip through the on-disk format. */
let loaded_keyring = load_keyring_raw(&keyring_path)?;
let loaded_bundle = load_public_key_bundle(&bundle_path)?;
assert_eq!(keyring.to_bytes(), loaded_keyring.to_bytes());
assert_eq!(keyring.try_to_bytes()?, loaded_keyring.try_to_bytes()?);
let bundle_bytes = keyring.public_key_bundle().try_as_bytes()?;
let loaded_bundle_bytes = loaded_bundle.try_as_bytes()?;
assert_eq!(
keyring.public_key_bundle().as_bytes(),
loaded_bundle.as_bytes()
bundle_bytes,
loaded_bundle_bytes
);
println!(
"\nPrivateKeyRing (base64):\n{}",
keyring.try_to_base64()?
);
println!("\nPrivateKeyRing (base64):\n{}", keyring.to_base64());
println!("\nPublicKeyBundle (base64):\n{}", loaded_bundle.to_base64());
println!(
"\nPublicKeyBundle (base64):\n{}",
loaded_bundle.try_to_base64()?
);
println!("Wrote keyring -> {}", keyring_path.display());
println!("Wrote bundle -> {}", bundle_path.display());

View file

@ -3,7 +3,9 @@ use std::collections::HashMap;
use mtp::codec::{
CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, InMemoryReplayGuard,
ProtectedOpenOptions, ProtectionPolicy, ProtectionPurpose, SignaturePolicy, TypeMap,
forward_relay_frame, open_protected_with, open_relay_content, open_relay_metadata_with,
forward_relay_frame, open_protected_with_checked,
open_relay_content_with_limits_without_replay,
open_relay_metadata_with_checked,
};
use mtp::crypto::{Keyring, PublicKeyBundle};
@ -65,7 +67,7 @@ fn process_direct_protected(
));
}
let opened = open_protected_with(
let opened = open_protected_with_checked(
msg,
std::slice::from_ref(&host_keyring),
None,
@ -76,7 +78,7 @@ fn process_direct_protected(
ProtectionPurpose::from(DIRECT_ENCRYPTION_PURPOSE),
SIGNATURE_POLICY,
),
Some(accepted_messages),
accepted_messages,
)
.map_err(|e| format!("direct protected message could not be authenticated: {e}"))?;
let signer_id = opened.signer_id;
@ -133,7 +135,7 @@ fn process_sealed_relay(
));
}
let metadata = open_relay_metadata_with(
let metadata = open_relay_metadata_with_checked(
msg,
std::slice::from_ref(&host_keyring),
None,
@ -141,7 +143,7 @@ fn process_sealed_relay(
resolve_signer_key(signer_id, registered_clients).map(|key| vec![key])
},
SIGNATURE_POLICY,
Some(accepted_messages),
accepted_messages,
)
.map_err(|e| format!("metadata relay could not authenticate metadata: {e}"))?;
println!(
@ -160,7 +162,7 @@ fn process_sealed_relay(
.len()
);
let content_result = open_relay_content(
let content_result = open_relay_content_with_limits_without_replay(
&metadata,
host_keyring,
&resolve_signer_key(metadata.signer_id(), registered_clients)

View file

@ -27,7 +27,7 @@ pub async fn export_host_public_keys(
save_public_key_bundle(&bundle, "host.mpkb")?;
/* The web client fetches the bundle as hex over HTTP. */
let bundle_hex = hex::encode(bundle.as_bytes());
let bundle_hex = hex::encode(bundle.try_as_bytes()?);
fs::write("host_public_key_bundle.hex", &bundle_hex).await?;
fs::create_dir_all("web-client/public").await?;
fs::write("web-client/public/host_public_key_bundle.hex", &bundle_hex).await?;

View file

@ -111,8 +111,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}) as Pin<Box<dyn Future<Output = u64> + Send>>
};
let decrypt_keyring_bytes = host_keyring.try_to_bytes()?;
let decrypt_keyring = Arc::new(
match mtp::crypto::Keyring::from_bytes(&host_keyring.to_bytes()) {
match mtp::crypto::Keyring::from_bytes(&decrypt_keyring_bytes) {
Ok(keyring) => keyring,
Err(e) => {
return Err(format!("failed to re-load host keyring for decryption: {e}").into());

View file

@ -6,8 +6,7 @@ edition = "2024"
[dependencies]
# Only the plain key types (`Keyring`, `PublicKeyBundle`, `CryptoError`) are
# needed here; those are always compiled, so no crypto features are required.
mtp-crypto = { version = "0.3.0", path = "../crypto", default-features = false, features = ["chacha20poly1305", "hkdf"] }
argon2 = "0.5"
mtp-crypto = { version = "0.3.0", path = "../crypto", default-features = false, features = ["chacha20poly1305", "hkdf", "password-kdf"] }
rand = "0.10.2"
thiserror = "2"

View file

@ -119,6 +119,20 @@ fn temporary_path(path: &Path, attempt: u64) -> io::Result<PathBuf> {
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[cfg(unix)]
fn sync_parent_directory(path: &Path) -> io::Result<()> {
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
fs::File::open(parent)?.sync_all()
}
#[cfg(not(unix))]
fn sync_parent_directory(_path: &Path) -> io::Result<()> {
Ok(())
}
fn write_secret_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
use std::io::Write;
@ -146,7 +160,7 @@ fn write_secret_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
let _ = fs::remove_file(&temporary);
return Err(error);
}
Ok(())
sync_parent_directory(path)
}
fn derive_key(
@ -156,21 +170,12 @@ fn derive_key(
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)
{
if salt.len() != SALT_LEN {
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)
Ok(Zeroizing::new(mtp_crypto::derive_password_key(
passphrase, salt, memory_kib, iterations, lanes,
)?))
}
fn protected_header_aad(parameters: &[u8]) -> Vec<u8> {
@ -206,7 +211,7 @@ pub fn save_keyring(
parameters.extend_from_slice(&ARGON2_LANES.to_be_bytes());
parameters.extend_from_slice(&salt);
let cipher = ChaCha20Poly1305::new(*key);
let plaintext = keyring.to_bytes();
let plaintext = keyring.try_to_bytes()?;
let encrypted = cipher.encrypt(&plaintext, &protected_header_aad(&parameters))?;
let mut payload = Vec::with_capacity(PROTECTED_PARAMS_LEN + encrypted.len());
payload.extend_from_slice(&parameters);
@ -259,7 +264,7 @@ pub fn load_keyring(path: impl AsRef<Path>, passphrase: &[u8]) -> Result<Keyring
/// Explicitly save the legacy plaintext format for tests and development.
#[cfg(any(test, feature = "raw"))]
pub fn save_keyring_raw(keyring: &Keyring, path: impl AsRef<Path>) -> Result<(), FileError> {
let payload = keyring.to_bytes();
let payload = keyring.try_to_bytes()?;
let bytes = Zeroizing::new(encode(KEYRING_MAGIC, RAW_FORMAT_VERSION, &payload));
write_secret_atomic(path.as_ref(), &bytes)?;
Ok(())
@ -286,7 +291,8 @@ pub fn save_public_key_bundle(
bundle: &PublicKeyBundle,
path: impl AsRef<Path>,
) -> Result<(), FileError> {
let bytes = encode(BUNDLE_MAGIC, BUNDLE_FORMAT_VERSION, &bundle.as_bytes());
let bundle_bytes = bundle.try_as_bytes()?;
let bytes = encode(BUNDLE_MAGIC, BUNDLE_FORMAT_VERSION, &bundle_bytes);
fs::write(path, bytes)?;
Ok(())
}
@ -339,7 +345,7 @@ mod tests {
let keyring = sample_keyring();
save_keyring(&keyring, &path, b"correct horse battery staple")?;
let loaded = load_keyring(&path, b"correct horse battery staple")?;
assert_eq!(keyring.to_bytes(), loaded.to_bytes());
assert_eq!(keyring.try_to_bytes()?, loaded.try_to_bytes()?);
let _ = fs::remove_file(&path);
Ok(())
}
@ -350,7 +356,7 @@ mod tests {
let bundle = Keyring::generate().public_key_bundle();
save_public_key_bundle(&bundle, &path)?;
let loaded = load_public_key_bundle(&path)?;
assert_eq!(bundle.as_bytes(), loaded.as_bytes());
assert_eq!(bundle.try_as_bytes()?, loaded.try_as_bytes()?);
let _ = fs::remove_file(&path);
Ok(())
}
@ -414,7 +420,7 @@ mod tests {
Err(FileError::UnprotectedKeyring)
));
let loaded = load_keyring_raw(&path)?;
assert_eq!(keyring.to_bytes(), loaded.to_bytes());
assert_eq!(keyring.try_to_bytes()?, loaded.try_to_bytes()?);
let _ = fs::remove_file(&path);
Ok(())
}
@ -423,7 +429,7 @@ mod tests {
fn protected_keyring_is_not_plaintext() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path(KEYRING_EXTENSION);
let keyring = sample_keyring();
let serialized = keyring.to_bytes();
let serialized = keyring.try_to_bytes()?;
save_keyring(&keyring, &path, b"passphrase")?;
let stored = fs::read(&path)?;
assert!(

View file

@ -9,6 +9,7 @@ mtp-codec = { version = "0.3.0", path = "../codec", features = ["registry"] }
mtp-transport = { version = "0.3.0", path = "../transport", features = ["host"] }
mtp-crypto = { version = "0.3.0", path = "../crypto", optional = true }
rand = "0.10"
thiserror = "2"
tokio = { version = "1", features = ["macros", "rt", "time", "sync"] }
tracing = "0.1"
wtransport = "0.7"

View file

@ -5,10 +5,14 @@ use std::collections::HashMap;
#[cfg(feature = "crypto")]
use std::collections::HashSet;
#[cfg(feature = "crypto")]
use std::collections::VecDeque;
#[cfg(feature = "crypto")]
use std::pin::Pin;
#[cfg(feature = "crypto")]
use std::sync::{Arc, Mutex};
#[cfg(feature = "crypto")]
use std::time::{Duration as StdDuration, Instant};
#[cfg(feature = "crypto")]
use tokio::time::Duration;
pub use mtp_transport::Policy;
@ -82,6 +86,158 @@ pub enum AuthenticationPolicy {
Unauthenticated,
}
/// Transport-supplied identity used to scope authentication attempt limits.
/// Concrete hosts should populate these fields from the accepted connection;
/// the zero/empty defaults exist only for transport-neutral callers.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AuthenticationContext {
pub peer_network_identity: Option<String>,
pub connection_id: u64,
}
#[cfg(feature = "crypto")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthenticationAttempt {
pub peer_network_identity: Option<String>,
pub connection_id: u64,
pub claimed_client_id: Option<u64>,
pub registration: bool,
}
#[cfg(feature = "crypto")]
#[derive(Debug, thiserror::Error)]
pub enum AuthenticationLimitError {
#[error("authentication limiter storage is unavailable")]
Store,
}
#[cfg(feature = "crypto")]
pub trait AuthenticationAttemptLimiter: Send + Sync {
fn allow(&self, context: &AuthenticationAttempt) -> Result<bool, AuthenticationLimitError>;
}
#[cfg(feature = "crypto")]
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
enum AuthenticationLimitKey {
Peer(String),
Connection(u64),
Client(u64),
Registration,
Global,
}
#[cfg(feature = "crypto")]
#[derive(Debug)]
pub struct InMemoryAuthenticationAttemptLimiter {
max_attempts: usize,
window: StdDuration,
max_keys: usize,
by_peer: bool,
by_connection: bool,
by_client: bool,
by_registration: bool,
attempts: Mutex<HashMap<AuthenticationLimitKey, VecDeque<Instant>>>,
}
#[cfg(feature = "crypto")]
impl InMemoryAuthenticationAttemptLimiter {
pub fn new(max_attempts: usize, window: StdDuration) -> Self {
Self {
max_attempts,
window,
max_keys: 100_000,
by_peer: true,
by_connection: true,
by_client: true,
by_registration: true,
attempts: Mutex::new(HashMap::new()),
}
}
pub fn with_keys(
mut self,
by_peer: bool,
by_connection: bool,
by_client: bool,
by_registration: bool,
) -> Self {
self.by_peer = by_peer;
self.by_connection = by_connection;
self.by_client = by_client;
self.by_registration = by_registration;
self
}
pub fn with_max_keys(mut self, max_keys: usize) -> Self {
self.max_keys = max_keys.max(1);
self
}
fn keys(&self, context: &AuthenticationAttempt) -> Vec<AuthenticationLimitKey> {
let mut keys = Vec::with_capacity(5);
if self.by_peer
&& let Some(peer) = context.peer_network_identity.as_ref()
{
keys.push(AuthenticationLimitKey::Peer(peer.clone()));
}
if self.by_connection && context.connection_id != 0 {
keys.push(AuthenticationLimitKey::Connection(context.connection_id));
}
if self.by_client
&& let Some(client_id) = context.claimed_client_id
{
keys.push(AuthenticationLimitKey::Client(client_id));
}
if self.by_registration && context.registration {
keys.push(AuthenticationLimitKey::Registration);
}
// Keep one global bucket as a backstop when an attacker varies the
// claimed client ID or presents no peer/connection identity.
keys.push(AuthenticationLimitKey::Global);
keys
}
}
#[cfg(feature = "crypto")]
impl AuthenticationAttemptLimiter for InMemoryAuthenticationAttemptLimiter {
fn allow(&self, context: &AuthenticationAttempt) -> Result<bool, AuthenticationLimitError> {
if self.max_attempts == 0 {
return Ok(false);
}
let now = Instant::now();
let cutoff = now.checked_sub(self.window);
let keys = self.keys(context);
let mut attempts = self
.attempts
.lock()
.map_err(|_| AuthenticationLimitError::Store)?;
for key in &keys {
if let Some(history) = attempts.get_mut(key) {
while history
.front()
.is_some_and(|timestamp| cutoff.is_some_and(|cutoff| *timestamp <= cutoff))
{
history.pop_front();
}
if history.len() >= self.max_attempts {
return Ok(false);
}
}
}
for key in keys {
if !attempts.contains_key(&key) && attempts.len() >= self.max_keys {
if let Some(oldest) = attempts.keys().next().cloned() {
attempts.remove(&oldest);
}
}
attempts.entry(key).or_default().push_back(now);
}
Ok(true)
}
}
pub struct HostConfig {
pub ip: IpAddr,
pub port: u16,
@ -113,6 +269,10 @@ pub struct HostConfig {
pub complete_register: CompleteRegister,
#[cfg(feature = "crypto")]
pub find_registered_client: Option<FindRegisteredClient>,
#[cfg(feature = "crypto")]
pub auth_limiter: Arc<dyn AuthenticationAttemptLimiter>,
#[cfg(feature = "crypto")]
pub conceal_authentication_identities: bool,
}
impl HostConfig {
@ -153,6 +313,13 @@ impl HostConfig {
complete_register: Box::new(|_, _| Box::pin(async { 0 })),
#[cfg(feature = "crypto")]
find_registered_client: None,
#[cfg(feature = "crypto")]
auth_limiter: Arc::new(InMemoryAuthenticationAttemptLimiter::new(
32,
StdDuration::from_secs(60),
)),
#[cfg(feature = "crypto")]
conceal_authentication_identities: true,
}
}
@ -210,4 +377,67 @@ impl HostConfig {
self.find_registered_client = Some(lookup);
self
}
#[cfg(feature = "crypto")]
pub fn with_authentication_limiter(
mut self,
limiter: Arc<dyn AuthenticationAttemptLimiter>,
) -> Self {
self.auth_limiter = limiter;
self
}
#[cfg(feature = "crypto")]
pub fn with_authentication_identity_concealment(mut self, conceal: bool) -> Self {
self.conceal_authentication_identities = conceal;
self
}
}
#[cfg(all(test, feature = "crypto"))]
mod tests {
use super::*;
#[test]
fn authentication_attempt_limiter_rejects_repeated_attempts() {
let limiter = InMemoryAuthenticationAttemptLimiter::new(1, StdDuration::from_secs(60))
.with_keys(false, true, false, false);
let attempt = AuthenticationAttempt {
peer_network_identity: None,
connection_id: 9,
claimed_client_id: Some(42),
registration: false,
};
assert!(limiter.allow(&attempt).expect("first attempt decision"));
assert!(!limiter.allow(&attempt).expect("second attempt decision"));
}
#[test]
fn authentication_attempt_limiter_can_scope_registration_separately() {
let limiter = InMemoryAuthenticationAttemptLimiter::new(2, StdDuration::from_secs(60))
.with_keys(false, false, false, true);
let login = AuthenticationAttempt {
peer_network_identity: None,
connection_id: 1,
claimed_client_id: None,
registration: false,
};
let registration = AuthenticationAttempt {
registration: true,
..login.clone()
};
assert!(limiter.allow(&login).expect("login attempt decision"));
assert!(
limiter
.allow(&registration)
.expect("registration attempt decision")
);
assert!(
!limiter
.allow(&registration)
.expect("repeated registration decision")
);
}
}

View file

@ -11,7 +11,10 @@ use tokio::sync::{Mutex, mpsc};
#[cfg(feature = "crypto")]
use crate::error::random_client_id;
#[cfg(feature = "pipes")]
use crate::pipe::{PipeDispatcher, PipeReceiver, PipeRequest, PipeSender, run_dispatcher};
use crate::pipe::{
PendingCreationGuard, PipeDispatcher, PipeReceiver, PipeRequest, PipeSender,
is_expired_creation, run_dispatcher,
};
#[cfg(feature = "pipes")]
use mtp_transport::Policy;
@ -145,10 +148,12 @@ where
remote_addr: Option<SocketAddr>,
) -> Self {
let policy = Arc::new(Policy::default());
let (app_tx, app_rx) = mpsc::channel(policy.receiver_queue_capacity);
let (pipe_req_tx, pipe_req_rx) = mpsc::channel(policy.receiver_queue_capacity);
let receiver_queue_capacity = policy.receiver_queue_capacity.max(1);
let (app_tx, app_rx) = mpsc::channel(receiver_queue_capacity);
let (pipe_req_tx, pipe_req_rx) = mpsc::channel(receiver_queue_capacity);
let dispatcher = Arc::new(PipeDispatcher {
pending_creations: Mutex::new(std::collections::HashMap::new()),
pending_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
expired_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
pending_pipes: Mutex::new(std::collections::HashMap::new()),
policy,
type_map: codec.type_map().clone(),
@ -198,10 +203,12 @@ where
remote_addr: Option<SocketAddr>,
policy: Arc<Policy>,
) -> Self {
let (app_tx, app_rx) = mpsc::channel(policy.receiver_queue_capacity);
let (pipe_req_tx, pipe_req_rx) = mpsc::channel(policy.receiver_queue_capacity);
let receiver_queue_capacity = policy.receiver_queue_capacity.max(1);
let (app_tx, app_rx) = mpsc::channel(receiver_queue_capacity);
let (pipe_req_tx, pipe_req_rx) = mpsc::channel(receiver_queue_capacity);
let dispatcher = Arc::new(PipeDispatcher {
pending_creations: Mutex::new(std::collections::HashMap::new()),
pending_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
expired_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
pending_pipes: Mutex::new(std::collections::HashMap::new()),
policy,
type_map: codec.type_map().clone(),
@ -321,19 +328,37 @@ where
pub async fn create_pipe(
&self,
description: &str,
) -> Result<crate::pipe::PipeHandle<S>, mtp_common::PipeError> {
) -> Result<crate::pipe::PipeHandle<S, P>, mtp_common::PipeError> {
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
let pipe_id = {
let mut pending = self.pipe_dispatcher.pending_creations.lock().await;
let mut pending = self
.pipe_dispatcher
.pending_creations
.lock()
.map_err(|_| mtp_common::PipeError::ConnectionClosed)?;
let pipe_id = loop {
let candidate = rand::random::<u32>();
if candidate != 0 && !pending.contains_key(&candidate) {
if candidate != 0
&& !pending.contains_key(&candidate)
&& !is_expired_creation(&self.pipe_dispatcher, candidate)
{
break candidate;
}
};
pending.insert(pipe_id, response_tx);
pipe_id
let token = Arc::new(());
pending.insert(
pipe_id,
crate::pipe::PendingCreation {
token: token.clone(),
sender: response_tx,
},
);
drop(pending);
(pipe_id, token)
};
let (pipe_id, token) = pipe_id;
let mut creation_guard =
PendingCreationGuard::new(self.pipe_dispatcher.clone(), pipe_id, token.clone());
let request = CommunicationValue::new_with_type_map(
CommunicationType::PipeRequest,
@ -342,19 +367,17 @@ where
.with_id(pipe_id)
.add_typed_default(DataType::Description, DataValue::Str(description.into()));
if let Err(error) = self.sender.send_pipe_message(&request).await {
self.pipe_dispatcher
.pending_creations
.lock()
.await
.remove(&pipe_id);
return Err(mtp_common::PipeError::from(error));
}
creation_guard.disarm();
Ok(crate::pipe::PipeHandle {
pipe_id,
description: description.to_owned(),
sender: self.sender.clone(),
response_rx,
dispatcher: self.pipe_dispatcher.clone(),
token,
})
}

View file

@ -4,7 +4,7 @@
//! and the web server's `MTPWebServer` to perform the MTP opening handshake,
//! version negotiation, authentication, and guest assignment.
use crate::config::HostConfig;
use crate::config::{AuthenticationContext, HostConfig};
use crate::error::AcceptError;
use mtp_codec::{
CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, Version,
@ -127,19 +127,32 @@ impl HandshakeEngine {
&self,
sender: &S,
receiver: &R,
) -> Result<HandshakeResult, AcceptError> {
self.accept_with_context(sender, receiver, AuthenticationContext::default())
.await
}
/// Run the opening handshake with transport-provided authentication
/// scoping information.
pub async fn accept_with_context<S: HandshakeSender, R: HandshakeReceiver>(
&self,
sender: &S,
receiver: &R,
context: AuthenticationContext,
) -> Result<HandshakeResult, AcceptError> {
#[cfg(feature = "crypto")]
{
self.accept_until(
self.accept_until_with_context(
sender,
receiver,
tokio::time::Instant::now() + self.config.auth_timeout,
context,
)
.await
}
#[cfg(not(feature = "crypto"))]
{
let result = self.accept_inner(sender, receiver).await;
let result = self.accept_inner(sender, receiver, &context).await;
if result.is_err() {
sender.close();
}
@ -159,7 +172,22 @@ impl HandshakeEngine {
receiver: &R,
deadline: tokio::time::Instant,
) -> Result<HandshakeResult, AcceptError> {
match tokio::time::timeout_at(deadline, self.accept_inner(sender, receiver)).await {
self.accept_until_with_context(sender, receiver, deadline, AuthenticationContext::default())
.await
}
/// Run the crypto handshake until a deadline with transport-provided
/// authentication scoping information.
#[cfg(feature = "crypto")]
pub async fn accept_until_with_context<S: HandshakeSender, R: HandshakeReceiver>(
&self,
sender: &S,
receiver: &R,
deadline: tokio::time::Instant,
context: AuthenticationContext,
) -> Result<HandshakeResult, AcceptError> {
match tokio::time::timeout_at(deadline, self.accept_inner(sender, receiver, &context)).await
{
Ok(result) => {
if result.is_err() {
sender.close();
@ -186,6 +214,7 @@ impl HandshakeEngine {
&self,
sender: &S,
receiver: &R,
_authentication_context: &AuthenticationContext,
) -> Result<HandshakeResult, AcceptError> {
let mut first_msg = receiver.receive().await.map_err(AcceptError::Receive)?;
@ -257,6 +286,42 @@ impl HandshakeEngine {
#[cfg(feature = "crypto")]
{
let claimed_client_id = match first_msg.get_data(DataType::Id) {
Some(DataValue::UnsignedNumber(value)) => u64::try_from(*value).ok(),
_ => None,
};
let registration = Some(first_msg.get_type())
== CommunicationType::Register.try_to_id(codec.type_map());
let authentication_requested = matches!(
self.config.authentication_policy,
crate::config::AuthenticationPolicy::ForceAuthentication
) || registration
|| first_msg.get_data(DataType::PublicKeys).is_some()
|| claimed_client_id.is_some_and(|client_id| client_id != 0);
if authentication_requested {
let attempt = crate::config::AuthenticationAttempt {
peer_network_identity: _authentication_context.peer_network_identity.clone(),
connection_id: _authentication_context.connection_id,
claimed_client_id,
registration,
};
match self.config.auth_limiter.allow(&attempt) {
Ok(true) => {}
Ok(false) | Err(_) => {
let error =
AcceptError::AuthenticationFailed("authentication rejected".into());
send_rejection_generic(
sender,
RejectionReason::RateLimited,
Some(codec.type_map()),
)
.await;
sender.close();
return Err(error);
}
}
}
match self.config.authentication_policy {
crate::config::AuthenticationPolicy::ForceAuthentication => {
self.force_auth_handshake(
@ -408,7 +473,14 @@ impl HandshakeEngine {
return Err(error);
}
};
let pk_bytes = bundle.as_bytes();
let pk_bytes = match bundle.try_as_bytes() {
Ok(bytes) => bytes,
Err(error) => {
let error = AcceptError::AuthenticationFailed(error.to_string());
reject_error_generic(sender, &error, tm).await;
return Err(error);
}
};
return self
.complete_auth_handshake(
sender,
@ -453,6 +525,28 @@ impl HandshakeEngine {
// Unknown or zero ID: an Identification carrying PublicKeys is an
// explicit authentication attempt, not a guest connection.
if first_msg.get_data(DataType::PublicKeys).is_some() {
if self.config.conceal_authentication_identities && cid > 0 {
/* Keep an unknown authenticated ID on the same
challenge/proof path as a known ID. The fixed host
identity makes the eventual proof fail without
disclosing whether the lookup succeeded. */
return self
.complete_auth_handshake(
sender,
receiver,
Flow::Login {
id: cid,
bundle: self.config.host_keyring.public_key_bundle(),
},
CommunicationType::IdentificationResponse,
&negotiated,
&codec,
description,
version_str,
client_version,
)
.await;
}
let error = AcceptError::AuthenticationFailed(
"unknown authenticated client identity".into(),
);
@ -525,20 +619,29 @@ impl HandshakeEngine {
let bundle = match (self.config.get_existing_client)(cid, description.clone()).await {
Some(b) => b,
None => {
let rejection = CommunicationValue::new_with_type_map(
CommunicationType::IdentificationResponse,
tm,
)
.add_typed_default(DataType::Connected, DataValue::BoolFalse)
.add_typed_default(
DataType::ErrorMessage,
DataValue::Str("unknown client id".into()),
);
let _ = sender.send(&rejection).await;
sender.close();
return Err(AcceptError::AuthenticationFailed(
"unknown client id".into(),
));
if self.config.conceal_authentication_identities {
// Use a valid fixed-cost dummy identity so an unknown
// client follows the same challenge/proof sequence as
// a registered client. The host public bundle is
// already public and the peer cannot produce its
// private-key proof.
self.config.host_keyring.public_key_bundle()
} else {
let rejection = CommunicationValue::new_with_type_map(
CommunicationType::IdentificationResponse,
tm,
)
.add_typed_default(DataType::Connected, DataValue::BoolFalse)
.add_typed_default(
DataType::ErrorMessage,
DataValue::Str("unknown client id".into()),
);
let _ = sender.send(&rejection).await;
sender.close();
return Err(AcceptError::AuthenticationFailed(
"unknown client id".into(),
));
}
}
};
(
@ -553,7 +656,14 @@ impl HandshakeEngine {
return Err(error);
}
};
let pk_bytes = bundle.as_bytes();
let pk_bytes = match bundle.try_as_bytes() {
Ok(bytes) => bytes,
Err(error) => {
let error = AcceptError::AuthenticationFailed(error.to_string());
reject_error_generic(sender, &error, tm).await;
return Err(error);
}
};
(
Flow::Register { bundle, pk_bytes },
CommunicationType::RegisterResponse,
@ -787,7 +897,14 @@ impl HandshakeEngine {
Flow::Login { id, bundle } => (id, bundle),
Flow::Register { bundle, .. } => {
let _registration_guard = self.config.registration_lock.lock().await;
let identity = bundle.as_bytes();
let identity = match bundle.try_as_bytes() {
Ok(identity) => identity,
Err(error) => {
let error = AcceptError::AuthenticationFailed(error.to_string());
reject_error_generic(sender, &error, tm).await;
return Err(error);
}
};
let cached_id = self
.config
.registration_ids

View file

@ -11,7 +11,7 @@ use std::time::Instant;
#[cfg(feature = "pipes")]
use tokio::sync::mpsc;
use crate::config::HostConfig;
use crate::config::{AuthenticationContext, HostConfig};
use crate::connection::MTPConnection;
use crate::engine::HandshakeEngine;
use crate::error::AcceptError;
@ -135,7 +135,16 @@ impl HandshakeContext {
receiver: Receiver,
) -> Result<Option<MTPConnection>, AcceptError> {
let engine = HandshakeEngine::new(self.registry.clone(), self.config.clone());
let result = engine.accept(&sender, &receiver).await?;
let authentication_context = AuthenticationContext {
peer_network_identity: sender
.handle()
.remote_addr()
.map(|address| address.to_string()),
connection_id: sender.handle().connection_id(),
};
let result = engine
.accept_with_context(&sender, &receiver, authentication_context)
.await?;
#[cfg(feature = "crypto")]
{
Ok(Some(self.connection_from_handshake_result(
@ -171,12 +180,13 @@ impl HandshakeContext {
receiver.respond_to_pings(sender.clone());
}
let (app_tx, app_rx) = mpsc::channel(self.config.policy.receiver_queue_capacity);
let (pipe_req_tx, pipe_req_rx) =
mpsc::channel(self.config.policy.receiver_queue_capacity);
let receiver_queue_capacity = self.config.policy.receiver_queue_capacity.max(1);
let (app_tx, app_rx) = mpsc::channel(receiver_queue_capacity);
let (pipe_req_tx, pipe_req_rx) = mpsc::channel(receiver_queue_capacity);
let dispatcher = Arc::new(PipeDispatcher {
pending_creations: tokio::sync::Mutex::new(std::collections::HashMap::new()),
pending_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
expired_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
pending_pipes: tokio::sync::Mutex::new(std::collections::HashMap::new()),
policy: Arc::new(self.config.policy),
type_map: type_map.clone(),
@ -260,12 +270,13 @@ impl HandshakeContext {
receiver.respond_to_pings(sender.clone());
}
let (app_tx, app_rx) = mpsc::channel(self.config.policy.receiver_queue_capacity);
let (pipe_req_tx, pipe_req_rx) =
mpsc::channel(self.config.policy.receiver_queue_capacity);
let receiver_queue_capacity = self.config.policy.receiver_queue_capacity.max(1);
let (app_tx, app_rx) = mpsc::channel(receiver_queue_capacity);
let (pipe_req_tx, pipe_req_rx) = mpsc::channel(receiver_queue_capacity);
let dispatcher = Arc::new(PipeDispatcher {
pending_creations: tokio::sync::Mutex::new(std::collections::HashMap::new()),
pending_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
expired_creations: std::sync::Mutex::new(std::collections::HashMap::new()),
pending_pipes: tokio::sync::Mutex::new(std::collections::HashMap::new()),
policy: Arc::new(self.config.policy),
type_map,

View file

@ -29,8 +29,9 @@ pub use mtp_codec::registry::Registry;
#[cfg(feature = "crypto")]
pub use config::{
AuthenticationPolicy, CompleteRegister, FindRegisteredClient, GetExistingClient,
GuestIdGenerator,
AuthenticationAttempt, AuthenticationAttemptLimiter, AuthenticationContext,
AuthenticationLimitError, AuthenticationPolicy, CompleteRegister, FindRegisteredClient,
GetExistingClient, GuestIdGenerator, InMemoryAuthenticationAttemptLimiter,
};
#[cfg(feature = "crypto")]
pub use error::AuthState;

View file

@ -3,6 +3,7 @@ use mtp_common::{CommunicationError, PipeError};
use mtp_transport::{PipeReader, PipeWriter, Policy, TransportEvent};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use tokio::sync::{Mutex, mpsc};
/// The sender operations needed by the transport-independent pipe protocol.
@ -93,14 +94,20 @@ where
}
}
pub struct PipeHandle<S: PipeSender> {
pub struct PipeHandle<S: PipeSender, P = wtransport::RecvStream> {
pub(crate) pipe_id: u32,
pub(crate) description: String,
pub(crate) sender: S,
pub(crate) response_rx: tokio::sync::oneshot::Receiver<Result<bool, PipeError>>,
pub(crate) dispatcher: Arc<PipeDispatcher<P>>,
pub(crate) token: Arc<()>,
}
impl<S: PipeSender> PipeHandle<S> {
impl<S, P> PipeHandle<S, P>
where
S: PipeSender,
P: tokio::io::AsyncRead + Send + Unpin + 'static,
{
pub fn pipe_id(&self) -> u32 {
self.pipe_id
}
@ -109,21 +116,42 @@ impl<S: PipeSender> PipeHandle<S> {
&self.description
}
pub async fn wait(self) -> Result<Option<PipeWriter<S::Writer>>, PipeError> {
match self.response_rx.await {
Ok(Ok(true)) => self
pub async fn wait(mut self) -> Result<Option<PipeWriter<S::Writer>>, PipeError> {
let response =
tokio::time::timeout(self.dispatcher.policy.read_timeout, &mut self.response_rx).await;
match response {
Ok(Ok(Ok(true))) => self
.sender
.open_pipe_stream(self.pipe_id, &self.description)
.await
.map(Some)
.map_err(PipeError::from),
Ok(Ok(false)) => Ok(None),
Ok(Err(error)) => Err(error),
Err(_) => Err(PipeError::StreamClosed),
Ok(Ok(Ok(false))) => Ok(None),
Ok(Ok(Err(error))) => {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
Err(error)
}
Ok(Err(_)) => {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
Err(PipeError::StreamClosed)
}
Err(_) => {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
Err(PipeError::HandshakeTimeout)
}
}
}
}
impl<S, P> Drop for PipeHandle<S, P>
where
S: PipeSender,
{
fn drop(&mut self) {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
}
}
pub struct PipeRequest<S, P> {
pub(crate) pipe_id: u32,
pub(crate) description: String,
@ -203,13 +231,132 @@ where
}
pub(crate) struct PipeDispatcher<P> {
pub(crate) pending_creations:
Mutex<HashMap<u32, tokio::sync::oneshot::Sender<Result<bool, PipeError>>>>,
pub(crate) pending_creations: StdMutex<HashMap<u32, PendingCreation>>,
pub(crate) expired_creations: StdMutex<HashMap<u32, tokio::time::Instant>>,
pub(crate) pending_pipes: Mutex<HashMap<u32, tokio::sync::oneshot::Sender<PipeReader<P>>>>,
pub(crate) policy: Arc<Policy>,
pub(crate) type_map: TypeMap,
}
pub(crate) struct PendingCreation {
pub(crate) token: Arc<()>,
pub(crate) sender: tokio::sync::oneshot::Sender<Result<bool, PipeError>>,
}
pub(crate) struct PendingCreationGuard<P> {
dispatcher: Arc<PipeDispatcher<P>>,
pipe_id: u32,
token: Arc<()>,
armed: bool,
}
impl<P> PendingCreationGuard<P> {
pub(crate) fn new(dispatcher: Arc<PipeDispatcher<P>>, pipe_id: u32, token: Arc<()>) -> Self {
Self {
dispatcher,
pipe_id,
token,
armed: true,
}
}
pub(crate) fn disarm(&mut self) {
self.armed = false;
}
}
impl<P> Drop for PendingCreationGuard<P> {
fn drop(&mut self) {
if self.armed {
expire_pending_creation(&self.dispatcher, self.pipe_id, &self.token);
}
}
}
const EXPIRED_CREATION_TOMBSTONE_TTL: tokio::time::Duration = tokio::time::Duration::from_secs(60);
const MAX_EXPIRED_CREATION_TOMBSTONES: usize = 1024;
pub(crate) fn expire_pending_creation<P>(
dispatcher: &PipeDispatcher<P>,
pipe_id: u32,
token: &Arc<()>,
) {
let removed = dispatcher
.pending_creations
.lock()
.ok()
.and_then(|mut pending| {
if pending
.get(&pipe_id)
.is_some_and(|entry| Arc::ptr_eq(&entry.token, token))
{
pending.remove(&pipe_id);
Some(())
} else {
None
}
});
if removed.is_none() {
return;
}
let Ok(mut expired) = dispatcher.expired_creations.lock() else {
return;
};
let now = tokio::time::Instant::now();
expired.retain(|_, expires_at| *expires_at > now);
if expired.len() >= MAX_EXPIRED_CREATION_TOMBSTONES
&& let Some(oldest) = expired
.iter()
.min_by_key(|(_, expires_at)| **expires_at)
.map(|(id, _)| *id)
{
expired.remove(&oldest);
}
expired.insert(pipe_id, now + EXPIRED_CREATION_TOMBSTONE_TTL);
}
fn consume_expired_creation<P>(dispatcher: &PipeDispatcher<P>, pipe_id: u32) -> bool {
let Ok(mut expired) = dispatcher.expired_creations.lock() else {
return false;
};
let now = tokio::time::Instant::now();
expired.retain(|_, expires_at| *expires_at > now);
expired.remove(&pipe_id).is_some()
}
pub(crate) fn is_expired_creation<P>(dispatcher: &PipeDispatcher<P>, pipe_id: u32) -> bool {
let Ok(mut expired) = dispatcher.expired_creations.lock() else {
return true;
};
let now = tokio::time::Instant::now();
expired.retain(|_, expires_at| *expires_at > now);
expired.contains_key(&pipe_id)
}
pub(crate) fn fail_pending_creations<P>(
dispatcher: &PipeDispatcher<P>,
error: &CommunicationError,
) {
let pending = dispatcher
.pending_creations
.lock()
.ok()
.map(|mut pending| std::mem::take(&mut *pending));
if let Some(pending) = pending {
let error = PipeError::from(error.clone());
for (_, pending) in pending {
let _ = pending.sender.send(Err(error.clone()));
}
}
if let Ok(mut expired) = dispatcher.expired_creations.lock() {
expired.clear();
}
}
pub(crate) async fn fail_pending_pipes<P>(dispatcher: &PipeDispatcher<P>) {
dispatcher.pending_pipes.lock().await.clear();
}
pub(crate) async fn run_dispatcher<S, R, P>(
receiver: R,
sender: S,
@ -256,10 +403,17 @@ pub(crate) async fn run_dispatcher<S, R, P>(
}
continue;
};
let mut pending = dispatcher.pending_creations.lock().await;
if let Some(reply) = pending.remove(&pipe_id) {
let _ =
reply.send(Ok(message.get_bool(DataType::Accepted).unwrap_or(false)));
let pending = dispatcher
.pending_creations
.lock()
.ok()
.and_then(|mut pending| pending.remove(&pipe_id));
if let Some(entry) = pending {
let _ = entry
.sender
.send(Ok(message.get_bool(DataType::Accepted).unwrap_or(false)));
} else if consume_expired_creation(&dispatcher, pipe_id) {
tracing::debug!(pipe_id, "ignored late pipe creation response");
}
continue;
}
@ -297,9 +451,12 @@ pub(crate) async fn run_dispatcher<S, R, P>(
let _ = pipe_req_tx.send(request).await;
}
Err(error) => {
fail_pending_creations(&dispatcher, &error);
fail_pending_pipes(&dispatcher).await;
if app_tx.send(Err(error)).await.is_err() {
break;
}
break;
}
}
}

View file

@ -42,6 +42,11 @@ impl H3TransportConnection {
pub(crate) fn remote_addr(&self) -> std::net::SocketAddr {
self.quinn.remote_address()
}
#[cfg(feature = "crypto")]
pub(crate) fn connection_id(&self) -> u64 {
self.quinn.stable_id() as u64
}
}
#[async_trait::async_trait]
@ -283,6 +288,8 @@ async fn accept_web_connection_inner(
let max_message_size = policy.max_message_size;
let transport = H3TransportConnection::new(session, quinn);
let remote_addr = transport.remote_addr();
#[cfg(feature = "crypto")]
let connection_id = transport.connection_id();
let policy = Arc::new(policy);
let sender = WebMtpSender::new(transport.clone(), policy.clone());
let receiver = WebMtpReceiver::new(transport, policy.clone());
@ -290,10 +297,14 @@ async fn accept_web_connection_inner(
let engine = mtp_host::HandshakeEngine::new(Registry::builtin(), host_config);
#[cfg(feature = "crypto")]
let result = engine
.accept_until(
.accept_until_with_context(
&sender,
&receiver,
deadline.expect("crypto WebTransport handshakes have a deadline"),
mtp_host::AuthenticationContext {
peer_network_identity: Some(remote_addr.to_string()),
connection_id,
},
)
.await?;
#[cfg(not(feature = "crypto"))]

View file

@ -63,10 +63,11 @@
"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:wasm-init": "tsc && node --test test/wasm-init.mjs",
"test:types": "tsc -p tsconfig.type-tests.json --noEmit",
"test:vite": "tsc && node test/vite-type-map.mjs",
"test:boundary": "node --test test/package-boundary.mjs",
"test": "pnpm run test:e2e && pnpm run test:secrets && pnpm run test:types && pnpm run test:vite && pnpm run test:boundary"
"test": "pnpm run test:e2e && pnpm run test:secrets && pnpm run test:wasm-init && pnpm run test:types && pnpm run test:vite && pnpm run test:boundary"
},
"devDependencies": {
"@types/node": "^26.0.1",

2635
src/sdk/client.ts Normal file

File diff suppressed because it is too large Load diff

959
src/sdk/codec.ts Normal file
View file

@ -0,0 +1,959 @@
import * as bindings from "mtp/raw";
import type { MTPCommunicationType } from "../type-map/index";
import { RESERVED_COMMUNICATION_TYPE_IDS } from "../type-map/reserved.js";
import { utf8Encode } from "./utils.js";
import { initWasmOnce } from "./wasm-init.js";
import type {
MTPBytesInput,
MTPCodec,
MTPCodecOptions,
MTPDataValue,
MTPDataValueInput,
MTPEncodeLimits,
MTPEncodedBytesInput,
MTPKeyringKeys,
MTPKeyMaterialInput,
MTPReceiveLimits,
MTPCrypto,
MTPPublicKeyBundleKeys,
MTPProtectedFrameInput,
MTPProtectionSignatureSuite,
ParsedFrame,
} from "./client.js";
const checkedKeyringGenerator = (
bindings as typeof bindings & {
keyring_generate_checked?: () => Uint8Array;
}
).keyring_generate_checked;
export const crypto: MTPCrypto = {
generateKeyring: () =>
checkedKeyringGenerator?.() ?? bindings.keyring_generate(),
generateEd25519: () => bindings.ed25519_generate(),
keyringFromEd25519: (secretKey, publicKey) =>
bindings.keyring_from_ed25519(secretKey, publicKey),
verifyEd25519: (publicKey, message, signature) =>
bindings.ed25519_verify(publicKey, message, signature),
deriveEncryptionKey: (ikm, salt, context) =>
bindings.wasm_derive_encryption_key(ikm, salt, context),
hkdfExpand: (ikm, salt, info, len) =>
bindings.wasm_hkdf_expand(ikm, salt, info, len),
sha256: (data) => bindings.wasm_sha256(data),
sha256Double: (data) => bindings.wasm_sha256_double(data),
keyringToKeys: (keyring) => keyringToKeys(keyring),
publicKeyBundleToKeys: (publicKeyBundle) =>
publicKeyBundleToKeys(publicKeyBundle),
encrypt: async (key, input) => {
const cipher = new bindings.WasmChaCha20Poly1305(key);
try {
return cipher.encrypt(input, new Uint8Array(0));
} finally {
cipher.free();
}
},
decrypt: async (key, input) => {
const cipher = new bindings.WasmChaCha20Poly1305(key);
try {
return cipher.decrypt(input, new Uint8Array(0));
} finally {
cipher.free();
}
},
encryptText: async (key, plaintext) => {
const cipher = new bindings.WasmChaCha20Poly1305(key);
try {
const ciphertext = cipher.encrypt(
utf8Encode(plaintext),
new Uint8Array(0),
);
return bytesToBase64(ciphertext);
} finally {
cipher.free();
}
},
decryptText: async (key, ciphertext) => {
const cipher = new bindings.WasmChaCha20Poly1305(key);
try {
const decoded = base64ToBytes(ciphertext);
const plaintext = cipher.decrypt(decoded, new Uint8Array(0));
return utf8Decode(plaintext);
} finally {
cipher.free();
}
},
encapsulate: (otherPublicKey) =>
bindings.wasm_kem_encapsulate(otherPublicKey),
decapsulate: (ownPrivateKey, ciphertext) =>
bindings.wasm_kem_decapsulate(ownPrivateKey, ciphertext),
};
export function encode(
type: MTPCommunicationType,
data: Record<string, unknown>,
options?: MTPCodecOptions,
): Uint8Array {
const limits: MTPEncodeLimits = {
maxDepth: MAX_DATA_VALUE_DEPTH,
maxValues: MAX_DATA_VALUE_VALUES,
maxOutputSize: 16 * 1024 * 1024,
};
const maxOutputSize = limits.maxOutputSize ?? 16 * 1024 * 1024;
validateMTPDataValue(data as MTPDataValueInput, limits);
const bounded = (
bindings as typeof bindings & {
build_frame_with_limits?: (
type: string,
data: Record<string, unknown>,
options: MTPCodecOptions,
limits: MTPEncodeLimits,
) => Uint8Array;
}
).build_frame_with_limits;
if (!bounded) {
throw new Error("bounded WASM frame encoding is unavailable; rebuild mtp-wasm");
}
const frame = bounded(type, data, options ?? {}, limits);
if (frame.length > maxOutputSize) {
throw new RangeError("MTP frame encoded output limit exceeded");
}
return frame;
}
export function decode(frame: MTPBytesInput): ParsedFrame {
return bindings.parse_frame(bytesFrom(frame, "frame"));
}
export function decodeWithLimits(
frame: MTPBytesInput,
limits: MTPReceiveLimits,
): ParsedFrame {
const parse = (
bindings as typeof bindings & {
parse_frame_with_limits?: (
frame: Uint8Array,
limits: MTPReceiveLimits,
) => ParsedFrame;
}
).parse_frame_with_limits;
if (!parse) {
throw new Error(
"configured receive limits require a rebuilt bounded WASM package",
);
}
return parse(bytesFrom(frame, "frame"), limits);
}
export function decodeDataValueWithLimits(
value: MTPBytesInput,
limits: MTPReceiveLimits,
): MTPDataValue {
const parse = (
bindings as typeof bindings & {
parse_data_value_with_limits?: (
value: Uint8Array,
limits: MTPReceiveLimits,
) => MTPDataValue;
}
).parse_data_value_with_limits;
if (!parse) {
throw new Error(
"configured receive limits require a rebuilt bounded WASM package",
);
}
return parse(bytesFrom(value, "data value"), limits);
}
export function format(frame: MTPBytesInput): string {
return bindings.format_frame(bytesFrom(frame, "frame"));
}
export const codec: MTPCodec = { encode, decode, format };
export function isBytes(value: unknown): value is MTPBytesInput {
return value instanceof Uint8Array || Array.isArray(value);
}
export function bytesFrom(value: MTPBytesInput, name: string): Uint8Array {
if (value instanceof Uint8Array) return value.slice();
if (Array.isArray(value)) {
for (const byte of value) {
if (!Number.isInteger(byte) || byte < 0 || byte > 255) {
throw new RangeError(`${name} contains a non-byte value`);
}
}
return Uint8Array.from(value);
}
throw new TypeError(`${name} must be a Uint8Array or number[]`);
}
export function strictHexDecode(value: string, name = "value"): Uint8Array {
if (typeof value !== "string") throw new TypeError(`${name} must be a string`);
const text = value.replace(/^0x/i, "");
if (text.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(text)) {
throw new TypeError(`${name} must be an even-length hexadecimal string`);
}
const bytes = new Uint8Array(text.length / 2);
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = Number.parseInt(text.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}
export function strictBase64Decode(value: string, name = "value"): Uint8Array {
if (typeof value !== "string") throw new TypeError(`${name} must be a string`);
if (value.length === 0) return new Uint8Array(0);
if (
value.length % 4 !== 0 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
value,
)
) {
throw new TypeError(`${name} is not valid padded base64`);
}
let bytes: Uint8Array;
try {
if (typeof atob === "function") {
const binary = atob(value);
bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
} else if (typeof Buffer !== "undefined") {
bytes = new Uint8Array(Buffer.from(value, "base64"));
} else {
throw new TypeError("base64 decoding is not available in this environment");
}
} catch (error) {
throw new TypeError(`${name} is not valid base64`, { cause: error });
}
if (bytesToBase64(bytes) !== value) {
throw new TypeError(`${name} is not canonical padded base64`);
}
return bytes;
}
export function bytesFromEncodedString(
value: string,
encoding: "hex" | "base64",
name: string,
): Uint8Array {
return encoding === "hex"
? strictHexDecode(value, name)
: strictBase64Decode(value, name);
}
/*
* Compatibility parser for the historical format-detecting API. New callers
* should select `bytesFromEncodedString` explicitly so a value cannot change
* meaning when it happens to contain only hexadecimal characters.
*/
/** @deprecated Use `bytesFromEncodedString(value, encoding, name)`. */
export function bytesFromString(value: string, name: string): Uint8Array {
const trimmed = value.trim();
if (!trimmed) throw new TypeError(`${name} must not be empty`);
const hex = trimmed.replace(/^(0x)/i, "").replace(/[\s:_-]/g, "");
if (/^[0-9a-fA-F]+$/.test(hex)) {
return strictHexDecode(hex, name);
}
return strictBase64Decode(trimmed, name);
}
const HEX_DIGITS = "0123456789abcdef";
function bytesToHex(bytes: Uint8Array): string {
let out = "";
for (let i = 0; i < bytes.length; i += 1) {
out += HEX_DIGITS[(bytes[i] >> 4) & 0xf] + HEX_DIGITS[bytes[i] & 0xf];
}
return out;
}
export function bytesToBase64(bytes: Uint8Array): string {
if (typeof btoa === "function") {
let binary = "";
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
if (typeof Buffer !== "undefined") {
return Buffer.from(bytes).toString("base64");
}
throw new TypeError("base64 encoding is not available in this environment");
}
export function base64ToBytes(input: string): Uint8Array {
return strictBase64Decode(input, "base64");
}
function utf8Decode(bytes: Uint8Array): string {
if (typeof TextDecoder !== "undefined") {
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch (error) {
throw new TypeError("invalid UTF-8", { cause: error });
}
}
let out = "";
let i = 0;
while (i < bytes.length) {
const b = bytes[i];
if (b < 0x80) {
out += String.fromCharCode(b);
i += 1;
} else if (b >= 0xc2 && b <= 0xdf) {
if (i + 1 >= bytes.length || (bytes[i + 1] & 0xc0) !== 0x80) {
throw new TypeError("invalid UTF-8");
}
out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i + 1] & 0x3f));
i += 2;
} else if (b >= 0xe0 && b <= 0xef) {
if (
i + 2 >= bytes.length ||
(bytes[i + 1] & 0xc0) !== 0x80 ||
(bytes[i + 2] & 0xc0) !== 0x80 ||
(b === 0xe0 && bytes[i + 1] < 0xa0) ||
(b === 0xed && bytes[i + 1] >= 0xa0)
) {
throw new TypeError("invalid UTF-8");
}
out += String.fromCharCode(
((b & 0x0f) << 12) |
((bytes[i + 1] & 0x3f) << 6) |
(bytes[i + 2] & 0x3f),
);
i += 3;
} else if (b >= 0xf0 && b <= 0xf4) {
if (
i + 3 >= bytes.length ||
(bytes[i + 1] & 0xc0) !== 0x80 ||
(bytes[i + 2] & 0xc0) !== 0x80 ||
(bytes[i + 3] & 0xc0) !== 0x80 ||
(b === 0xf0 && bytes[i + 1] < 0x90) ||
(b === 0xf4 && bytes[i + 1] >= 0x90)
) {
throw new TypeError("invalid UTF-8");
}
const cp =
((b & 0x07) << 18) |
((bytes[i + 1] & 0x3f) << 12) |
((bytes[i + 2] & 0x3f) << 6) |
(bytes[i + 3] & 0x3f);
out += String.fromCodePoint(cp);
i += 4;
} else {
throw new TypeError("invalid UTF-8");
}
}
return out;
}
function requiredSecretKeyLength(): number {
const lengthBinding = (
bindings as typeof bindings & {
mtp_symmetric_key_length?: () => number;
}
).mtp_symmetric_key_length;
if (!lengthBinding) return 32;
try {
return lengthBinding();
} catch {
// The generated WASM wrapper is callable only after initialization. Keep
// the historical size as a pre-initialization validation fallback.
return 32;
}
}
export function secretKeyFromBytes(value: MTPBytesInput): Uint8Array {
const bytes = bytesFrom(value, "secret key");
const requiredLength = requiredSecretKeyLength();
if (bytes.length !== requiredLength) {
throw new RangeError(`secret key must be exactly ${requiredLength} bytes`);
}
return bytes;
}
export function secretKeyFromHex(value: string): Uint8Array {
return secretKeyFromBytes(strictHexDecode(value, "secret key"));
}
export function secretKeyFromBase64(value: string): Uint8Array {
return secretKeyFromBytes(strictBase64Decode(value, "secret key"));
}
/*
* Compatibility entry point. It now accepts only explicitly encoded key
* material; arbitrary strings are no longer silently treated as passphrases.
*/
/** @deprecated Use `secretKeyFromBytes`, `secretKeyFromHex`, or `secretKeyFromBase64`. */
export function secretKeyFromString(secret: string): Uint8Array {
if (typeof secret !== "string" || !secret.trim()) {
throw new TypeError("secret must be a non-empty string");
}
const trimmed = secret.trim();
const hex = trimmed.replace(/^(0x)/i, "");
if (/^[0-9a-fA-F]+$/.test(hex)) return secretKeyFromHex(hex);
return secretKeyFromBase64(trimmed);
}
/**
* Reproduce the pre-v1 implicit-HKDF derivation for data migration only.
*
* @deprecated Do not use for new secrets. Replace this with explicit key
* material or `deriveKeyFromPassphrase` and persist a password-KDF salt.
*/
export function legacySecretKeyFromStringV1(secret: string): Uint8Array {
if (typeof secret !== "string" || !secret.trim()) {
throw new TypeError("secret must be a non-empty string");
}
const trimmed = secret.trim();
const hex = trimmed.replace(/^(0x)/i, "").replace(/[\s:_-]/g, "");
if (/^[0-9a-fA-F]+$/.test(hex) && hex.length === 64) {
return strictHexDecode(hex, "legacy secret key");
}
try {
const decoded = bytesFromString(trimmed, "legacy secret key");
if (decoded.length === requiredSecretKeyLength()) return decoded;
} catch {
// Preserve the historical fallback to HKDF for non-encoded strings.
}
const context = utf8Encode("mtp-symmetric-key");
return bindings.wasm_derive_encryption_key(
utf8Encode(trimmed),
context,
context,
);
}
export interface PasswordKdfParameters {
memoryKiB: number;
iterations: number;
lanes: number;
}
function validatePasswordKdfInput(
passphrase: string,
salt: MTPBytesInput,
parameters: PasswordKdfParameters,
): { passphrase: string; salt: Uint8Array; parameters: PasswordKdfParameters } {
if (typeof passphrase !== "string" || passphrase.length === 0) {
throw new TypeError("passphrase must not be empty");
}
const saltBytes = bytesFrom(salt, "passphrase salt");
if (saltBytes.length < 16) {
throw new RangeError("passphrase salt must be at least 16 bytes");
}
if (
!Number.isInteger(parameters.memoryKiB) ||
parameters.memoryKiB < 8 * 1024 ||
parameters.memoryKiB > 256 * 1024 ||
!Number.isInteger(parameters.iterations) ||
parameters.iterations < 1 ||
parameters.iterations > 10 ||
!Number.isInteger(parameters.lanes) ||
parameters.lanes < 1 ||
parameters.lanes > 8
) {
throw new RangeError("invalid Argon2id password-KDF parameters");
}
return { passphrase, salt: saltBytes, parameters };
}
function deriveKeyFromPassphraseSyncImpl(
passphrase: string,
salt: MTPBytesInput,
parameters: PasswordKdfParameters,
): Uint8Array {
const validated = validatePasswordKdfInput(passphrase, salt, parameters);
const kdf = (bindings as unknown as {
wasm_argon2id?: (
passphrase: Uint8Array,
salt: Uint8Array,
memoryKiB: number,
iterations: number,
lanes: number,
) => Uint8Array;
}).wasm_argon2id;
if (!kdf) {
throw new Error("Argon2id password derivation is unavailable in this WASM build");
}
return kdf(
utf8Encode(validated.passphrase),
validated.salt,
validated.parameters.memoryKiB,
validated.parameters.iterations,
validated.parameters.lanes,
);
}
/**
* Derive a passphrase key without yielding. Prefer the asynchronous API in
* browser applications; this form is retained for workers and synchronous
* command-line migrations.
*/
/** @deprecated Use `deriveKeyFromPassphrase` in browser-facing code. */
export function deriveKeyFromPassphraseSync(
passphrase: string,
salt: MTPBytesInput,
parameters: PasswordKdfParameters,
): Uint8Array {
return deriveKeyFromPassphraseSyncImpl(passphrase, salt, parameters);
}
/**
* Derive a passphrase key off the browser main thread when workers are
* available. The worker imports the same generated WASM binding, so the
* Argon2id computation does not block UI/event-loop work.
*/
export function deriveKeyFromPassphrase(
passphrase: string,
salt: MTPBytesInput,
parameters: PasswordKdfParameters,
): Promise<Uint8Array> {
const validated = validatePasswordKdfInput(passphrase, salt, parameters);
if (typeof Worker === "undefined") {
return initWasmOnce().then(
() =>
new Promise((resolve) => {
setTimeout(
() =>
resolve(
deriveKeyFromPassphraseSyncImpl(
validated.passphrase,
validated.salt,
validated.parameters,
),
),
0,
);
}),
);
}
const worker = new Worker(new URL("./passphrase-worker.js", import.meta.url), {
type: "module",
});
return new Promise<Uint8Array>((resolve, reject) => {
const cleanup = () => worker.terminate();
worker.onmessage = (event: MessageEvent<Uint8Array | { error: string }>) => {
cleanup();
if (event.data && "error" in event.data) {
reject(new Error(event.data.error));
} else {
resolve(new Uint8Array(event.data));
}
};
worker.onerror = (event) => {
cleanup();
reject(new Error(event.message || "Argon2id worker failed"));
};
const passphraseBytes = utf8Encode(validated.passphrase);
const saltBytes = validated.salt.slice();
worker.postMessage(
{
passphrase: passphraseBytes,
salt: saltBytes,
parameters: validated.parameters,
},
[passphraseBytes.buffer, saltBytes.buffer],
);
});
}
export function normalizeBytes(
value: string | MTPBytesInput | MTPEncodedBytesInput,
name: string,
encoding?: "hex" | "base64",
): Uint8Array {
if (typeof value === "string") {
if (!encoding) {
throw new TypeError(
`${name} string input requires an explicit 'hex' or 'base64' encoding`,
);
}
return bytesFromEncodedString(value, encoding, name);
}
if (
value !== null &&
typeof value === "object" &&
!(value instanceof Uint8Array) &&
!Array.isArray(value)
) {
const encoded = value as Partial<MTPEncodedBytesInput>;
if (
typeof encoded.value !== "string" ||
(encoded.encoding !== "hex" && encoded.encoding !== "base64")
) {
throw new TypeError(
`${name} must be bytes or { value: string, encoding: 'hex' | 'base64' }`,
);
}
return bytesFromEncodedString(encoded.value, encoded.encoding, name);
}
return bytesFrom(value, name);
}
export function inputU64(value: bigint | number | string, name: string): bigint {
if (typeof value === "number" && !Number.isSafeInteger(value)) {
throw new RangeError(
`${name} must be a safe integer number, bigint, or integer string`,
);
}
let result: bigint;
try {
result = BigInt(value);
} catch (error) {
throw new RangeError(`${name} must be an integer`, { cause: error });
}
if (result < 0n || result > 0xffff_ffff_ffff_ffffn) {
throw new RangeError(`${name} must be a u64`);
}
return result;
}
export function toBigInt(
value: bigint | string | number | null | undefined,
): bigint | null {
if (value == null || value === "") return null;
return inputU64(value, "clientId");
}
const KEM_PUBLIC_KEY_LEN = 1216;
const SIG_PQ_PUBLIC_KEY_LEN = 1952;
const SIG_CL_PUBLIC_KEY_LEN = 32;
export function keyringToKeys(keyring: MTPKeyMaterialInput): MTPKeyringKeys {
const bytes = normalizeBytes(keyring, "keyring");
if (bytes.length < 12) {
throw new TypeError("keyring data is too short to contain 6 keys");
}
let offset = 0;
const readKey = () => {
if (offset + 2 > bytes.length) throw new TypeError("keyring is truncated");
const len = (bytes[offset] << 8) | bytes[offset + 1];
offset += 2;
if (offset + len > bytes.length) throw new TypeError("keyring is truncated");
const key = bytes.slice(offset, offset + len);
offset += len;
return key;
};
const result = {
kemPublicKey: readKey(),
kemSecretKey: readKey(),
sigPqPublicKey: readKey(),
sigPqSecretKey: readKey(),
sigClPublicKey: readKey(),
sigClSecretKey: readKey(),
};
if (offset !== bytes.length) throw new TypeError("keyring has trailing data");
return result;
}
export function publicKeyBundleToKeys(
publicKeyBundle: MTPKeyMaterialInput,
): MTPPublicKeyBundleKeys {
const bytes = normalizeBytes(publicKeyBundle, "publicKeyBundle");
if (bytes.length < 6) {
throw new TypeError("public key bundle data is too short to contain 3 keys");
}
let offset = 0;
const readKey = () => {
if (offset + 2 > bytes.length) {
throw new TypeError("public key bundle is truncated");
}
const len = (bytes[offset] << 8) | bytes[offset + 1];
offset += 2;
if (offset + len > bytes.length) {
throw new TypeError("public key bundle is truncated");
}
const key = bytes.slice(offset, offset + len);
offset += len;
return key;
};
const result = {
kemPublicKey: readKey(),
sigPqPublicKey: readKey(),
sigClPublicKey: readKey(),
};
if (offset !== bytes.length) {
throw new TypeError("public key bundle has trailing data");
}
if (
result.kemPublicKey.length !== KEM_PUBLIC_KEY_LEN ||
result.sigPqPublicKey.length !== SIG_PQ_PUBLIC_KEY_LEN ||
result.sigClPublicKey.length !== SIG_CL_PUBLIC_KEY_LEN
) {
throw new TypeError("public key bundle contains invalid suite key lengths");
}
return result;
}
export function cloneParsedValue(value: unknown): unknown {
if (value instanceof Uint8Array) return value.slice();
if (Array.isArray(value)) return value.map(cloneParsedValue);
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, cloneParsedValue(entry)]),
);
}
return value;
}
export function cloneParsedFrame(frame: ParsedFrame): ParsedFrame {
return cloneParsedValue(frame) as ParsedFrame;
}
function parsedDataObject(
data: ParsedFrame["data"] | null | undefined,
): Record<string, unknown> {
if (
data === null ||
typeof data !== "object" ||
Array.isArray(data) ||
data instanceof Uint8Array
) {
return {};
}
const object = data as Record<string, unknown>;
if (object.kind === "encrypted" || object.kind === "signed") return {};
return object;
}
export function errorMessage(
frame: Pick<ParsedFrame, "type" | "data"> | null | undefined,
): string {
const data = parsedDataObject(frame?.data);
return String(
data.ErrorMessage ??
data.Error ??
data.Description ??
`Received ${frame?.type ?? "error"} frame`,
);
}
export function parseProtectedFrame(
frame: MTPProtectedFrameInput,
limits?: MTPReceiveLimits,
): ParsedFrame {
const parse = (bytes: Uint8Array): ParsedFrame =>
limits ? decodeWithLimits(bytes, limits) : bindings.parse_frame(bytes);
if (isBytes(frame)) return parse(bytesFrom(frame, "frame"));
if (
frame === null ||
typeof frame !== "object" ||
typeof frame.type !== "string"
) {
throw new TypeError("frame must be a parsed MTP frame or serialized bytes");
}
if (frame.raw instanceof Uint8Array) return parse(frame.raw);
return frame;
}
export function assertKnownCommunicationType(frame: ParsedFrame): void {
if (!frame.type || /^[0-9]+$/.test(frame.type)) {
throw new Error(`Unknown communication type: ${frame.type || "unknown"}`);
}
try {
bindings.build_frame(frame.type, null, {});
} catch (error) {
throw new Error(`Unknown communication type: ${frame.type}`, { cause: error });
}
}
export function protectedFrameBytes(
frame: ParsedFrame,
limits?: MTPReceiveLimits,
): Uint8Array {
if (frame.raw instanceof Uint8Array) return frame.raw.slice();
const data =
frame.data !== null &&
typeof frame.data === "object" &&
!Array.isArray(frame.data) &&
!(frame.data instanceof Uint8Array)
? (frame.data as Record<string, unknown>)
: null;
const encoded = data?.encoded;
if (data?.kind !== "encrypted" || !(encoded instanceof Uint8Array)) {
throw new Error("protected frame payload is not encrypted");
}
const options = {
id: frame.id,
...(frame.sender == null ? {} : { sender: frame.sender }),
...(frame.receiver == null ? {} : { receiver: frame.receiver }),
};
const bounded = (
bindings as typeof bindings & {
build_frame_with_payload_with_limits?: (
type: string,
payload: Uint8Array,
options: MTPCodecOptions,
limits: MTPReceiveLimits,
) => Uint8Array;
}
).build_frame_with_payload_with_limits;
if (!bounded) {
throw new Error("bounded WASM frame encoding is unavailable; rebuild mtp-wasm");
}
return bounded(frame.type, encoded, options, limits ?? {});
}
export function assertApplicationCommunicationType(type: string): string {
if (typeof type !== "string" || !type.trim() || /^[0-9]+$/.test(type)) {
throw new Error(`Unknown communication type: ${type || "unknown"}`);
}
if (Object.prototype.hasOwnProperty.call(RESERVED_COMMUNICATION_TYPE_IDS, type)) {
throw new Error(
`MTP control communication type ${type} cannot be used as application content`,
);
}
try {
bindings.build_frame(type, null, {});
} catch (error) {
throw new Error(`Unknown communication type: ${type}`, { cause: error });
}
return type;
}
export const MAX_DATA_VALUE_DEPTH = 64;
export const MAX_DATA_VALUE_VALUES = 65_536;
const DEFAULT_ENCODE_LIMITS: Required<MTPEncodeLimits> = {
maxDepth: MAX_DATA_VALUE_DEPTH,
maxValues: MAX_DATA_VALUE_VALUES,
maxOutputSize: 16 * 1024 * 1024,
};
function normalizedEncodeLimits(
limits: MTPEncodeLimits | undefined,
): Required<MTPEncodeLimits> {
const result = { ...DEFAULT_ENCODE_LIMITS, ...(limits ?? {}) };
for (const [key, value] of Object.entries(result)) {
if (!Number.isSafeInteger(value) || value < 0) {
throw new TypeError(`encode limits ${key} must be a non-negative safe integer`);
}
}
return result as Required<MTPEncodeLimits>;
}
/** Validate a JS DataValue before crossing into the recursive WASM parser. */
export function validateMTPDataValue(
value: MTPDataValueInput,
limits?: MTPEncodeLimits,
): void {
const effective = normalizedEncodeLimits(limits);
const ancestors = new WeakSet<object>();
let values = 0;
const validate = (candidate: unknown, depth: number): void => {
values += 1;
if (values > effective.maxValues) {
throw new RangeError("MTP DataValue value-count limit exceeded");
}
if (depth > effective.maxDepth) {
throw new RangeError("MTP DataValue nesting-depth limit exceeded");
}
if (
candidate === null ||
typeof candidate === "boolean" ||
typeof candidate === "string" ||
typeof candidate === "bigint" ||
candidate instanceof Uint8Array
) {
return;
}
if (typeof candidate === "number") {
if (Number.isInteger(candidate) && !Number.isSafeInteger(candidate)) {
throw new TypeError("unsafe integral MTP DataValue inputs must use bigint");
}
return;
}
if (typeof candidate !== "object") {
throw new TypeError(`unsupported MTP DataValue input: ${typeof candidate}`);
}
const object = candidate as object;
if (ancestors.has(object)) throw new TypeError("MTP DataValue input must not be cyclic");
if (
!Array.isArray(candidate) &&
Object.getPrototypeOf(candidate) !== Object.prototype &&
Object.getPrototypeOf(candidate) !== null
) {
throw new TypeError("MTP DataValue containers must be plain objects");
}
ancestors.add(object);
const entries = Array.isArray(candidate)
? candidate
: Object.values(candidate as Record<string, unknown>);
try {
for (const entry of entries) validate(entry, depth + 1);
} finally {
ancestors.delete(object);
}
};
validate(value, 0);
}
export function encodeMTPDataValue(
value: MTPDataValueInput,
limits?: MTPEncodeLimits,
): Uint8Array {
const effective = normalizedEncodeLimits(limits);
validateMTPDataValue(value, effective);
const bounded = (
bindings as typeof bindings & {
encode_data_value_with_limits?: (
value: MTPDataValueInput,
limits: MTPEncodeLimits,
) => Uint8Array;
}
).encode_data_value_with_limits;
if (!bounded) {
throw new Error("bounded WASM DataValue encoding is unavailable; rebuild mtp-wasm");
}
const encoded = bounded(value, effective);
if (encoded.length > effective.maxOutputSize) {
throw new RangeError("MTP DataValue encoded output limit exceeded");
}
return encoded;
}
export function inputDataValueBigInt(value: unknown, name: string): bigint {
try {
if (typeof value === "bigint") return value;
if (typeof value === "number" && Number.isSafeInteger(value)) return BigInt(value);
if (typeof value === "string" && value.length > 0) return BigInt(value);
} catch {
// Normalize malformed protected metadata below.
}
throw new Error(`protected metadata field ${name} is not an integer`);
}
export function inputDataValueString(value: unknown, name: string): string {
if (typeof value === "string" && value.length > 0) return value;
throw new Error(`protected metadata field ${name} is not a non-empty string`);
}
export function signatureSuiteValue(
suite: MTPProtectionSignatureSuite,
): number {
return suite === "dual"
? bindings.mtp_protection_signature_suite_dual()
: bindings.mtp_protection_signature_suite_ed25519();
}
export function formatDataValue(value: MTPDataValue): MTPDataValue {
return cloneParsedValue(value) as MTPDataValue;
}

26
src/sdk/credentials.ts Normal file
View file

@ -0,0 +1,26 @@
import type { MTPClientCredentials } from "./index.js";
export type InternalCredentials = {
clientId: bigint | null;
keyring: Uint8Array;
hostPublicKey?: Uint8Array;
};
export function publicCredentials(
credentials: InternalCredentials | null,
): MTPClientCredentials | null {
if (!credentials) {
return null;
}
return {
clientId: credentials.clientId,
keyring: credentials.keyring.slice(),
hostPublicKey: credentials.hostPublicKey?.slice(),
};
}
export function zeroCredentials(credentials: InternalCredentials | null): void {
// The host public key is intentionally not wiped: it is public configuration
// and may also be retained by the connection options.
credentials?.keyring.fill(0);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,33 @@
import initWasm, * as bindings from "mtp/raw";
interface PasswordKdfWorkerRequest {
passphrase: Uint8Array;
salt: Uint8Array;
parameters: {
memoryKiB: number;
iterations: number;
lanes: number;
};
}
const scope = globalThis as unknown as {
onmessage: ((event: MessageEvent<PasswordKdfWorkerRequest>) => void) | null;
postMessage(message: Uint8Array | { error: string }, transfer?: Transferable[]): void;
};
scope.onmessage = async (event) => {
try {
await initWasm();
const { passphrase, salt, parameters } = event.data;
const key = bindings.wasm_argon2id(
passphrase,
salt,
parameters.memoryKiB,
parameters.iterations,
parameters.lanes,
);
scope.postMessage(key, [key.buffer]);
} catch (error) {
scope.postMessage({ error: String(error) });
}
};

258
src/sdk/protection.ts Normal file
View file

@ -0,0 +1,258 @@
import type { InternalCredentials } from "./credentials.js";
import {
inputU64,
keyringToKeys,
normalizeBytes,
publicKeyBundleToKeys,
signatureSuiteValue,
} from "./codec.js";
import {
MTPSignatureVerificationError,
signerKeysUnavailable,
} from "./signature-policy.js";
import type { MTPSignatureVerificationPolicy } from "./signature-policy.js";
import type {
MTPDecryptionIdentity,
MTPProtectionIdentity,
MTPProtectionSignatureSuite,
MTPReplayGuard,
MTPSignerKeyResolver,
MTPBytesInput,
MTPKeyMaterialInput,
} from "./client.js";
export class InMemoryReplayGuard implements MTPReplayGuard {
#accepted = new Set<string>();
readonly #capacity = 10_000;
accept(signerId: bigint, messageId: string, _createdAt: bigint): boolean {
const key = `${signerId}:${messageId}`;
if (this.#accepted.has(key)) return false;
this.#accepted.add(key);
if (this.#accepted.size > this.#capacity) {
const oldest = this.#accepted.values().next().value;
if (oldest !== undefined) this.#accepted.delete(oldest);
}
return true;
}
}
export class MTPReplayError extends Error {
readonly signerId: bigint;
readonly messageId: string;
constructor(signerId: bigint, messageId: string) {
super(`message ${messageId} from signer ${signerId} was already accepted`);
this.name = "MTPReplayError";
this.signerId = signerId;
this.messageId = messageId;
}
}
export class MTPMissingProtectedVersionError extends Error {
constructor() {
super("protected message does not declare a protected version");
this.name = "MTPMissingProtectedVersionError";
}
}
export class MTPUnsupportedProtectedVersionError extends Error {
readonly protectedVersion: bigint;
constructor(protectedVersion: bigint) {
super(`unsupported protected message version ${protectedVersion}`);
this.name = "MTPUnsupportedProtectedVersionError";
this.protectedVersion = protectedVersion;
}
}
export class MTPResourceLimitError extends Error {
constructor(message = "MTP receive resource limit exceeded") {
super(message);
this.name = "MTPResourceLimitError";
}
}
export interface ResolvedProtectionIdentity {
signerId: bigint;
keyring: Uint8Array;
}
export interface ResolvedDecryptionIdentity {
id?: bigint;
keyrings: Uint8Array[];
}
export interface SignerResolutionOptions {
expectedSignerId?: bigint | number | string;
resolveSignerPublicKeys?: MTPSignerKeyResolver;
}
export function protectionSignatureSuiteValue(
suite: MTPProtectionSignatureSuite,
): number {
return signatureSuiteValue(suite);
}
export function effectiveProtectionSignatureSuite(
keyring: Uint8Array,
requested?: MTPProtectionSignatureSuite,
): MTPProtectionSignatureSuite {
const keys = keyringToKeys(keyring);
const hasPqPublicKey = keys.sigPqPublicKey.length > 0;
const hasPqSecretKey = keys.sigPqSecretKey.length > 0;
const suite = requested ?? "ed25519";
if (suite !== "ed25519" && suite !== "dual") {
throw new Error("signatureSuite must be 'ed25519' or 'dual'");
}
if (suite === "dual" && !(hasPqPublicKey && hasPqSecretKey)) {
throw new Error(
"dual protected signatures require a complete ML-DSA key pair; choose 'ed25519' for a partial keyring",
);
}
return suite;
}
function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false;
}
return true;
}
function normalizeDecryptionKeyrings(
identity: MTPDecryptionIdentity,
): Uint8Array[] {
const current = normalizeBytes(identity.keyring, "recipient.keyring");
if (current.length === 0) throw new Error("recipient.keyring must not be empty");
if (
identity.keyringHistory !== undefined &&
!Array.isArray(identity.keyringHistory)
) {
throw new TypeError("recipient.keyringHistory must be an array");
}
const keyrings: Uint8Array[] = [];
const add = (value: MTPKeyMaterialInput, name: string): void => {
const bytes = normalizeBytes(value, name);
if (bytes.length === 0) throw new Error(`${name} must not be empty`);
if (!keyrings.some((existing) => sameBytes(existing, bytes))) {
keyrings.push(bytes.slice());
}
};
add(current, "recipient.keyring");
for (const [index, history] of (identity.keyringHistory ?? []).entries()) {
add(history, `recipient.keyringHistory[${index}]`);
}
if (keyrings.length === 0) throw new Error("recipient must contain at least one keyring");
return keyrings;
}
export function normalizeRecipientBundles(
recipients: MTPKeyMaterialInput[],
name: string,
): Uint8Array[] {
if (!Array.isArray(recipients) || recipients.length === 0) {
throw new TypeError(`${name} must contain at least one public key bundle`);
}
return recipients.map((value, index) => {
const bundle = normalizeBytes(value, `${name}[${index}]`);
publicKeyBundleToKeys(bundle);
return bundle.slice();
});
}
export function resolveProtectionIdentity(
explicit: MTPProtectionIdentity | undefined,
stored: InternalCredentials | null,
): ResolvedProtectionIdentity {
if (explicit) {
return {
signerId: inputU64(explicit.signerId, "identity.signerId"),
keyring: normalizeBytes(explicit.keyring, "identity.keyring").slice(),
};
}
if (stored?.clientId != null && stored.keyring.length > 0) {
return { signerId: stored.clientId, keyring: stored.keyring.slice() };
}
throw new Error(
"protected send requires an explicit protection identity or stored registered credentials",
);
}
export function resolveDecryptionIdentity(
explicit: MTPDecryptionIdentity | undefined,
stored: InternalCredentials | null,
): ResolvedDecryptionIdentity {
if (explicit) {
return {
id: explicit.id == null ? undefined : inputU64(explicit.id, "recipient.id"),
keyrings: normalizeDecryptionKeyrings(explicit),
};
}
if (stored?.clientId != null && stored.keyring.length > 0) {
return {
id: stored.clientId,
keyrings: normalizeDecryptionKeyrings({
id: stored.clientId,
keyring: stored.keyring,
}),
};
}
throw new Error(
"protected receive requires an explicit decryption identity or stored registered credentials",
);
}
export function protectedOpeningError(error: unknown, signerId?: bigint): Error {
if (error !== null && typeof error === "object") {
const structured = error as { code?: unknown; protectedVersion?: unknown };
if (typeof structured.code === "string") {
switch (structured.code) {
case "missing-protected-version":
return new MTPMissingProtectedVersionError();
case "unsupported-protected-version":
if (
typeof structured.protectedVersion === "bigint" ||
typeof structured.protectedVersion === "number" ||
typeof structured.protectedVersion === "string"
) {
return new MTPUnsupportedProtectedVersionError(
inputU64(structured.protectedVersion, "protectedVersion"),
);
}
break;
case "no-matching-recipient":
return new Error("Unable to decrypt protected value with supplied recipient keyrings");
case "reserved-application-type":
return new Error("MTP control communication types cannot be used as application content");
case "signature-policy-mismatch":
return new MTPSignatureVerificationError("policy-rejected", signerId);
case "unsupported-signature-suite":
return new MTPSignatureVerificationError("unsupported-suite", signerId);
case "invalid-signature":
return new MTPSignatureVerificationError("invalid-signature", signerId);
case "signer-id-mismatch":
return new Error("protected signer ID mismatch");
case "receiver-id-mismatch":
return new Error("protected frame receiver ID mismatch");
case "message-type-mismatch":
return new Error("protected message type does not match outer routing");
case "final-recipient-mismatch":
return new Error("protected final recipient does not match outer routing receiver");
case "sender-id-mismatch":
return new Error("protected frame sender does not match authenticated signer");
case "signer-key-not-found":
return signerKeysUnavailable(signerId);
case "replay":
return new Error("protected message was already accepted");
case "resource-limit":
return new MTPResourceLimitError();
}
}
}
return error instanceof Error ? error : new Error(String(error));
}
export type { MTPSignatureVerificationPolicy };

204
src/sdk/relay.ts Normal file
View file

@ -0,0 +1,204 @@
import type * as RawBindings from "../raw/index";
import { cloneParsedFrame, cloneParsedValue, inputU64 } from "./codec.js";
import {
MTPSignatureVerificationError,
signerKeysUnavailable,
} from "./signature-policy.js";
import { MTPResourceLimitError } from "./protection.js";
import type { MTPSignatureVerificationPolicy } from "./signature-policy.js";
import type {
MTPDataValue,
MTPReceiveLimits,
MTPVerifiedRelayContent,
ParsedFrame,
} from "./client.js";
export class MTPMissingRelayVersionError extends Error {
constructor() {
super("relay frame does not declare a relay version");
this.name = "MTPMissingRelayVersionError";
}
}
export class MTPUnsupportedRelayVersionError extends Error {
readonly relayVersion: bigint;
constructor(relayVersion: bigint) {
super(`unsupported relay version ${relayVersion}`);
this.name = "MTPUnsupportedRelayVersionError";
this.relayVersion = relayVersion;
}
}
export function relayOpeningError(error: unknown, signerId?: bigint): Error {
if (error !== null && typeof error === "object") {
const structured = error as { code?: unknown; relayVersion?: unknown };
if (typeof structured.code === "string") {
switch (structured.code) {
case "missing-relay-version":
return new MTPMissingRelayVersionError();
case "unsupported-relay-version":
if (
typeof structured.relayVersion === "bigint" ||
typeof structured.relayVersion === "number" ||
typeof structured.relayVersion === "string"
) {
return new MTPUnsupportedRelayVersionError(
inputU64(structured.relayVersion, "relayVersion"),
);
}
break;
case "no-matching-recipient":
return new Error("Unable to decrypt protected value with supplied recipient keyrings");
case "not-final-recipient":
return new Error("relay content is addressed to a different final recipient");
case "reserved-application-type":
return new Error("relay application message type is reserved for MTP control");
case "signature-policy-mismatch":
return new MTPSignatureVerificationError("policy-rejected", signerId);
case "unsupported-signature-suite":
return new MTPSignatureVerificationError("unsupported-suite", signerId);
case "invalid-signature":
return new MTPSignatureVerificationError("invalid-signature", signerId);
case "signer-id-mismatch":
return new Error("relay signer ID mismatch");
case "purpose-mismatch":
return new Error("relay protection purpose mismatch");
case "signer-key-not-found":
return signerKeysUnavailable(signerId);
case "replay":
return new Error("relay message was already accepted");
case "resource-limit":
return new MTPResourceLimitError();
}
}
}
return error instanceof Error ? error : new Error(String(error));
}
export interface MTPRelayMetadataState {
frame: ParsedFrame;
native: RawBindings.WasmVerifiedRelayMetadata;
relayVersion: number;
signerId: bigint;
finalRecipientId: bigint;
messageId: string;
createdAt: bigint;
hasMetadata: boolean;
metadata?: MTPDataValue;
encryptedContent: Uint8Array;
signerPublicKeys: Uint8Array[];
matchedSignerKeyIndex: number;
signaturePolicy: MTPSignatureVerificationPolicy;
receiveLimits?: MTPReceiveLimits;
receiveLimitsExplicit: boolean;
disposed: boolean;
finalizerToken: object;
}
export const relayMetadataState = new WeakMap<
MTPVerifiedRelayMetadata,
MTPRelayMetadataState
>();
const relayMetadataFinalizer = new FinalizationRegistry<
RawBindings.WasmVerifiedRelayMetadata
>((native) => {
try {
native.free();
} catch {
// The WASM instance may already have been torn down during page unload.
}
});
export const RELAY_METADATA_TOKEN = Symbol("mtp-authenticated-relay-metadata");
export class MTPVerifiedRelayMetadata {
constructor(
token: typeof RELAY_METADATA_TOKEN,
state: MTPRelayMetadataState,
) {
if (token !== RELAY_METADATA_TOKEN) {
throw new Error("relay metadata must be created by authenticated opening");
}
relayMetadataState.set(this, state);
}
private get state(): MTPRelayMetadataState {
const state = relayMetadataState.get(this);
if (!state) throw new Error("relay metadata authentication state is missing");
if (state.disposed) throw new Error("relay metadata has been disposed");
return state;
}
dispose(): void {
const state = relayMetadataState.get(this);
if (!state || state.disposed) return;
state.disposed = true;
relayMetadataFinalizer.unregister(state.finalizerToken);
try {
state.native.free();
} catch {
// The WASM instance may already have been torn down during page unload.
}
}
free(): void {
this.dispose();
}
[Symbol.dispose](): void {
this.dispose();
}
get frame(): ParsedFrame {
return cloneParsedFrame(this.state.frame);
}
get signerId(): bigint {
return this.state.signerId;
}
get relayVersion(): number {
return this.state.relayVersion;
}
get finalRecipientId(): bigint {
return this.state.finalRecipientId;
}
get messageId(): string {
return this.state.messageId;
}
get createdAt(): bigint {
return this.state.createdAt;
}
get metadata(): MTPDataValue | undefined {
return this.state.hasMetadata
? (cloneParsedValue(this.state.metadata) as MTPDataValue)
: undefined;
}
get encryptedContent(): Uint8Array {
return this.state.encryptedContent.slice();
}
get signerPublicKeys(): Uint8Array[] {
return this.state.signerPublicKeys.map((bundle) => bundle.slice());
}
get matchedSignerKeyIndex(): number {
return this.state.matchedSignerKeyIndex;
}
get matchedSignerPublicKey(): Uint8Array {
const key = this.state.signerPublicKeys[this.state.matchedSignerKeyIndex];
if (!key) throw new Error("relay verification matched an unavailable signer key");
return key.slice();
}
get signaturePolicy(): MTPSignatureVerificationPolicy {
return this.state.signaturePolicy;
}
}
export function registerRelayMetadata(
metadata: MTPVerifiedRelayMetadata,
native: RawBindings.WasmVerifiedRelayMetadata,
finalizerToken: object,
): void {
relayMetadataFinalizer.register(metadata, native, finalizerToken);
}
export type { MTPVerifiedRelayContent };

View file

@ -92,8 +92,14 @@ export function signatureVerificationPolicyValue(
return bindings.mtp_protection_signature_suite_ed25519();
case "dual":
return bindings.mtp_protection_signature_suite_dual();
case "any-supported":
return 0;
case "any-supported": {
const compatibility = (
bindings as typeof bindings & {
mtp_protection_signature_suite_any_supported?: () => number;
}
).mtp_protection_signature_suite_any_supported;
return compatibility?.() ?? 0;
}
}
}

27
src/sdk/timeout.ts Normal file
View file

@ -0,0 +1,27 @@
export async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number | undefined,
message: string,
cancel?: () => void,
): Promise<T> {
if (!timeoutMs) {
return await promise;
}
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_resolve, reject) => {
timeoutId = setTimeout(() => {
cancel?.();
reject(new Error(message));
}, timeoutMs);
}),
]);
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}

27
src/sdk/wasm-init.ts Normal file
View file

@ -0,0 +1,27 @@
import initWasm from "mtp/raw";
type WasmInitInput = Parameters<typeof initWasm>[0];
type WasmExports = Awaited<ReturnType<typeof initWasm>>;
type WasmInitializer = (input?: WasmInitInput) => Promise<WasmExports>;
export function createWasmInitializer(
initialize: WasmInitializer = initWasm,
): WasmInitializer {
let wasmInitPromise: Promise<WasmExports> | undefined;
/**
* Keep the successful WASM singleton, but make a failed attempt retryable.
* A rejected promise is never retained in the module cache.
*/
return (input?: WasmInitInput): Promise<WasmExports> => {
if (!wasmInitPromise) {
wasmInitPromise = initialize(input).catch((error) => {
wasmInitPromise = undefined;
throw error;
});
}
return wasmInitPromise;
}
}
export const initWasmOnce = createWasmInitializer();

20
test/wasm-init.mjs Normal file
View file

@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createWasmInitializer } from "../dist/sdk/wasm-init.js";
test("WASM initialization can retry after a rejected attempt", async () => {
let attempts = 0;
const expected = { initialized: true };
const init = createWasmInitializer(async () => {
attempts += 1;
if (attempts === 1) {
throw new Error("initialization failed");
}
return expected;
});
await assert.rejects(init(), /initialization failed/);
assert.equal(await init(), expected);
assert.equal(await init(), expected);
assert.equal(attempts, 2);
});

View file

@ -1,12 +1,14 @@
use crate::ConnectionHandle;
use crate::framing::RetryClassifier;
#[cfg(feature = "pipes")]
use crate::pipe::PipeReader;
use mtp_codec::{CommunicationValue, DecodeLimits, TypeMap};
use mtp_codec::{CommunicationValue, DecodeError, DecodeLimits, EncodeLimits, TypeMap};
use mtp_common::CommunicationError;
use std::ops::Deref;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::{Mutex, Notify, RwLock, Semaphore, mpsc};
use tokio::time::{Duration, sleep, timeout};
use tokio::time::{Duration, Instant, sleep, timeout, timeout_at};
use tracing::{debug, info, instrument, trace, warn};
use wtransport::Connection;
@ -19,6 +21,63 @@ pub enum TransportEvent<R = wtransport::RecvStream> {
const APPLICATION_CLOSE_REASON: &str = "mtp-close";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DecodeRejectionClass {
Malformed,
ResourceLimit,
DuplicateField,
}
pub fn classify_decode_error(error: &DecodeError) -> DecodeRejectionClass {
match error {
DecodeError::MalformedEncoding => DecodeRejectionClass::Malformed,
DecodeError::DepthLimit
| DecodeError::ValueCountLimit
| DecodeError::BlobLimit
| DecodeError::AllocationLimit
| DecodeError::RecipientLimit => DecodeRejectionClass::ResourceLimit,
DecodeError::DuplicateField => DecodeRejectionClass::DuplicateField,
}
}
#[derive(Debug, Default)]
pub struct DecodeRejectionCounters {
malformed: AtomicU64,
resource_limit: AtomicU64,
duplicate_field: AtomicU64,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DecodeRejectionCounts {
pub malformed: u64,
pub resource_limit: u64,
pub duplicate_field: u64,
}
impl DecodeRejectionCounters {
pub(crate) fn record(&self, error: &DecodeError) {
match classify_decode_error(error) {
DecodeRejectionClass::Malformed => {
self.malformed.fetch_add(1, Ordering::Relaxed);
}
DecodeRejectionClass::ResourceLimit => {
self.resource_limit.fetch_add(1, Ordering::Relaxed);
}
DecodeRejectionClass::DuplicateField => {
self.duplicate_field.fetch_add(1, Ordering::Relaxed);
}
}
}
pub(crate) fn snapshot(&self) -> DecodeRejectionCounts {
DecodeRejectionCounts {
malformed: self.malformed.load(Ordering::Relaxed),
resource_limit: self.resource_limit.load(Ordering::Relaxed),
duplicate_field: self.duplicate_field.load(Ordering::Relaxed),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SendMode {
PersistentStream,
@ -135,27 +194,62 @@ impl Policy {
}
}
/// A validated policy snapshot used after a public [`Policy`] crosses into a
/// transport implementation. `Policy` intentionally remains a plain public
/// struct for source compatibility, so callers can construct it directly and
/// bypass builder methods. Every transport constructor takes this snapshot
/// before creating channels or semaphores.
#[derive(Debug, Clone, Copy)]
pub(crate) struct RuntimePolicy(Policy);
impl RuntimePolicy {
pub(crate) fn from_public(policy: &Policy) -> Self {
let mut policy = *policy;
policy.receiver_queue_capacity = policy.receiver_queue_capacity.max(1);
policy.max_concurrent_stream_tasks = policy.max_concurrent_stream_tasks.max(1);
Self(policy)
}
}
impl Deref for RuntimePolicy {
type Target = Policy;
fn deref(&self) -> &Self::Target {
&self.0
}
}
enum ReceivedFrame {
Message(CommunicationValue),
ClosedByPeer,
Idle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SenderState {
Open,
Closing,
Closed,
}
#[derive(Clone)]
pub struct Sender {
send_guard: Arc<Mutex<()>>,
stream_guard: Arc<Mutex<Option<wtransport::SendStream>>>,
state: Arc<Mutex<SenderState>>,
handle: Arc<ConnectionHandle>,
connection: Connection,
policy: Arc<Policy>,
policy: Arc<RuntimePolicy>,
type_map: Arc<RwLock<TypeMap>>,
}
impl Sender {
pub fn new(connection: Connection, handle: Arc<ConnectionHandle>, policy: Arc<Policy>) -> Self {
let policy = Arc::new(RuntimePolicy::from_public(&policy));
Self {
send_guard: Arc::new(Mutex::new(())),
stream_guard: Arc::new(Mutex::new(None)),
state: Arc::new(Mutex::new(SenderState::Open)),
handle,
connection,
policy,
@ -174,7 +268,11 @@ impl Sender {
data: &CommunicationValue,
policy: &Policy,
) -> Result<(), CommunicationError> {
let bytes = data.to_bytes().map_err(|_| CommunicationError::Encode)?;
let bytes = data
.to_bytes_with_limits(EncodeLimits::for_transport_message_size(
policy.max_message_size,
))
.map_err(|_| CommunicationError::Encode)?;
if bytes.len() as u64 > policy.max_message_size
|| bytes.len() as u64 >= policy.close_frame_len as u64
{
@ -269,11 +367,11 @@ impl Sender {
return Ok(());
}
let err = res.err().unwrap_or(CommunicationError::StreamError);
if !matches!(
err,
CommunicationError::StreamError | CommunicationError::StreamClosed
) {
let err = match res {
Ok(()) => return Ok(()),
Err(error) => error,
};
if !RetryClassifier::retry_persistent_stream(&err) {
return Err(err);
}
*stream_opt = None;
@ -356,20 +454,24 @@ impl Sender {
#[instrument(skip(self, data), level = "trace")]
pub async fn send(&self, data: &CommunicationValue) -> Result<(), CommunicationError> {
if self.handle.is_closed() {
return Err(self
.handle
.close_reason()
.unwrap_or(CommunicationError::UseAfterClosed));
}
let _send_lock = self.send_guard.lock().await;
{
let state = self.state.lock().await;
if *state != SenderState::Open {
return Err(self
.handle
.close_reason()
.unwrap_or(CommunicationError::StreamClosed));
}
}
if self.connection.quic_connection().close_reason().is_some() {
let reason = self
.handle
.close_reason()
.unwrap_or(CommunicationError::StreamClosed);
*self.state.lock().await = SenderState::Closed;
self.handle.close(Some(reason.clone()));
return Err(reason);
}
@ -402,6 +504,7 @@ impl Sender {
if self.connection.quic_connection().close_reason().is_some()
|| matches!(normalized, CommunicationError::StreamClosed)
{
*self.state.lock().await = SenderState::Closed;
self.handle.close(Some(normalized.clone()));
}
@ -413,6 +516,12 @@ impl Sender {
#[instrument(skip(self), level = "trace")]
pub async fn finish_stream(&self) -> Result<(), CommunicationError> {
let _send_lock = self.send_guard.lock().await;
if *self.state.lock().await != SenderState::Open {
return Err(self
.handle
.close_reason()
.unwrap_or(CommunicationError::StreamClosed));
}
let mut stream_opt = self.stream_guard.lock().await;
if let Some(mut stream) = stream_opt.take() {
match timeout(self.policy.write_timeout, stream.finish()).await {
@ -445,11 +554,12 @@ impl Sender {
pipe_id: u32,
description: &str,
) -> Result<crate::pipe::PipeWriter, CommunicationError> {
if self.handle.is_closed() {
let _send_lock = self.send_guard.lock().await;
if *self.state.lock().await != SenderState::Open {
return Err(self
.handle
.close_reason()
.unwrap_or(CommunicationError::UseAfterClosed));
.unwrap_or(CommunicationError::StreamClosed));
}
if self.connection.quic_connection().close_reason().is_some() {
@ -486,31 +596,47 @@ impl Sender {
let handle = self.handle.clone();
let policy = self.policy.clone();
let stream_guard = self.stream_guard.clone();
let send_guard = self.send_guard.clone();
let state = self.state.clone();
tokio::spawn(async move {
let _send_lock = send_guard.lock().await;
{
let mut sender_state = state.lock().await;
if *sender_state != SenderState::Open {
return;
}
*sender_state = SenderState::Closing;
}
if connection.quic_connection().close_reason().is_some() || handle.is_closed() {
*state.lock().await = SenderState::Closed;
handle.close(Some(CommunicationError::StreamClosed));
return;
}
if let Some(mut stream) = stream_guard.lock().await.take() {
match timeout(policy.write_timeout, stream.finish()).await {
Ok(Ok(())) => {}
Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => warn!(
"[Sender] persistent stream finish failed: peer sent STOP_SENDING (error code {code})"
),
Ok(Err(e)) => {
warn!("[Sender] persistent stream finish failed: {e}")
{
if let Some(mut stream) = stream_guard.lock().await.take() {
match timeout(policy.write_timeout, stream.finish()).await {
Ok(Ok(())) => {}
Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => warn!(
"[Sender] persistent stream finish failed: peer sent STOP_SENDING (error code {code})"
),
Ok(Err(e)) => {
warn!("[Sender] persistent stream finish failed: {e}")
}
Err(_) => warn!("[Sender] persistent stream finish timed out"),
}
Err(_) => warn!("[Sender] persistent stream finish timed out"),
}
}
let _ = Self::send_close_frame(&connection, &policy).await;
*state.lock().await = SenderState::Closed;
handle.close(Some(CommunicationError::StreamClosed));
info!(target = "mtp.transport", "connection closed");
drop(_send_lock);
sleep(policy.force_close_delay).await;
if connection.quic_connection().close_reason().is_none() {
connection.quic_connection().close(
@ -528,35 +654,48 @@ impl Sender {
let connection = self.connection.clone();
let handle = self.handle.clone();
let policy = self.policy.clone();
let mut stream_opt = self.stream_guard.lock().await;
let _send_lock = self.send_guard.lock().await;
{
let mut state = self.state.lock().await;
if *state != SenderState::Open {
return;
}
*state = SenderState::Closing;
}
if connection.quic_connection().close_reason().is_some() || handle.is_closed() {
*self.state.lock().await = SenderState::Closed;
handle.close(Some(CommunicationError::StreamClosed));
return;
}
if let Some(mut stream) = stream_opt.take() {
let close_bytes = policy.close_frame_len.to_be_bytes();
let close_write = async {
stream.write_all(&close_bytes).await?;
stream.finish().await
};
{
let mut stream_opt = self.stream_guard.lock().await;
if let Some(mut stream) = stream_opt.take() {
let close_bytes = policy.close_frame_len.to_be_bytes();
let close_write = async {
stream.write_all(&close_bytes).await?;
stream.finish().await
};
match timeout(policy.write_timeout, close_write).await {
Ok(Ok(())) => {}
Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => {
warn!("[Sender] close failed: peer sent STOP_SENDING (error code {code})")
match timeout(policy.write_timeout, close_write).await {
Ok(Ok(())) => {}
Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => {
warn!("[Sender] close failed: peer sent STOP_SENDING (error code {code})")
}
Ok(Err(e)) => warn!("[Sender] close failed: {e}"),
Err(_) => warn!("[Sender] close timed out"),
}
Ok(Err(e)) => warn!("[Sender] close failed: {e}"),
Err(_) => warn!("[Sender] close timed out"),
} else {
let _ = Self::send_close_frame(&connection, &policy).await;
}
} else {
let _ = Self::send_close_frame(&connection, &policy).await;
}
*self.state.lock().await = SenderState::Closed;
handle.close(Some(CommunicationError::StreamClosed));
info!(target = "mtp.transport", "connection closed");
drop(_send_lock);
sleep(policy.force_close_delay).await;
if connection.quic_connection().close_reason().is_none() {
connection.quic_connection().close(
@ -605,6 +744,7 @@ struct ReceiverInner {
queue_notify: Arc<Notify>,
max_message_size: Arc<AtomicU64>,
type_map: Arc<RwLock<TypeMap>>,
decode_rejections: Arc<DecodeRejectionCounters>,
}
impl Clone for Receiver {
@ -626,12 +766,26 @@ impl Drop for Receiver {
#[derive(Clone, Default)]
struct PingControl {
pong_sender: Option<Sender>,
pong_observer: Option<mpsc::UnboundedSender<CommunicationValue>>,
pong_observer: Option<mpsc::Sender<CommunicationValue>>,
expected_pong_id: Option<u32>,
}
impl PingControl {
fn accepts_pong(&mut self, id: Option<u32>) -> bool {
if self.expected_pong_id == id && id.is_some() {
self.expected_pong_id = None;
true
} else {
false
}
}
}
impl Receiver {
pub fn new(connection: Connection, handle: Arc<ConnectionHandle>, policy: Arc<Policy>) -> Self {
Self::new_with_max_message_size(connection, handle, policy.clone(), policy.max_message_size)
let policy = Arc::new(RuntimePolicy::from_public(&policy));
let max_message_size = policy.max_message_size;
Self::new_with_max_message_size(connection, handle, policy, max_message_size)
}
#[cfg(feature = "host")]
@ -640,6 +794,7 @@ impl Receiver {
handle: Arc<ConnectionHandle>,
policy: Arc<Policy>,
) -> Self {
let policy = Arc::new(RuntimePolicy::from_public(&policy));
let initial_max = policy
.handshake_max_message_size
.min(policy.max_message_size);
@ -649,7 +804,7 @@ impl Receiver {
fn new_with_max_message_size(
connection: Connection,
handle: Arc<ConnectionHandle>,
policy: Arc<Policy>,
policy: Arc<RuntimePolicy>,
initial_max_message_size: u64,
) -> Self {
#[cfg(feature = "pipes")]
@ -674,6 +829,8 @@ impl Receiver {
let accept_max_message_size = max_message_size.clone();
let type_map = Arc::new(RwLock::new(TypeMap::latest()));
let accept_type_map = type_map.clone();
let decode_rejections = Arc::new(DecodeRejectionCounters::default());
let accept_decode_rejections = decode_rejections.clone();
let stream_limit = Arc::new(Semaphore::new(policy.max_concurrent_stream_tasks.max(1)));
let accept_stream_limit = stream_limit.clone();
debug!(
@ -742,6 +899,7 @@ impl Receiver {
let stream_ping_control = accept_ping_control.clone();
let stream_max_message_size = accept_max_message_size.clone();
let stream_type_map = accept_type_map.clone();
let stream_decode_rejections = accept_decode_rejections.clone();
tokio::spawn(async move {
let _permit = permit;
@ -761,7 +919,14 @@ impl Receiver {
}
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,
&stream_decode_rejections,
)
.await
{
Ok(ReceivedFrame::Message(mut msg)) => {
let negotiated_type_map =
stream_type_map.read().await.clone();
@ -805,24 +970,17 @@ impl Receiver {
}
}
let control = {
let control = stream_ping_control.read().await;
if msg.is_type(mtp_codec::CommunicationType::Ping) {
control
.pong_sender
.clone()
.map(|sender| (Some(sender), None))
} else if msg.is_type(mtp_codec::CommunicationType::Pong) {
control
.pong_observer
.clone()
.map(|observer| (None, Some(observer)))
} else {
None
}
let pong_sender = if msg.is_type(mtp_codec::CommunicationType::Ping) {
stream_ping_control
.read()
.await
.pong_sender
.clone()
} else {
None
};
if let Some((Some(sender), _)) = control {
if let Some(sender) = pong_sender {
let mut pong = CommunicationValue::new_with_type_map(
mtp_codec::CommunicationType::Pong,
&negotiated_type_map,
@ -844,8 +1002,18 @@ impl Receiver {
continue;
}
if let Some((_, Some(observer))) = control {
let _ = observer.send(msg);
if msg.is_type(mtp_codec::CommunicationType::Pong) {
let observer = {
let mut control = stream_ping_control.write().await;
if control.accepts_pong(msg.id()) {
control.pong_observer.clone()
} else {
None
}
};
if let Some(observer) = observer {
let _ = observer.try_send(msg);
}
continue;
}
@ -942,6 +1110,7 @@ impl Receiver {
queue_notify,
max_message_size,
type_map,
decode_rejections,
}),
}
}
@ -958,6 +1127,14 @@ impl Receiver {
*self.inner.type_map.write().await = type_map.clone();
}
/// Return local counts for frames rejected by the structured decoder.
///
/// These counters are intentionally local-only; peers continue to receive
/// the generic protocol parse failure.
pub fn decode_rejection_counts(&self) -> DecodeRejectionCounts {
self.inner.decode_rejections.snapshot()
}
/* Respond to reserved Ping frames without exposing them to application I/O. */
pub fn respond_to_pings(&self, sender: Sender) {
if let Ok(mut control) = self.inner.ping_control.try_write() {
@ -967,16 +1144,21 @@ impl Receiver {
}
}
/* Route reserved Pong frames to a connection-level observer. */
pub async fn observe_pongs(&self, observer: mpsc::UnboundedSender<CommunicationValue>) {
/* Route only the currently expected reserved Pong through a bounded observer. */
pub async fn observe_pongs_bounded(&self, observer: mpsc::Sender<CommunicationValue>) {
self.inner.ping_control.write().await.pong_observer = Some(observer);
}
#[instrument(skip(stream, policy), level = "trace")]
pub async fn set_expected_pong_id(&self, expected_pong_id: Option<u32>) {
self.inner.ping_control.write().await.expected_pong_id = expected_pong_id;
}
#[instrument(skip(stream, policy, decode_rejections), level = "trace")]
async fn read_one_frame(
stream: &mut wtransport::RecvStream,
policy: &Policy,
policy: &RuntimePolicy,
max_message_size: u64,
decode_rejections: &DecodeRejectionCounters,
) -> Result<ReceivedFrame, CommunicationError> {
use wtransport::error::{StreamReadError, StreamReadExactError};
@ -1011,6 +1193,7 @@ impl Receiver {
if len == policy.close_frame_len {
return Ok(ReceivedFrame::ClosedByPeer);
}
let deadline = Instant::now() + policy.read_timeout;
let body_len = len as usize;
let frame_len = body_len
@ -1020,29 +1203,29 @@ impl Receiver {
return Err(CommunicationError::MessageTooLarge);
}
// Grow in bounded chunks instead of trusting the peer's length prefix
// enough to allocate the complete frame up front.
let mut buf = Vec::new();
buf.try_reserve(body_len.min(16 * 1024))
// The length has already been checked against the admitted frame
// limit, so reserve one bounded framing buffer and decode it without a
// second prefix-plus-body allocation/copy.
let mut frame = Vec::new();
frame
.try_reserve_exact(frame_len)
.map_err(|_| CommunicationError::MessageTooLarge)?;
while buf.len() < body_len {
let chunk_len = (body_len - buf.len()).min(16 * 1024);
let mut chunk = [0u8; 16 * 1024];
match timeout(
policy.read_timeout,
stream.read_exact(&mut chunk[..chunk_len]),
frame.extend_from_slice(&len_buf);
frame.resize(frame_len, 0);
let mut body_offset = 4usize;
while body_offset < frame_len {
let chunk_len = (frame_len - body_offset).min(16 * 1024);
match timeout_at(
deadline,
stream.read_exact(&mut frame[body_offset..body_offset + chunk_len]),
)
.await
{
Ok(Ok(())) => {
buf.try_reserve(chunk_len)
.map_err(|_| CommunicationError::MessageTooLarge)?;
buf.extend_from_slice(&chunk[..chunk_len]);
}
Ok(Ok(())) => body_offset += chunk_len,
Ok(Err(StreamReadExactError::FinishedEarly(n))) => {
warn!(
"[Receiver] body read ended early ({}/{body_len} bytes): stream closed by peer",
buf.len() + n
body_offset.saturating_sub(4) + n
);
return Err(CommunicationError::StreamError);
}
@ -1063,14 +1246,21 @@ impl Receiver {
}
}
let mut frame = Vec::with_capacity(frame_len);
frame.extend_from_slice(&len_buf);
frame.extend_from_slice(&buf);
let message = CommunicationValue::from_bytes_with_limits(
let message = match CommunicationValue::try_from_bytes_with_limits(
&frame,
DecodeLimits::for_transport_message_size(max_message_size),
)
.map_err(|_| CommunicationError::ParseCommunicationValue)?;
) {
Ok(message) => message,
Err(error) => {
decode_rejections.record(&error);
warn!(
?error,
class = ?classify_decode_error(&error),
"[Receiver] rejected frame during bounded decode"
);
return Err(CommunicationError::ParseCommunicationValue);
}
};
Ok(ReceivedFrame::Message(message))
}
@ -1267,4 +1457,61 @@ mod tests {
let debug_str = format!("{:?}", p);
assert!(debug_str.contains("Policy"));
}
#[test]
fn runtime_policy_normalizes_zero_channel_and_task_limits() {
let mut policy = Policy::default();
policy.receiver_queue_capacity = 0;
policy.max_concurrent_stream_tasks = 0;
let runtime = RuntimePolicy::from_public(&policy);
assert_eq!(runtime.receiver_queue_capacity, 1);
assert_eq!(runtime.max_concurrent_stream_tasks, 1);
assert_eq!(policy.receiver_queue_capacity, 0);
assert_eq!(policy.max_concurrent_stream_tasks, 0);
}
#[test]
fn ping_control_accepts_only_the_current_expected_id() {
let mut control = PingControl {
expected_pong_id: Some(7),
..PingControl::default()
};
assert!(!control.accepts_pong(Some(6)));
assert_eq!(control.expected_pong_id, Some(7));
assert!(control.accepts_pong(Some(7)));
assert_eq!(control.expected_pong_id, None);
assert!(!control.accepts_pong(Some(7)));
}
#[test]
fn decode_rejection_classes_are_stable_and_counted() {
assert_eq!(
classify_decode_error(&DecodeError::MalformedEncoding),
DecodeRejectionClass::Malformed
);
assert_eq!(
classify_decode_error(&DecodeError::AllocationLimit),
DecodeRejectionClass::ResourceLimit
);
assert_eq!(
classify_decode_error(&DecodeError::DuplicateField),
DecodeRejectionClass::DuplicateField
);
let counters = DecodeRejectionCounters::default();
counters.record(&DecodeError::MalformedEncoding);
counters.record(&DecodeError::DepthLimit);
counters.record(&DecodeError::DuplicateField);
assert_eq!(
counters.snapshot(),
DecodeRejectionCounts {
malformed: 1,
resource_limit: 1,
duplicate_field: 1,
}
);
}
}

View file

@ -2,22 +2,26 @@ use mtp_common::CommunicationError;
use std::net::SocketAddr;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
atomic::{AtomicBool, AtomicU64, Ordering},
};
use tokio::sync::watch;
#[derive(Debug)]
pub struct ConnectionHandle {
connection_id: u64,
closed: AtomicBool,
close_tx: watch::Sender<Option<CommunicationError>>,
close_rx: watch::Receiver<Option<CommunicationError>>,
remote_addr: Option<SocketAddr>,
}
static NEXT_CONNECTION_ID: AtomicU64 = AtomicU64::new(1);
impl ConnectionHandle {
pub fn new() -> Self {
let (close_tx, close_rx) = watch::channel(None);
Self {
connection_id: NEXT_CONNECTION_ID.fetch_add(1, Ordering::Relaxed).max(1),
closed: AtomicBool::new(false),
close_tx,
close_rx,
@ -35,6 +39,11 @@ impl ConnectionHandle {
self.remote_addr
}
/// Stable process-local identifier for authentication-rate-limit scopes.
pub fn connection_id(&self) -> u64 {
self.connection_id
}
pub fn is_open(&self) -> bool {
!self.closed.load(Ordering::SeqCst)
}

View file

@ -1,7 +1,21 @@
use crate::{Policy, TransportSendStream};
use mtp_codec::CommunicationValue;
use mtp_codec::{CommunicationValue, EncodeLimits};
use mtp_common::CommunicationError;
/// Classifies failures that may be recovered by replacing a persistent
/// application stream. Encoding and frame-size failures are deterministic and
/// must reach the caller without opening more streams.
pub(crate) struct RetryClassifier;
impl RetryClassifier {
pub(crate) fn retry_persistent_stream(error: &CommunicationError) -> bool {
matches!(
error,
CommunicationError::StreamError | CommunicationError::StreamClosed
)
}
}
/// Writes the canonical self-framed MTP value used by every transport.
///
/// `CommunicationValue` already begins with the four-byte body length. The
@ -12,7 +26,11 @@ pub(crate) async fn write_frame<S: TransportSendStream>(
value: &CommunicationValue,
policy: &Policy,
) -> Result<(), CommunicationError> {
let bytes = value.to_bytes().map_err(|_| CommunicationError::Encode)?;
let bytes = value
.to_bytes_with_limits(EncodeLimits::for_transport_message_size(
policy.max_message_size,
))
.map_err(|_| CommunicationError::Encode)?;
if bytes.len() as u64 > policy.max_message_size
|| bytes.len() as u64 >= policy.close_frame_len as u64
{

View file

@ -5,21 +5,23 @@
//! wrappers while the framing implementation below is shared by adapters.
use crate::{
Policy, TransportConnection, TransportRecvStream, TransportSendStream, framing::write_frame,
Policy, TransportConnection, TransportRecvStream, TransportSendStream,
connection::{DecodeRejectionCounters, RuntimePolicy, classify_decode_error},
framing::{RetryClassifier, write_frame},
};
use mtp_codec::{CommunicationValue, DecodeLimits, TypeMap};
use mtp_common::CommunicationError;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::sync::{Mutex, RwLock, Semaphore, mpsc};
use tokio::time::timeout;
use tokio::sync::{Mutex, Notify, RwLock, Semaphore, mpsc};
use tokio::time::{Instant, timeout, timeout_at};
#[cfg(feature = "pipes")]
use crate::pipe::{PipeReader, PipeWriter};
pub struct GenericSender<C: TransportConnection> {
connection: C,
policy: Arc<Policy>,
policy: Arc<RuntimePolicy>,
persistent: Arc<Mutex<Option<C::SendStream>>>,
send_lock: Arc<Mutex<()>>,
type_map: Arc<RwLock<TypeMap>>,
@ -39,6 +41,7 @@ impl<C: TransportConnection> Clone for GenericSender<C> {
impl<C: TransportConnection> GenericSender<C> {
pub fn new(connection: C, policy: Arc<Policy>) -> Self {
let policy = Arc::new(RuntimePolicy::from_public(&policy));
Self {
connection,
policy,
@ -84,20 +87,30 @@ impl<C: TransportConnection> GenericSender<C> {
if stream.is_none() {
*stream = Some(self.open().await?);
}
let result = timeout(
self.policy.write_timeout,
write_frame(stream.as_mut().unwrap(), value, &self.policy),
)
.await
.map_err(|_| CommunicationError::StreamError)
.and_then(|r| r);
let result = match stream.as_mut() {
Some(stream) => timeout(
self.policy.write_timeout,
write_frame(stream, value, &self.policy),
)
.await
.map_err(|_| CommunicationError::StreamError)
.and_then(|result| result),
None => Err(CommunicationError::StreamError),
};
if result.is_ok() {
return result;
return Ok(());
}
let error = match result {
Ok(()) => return Ok(()),
Err(error) => error,
};
if !RetryClassifier::retry_persistent_stream(&error) {
return Err(error);
}
*stream = None;
attempts += 1;
if attempts > self.policy.persistent_stream_max_retries {
return result;
return Err(error);
}
tokio::time::sleep(
self.policy.persistent_stream_retry_backoff * attempts as u32,
@ -114,6 +127,7 @@ impl<C: TransportConnection> GenericSender<C> {
pipe_id: u32,
description: &str,
) -> Result<PipeWriter<C::SendStream>, CommunicationError> {
let _send_lock = self.send_lock.lock().await;
if self.connection.close_reason().is_some() {
return Err(CommunicationError::StreamClosed);
}
@ -179,6 +193,8 @@ pub struct GenericReceiver<C: TransportConnection> {
ping_sender: Arc<RwLock<Option<GenericSender<C>>>>,
max_message_size: Arc<AtomicU64>,
type_map: Arc<RwLock<TypeMap>>,
queue_notify: Arc<Notify>,
decode_rejections: Arc<DecodeRejectionCounters>,
_accept_task: Arc<tokio::task::JoinHandle<()>>,
}
@ -192,6 +208,8 @@ impl<C: TransportConnection> Clone for GenericReceiver<C> {
ping_sender: self.ping_sender.clone(),
max_message_size: self.max_message_size.clone(),
type_map: self.type_map.clone(),
queue_notify: self.queue_notify.clone(),
decode_rejections: self.decode_rejections.clone(),
_accept_task: self._accept_task.clone(),
}
}
@ -207,6 +225,7 @@ impl<C: TransportConnection> Drop for GenericReceiver<C> {
impl<C: TransportConnection> GenericReceiver<C> {
pub fn new(connection: C, policy: Arc<Policy>) -> Self {
let policy = Arc::new(RuntimePolicy::from_public(&policy));
let (tx, rx) = mpsc::channel(policy.receiver_queue_capacity);
#[cfg(feature = "pipes")]
let (pipe_tx, pipe_rx) = mpsc::channel(policy.receiver_queue_capacity);
@ -222,6 +241,10 @@ impl<C: TransportConnection> GenericReceiver<C> {
let task_max_message_size = max_message_size.clone();
let type_map = Arc::new(RwLock::new(TypeMap::latest()));
let task_type_map = type_map.clone();
let queue_notify = Arc::new(Notify::new());
let task_queue_notify = queue_notify.clone();
let decode_rejections = Arc::new(DecodeRejectionCounters::default());
let task_decode_rejections = decode_rejections.clone();
let task_accept_task_tx = tx.clone();
#[cfg(feature = "pipes")]
let task_accept_task_pipe_tx = pipe_tx.clone();
@ -237,8 +260,10 @@ impl<C: TransportConnection> GenericReceiver<C> {
#[cfg(not(feature = "pipes"))]
let cap_full = task_accept_task_tx.capacity() == 0;
let notified = task_queue_notify.notified();
tokio::pin!(notified);
if cap_full {
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
notified.await;
continue;
}
@ -277,11 +302,12 @@ impl<C: TransportConnection> GenericReceiver<C> {
let ping_sender = task_ping_sender.clone();
let connection = task_connection.clone();
let type_map = task_type_map.clone();
let decode_rejections = task_decode_rejections.clone();
tokio::spawn(async move {
let _permit = permit;
let mut stream = stream;
let mut frames = 0usize;
loop {
'stream: loop {
if policy
.max_frames_per_stream
.is_some_and(|max| frames >= max)
@ -304,7 +330,7 @@ impl<C: TransportConnection> GenericReceiver<C> {
policy.application_close_code,
b"frame header read error",
);
break;
break 'stream;
}
Err(_) => {
break;
@ -314,6 +340,7 @@ impl<C: TransportConnection> GenericReceiver<C> {
if len == policy.close_frame_len {
break;
}
let deadline = Instant::now() + policy.read_timeout;
let frame_limit = max_message_size.load(Ordering::Relaxed);
let body_len = len as usize;
let frame_len = match body_len.checked_add(4) {
@ -330,29 +357,25 @@ impl<C: TransportConnection> GenericReceiver<C> {
connection.close(policy.application_close_code, b"frame too large");
break;
}
let target_len = body_len;
let mut body = Vec::new();
if body.try_reserve(target_len.min(16 * 1024)).is_err() {
tracing::warn!(
target_len,
"MTP receive stream could not reserve frame body"
);
let mut frame = Vec::new();
if frame.try_reserve_exact(frame_len).is_err() {
tracing::warn!(frame_len, "MTP receive stream could not reserve frame");
let _ = tx.send(Err(CommunicationError::MessageTooLarge)).await;
connection
.close(policy.application_close_code, b"frame allocation failed");
break;
}
while body.len() < target_len {
let chunk_len = (target_len - body.len()).min(16 * 1024);
let mut chunk = [0u8; 16 * 1024];
let body_read = tokio::time::timeout(
policy.read_timeout,
stream.read_exact(&mut chunk[..chunk_len]),
frame.extend_from_slice(&len.to_be_bytes());
frame.resize(frame_len, 0);
let mut body_offset = 4usize;
while body_offset < frame_len {
let chunk_len = (frame_len - body_offset).min(16 * 1024);
let body_read = timeout_at(
deadline,
stream.read_exact(&mut frame[body_offset..body_offset + chunk_len]),
)
.await;
if !matches!(&body_read, Ok(Ok(())))
|| body.try_reserve(chunk_len).is_err()
{
if !matches!(&body_read, Ok(Ok(()))) {
tracing::warn!(
pipe_chunk_len = chunk_len,
?body_read,
@ -361,24 +384,23 @@ impl<C: TransportConnection> GenericReceiver<C> {
let _ = tx.send(Err(CommunicationError::StreamError)).await;
connection
.close(policy.application_close_code, b"frame body read error");
break;
break 'stream;
}
body.extend_from_slice(&chunk[..chunk_len]);
}
if body.len() != target_len {
break;
body_offset += chunk_len;
}
frames += 1;
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(
let mut message = match CommunicationValue::try_from_bytes_with_limits(
&frame,
DecodeLimits::for_transport_message_size(frame_limit),
) {
Ok(message) => message,
Err(_) => {
tracing::warn!("MTP receive stream contained an invalid frame");
Err(error) => {
tracing::warn!(
?error,
class = ?classify_decode_error(&error),
"MTP receive stream rejected by bounded decode"
);
decode_rejections.record(&error);
let _ = tx
.send(Err(CommunicationError::ParseCommunicationValue))
.await;
@ -463,6 +485,8 @@ impl<C: TransportConnection> GenericReceiver<C> {
ping_sender,
max_message_size,
type_map,
queue_notify,
decode_rejections,
_accept_task: Arc::new(accept_task),
}
}
@ -480,13 +504,23 @@ impl<C: TransportConnection> GenericReceiver<C> {
pub async fn set_type_map(&self, type_map: &TypeMap) {
*self.type_map.write().await = type_map.clone();
}
/// Return local counts for frames rejected by the structured decoder.
pub fn decode_rejection_counts(&self) -> crate::DecodeRejectionCounts {
self.decode_rejections.snapshot()
}
pub async fn receive(&self) -> Result<CommunicationValue, CommunicationError> {
self.incoming
let result = self
.incoming
.lock()
.await
.recv()
.await
.unwrap_or(Err(CommunicationError::StreamClosed))
.unwrap_or(Err(CommunicationError::StreamClosed));
if result.is_ok() {
self.queue_notify.notify_one();
}
result
}
#[cfg(feature = "pipes")]
@ -498,7 +532,10 @@ impl<C: TransportConnection> GenericReceiver<C> {
tokio::select! {
msg = incoming.recv() => {
match msg {
Some(Ok(val)) => Ok(crate::TransportEvent::Message(val)),
Some(Ok(val)) => {
self.queue_notify.notify_one();
Ok(crate::TransportEvent::Message(val))
}
Some(Err(e)) => Err(e),
None => Err(self
.connection
@ -508,7 +545,10 @@ impl<C: TransportConnection> GenericReceiver<C> {
}
pipe = pipes.recv() => {
match pipe {
Some(reader) => Ok(crate::TransportEvent::Pipe(reader)),
Some(reader) => {
self.queue_notify.notify_one();
Ok(crate::TransportEvent::Pipe(reader))
}
None => Err(self
.connection
.close_reason()
@ -520,12 +560,17 @@ impl<C: TransportConnection> GenericReceiver<C> {
#[cfg(feature = "pipes")]
pub async fn receive_pipe(&self) -> Result<PipeReader<C::RecvStream>, CommunicationError> {
self.pipes
let result = self
.pipes
.lock()
.await
.recv()
.await
.ok_or(CommunicationError::StreamClosed)
.ok_or(CommunicationError::StreamClosed);
if result.is_ok() {
self.queue_notify.notify_one();
}
result
}
#[cfg(feature = "pipes")]
@ -534,7 +579,10 @@ impl<C: TransportConnection> GenericReceiver<C> {
) -> Result<Option<PipeReader<C::RecvStream>>, CommunicationError> {
match self.pipes.try_lock() {
Ok(mut rx) => match rx.try_recv() {
Ok(reader) => Ok(Some(reader)),
Ok(reader) => {
self.queue_notify.notify_one();
Ok(Some(reader))
}
Err(mpsc::error::TryRecvError::Empty) => Ok(None),
Err(mpsc::error::TryRecvError::Disconnected) => {
Err(CommunicationError::StreamClosed)

View file

@ -11,7 +11,10 @@ pub mod encrypted_pipe;
#[cfg(feature = "pipes")]
pub mod pipe;
pub use connection::{Policy, Receiver, SendMode, Sender};
pub use connection::{
DecodeRejectionClass, DecodeRejectionCounters, DecodeRejectionCounts, Policy, Receiver,
SendMode, Sender, classify_decode_error,
};
pub use generic_connection::{GenericReceiver, GenericSender};
#[cfg(feature = "pipes")]

View file

@ -265,7 +265,7 @@ async fn test_regular_messages_still_work_alongside_pipes() -> Result<(), Box<dy
let sender = GenericSender::new(conn_a, policy.clone());
let receiver = GenericReceiver::new(conn_b, policy);
let msg = CommunicationValue::new(mtp_codec::CommunicationType::Pong);
let msg = CommunicationValue::new(mtp_codec::CommunicationType::BadRequest);
sender.send(&msg).await?;
let _pipe_writer = sender.open_pipe(1, "mixed-pipe").await?;
@ -273,7 +273,7 @@ async fn test_regular_messages_still_work_alongside_pipes() -> Result<(), Box<dy
let received = receiver.receive().await?;
assert_eq!(
received.get_type(),
mtp_codec::CommunicationType::Pong
mtp_codec::CommunicationType::BadRequest
.try_to_id(&mtp_codec::TypeMap::latest())
.unwrap()
);

View file

@ -127,12 +127,12 @@ async fn test_send_receive_roundtrip() -> Result<(), Box<dyn std::error::Error>>
assert_numbered_message(&received, CommunicationType::Ping, 42, &tm);
// Host sends a response
let resp = numbered_message(CommunicationType::Pong, 99, &tm);
let resp = numbered_message(CommunicationType::BadRequest, 99, &tm);
host_tx.send(&resp).await?;
// Client receives it
let client_received = client_rx.receive().await?;
assert_numbered_message(&client_received, CommunicationType::Pong, 99, &tm);
assert_numbered_message(&client_received, CommunicationType::BadRequest, 99, &tm);
// Close both sides
client_tx.close().await;
@ -179,13 +179,13 @@ async fn test_concurrent_messages() -> Result<(), Box<dyn std::error::Error>> {
// Send 3 responses back
for i in 0..3u128 {
let msg = numbered_message(CommunicationType::Pong, i * 10, &tm);
let msg = numbered_message(CommunicationType::BadRequest, i * 10, &tm);
client_tx.send(&msg).await?;
}
for i in 0..3u128 {
let received = host_rx.receive().await?;
assert_numbered_message(&received, CommunicationType::Pong, i * 10, &tm);
assert_numbered_message(&received, CommunicationType::BadRequest, i * 10, &tm);
}
client_tx.close().await;
@ -260,11 +260,11 @@ async fn test_drop_receiver_keeps_sender_alive() -> Result<(), Box<dyn std::erro
let tm = TypeMap::latest();
let resp = numbered_message(CommunicationType::Pong, 7, &tm);
let resp = numbered_message(CommunicationType::BadRequest, 7, &tm);
host_tx.send(&resp).await?;
let got = client_rx.receive().await?;
assert_numbered_message(&got, CommunicationType::Pong, 7, &tm);
assert_numbered_message(&got, CommunicationType::BadRequest, 7, &tm);
client_tx.close().await;
host_tx.close().await;
@ -284,10 +284,10 @@ async fn test_persistent_stream_reopens_after_local_finish()
client_tx.finish_stream().await?;
let msg2 = numbered_message(CommunicationType::Pong, 22, &tm);
let msg2 = numbered_message(CommunicationType::BadRequest, 22, &tm);
client_tx.send(&msg2).await?;
let received2 = host_rx.receive().await?;
assert_numbered_message(&received2, CommunicationType::Pong, 22, &tm);
assert_numbered_message(&received2, CommunicationType::BadRequest, 22, &tm);
client_tx.close().await;
host_tx.close().await;

View file

@ -27,7 +27,7 @@ getrandom-v04 = { package = "getrandom", version = "0.4.3", features = ["wasm_js
mtp-common = { version = "0.3.0", path = "../common" }
mtp-type-map = { version = "0.3.0", path = "../type-map" }
mtp-codec = { version = "0.3.0", path = "../codec", features = ["crypto", "pipes", "registry"] }
mtp-crypto = { version = "0.3.0", path = "../crypto", features = ["wasm"] }
mtp-crypto = { version = "0.3.0", path = "../crypto", features = ["wasm", "password-kdf"] }
zeroize = "1.9"
wasm-bindgen-test = "0.3.76"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,630 @@
use wasm_bindgen::prelude::*;
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, PROTOCOL_VERSION};
use crate::auth;
use crate::client::{ConnectionState, WasmClient};
use crate::config::ConnectionConfig;
use crate::error::js_error;
use crate::transport::WasmTransport;
#[wasm_bindgen]
#[allow(deprecated)]
impl WasmClient {
pub async fn connect(&self, config: &ConnectionConfig) -> Result<(), JsValue> {
self.connect_owned(config.clone()).await
}
#[wasm_bindgen(js_name = connectOwned)]
pub async fn connect_owned(&self, config: ConnectionConfig) -> Result<(), JsValue> {
let generation = self.begin_connection();
let transport = match WasmTransport::connect_with_limits(
&config.url,
config.server_certificate_hashes.clone(),
config.max_message_size,
self.receive_decode_limits(),
)
.await
{
Ok(transport) => transport,
Err(error) => {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(error);
}
};
if !self.install_attempt_transport(&transport, generation) {
return Err(js_error("connection attempt superseded"));
}
let result = async {
let version_str = format!("{}", PROTOCOL_VERSION);
let opening_codec = mtp_codec::registry::VersionedCodec::for_version(
mtp_codec::registry::Registry::builtin(),
PROTOCOL_VERSION,
)
.ok_or_else(|| js_error("client protocol version is not registered"))?;
transport.set_type_map(opening_codec.type_map());
let mut ident = CommunicationValue::new_with_type_map(
CommunicationType::Identification,
opening_codec.type_map(),
)
.add_typed_default(DataType::Version, DataValue::Str(version_str))
.add_typed_default(
DataType::Id,
DataValue::UnsignedNumber(config.client_id as u128),
);
if let Some(desc) = &config.description {
ident =
ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone()));
}
let ident_bytes = ident
.to_bytes()
.map_err(|e| js_error(format!("encode failed: {}", e)))?;
transport.send_frame(&ident_bytes).await?;
let outcome_bytes = transport.read_one_frame().await?;
let outcome = CommunicationValue::try_from_bytes_with_type_map_and_limits(
&outcome_bytes,
opening_codec.type_map(),
transport.decode_limits(),
)
.map_err(|e| js_error(format!("parse handshake outcome: {e}")))?;
if Some(outcome.get_type())
== CommunicationType::ErrorBadVersion.try_to_id(opening_codec.type_map())
{
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error(
outcome
.get_str(DataType::ErrorMessage)
.unwrap_or("host does not support this protocol version"),
));
}
let negotiated_version = match outcome.get_data(DataType::Version) {
Some(DataValue::Str(version)) => mtp_codec::Version::parse(version)
.ok_or_else(|| js_error("host omitted a valid negotiated protocol version"))?,
_ => return Err(js_error("host omitted a valid negotiated protocol version")),
};
if negotiated_version != PROTOCOL_VERSION {
return Err(js_error(
"host selected a protocol version the client did not offer",
));
}
let codec = mtp_codec::registry::VersionedCodec::for_version(
mtp_codec::registry::Registry::builtin(),
negotiated_version,
)
.ok_or_else(|| js_error("host returned an unsupported negotiated protocol version"))?;
transport.set_type_map(codec.type_map());
let outcome = CommunicationValue::try_from_bytes_with_type_map_and_limits(
&outcome_bytes,
codec.type_map(),
transport.decode_limits(),
)
.map_err(|e| js_error(format!("parse negotiated handshake outcome: {e}")))?;
let tm = codec.type_map();
let expected = CommunicationType::IdentificationResponse
.try_to_id(&tm)
.ok_or_else(|| js_error("IdentificationResponse is absent from the type map"))?;
if outcome.get_type() != expected
|| outcome.get_data(DataType::Connected) != Some(&DataValue::BoolTrue)
{
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error(
outcome
.get_str(DataType::ErrorMessage)
.unwrap_or("host rejected the connection"),
));
}
let assigned_id = match outcome.get_data(DataType::Id) {
Some(DataValue::UnsignedNumber(id)) => {
u64::try_from(*id).map_err(|_| js_error("assigned ID is out of range"))?
}
_ => {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error("host omitted the assigned client ID"));
}
};
if !self.start_receive_loop(transport.clone(), generation, assigned_id) {
return Err(js_error("connection attempt superseded"));
}
Ok(())
}
.await;
if let Err(error) = &result {
self.abort_attempt(&transport, generation);
let _ = error;
}
result
}
#[wasm_bindgen]
#[deprecated(
note = "use the SDK authentication methods; this raw method remains for compatibility"
)]
pub async fn auth_connect(
&self,
config: &ConnectionConfig,
host_public_key_bytes: &[u8],
keyring_bytes: &[u8],
client_id: u64,
) -> Result<u64, JsValue> {
self.auth_connect_owned(
config.clone(),
host_public_key_bytes.to_vec(),
keyring_bytes.to_vec(),
client_id,
)
.await
}
#[wasm_bindgen(js_name = authConnectOwned)]
pub async fn auth_connect_owned(
&self,
config: ConnectionConfig,
host_public_key_bytes: Vec<u8>,
keyring_bytes: Vec<u8>,
client_id: u64,
) -> Result<u64, JsValue> {
let generation = self.begin_connection();
let host_pk = match mtp_crypto::PublicKeyBundle::from_bytes(&host_public_key_bytes) {
Ok(value) => value,
Err(error) => {
let error = js_error(format!("invalid host public key: {}", error));
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(error);
}
};
let keyring = match mtp_crypto::Keyring::from_bytes(&keyring_bytes) {
Ok(value) => value,
Err(error) => {
let error = js_error(format!("invalid keyring: {}", error));
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(error);
}
};
let handshake_codec = mtp_codec::registry::VersionedCodec::for_version(
mtp_codec::registry::Registry::builtin(),
PROTOCOL_VERSION,
)
.ok_or_else(|| js_error("client protocol version is not registered"))?;
let tm = handshake_codec.type_map().clone();
let version_str = format!("{}", PROTOCOL_VERSION);
let public_key_bytes = keyring
.public_key_bundle()
.try_as_bytes()
.map_err(|error| js_error(format!("public key serialization failed: {error}")))?;
let transport = match WasmTransport::connect_with_limits(
&config.url,
config.server_certificate_hashes.clone(),
config.max_message_size,
self.receive_decode_limits(),
)
.await
{
Ok(transport) => transport,
Err(error) => {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(error);
}
};
transport.set_type_map(&tm);
if !self.install_attempt_transport(&transport, generation) {
return Err(js_error("connection attempt superseded"));
}
let result = async {
let mut hello =
CommunicationValue::new_with_type_map(CommunicationType::Identification, &tm)
.add_typed_default(DataType::Version, DataValue::Str(version_str.clone()))
.add_typed_default(DataType::Id, DataValue::UnsignedNumber(client_id as u128))
// Mark this as an authentication-capable opening so a
// non-crypto host can reject it explicitly.
.add_typed_default(
DataType::PublicKeys,
DataValue::Bytes(public_key_bytes.clone()),
);
if let Some(desc) = &config.description {
hello =
hello.add_typed_default(DataType::Description, DataValue::Str(desc.clone()));
}
let hello_bytes = hello
.to_bytes()
.map_err(|e| js_error(format!("encode failed: {}", e)))?;
transport.send_frame(&hello_bytes).await?;
let server_challenge = self
.read_verified_challenge(
&transport,
&tm,
&host_pk,
client_id,
"auth_connect challenge",
config.require_pq,
!keyring.sig_pq_secret_key.as_bytes().is_empty(),
generation,
)
.await?;
let client_nonce = auth::random_nonce()?;
let proof_payload = mtp_crypto::auth::login_proof_payload(
&version_str,
client_id,
server_challenge,
client_nonce,
);
let proof =
auth::signed_challenge_response_bytes(&keyring, &proof_payload, client_nonce, &tm)?;
transport.send_frame(&proof).await?;
let response = transport.read_one_frame().await?;
let resp_comm = CommunicationValue::try_from_bytes_with_type_map_and_limits(
&response,
&tm,
transport.decode_limits(),
)
.map_err(|e| js_error(format!("parse response: {}", e)))?;
let negotiated_version = match resp_comm.get_data(DataType::Version) {
Some(DataValue::Str(version)) => mtp_codec::Version::parse(version)
.ok_or_else(|| js_error("host returned an invalid negotiated version"))?,
_ => return Err(js_error("host omitted the negotiated version")),
};
if negotiated_version != PROTOCOL_VERSION {
return Err(js_error(
"host selected a protocol version the client did not offer",
));
}
let codec = mtp_codec::registry::VersionedCodec::for_version(
mtp_codec::registry::Registry::builtin(),
negotiated_version,
)
.ok_or_else(|| js_error("host returned an unsupported negotiated version"))?;
transport.set_type_map(codec.type_map());
let resp_comm = CommunicationValue::try_from_bytes_with_type_map_and_limits(
&response,
codec.type_map(),
transport.decode_limits(),
)
.map_err(|e| js_error(format!("parse negotiated response: {}", e)))?;
let tm = codec.type_map();
let resp_type = resp_comm.get_type();
let expected_type = CommunicationType::IdentificationResponse
.try_to_id(&tm)
.ok_or_else(|| js_error("IdentificationResponse is absent from the type map"))?;
if resp_type != expected_type {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(auth::unexpected_response_type_error(
"auth_connect",
expected_type,
resp_type,
&response,
&resp_comm,
));
}
if resp_comm.get_data(DataType::Connected) != Some(&DataValue::BoolTrue) {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error(
resp_comm
.get_str(DataType::ErrorMessage)
.unwrap_or("host rejected authentication"),
));
}
if let Err(e) = auth::verify_host_final(
&resp_comm,
&tm,
&host_pk,
client_id,
client_nonce,
server_challenge,
config.require_pq,
) {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(e);
}
let assigned_id = match resp_comm.get_data(DataType::Id) {
Some(DataValue::UnsignedNumber(n)) => match u64::try_from(*n) {
Ok(id) => id,
Err(_) => {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error("assigned ID is out of range"));
}
},
_ => {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error("missing assigned ID"));
}
};
if !self.start_receive_loop(transport.clone(), generation, assigned_id) {
return Err(js_error("connection attempt superseded"));
}
Ok(assigned_id)
}
.await;
if let Err(error) = &result {
self.abort_attempt(&transport, generation);
let _ = error;
}
result
}
#[wasm_bindgen]
#[deprecated(
note = "use the SDK registration methods; this raw method remains for compatibility"
)]
pub async fn auth_register(
&self,
config: &ConnectionConfig,
host_public_key_bytes: &[u8],
keyring_bytes: &[u8],
) -> Result<u64, JsValue> {
self.auth_register_owned(
config.clone(),
host_public_key_bytes.to_vec(),
keyring_bytes.to_vec(),
)
.await
}
#[wasm_bindgen(js_name = authRegisterOwned)]
pub async fn auth_register_owned(
&self,
config: ConnectionConfig,
host_public_key_bytes: Vec<u8>,
keyring_bytes: Vec<u8>,
) -> Result<u64, JsValue> {
let generation = self.begin_connection();
let host_pk = match mtp_crypto::PublicKeyBundle::from_bytes(&host_public_key_bytes) {
Ok(value) => value,
Err(error) => {
let error = js_error(format!("invalid host public key: {}", error));
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(error);
}
};
let keyring = match mtp_crypto::Keyring::from_bytes(&keyring_bytes) {
Ok(value) => value,
Err(error) => {
let error = js_error(format!("invalid keyring: {}", error));
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(error);
}
};
let handshake_codec = mtp_codec::registry::VersionedCodec::for_version(
mtp_codec::registry::Registry::builtin(),
PROTOCOL_VERSION,
)
.ok_or_else(|| js_error("client protocol version is not registered"))?;
let tm = handshake_codec.type_map().clone();
let version_str = format!("{}", PROTOCOL_VERSION);
let pk_bytes = keyring
.public_key_bundle()
.try_as_bytes()
.map_err(|error| js_error(format!("public key serialization failed: {error}")))?;
let transport = match WasmTransport::connect_with_limits(
&config.url,
config.server_certificate_hashes.clone(),
config.max_message_size,
self.receive_decode_limits(),
)
.await
{
Ok(transport) => transport,
Err(error) => {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(error);
}
};
transport.set_type_map(&tm);
if !self.install_attempt_transport(&transport, generation) {
return Err(js_error("connection attempt superseded"));
}
let result = async {
let mut hello = CommunicationValue::new_with_type_map(CommunicationType::Register, &tm)
.add_typed_default(DataType::Version, DataValue::Str(version_str.clone()))
.add_typed_default(DataType::PublicKeys, DataValue::Bytes(pk_bytes.clone()));
if let Some(desc) = &config.description {
hello =
hello.add_typed_default(DataType::Description, DataValue::Str(desc.clone()));
}
let hello_bytes = hello
.to_bytes()
.map_err(|e| js_error(format!("encode failed: {}", e)))?;
transport.send_frame(&hello_bytes).await?;
let server_challenge = self
.read_verified_challenge(
&transport,
&tm,
&host_pk,
0,
"auth_register challenge",
config.require_pq,
!keyring.sig_pq_secret_key.as_bytes().is_empty(),
generation,
)
.await?;
let client_nonce = auth::random_nonce()?;
let proof_payload = mtp_crypto::auth::register_proof_payload(
&version_str,
&pk_bytes,
server_challenge,
client_nonce,
);
let proof =
auth::signed_challenge_response_bytes(&keyring, &proof_payload, client_nonce, &tm)?;
transport.send_frame(&proof).await?;
let response = transport.read_one_frame().await?;
let resp_comm = CommunicationValue::try_from_bytes_with_type_map_and_limits(
&response,
&tm,
transport.decode_limits(),
)
.map_err(|e| js_error(format!("parse response: {}", e)))?;
let negotiated_version = match resp_comm.get_data(DataType::Version) {
Some(DataValue::Str(version)) => mtp_codec::Version::parse(version)
.ok_or_else(|| js_error("host returned an invalid negotiated version"))?,
_ => return Err(js_error("host omitted the negotiated version")),
};
if negotiated_version != PROTOCOL_VERSION {
return Err(js_error(
"host selected a protocol version the client did not offer",
));
}
let codec = mtp_codec::registry::VersionedCodec::for_version(
mtp_codec::registry::Registry::builtin(),
negotiated_version,
)
.ok_or_else(|| js_error("host returned an unsupported negotiated version"))?;
transport.set_type_map(codec.type_map());
let resp_comm = CommunicationValue::try_from_bytes_with_type_map_and_limits(
&response,
codec.type_map(),
transport.decode_limits(),
)
.map_err(|e| js_error(format!("parse negotiated response: {}", e)))?;
let tm = codec.type_map();
let resp_type = resp_comm.get_type();
let expected_type = CommunicationType::RegisterResponse
.try_to_id(&tm)
.ok_or_else(|| js_error("RegisterResponse is absent from the type map"))?;
if resp_type != expected_type {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(auth::unexpected_response_type_error(
"auth_register",
expected_type,
resp_type,
&response,
&resp_comm,
));
}
if resp_comm.get_data(DataType::Connected) != Some(&DataValue::BoolTrue) {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error(
resp_comm
.get_str(DataType::ErrorMessage)
.unwrap_or("host rejected registration"),
));
}
let assigned_id = match resp_comm.get_data(DataType::Id) {
Some(DataValue::UnsignedNumber(n)) => match u64::try_from(*n) {
Ok(id) => id,
Err(_) => {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error("assigned ID is out of range"));
}
},
_ => {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error("missing assigned ID"));
}
};
if let Err(e) = auth::verify_host_final(
&resp_comm,
&tm,
&host_pk,
assigned_id,
client_nonce,
server_challenge,
config.require_pq,
) {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(e);
}
if !self.start_receive_loop(transport.clone(), generation, assigned_id) {
return Err(js_error("connection attempt superseded"));
}
Ok(assigned_id)
}
.await;
if let Err(error) = &result {
self.abort_attempt(&transport, generation);
let _ = error;
}
result
}
async fn read_verified_challenge(
&self,
transport: &WasmTransport,
tm: &mtp_codec::TypeMap,
host_pk: &mtp_crypto::PublicKeyBundle,
bound_id: u64,
context: &str,
require_pq: bool,
client_has_pq_key: bool,
generation: u32,
) -> Result<u128, JsValue> {
let challenge_bytes = transport.read_one_frame().await?;
let challenge = CommunicationValue::try_from_bytes_with_type_map_and_limits(
&challenge_bytes,
tm,
transport.decode_limits(),
)
.map_err(|e| js_error(format!("parse challenge: {}", e)))?;
let expected = CommunicationType::Challenge
.try_to_id(tm)
.ok_or_else(|| js_error("Challenge is absent from the type map"))?;
if challenge.get_type() != expected {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(auth::unexpected_response_type_error(
context,
expected,
challenge.get_type(),
&challenge_bytes,
&challenge,
));
}
let server_challenge = match challenge.get_data(DataType::ServerNonce) {
Some(DataValue::UnsignedNumber(n)) => *n,
_ => {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error("missing server challenge"));
}
};
if challenge.get_data(DataType::RequirePq) == Some(&DataValue::BoolTrue)
&& !client_has_pq_key
{
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(js_error(
"host requires post-quantum authentication but the client PQ key is absent",
));
}
if let Err(e) = auth::verify_host_challenge(
&challenge,
tm,
host_pk,
bound_id,
server_challenge,
require_pq,
) {
self.set_state_if_current(generation, ConnectionState::Disconnected);
return Err(e);
}
Ok(server_challenge)
}
}

View file

@ -0,0 +1,102 @@
use wasm_bindgen::prelude::*;
use crate::client::{ConnectionState, WasmClient};
use crate::client_pipe;
use crate::transport::WasmTransport;
use super::dispatch::set_shared_state;
#[wasm_bindgen]
impl WasmClient {
pub fn disconnect(&self) {
self.connection_generation
.set(self.connection_generation.get().wrapping_add(1));
self.stop_protocol_pings();
if let Some(t) = self.transport.borrow_mut().take() {
t.close();
}
if let Some(t) = self.attempt_transport.borrow_mut().take() {
t.close();
}
self.subscriptions.borrow_mut().clear();
self.reject_pending_requests("disconnected");
client_pipe::reject_pending_pipe_creations(&self.pending_pipe_creations, "disconnected");
self.expired_pipe_creations.borrow_mut().clear();
client_pipe::reject_pending_pipes(&self.pending_pipes, "disconnected");
self.connection_client_id.set(0);
self.set_state(ConnectionState::Disconnected);
}
pub(super) fn set_state(&self, new_state: ConnectionState) {
set_shared_state(
&self.state,
&self.pending_state_callbacks,
self.state_callback.as_ref(),
new_state,
);
}
pub(super) fn set_state_if_current(&self, generation: u32, new_state: ConnectionState) {
if self.connection_generation.get() == generation {
self.set_state(new_state);
}
}
pub(super) fn install_attempt_transport(
&self,
transport: &WasmTransport,
generation: u32,
) -> bool {
if self.connection_generation.get() != generation {
transport.close();
return false;
}
*self.attempt_transport.borrow_mut() = Some(transport.clone());
true
}
pub(super) fn abort_attempt(&self, transport: &WasmTransport, generation: u32) {
transport.close();
if self.connection_generation.get() != generation {
return;
}
if let Some(current) = self.attempt_transport.borrow_mut().take() {
current.close();
}
if let Some(current) = self.transport.borrow_mut().take() {
current.close();
}
self.stop_protocol_pings();
self.reject_pending_requests("connection failed");
client_pipe::reject_pending_pipe_creations(
&self.pending_pipe_creations,
"connection failed",
);
self.expired_pipe_creations.borrow_mut().clear();
client_pipe::reject_pending_pipes(&self.pending_pipes, "connection failed");
self.connection_client_id.set(0);
self.set_state(ConnectionState::Disconnected);
}
pub(super) fn begin_connection(&self) -> u32 {
let generation = self.connection_generation.get().wrapping_add(1);
self.connection_generation.set(generation);
self.stop_protocol_pings();
if let Some(transport) = self.transport.borrow_mut().take() {
transport.close();
}
if let Some(transport) = self.attempt_transport.borrow_mut().take() {
transport.close();
}
self.reject_pending_requests("connection replaced");
client_pipe::reject_pending_pipe_creations(
&self.pending_pipe_creations,
"connection replaced",
);
self.expired_pipe_creations.borrow_mut().clear();
client_pipe::reject_pending_pipes(&self.pending_pipes, "connection replaced");
self.connection_client_id.set(0);
self.set_state(ConnectionState::Connecting);
generation
}
}

184
wasm/src/client/dispatch.rs Normal file
View file

@ -0,0 +1,184 @@
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, VecDeque};
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use crate::client::ConnectionState;
use crate::client_pipe::{self, PendingRequest};
pub(super) struct PingTimer {
pub(super) id: i32,
pub(super) closure: Closure<dyn FnMut()>,
}
pub(super) struct PendingPing {
pub(super) generation: u32,
pub(super) sent_at: f64,
}
pub(super) fn frame_property(frame: &JsValue, key: &str) -> Option<JsValue> {
js_sys::Reflect::get(frame, &JsValue::from_str(key))
.ok()
.filter(|value| !value.is_null() && !value.is_undefined())
}
pub(super) fn frame_id(frame: &JsValue) -> Option<u32> {
frame_property(frame, "id")
.and_then(|value| value.as_f64())
.filter(|value| {
value.is_finite() && value.fract() == 0.0 && (0.0..=u32::MAX as f64).contains(value)
})
.and_then(|value| u32::try_from(value as u64).ok())
}
pub(super) fn frame_type(frame: &JsValue) -> Option<String> {
frame_property(frame, "type").and_then(|value| value.as_string())
}
pub(super) fn route_incoming_frame(
frame: &JsValue,
generation: u32,
on_message: &js_sys::Function,
subscriptions: &Rc<RefCell<HashMap<u32, (String, js_sys::Function)>>>,
pending_requests: &Rc<RefCell<HashMap<u32, PendingRequest>>>,
expired_requests: &Rc<RefCell<HashMap<u32, f64>>>,
pending_pings: &Rc<RefCell<HashMap<u32, PendingPing>>>,
ping_ms: &Rc<Cell<Option<f64>>>,
) {
let message_type = frame_type(frame);
if message_type.as_deref() == Some("Pong")
&& let Some(ping_id) = frame_id(frame)
{
let sent_at = pending_pings
.borrow()
.get(&ping_id)
.filter(|ping| ping.generation == generation)
.map(|ping| ping.sent_at);
if let Some(sent_at) = sent_at {
pending_pings.borrow_mut().remove(&ping_id);
ping_ms.set(Some(js_sys::Date::now() - sent_at));
return;
}
}
if let Some(request_id) = frame_id(frame) {
let pending = {
let mut requests = pending_requests.borrow_mut();
if requests
.get(&request_id)
.is_some_and(|request| request.generation == generation)
{
requests.remove(&request_id)
} else {
None
}
};
if let Some(pending) = pending {
let type_matches = pending
.response_type
.as_ref()
.zip(message_type.as_ref())
.map(|(expected, actual)| expected == actual)
.unwrap_or(true);
if type_matches {
let _ = pending.sender.send(Ok(frame.clone()));
} else {
let actual = message_type.clone().unwrap_or_else(|| "unknown".into());
let _ = pending.sender.send(Err(crate::error::js_error(format!(
"unexpected response type: expected {}, got {}",
pending.response_type.unwrap_or_else(|| "unknown".into()),
actual
))));
}
return;
}
if client_pipe::consume_expired_request(expired_requests, request_id) {
return;
}
}
let _ = on_message.call1(&JsValue::NULL, frame);
let Some(message_type) = message_type else {
return;
};
let callbacks: Vec<js_sys::Function> = subscriptions
.borrow()
.iter()
.filter(|(_, (t, _))| t == &message_type)
.map(|(_, (_, cb))| cb.clone())
.collect();
for callback in callbacks {
let _ = callback.call1(&JsValue::NULL, frame);
}
}
pub(super) fn stop_ping_timer(ping_timer: &Rc<RefCell<Option<PingTimer>>>) {
let Some(timer) = ping_timer.borrow_mut().take() else {
return;
};
if let Ok(clear_interval) =
js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("clearInterval"))
.and_then(|value| value.dyn_into::<js_sys::Function>())
{
let _ = clear_interval.call1(&JsValue::NULL, &JsValue::from_f64(timer.id as f64));
}
drop(timer.closure);
}
pub(super) fn reject_pending_requests(
pending_requests: &Rc<RefCell<HashMap<u32, PendingRequest>>>,
message: &str,
) {
let pending = std::mem::take(&mut *pending_requests.borrow_mut());
for (_, pending) in pending {
let _ = pending.sender.send(Err(crate::error::js_error(message)));
}
}
pub(super) async fn wait_for_timeout(timeout_ms: u32) -> Result<(), JsValue> {
let promise = js_sys::Promise::new(&mut |resolve, reject| {
let result = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("setTimeout"))
.and_then(|value| value.dyn_into::<js_sys::Function>())
.and_then(|set_timeout| {
set_timeout.call2(
&JsValue::NULL,
&resolve,
&JsValue::from_f64(timeout_ms as f64),
)
});
if let Err(error) = result {
let _ = reject.call1(&JsValue::NULL, &error);
}
});
wasm_bindgen_futures::JsFuture::from(promise).await?;
Ok(())
}
pub(super) fn set_shared_state(
state: &Rc<Cell<ConnectionState>>,
pending_state_callbacks: &Rc<RefCell<VecDeque<ConnectionState>>>,
state_callback: &JsValue,
new_state: ConnectionState,
) {
state.set(new_state);
pending_state_callbacks.borrow_mut().push_back(new_state);
let global = js_sys::global();
let qmt = js_sys::Reflect::get(&global, &JsValue::from_str("queueMicrotask"))
.and_then(|f| f.dyn_into::<js_sys::Function>());
let scheduled = qmt
.and_then(|qmt| qmt.call1(&global, state_callback))
.is_ok();
if !scheduled
&& js_sys::Reflect::get(&global, &JsValue::from_str("setTimeout"))
.and_then(|f| f.dyn_into::<js_sys::Function>())
.and_then(|set_timeout| {
set_timeout.call2(&global, state_callback, &JsValue::from_f64(0.0))
})
.is_err()
{
pending_state_callbacks.borrow_mut().pop_back();
}
}

299
wasm/src/client/mod.rs Normal file
View file

@ -0,0 +1,299 @@
// WASM client facade. Lifecycle, authentication, receive dispatch, and pipes
// live in private child modules below.
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::collections::VecDeque;
use std::rc::Rc;
use futures_channel::oneshot;
use futures_util::{FutureExt, pin_mut, select};
use wasm_bindgen::prelude::*;
use mtp_codec::{CommunicationValue, DecodeLimits, EncodeLimits};
use crate::client_pipe::{self, PendingRequest};
use crate::error::js_error;
use crate::transport::WasmTransport;
mod authentication;
mod connection;
mod dispatch;
mod pipes;
mod receive;
use dispatch::{PendingPing, PingTimer, wait_for_timeout};
const DEFAULT_REQUEST_TIMEOUT_MS: u32 = 30_000;
const MAX_SAFE_JS_INTEGER: f64 = 9_007_199_254_740_991.0;
fn decode_limit(value: &JsValue, key: &str, default: usize) -> Result<usize, JsValue> {
if value.is_null() || value.is_undefined() {
return Ok(default);
}
let value = js_sys::Reflect::get(value, &JsValue::from_str(key))?;
if value.is_null() || value.is_undefined() {
return Ok(default);
}
let Some(number) = value.as_f64() else {
return Err(js_error(format!("{key} must be a number")));
};
if !number.is_finite() || number.fract() != 0.0 || number < 0.0 || number > MAX_SAFE_JS_INTEGER
{
return Err(js_error(format!("{key} must be a non-negative integer")));
}
usize::try_from(number as u64).map_err(|_| js_error(format!("{key} is out of range")))
}
pub(crate) fn encode_limits_from_js(value: &JsValue) -> Result<EncodeLimits, JsValue> {
let defaults = EncodeLimits::default();
Ok(EncodeLimits {
max_depth: decode_limit(value, "maxDepth", defaults.max_depth)?,
max_values: decode_limit(value, "maxValues", defaults.max_values)?,
max_output_size: decode_limit(value, "maxOutputSize", defaults.max_output_size)?,
})
}
pub(crate) fn decode_limits_from_js(value: &JsValue) -> Result<DecodeLimits, JsValue> {
let defaults = DecodeLimits::default();
Ok(DecodeLimits {
max_depth: decode_limit(value, "maxDepth", defaults.max_depth)?,
max_values: decode_limit(value, "maxValues", defaults.max_values)?,
max_blob_size: decode_limit(value, "maxBlobSize", defaults.max_blob_size)?,
max_recipients: decode_limit(value, "maxRecipients", defaults.max_recipients)?,
max_allocated_bytes: decode_limit(
value,
"maxAllocatedBytes",
defaults.max_allocated_bytes,
)?,
})
}
#[wasm_bindgen]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionState {
Disconnected = 0,
Connecting = 1,
Connected = 2,
Failed = 3,
}
#[wasm_bindgen]
pub struct WasmClient {
transport: Rc<RefCell<Option<WasmTransport>>>,
attempt_transport: Rc<RefCell<Option<WasmTransport>>>,
connection_generation: Rc<Cell<u32>>,
state: Rc<Cell<ConnectionState>>,
pending_state_callbacks: Rc<RefCell<VecDeque<ConnectionState>>>,
state_callback: Closure<dyn FnMut()>,
pub(crate) on_message: js_sys::Function,
pub(crate) on_error: js_sys::Function,
subscriptions: Rc<RefCell<HashMap<u32, (String, js_sys::Function)>>>,
next_subscription_id: Rc<Cell<u32>>,
pending_requests: Rc<RefCell<HashMap<u32, PendingRequest>>>,
expired_requests: Rc<RefCell<HashMap<u32, f64>>>,
ping_timer: Rc<RefCell<Option<PingTimer>>>,
pending_pings: Rc<RefCell<HashMap<u32, PendingPing>>>,
ping_ms: Rc<Cell<Option<f64>>>,
pending_pipe_creations: client_pipe::PendingPipeCreations,
expired_pipe_creations: Rc<RefCell<HashMap<u32, f64>>>,
pending_pipes: client_pipe::PendingPipes,
connection_client_id: Rc<Cell<u64>>,
on_pipe_request: Rc<RefCell<Option<js_sys::Function>>>,
receive_decode_limits: Rc<RefCell<Option<DecodeLimits>>>,
}
#[wasm_bindgen]
#[allow(deprecated)]
impl WasmClient {
#[wasm_bindgen(constructor)]
pub fn new(
on_state_change: Option<js_sys::Function>,
on_message: Option<js_sys::Function>,
on_error: Option<js_sys::Function>,
) -> Self {
let noop = || js_sys::Function::new_no_args("");
let on_state_change = on_state_change.unwrap_or_else(noop);
let pending_state_callbacks = Rc::new(RefCell::new(VecDeque::new()));
let callback_queue = pending_state_callbacks.clone();
let callback = on_state_change.clone();
let state_callback = Closure::wrap(Box::new(move || {
let state = callback_queue.borrow_mut().pop_front();
if let Some(state) = state {
let _ = callback.call1(&JsValue::NULL, &JsValue::from(state as u8));
}
}) as Box<dyn FnMut()>);
Self {
transport: Rc::new(RefCell::new(None)),
attempt_transport: Rc::new(RefCell::new(None)),
connection_generation: Rc::new(Cell::new(0)),
state: Rc::new(Cell::new(ConnectionState::Disconnected)),
pending_state_callbacks,
state_callback,
on_message: on_message.unwrap_or_else(noop),
on_error: on_error.unwrap_or_else(noop),
subscriptions: Rc::new(RefCell::new(HashMap::new())),
next_subscription_id: Rc::new(Cell::new(1)),
pending_requests: Rc::new(RefCell::new(HashMap::new())),
expired_requests: Rc::new(RefCell::new(HashMap::new())),
ping_timer: Rc::new(RefCell::new(None)),
pending_pings: Rc::new(RefCell::new(HashMap::new())),
ping_ms: Rc::new(Cell::new(None)),
pending_pipe_creations: Rc::new(RefCell::new(HashMap::new())),
expired_pipe_creations: Rc::new(RefCell::new(HashMap::new())),
pending_pipes: Rc::new(RefCell::new(HashMap::new())),
connection_client_id: Rc::new(Cell::new(0)),
on_pipe_request: Rc::new(RefCell::new(None)),
receive_decode_limits: Rc::new(RefCell::new(None)),
}
}
pub fn is_supported() -> bool {
js_sys::Reflect::has(&js_sys::global(), &JsValue::from_str("WebTransport")).unwrap_or(false)
}
#[wasm_bindgen(getter)]
pub fn state(&self) -> u8 {
self.state.get() as u8
}
#[wasm_bindgen(getter)]
pub fn ping_ms(&self) -> Option<f64> {
self.ping_ms.get()
}
#[wasm_bindgen(getter)]
pub fn client_id(&self) -> u64 {
self.connection_client_id.get()
}
/// Apply one decoder policy to frames received by this raw WASM client.
/// The high-level SDK calls this before authentication so handshake,
/// transport, and protected opening share the same policy input.
#[wasm_bindgen]
pub fn set_receive_limits(&self, limits: JsValue) -> Result<(), JsValue> {
let parsed = if limits.is_null() || limits.is_undefined() {
None
} else {
Some(decode_limits_from_js(&limits)?)
};
*self.receive_decode_limits.borrow_mut() = parsed;
Ok(())
}
pub(super) fn receive_decode_limits(&self) -> Option<DecodeLimits> {
*self.receive_decode_limits.borrow()
}
#[wasm_bindgen]
#[deprecated(
note = "use the SDK connection methods; this raw method remains for compatibility"
)]
pub async fn send(&self, frame: Vec<u8>) -> Result<(), JsValue> {
if self.state.get() != ConnectionState::Connected {
return Err(js_error("not connected"));
}
let transport = self.transport.borrow().clone();
match transport {
Some(t) => t.send_frame(&frame).await,
None => Err(js_error("not connected")),
}
}
#[wasm_bindgen]
pub async fn request(
&self,
frame: Vec<u8>,
response_type: Option<String>,
timeout_ms: Option<u32>,
) -> Result<JsValue, JsValue> {
if self.state.get() != ConnectionState::Connected {
return Err(js_error("not connected"));
}
let generation = self.connection_generation.get();
let Some(transport) = self.transport.borrow().clone() else {
return Err(js_error("not connected"));
};
let request = CommunicationValue::try_from_bytes_with_type_map_and_limits(
&frame,
&transport.type_map(),
transport.decode_limits(),
)
.map_err(|e| js_error(format!("parse request: {}", e)))?;
let request_id = request
.id()
.ok_or_else(|| js_error("request frame must contain an id"))?;
if request_id == 0 {
return Err(js_error("request frame must have a non-zero id"));
}
if client_pipe::is_expired_request(&self.expired_requests, request_id) {
return Err(js_error(format!(
"request id {request_id} recently timed out; use a new request id"
)));
}
let (sender, receiver) = oneshot::channel();
let token = Rc::new(());
{
let mut pending = self.pending_requests.borrow_mut();
if pending.contains_key(&request_id) {
return Err(js_error(format!(
"request id {request_id} is already pending"
)));
}
pending.insert(
request_id,
PendingRequest {
generation,
token: token.clone(),
response_type,
sender,
},
);
}
let timeout_ms = timeout_ms.unwrap_or(DEFAULT_REQUEST_TIMEOUT_MS);
let response = async {
transport.send_frame(&frame).await?;
match receiver.await {
Ok(result) => result,
Err(_) => Err(js_error("request cancelled")),
}
}
.fuse();
let timeout = wait_for_timeout(timeout_ms).fuse();
pin_mut!(response, timeout);
select! {
result = response => {
if result.is_err() {
client_pipe::remove_pending_request(&self.pending_requests, request_id, &token);
}
result
},
result = timeout => {
client_pipe::expire_pending_request(
&self.pending_requests,
&self.expired_requests,
request_id,
&token,
);
result?;
Err(js_error(format!(
"request {request_id} timed out after {timeout_ms}ms"
)))
},
}
}
#[wasm_bindgen]
pub fn subscribe(&self, message_type: String, callback: js_sys::Function) -> u32 {
let id = self.next_subscription_id.get();
self.next_subscription_id.set(id.wrapping_add(1).max(1));
self.subscriptions
.borrow_mut()
.insert(id, (message_type, callback));
id
}
#[wasm_bindgen]
pub fn unsubscribe(&self, id: u32) -> bool {
self.subscriptions.borrow_mut().remove(&id).is_some()
}
}

76
wasm/src/client/pipes.rs Normal file
View file

@ -0,0 +1,76 @@
use wasm_bindgen::prelude::*;
use crate::client::{ConnectionState, WasmClient};
use crate::client_pipe;
use crate::error::js_error;
use crate::pipe::PipeReader;
#[wasm_bindgen]
impl WasmClient {
pub fn set_on_pipe_request(&self, callback: Option<js_sys::Function>) {
*self.on_pipe_request.borrow_mut() = callback;
}
#[wasm_bindgen]
pub async fn create_pipe(
&self,
description: &str,
) -> Result<client_pipe::WasmPipeHandle, JsValue> {
if self.state.get() != ConnectionState::Connected {
return Err(js_error("not connected"));
}
let transport = self
.transport
.borrow()
.clone()
.ok_or_else(|| js_error("not connected"))?;
let pipe_id = client_pipe::random_pipe_id()?;
client_pipe::wasm_create_pipe(
&transport,
description,
pipe_id,
&self.pending_pipe_creations,
&self.expired_pipe_creations,
self.connection_generation.get(),
&self.connection_generation,
)
.await
}
#[wasm_bindgen]
pub async fn accept_pipe(&self, pipe_id: u32) -> Result<PipeReader, JsValue> {
if self.state.get() != ConnectionState::Connected {
return Err(js_error("not connected"));
}
let transport = self
.transport
.borrow()
.clone()
.ok_or_else(|| js_error("not connected"))?;
let generation = self.connection_generation.get();
client_pipe::wasm_accept_pipe(
&transport,
pipe_id,
&self.pending_pipes,
generation,
&self.connection_generation,
)
.await
}
#[wasm_bindgen]
pub async fn deny_pipe(&self, pipe_id: u32) -> Result<(), JsValue> {
if self.state.get() != ConnectionState::Connected {
return Err(js_error("not connected"));
}
let transport = self
.transport
.borrow()
.clone()
.ok_or_else(|| js_error("not connected"))?;
client_pipe::wasm_deny_pipe(&transport, pipe_id).await
}
}

328
wasm/src/client/receive.rs Normal file
View file

@ -0,0 +1,328 @@
use wasm_bindgen::prelude::*;
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use crate::client::{ConnectionState, WasmClient};
use crate::client_pipe;
use crate::error::js_error;
use crate::pipe::PipeReader;
use crate::transport::WasmTransport;
use super::MAX_SAFE_JS_INTEGER;
use super::dispatch::{
PendingPing, PingTimer, frame_id, frame_property, frame_type, reject_pending_requests,
route_incoming_frame, set_shared_state, stop_ping_timer,
};
#[wasm_bindgen]
impl WasmClient {
pub fn start_protocol_pings(&self, interval_ms: u32, client_id: u64) -> Result<(), JsValue> {
self.stop_protocol_pings();
if self.state.get() != ConnectionState::Connected {
return Err(js_error("not connected"));
}
let Some(transport) = self.transport.borrow().clone() else {
return Err(js_error("not connected"));
};
let generation = self.connection_generation.get();
let current_generation = self.connection_generation.clone();
let interval_ms = i32::try_from(interval_ms.max(1_000))
.map_err(|_| js_error("ping interval is too large"))?;
let on_error = self.on_error.clone();
let pending_pings = self.pending_pings.clone();
let closure = Closure::wrap(Box::new(move || {
if current_generation.get() != generation {
return;
}
let transport = transport.clone();
let on_error = on_error.clone();
let pending_pings = pending_pings.clone();
let current_generation = current_generation.clone();
wasm_bindgen_futures::spawn_local(async move {
if current_generation.get() != generation {
return;
}
let sent_at = js_sys::Date::now();
pending_pings.borrow_mut().retain(|_, pending| {
pending.generation == generation
&& sent_at - pending.sent_at < interval_ms as f64 * 3.0
});
let timestamp = if sent_at.is_finite()
&& sent_at >= 0.0
&& sent_at <= MAX_SAFE_JS_INTEGER
&& sent_at.fract() == 0.0
{
sent_at as u64
} else {
let _ = on_error.call1(&JsValue::NULL, &js_error("invalid clock value"));
return;
};
let type_map = transport.type_map();
let frame =
CommunicationValue::new_with_type_map(CommunicationType::Ping, &type_map)
.add_typed_default(
DataType::Description,
DataValue::Str("protocol ping".into()),
)
.add_typed_default(
DataType::Timestamp,
DataValue::UnsignedNumber(timestamp as u128),
)
.with_sender(client_id);
let Some(ping_id) = frame.id() else {
let _ = on_error.call1(&JsValue::NULL, &js_error("ping frame has no id"));
return;
};
let frame = frame
.to_bytes()
.map_err(|e| js_error(format!("encode ping failed: {}", e)));
match frame {
Ok(frame) => {
if current_generation.get() != generation {
return;
}
pending_pings.borrow_mut().insert(
ping_id,
PendingPing {
generation,
sent_at,
},
);
if let Err(error) = transport.send_frame(&frame).await {
if pending_pings
.borrow()
.get(&ping_id)
.is_some_and(|ping| ping.generation == generation)
{
pending_pings.borrow_mut().remove(&ping_id);
}
let _ = on_error.call1(&JsValue::NULL, &error);
}
}
Err(error) => {
let _ = on_error.call1(&JsValue::NULL, &error);
}
}
});
}) as Box<dyn FnMut()>);
let set_interval =
js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("setInterval"))?
.dyn_into::<js_sys::Function>()?;
let id = set_interval
.call2(
&JsValue::NULL,
closure.as_ref().unchecked_ref(),
&JsValue::from_f64(interval_ms as f64),
)?
.as_f64()
.filter(|value| {
value.is_finite()
&& value.fract() == 0.0
&& (i32::MIN as f64..=i32::MAX as f64).contains(value)
})
.and_then(|value| i32::try_from(value as i64).ok())
.ok_or_else(|| js_error("setInterval did not return a valid id"))?;
*self.ping_timer.borrow_mut() = Some(PingTimer { id, closure });
Ok(())
}
#[wasm_bindgen]
pub fn stop_protocol_pings(&self) {
self.pending_pings.borrow_mut().clear();
self.ping_ms.set(None);
let Some(timer) = self.ping_timer.borrow_mut().take() else {
return;
};
if let Ok(clear_interval) =
js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("clearInterval"))
.and_then(|value| value.dyn_into::<js_sys::Function>())
{
let _ = clear_interval.call1(&JsValue::NULL, &JsValue::from_f64(timer.id as f64));
}
drop(timer.closure);
}
pub(super) fn start_receive_loop(
&self,
transport: WasmTransport,
generation: u32,
client_id: u64,
) -> bool {
if self.connection_generation.get() != generation {
transport.close();
return false;
}
let loop_transport = transport.clone();
self.attempt_transport.borrow_mut().take();
*self.transport.borrow_mut() = Some(transport);
self.set_state(ConnectionState::Connected);
let connection_generation = self.connection_generation.clone();
let error_generation = connection_generation.clone();
let state = self.state.clone();
let pending_state_callbacks = self.pending_state_callbacks.clone();
let state_callback = self.state_callback.as_ref().clone();
let on_msg = self.on_message.clone();
let on_err = self.on_error.clone();
let subscriptions = self.subscriptions.clone();
let pending_requests = self.pending_requests.clone();
let loop_pending_requests = pending_requests.clone();
let expired_requests = self.expired_requests.clone();
let loop_expired_requests = expired_requests.clone();
let ping_timer = self.ping_timer.clone();
let pending_pings = self.pending_pings.clone();
let loop_pending_pings = pending_pings.clone();
let ping_ms = self.ping_ms.clone();
let loop_ping_ms = ping_ms.clone();
let pending_pipe_creations = self.pending_pipe_creations.clone();
let expired_pipe_creations = self.expired_pipe_creations.clone();
let pending_pipes = self.pending_pipes.clone();
let loop_pending_pipes = pending_pipes.clone();
let on_pipe_request = self.on_pipe_request.clone();
let loop_pipe_creations = pending_pipe_creations.clone();
let loop_expired_pipe_creations = expired_pipe_creations.clone();
let loop_generation = generation;
let frame_generation = connection_generation.clone();
let transport_for_cleanup = self.transport.clone();
let connection_client_id = self.connection_client_id.clone();
wasm_bindgen_futures::spawn_local(async move {
loop_transport
.receive_loop_with_pipes(
move |frame: JsValue| {
if frame_generation.get() != loop_generation {
return;
}
let message_type = frame_type(&frame);
if let Some(ref msg_type) = message_type {
if msg_type == "PipeRequest" {
let Some(pipe_id) = frame_id(&frame).filter(|id| *id != 0) else {
return;
};
let description = frame_property(&frame, "data")
.and_then(|data| {
let desc = js_sys::Reflect::get(
&data,
&JsValue::from_str("Description"),
)
.ok()?;
desc.as_string()
})
.unwrap_or_default();
let cb = on_pipe_request.borrow();
if let Some(ref callback) = *cb {
let obj = js_sys::Object::new();
let _ = js_sys::Reflect::set(
&obj,
&"pipeId".into(),
&JsValue::from_f64(pipe_id as f64),
);
let _ = js_sys::Reflect::set(
&obj,
&"description".into(),
&JsValue::from_str(&description),
);
let _ = callback.call1(&JsValue::NULL, &obj.into());
}
return;
}
if msg_type == "PipeResponse" {
let Some(pipe_id) = frame_id(&frame).filter(|id| *id != 0) else {
return;
};
let accepted = frame_property(&frame, "data")
.and_then(|data| {
let acc = js_sys::Reflect::get(
&data,
&JsValue::from_str("Accepted"),
)
.ok()?;
acc.as_bool()
})
.unwrap_or(false);
let pending = {
let mut pending = loop_pipe_creations.borrow_mut();
if pending
.get(&pipe_id)
.is_some_and(|entry| entry.generation == loop_generation)
{
pending.remove(&pipe_id)
} else {
None
}
};
if let Some(entry) = pending {
let _ = entry.sender.send(Ok(accepted));
} else {
let _ = client_pipe::consume_expired_pipe_creation(
&loop_expired_pipe_creations,
pipe_id,
);
}
return;
}
}
route_incoming_frame(
&frame,
loop_generation,
&on_msg,
&subscriptions,
&loop_pending_requests,
&loop_expired_requests,
&loop_pending_pings,
&loop_ping_ms,
);
},
move |error| {
if error_generation.get() == generation {
let _ = on_err.call1(&JsValue::NULL, &error);
}
},
move |pipe_reader: PipeReader| {
let pipe_id = pipe_reader.pipe_id();
let mut pending = loop_pending_pipes.borrow_mut();
if pending
.get(&pipe_id)
.is_some_and(|entry| entry.generation == loop_generation)
&& let Some(entry) = pending.remove(&pipe_id)
{
let _ = entry.sender.send(Ok(pipe_reader));
}
},
)
.await;
if connection_generation.get() != generation {
return;
}
if let Some(current_transport) = transport_for_cleanup.borrow_mut().take() {
current_transport.close();
}
set_shared_state(
&state,
&pending_state_callbacks,
&state_callback,
ConnectionState::Disconnected,
);
stop_ping_timer(&ping_timer);
pending_pings.borrow_mut().clear();
ping_ms.set(None);
reject_pending_requests(&pending_requests, "disconnected");
expired_requests.borrow_mut().clear();
client_pipe::reject_pending_pipe_creations(&pending_pipe_creations, "disconnected");
expired_pipe_creations.borrow_mut().clear();
client_pipe::reject_pending_pipes(&pending_pipes, "disconnected");
connection_client_id.set(0);
});
self.connection_client_id.set(client_id);
true
}
pub(super) fn reject_pending_requests(&self, message: &str) {
reject_pending_requests(&self.pending_requests, message);
self.expired_requests.borrow_mut().clear();
}
}

View file

@ -3,8 +3,10 @@ use std::collections::HashMap;
use std::rc::Rc;
use futures_channel::oneshot;
use futures_util::{FutureExt, pin_mut, select};
use tracing::debug;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue};
@ -21,12 +23,17 @@ pub(crate) struct PendingRequest {
pub(crate) struct PendingPipeCreation {
pub(crate) generation: u32,
pub(crate) token: Rc<()>,
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>>>;
const DEFAULT_PIPE_CREATION_TIMEOUT_MS: u32 = 30_000;
const EXPIRED_PIPE_CREATION_TOMBSTONE_TTL_MS: f64 = 60_000.0;
const MAX_EXPIRED_PIPE_CREATION_TOMBSTONES: usize = 1024;
pub(crate) struct PendingPipe {
pub(crate) generation: u32,
pub(crate) sender: oneshot::Sender<Result<PipeReader, JsValue>>,
@ -114,6 +121,10 @@ pub struct WasmPipeHandle {
description: String,
transport: WasmTransport,
response_rx: PipeResponseCell,
pending: PendingPipeCreations,
expired: Rc<RefCell<HashMap<u32, f64>>>,
generation: u32,
token: Rc<()>,
}
#[wasm_bindgen]
@ -125,9 +136,37 @@ impl WasmPipeHandle {
.take()
.ok_or_else(|| js_error("handle already consumed"))?;
let accepted = rx
.await
.map_err(|_| js_error("pipe handle channel closed"))?;
let response = rx.fuse();
let timeout = wait_for_timeout(DEFAULT_PIPE_CREATION_TIMEOUT_MS).fuse();
pin_mut!(response, timeout);
let accepted = select! {
result = response => match result {
Ok(result) => result,
Err(_) => {
expire_pending_pipe_creation(
&self.pending,
&self.expired,
self.pipe_id,
self.generation,
&self.token,
);
return Err(js_error("pipe handle channel closed"));
}
},
result = timeout => {
result?;
expire_pending_pipe_creation(
&self.pending,
&self.expired,
self.pipe_id,
self.generation,
&self.token,
);
return Err(js_error(format!(
"pipe creation timed out after {DEFAULT_PIPE_CREATION_TIMEOUT_MS}ms"
)));
},
};
match accepted {
Ok(true) => {
@ -153,6 +192,18 @@ impl WasmPipeHandle {
}
}
impl Drop for WasmPipeHandle {
fn drop(&mut self) {
expire_pending_pipe_creation(
&self.pending,
&self.expired,
self.pipe_id,
self.generation,
&self.token,
);
}
}
pub(crate) fn random_pipe_id() -> Result<u32, JsValue> {
let mut bytes = [0u8; 4];
getrandom_v04::fill(&mut bytes).map_err(|_| js_error("rng failed"))?;
@ -166,6 +217,146 @@ pub(crate) fn reject_pending_pipe_creations(pending: &PendingPipeCreations, mess
}
}
async fn wait_for_timeout(timeout_ms: u32) -> Result<(), JsValue> {
let promise = js_sys::Promise::new(&mut |resolve, reject| {
let result = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("setTimeout"))
.and_then(|value| value.dyn_into::<js_sys::Function>())
.and_then(|set_timeout| {
set_timeout.call2(
&JsValue::NULL,
&resolve,
&JsValue::from_f64(timeout_ms as f64),
)
});
if let Err(error) = result {
let _ = reject.call1(&JsValue::NULL, &error);
}
});
JsFuture::from(promise).await?;
Ok(())
}
fn expire_pending_pipe_creation(
pending: &PendingPipeCreations,
expired: &Rc<RefCell<HashMap<u32, f64>>>,
pipe_id: u32,
generation: u32,
token: &Rc<()>,
) {
let removed = {
let mut pending = pending.borrow_mut();
if pending
.get(&pipe_id)
.is_some_and(|entry| entry.generation == generation && Rc::ptr_eq(&entry.token, token))
{
pending.remove(&pipe_id);
true
} else {
false
}
};
if !removed {
return;
}
let now = js_sys::Date::now();
let mut expired = expired.borrow_mut();
expired.retain(|_, expires_at| *expires_at > now);
if expired.len() >= MAX_EXPIRED_PIPE_CREATION_TOMBSTONES
&& let Some(oldest) = expired
.iter()
.min_by(|(_, left), (_, right)| left.total_cmp(right))
.map(|(id, _)| *id)
{
expired.remove(&oldest);
}
expired.insert(pipe_id, now + EXPIRED_PIPE_CREATION_TOMBSTONE_TTL_MS);
}
struct PendingPipeCreationGuard {
pending: PendingPipeCreations,
expired: Rc<RefCell<HashMap<u32, f64>>>,
pipe_id: u32,
generation: u32,
token: Rc<()>,
armed: bool,
}
impl PendingPipeCreationGuard {
fn new(
pending: PendingPipeCreations,
expired: Rc<RefCell<HashMap<u32, f64>>>,
pipe_id: u32,
generation: u32,
token: Rc<()>,
) -> Self {
Self {
pending,
expired,
pipe_id,
generation,
token,
armed: true,
}
}
fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for PendingPipeCreationGuard {
fn drop(&mut self) {
if self.armed {
expire_pending_pipe_creation(
&self.pending,
&self.expired,
self.pipe_id,
self.generation,
&self.token,
);
}
}
}
struct PendingPipeGuard {
pending: PendingPipes,
pipe_id: u32,
generation: u32,
}
impl PendingPipeGuard {
fn new(pending: PendingPipes, pipe_id: u32, generation: u32) -> Self {
Self {
pending,
pipe_id,
generation,
}
}
}
impl Drop for PendingPipeGuard {
fn drop(&mut self) {
remove_pending_pipe(&self.pending, self.pipe_id, self.generation);
}
}
pub(crate) fn consume_expired_pipe_creation(
expired: &Rc<RefCell<HashMap<u32, f64>>>,
pipe_id: u32,
) -> bool {
let now = js_sys::Date::now();
let mut expired = expired.borrow_mut();
expired.retain(|_, expires_at| *expires_at > now);
expired.remove(&pipe_id).is_some()
}
fn is_expired_pipe_creation(expired: &Rc<RefCell<HashMap<u32, f64>>>, pipe_id: u32) -> bool {
let now = js_sys::Date::now();
let mut expired = expired.borrow_mut();
expired.retain(|_, expires_at| *expires_at > now);
expired.contains_key(&pipe_id)
}
pub(crate) fn reject_pending_pipes(pending: &PendingPipes, message: &str) {
let pending = std::mem::take(&mut *pending.borrow_mut());
for (_, entry) in pending {
@ -178,19 +369,26 @@ pub(crate) async fn wasm_create_pipe(
description: &str,
pipe_id: u32,
pending_pipe_creations: &PendingPipeCreations,
expired_pipe_creations: &Rc<RefCell<HashMap<u32, f64>>>,
generation: u32,
current_generation: &Rc<std::cell::Cell<u32>>,
) -> Result<WasmPipeHandle, JsValue> {
let (tx, rx) = oneshot::channel();
let token = Rc::new(());
let mut pipe_id = pipe_id;
for _ in 0..128 {
let occupied = pipe_id == 0 || pending_pipe_creations.borrow().contains_key(&pipe_id);
let occupied = pipe_id == 0
|| pending_pipe_creations.borrow().contains_key(&pipe_id)
|| is_expired_pipe_creation(expired_pipe_creations, pipe_id);
if !occupied {
break;
}
pipe_id = random_pipe_id()?;
}
if pipe_id == 0 || pending_pipe_creations.borrow().contains_key(&pipe_id) {
if pipe_id == 0
|| pending_pipe_creations.borrow().contains_key(&pipe_id)
|| is_expired_pipe_creation(expired_pipe_creations, pipe_id)
{
return Err(js_error("could not allocate a unique pipe id"));
}
let type_map = transport.type_map();
@ -207,9 +405,17 @@ pub(crate) async fn wasm_create_pipe(
pipe_id,
PendingPipeCreation {
generation,
token: token.clone(),
sender: tx,
},
);
let mut creation_guard = PendingPipeCreationGuard::new(
pending_pipe_creations.clone(),
expired_pipe_creations.clone(),
pipe_id,
generation,
token.clone(),
);
debug!(
target = "mtp.wasm",
pipe_id,
@ -218,31 +424,22 @@ pub(crate) async fn wasm_create_pipe(
"sending pipe request"
);
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"));
}
creation_guard.disarm();
Ok(WasmPipeHandle {
pipe_id,
description: description.to_string(),
transport: transport.clone(),
response_rx: Rc::new(RefCell::new(Some(rx))),
pending: pending_pipe_creations.clone(),
expired: expired_pipe_creations.clone(),
generation,
token,
})
}
@ -282,6 +479,7 @@ pub(crate) async fn wasm_accept_pipe(
},
);
}
let _acceptance_guard = PendingPipeGuard::new(pending_pipes.clone(), pipe_id, generation);
debug!(
target = "mtp.wasm",
@ -291,28 +489,42 @@ pub(crate) async fn wasm_accept_pipe(
"sending pipe response"
);
if let Err(error) = transport.send_frame(&resp_bytes).await {
let mut pending = pending_pipes.borrow_mut();
if pending
.get(&pipe_id)
.is_some_and(|entry| entry.generation == generation)
{
pending.remove(&pipe_id);
}
return Err(error);
}
if current_generation.get() != generation {
let mut pending = pending_pipes.borrow_mut();
if pending
.get(&pipe_id)
.is_some_and(|entry| entry.generation == generation)
{
pending.remove(&pipe_id);
}
return Err(js_error("connection attempt superseded"));
}
rx.await
.map_err(|_| js_error("pipe closed before stream arrived"))?
let response = rx.fuse();
let timeout = wait_for_timeout(DEFAULT_PIPE_CREATION_TIMEOUT_MS).fuse();
pin_mut!(response, timeout);
let result = select! {
result = response => match result {
Ok(result) => result,
Err(_) => {
remove_pending_pipe(pending_pipes, pipe_id, generation);
return Err(js_error("pipe closed before stream arrived"));
}
},
result = timeout => {
result?;
remove_pending_pipe(pending_pipes, pipe_id, generation);
return Err(js_error(format!(
"pipe acceptance timed out after {DEFAULT_PIPE_CREATION_TIMEOUT_MS}ms"
)));
},
};
result
}
fn remove_pending_pipe(pending_pipes: &PendingPipes, pipe_id: u32, generation: u32) {
let mut pending = pending_pipes.borrow_mut();
if pending
.get(&pipe_id)
.is_some_and(|entry| entry.generation == generation)
{
pending.remove(&pipe_id);
}
}
pub(crate) async fn wasm_deny_pipe(transport: &WasmTransport, pipe_id: u32) -> Result<(), JsValue> {

View file

@ -1,5 +1,6 @@
use wasm_bindgen::prelude::*;
#[derive(Clone)]
#[wasm_bindgen]
pub struct ConnectionConfig {
pub(crate) url: String,

View file

@ -2,8 +2,8 @@ use wasm_bindgen::prelude::*;
use zeroize::Zeroizing;
use mtp_codec::{
DataValue, MtpProtectionPurpose, PROTOCOL_VERSION, ProtectionPolicy, ProtectionPurpose,
SealedRelayBuilder, SignaturePolicy, TypeMap,
DataValue, DecodeLimits, EncodeLimits, MtpProtectionPurpose, PROTOCOL_VERSION,
ProtectionPolicy, ProtectionPurpose, SealedRelayBuilder, SignaturePolicy, TypeMap,
};
use mtp_crypto::{
AeadDecrypt, AeadEncrypt, DualSigner, Ed25519Signer, HybridKem, KemPrivateKey, KemPublicKey,
@ -12,10 +12,18 @@ use mtp_crypto::{
};
use crate::error::{from_protection_error, js_error};
use crate::relay::{decode_frame, relay_error, structured_error};
use crate::relay::{decode_error, 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"))
DataValue::try_from_bytes_with_limits(value, DecodeLimits::default()).map_err(|error| {
let value = decode_error(error, "DataValue decoding failed");
let _ = js_sys::Reflect::set(
&value,
&JsValue::from_str("code"),
&JsValue::from_str("invalid-data-value"),
);
value
})
}
fn decode_public_key_bundle(
@ -74,10 +82,19 @@ pub struct WasmKeyring {
#[wasm_bindgen]
impl WasmKeyring {
/// Serialise the keyring to bytes.
/// Serialise the keyring to bytes and report malformed caller-owned
/// material as a JavaScript exception.
#[wasm_bindgen]
pub fn to_bytes(&self) -> Vec<u8> {
self.inner.to_bytes().to_vec()
pub fn to_bytes(&self) -> Result<Vec<u8>, JsValue> {
self.try_to_bytes()
}
#[wasm_bindgen]
pub fn try_to_bytes(&self) -> Result<Vec<u8>, JsValue> {
self.inner
.try_to_bytes()
.map(|bytes| bytes.to_vec())
.map_err(|error| js_error(format!("Keyring serialization failed: {error}")))
}
/// Deserialise a keyring from bytes.
@ -118,8 +135,17 @@ impl WasmKeyring {
/// Generate a full keyring with KEM, ML-DSA, and Ed25519 keys.
#[wasm_bindgen]
pub fn keyring_generate() -> Vec<u8> {
Keyring::generate().to_bytes().to_vec()
pub fn keyring_generate() -> Result<Vec<u8>, JsValue> {
keyring_generate_checked()
}
/// Generate a full keyring and report serialization failures to JavaScript.
#[wasm_bindgen]
pub fn keyring_generate_checked() -> Result<Vec<u8>, JsValue> {
Keyring::generate()
.try_to_bytes()
.map(|bytes| bytes.to_vec())
.map_err(|error| js_error(format!("generated keyring serialization failed: {error}")))
}
/// Build a [`Keyring`] containing only an Ed25519 keypair (no KEM, no ML-DSA).
@ -142,7 +168,10 @@ pub fn keyring_from_ed25519(secret_key: &[u8], public_key: &[u8]) -> Result<Vec<
SignaturePublicKey::new(public_key.to_vec()),
SignaturePrivateKey::new(secret_key.to_vec()),
);
Ok(keyring.to_bytes().to_vec())
keyring
.try_to_bytes()
.map(|bytes| bytes.to_vec())
.map_err(|error| js_error(format!("Keyring serialization failed: {error}")))
}
// ===========================================================================
@ -172,8 +201,15 @@ impl WasmPublicKeyBundle {
}
#[wasm_bindgen]
pub fn to_bytes(&self) -> Vec<u8> {
self.inner.as_bytes()
pub fn to_bytes(&self) -> Result<Vec<u8>, JsValue> {
self.try_to_bytes()
}
#[wasm_bindgen]
pub fn try_to_bytes(&self) -> Result<Vec<u8>, JsValue> {
self.inner
.try_as_bytes()
.map_err(|error| js_error(format!("public key bundle serialization failed: {error}")))
}
#[wasm_bindgen]
@ -428,6 +464,14 @@ pub fn wasm_sha256_double(data: &[u8]) -> Vec<u8> {
// KDF
// ===========================================================================
/// Length, in bytes, of symmetric keys produced by the MTP key-derivation
/// bindings. SDKs should query this instead of duplicating the crypto
/// primitive's output size.
#[wasm_bindgen]
pub fn mtp_symmetric_key_length() -> u32 {
32
}
/// HKDF-expand: derive `len` bytes from `ikm` with `salt` and `info`.
#[wasm_bindgen]
pub fn wasm_hkdf_expand(
@ -452,6 +496,21 @@ pub fn wasm_derive_encryption_key(
.map_err(|e| js_error(format!("derive_encryption_key failed: {}", e)))
}
/// Derive a 32-byte key from a passphrase using explicit Argon2id parameters.
/// The salt and parameters are part of the caller's protected-data format.
#[wasm_bindgen]
pub fn wasm_argon2id(
passphrase: &[u8],
salt: &[u8],
memory_kib: u32,
iterations: u32,
lanes: u32,
) -> Result<Vec<u8>, JsValue> {
mtp_crypto::derive_password_key(passphrase, salt, memory_kib, iterations, lanes)
.map(|key| key.to_vec())
.map_err(|e| js_error(format!("argon2id password derivation failed: {e}")))
}
/// Signature suites accepted by high-level protected-value APIs.
pub const PROTECTION_SIGNATURE_SUITE_ED25519: u8 = 0x01;
pub const PROTECTION_SIGNATURE_SUITE_DUAL: u8 = 0x03;
@ -564,10 +623,11 @@ pub fn verify_data_value_with_policy(
let value = decode_data_value(value)?;
let bundle = decode_public_key_bundle(public_key_bundle, None)?;
let result = if signature_suite == 0 {
value.verify(
value.verify_with_policy(
expected_signer_id,
&bundle,
ProtectionPurpose::from(expected_purpose),
ProtectionPolicy::any_supported(),
)
} else {
value.verify_with_policy(
@ -650,7 +710,11 @@ pub fn decrypt_data_value_with_keyrings(
let keyrings = keyrings_from_js(&keyrings)?;
let references: Vec<&Keyring> = keyrings.iter().collect();
value
.decrypt_with_keyrings(&references, ProtectionPurpose::from(expected_purpose))
.decrypt_with_keyrings_and_limits(
&references,
ProtectionPurpose::from(expected_purpose),
DecodeLimits::default(),
)
.map_err(from_protection_error)?
.to_bytes()
.map_err(|e| js_error(format!("decryption failed: {e}")))
@ -746,6 +810,13 @@ pub fn mtp_protection_signature_suite_dual() -> u8 {
PROTECTION_SIGNATURE_SUITE_DUAL
}
/// Explicit compatibility policy value accepting any signature suite
/// supported by this WASM build. New callers should prefer a fixed suite.
#[wasm_bindgen]
pub fn mtp_protection_signature_suite_any_supported() -> u8 {
0
}
/// Forward a sealed relay frame to another clear next hop without opening or
/// re-encoding its authenticated encrypted payload.
#[wasm_bindgen]
@ -776,12 +847,27 @@ fn build_encrypted_relay_frame_impl(
signer: &dyn SignatureScheme,
metadata_recipient_public_key_bundles: JsValue,
content_recipient_public_key_bundles: JsValue,
limits: JsValue,
) -> Result<Vec<u8>, JsValue> {
let tm = TypeMap::new(PROTOCOL_VERSION);
let application_content = crate::frame::js_to_data_value(&data, &tm)?;
let encode_limits = if limits.is_null() || limits.is_undefined() {
EncodeLimits::default()
} else {
crate::client::encode_limits_from_js(&limits)?
};
let relay_options =
crate::relay::relay_open_options(ProtectionPolicy::any_supported(), &limits)?;
let application_content =
crate::frame::js_to_data_value_with_limits(&data, &tm, encode_limits)?;
let application_metadata = encoded_metadata
.as_deref()
.map(decode_data_value)
.map(|bytes| {
DataValue::try_from_bytes_with_limits(
bytes,
DecodeLimits::for_transport_message_size(encode_limits.max_output_size as u64),
)
.map_err(|error| crate::relay::decode_error(error, "metadata decoding failed"))
})
.transpose()?;
let content_recipients = public_key_bundles_from_js(&content_recipient_public_key_bundles)?;
let metadata_recipients = public_key_bundles_from_js(&metadata_recipient_public_key_bundles)?;
@ -798,6 +884,8 @@ fn build_encrypted_relay_frame_impl(
.created_at(created_at)
.metadata_recipients(metadata_recipients)
.content_recipients(content_recipients)
.encode_limits(encode_limits)
.protected_limits(relay_options.protected_limits)
.type_map(&tm);
let builder = match application_metadata {
Some(metadata) => builder.metadata(metadata),
@ -807,7 +895,7 @@ fn build_encrypted_relay_frame_impl(
builder
.build()
.map_err(relay_error)?
.to_bytes()
.to_bytes_with_limits(encode_limits)
.map_err(|e| js_error(format!("relay frame encoding failed: {e}")))
}
@ -844,6 +932,45 @@ pub fn build_encrypted_relay_frame_with_keyring(
&signer,
metadata_recipient_public_key_bundles,
content_recipient_public_key_bundles,
JsValue::UNDEFINED,
)
}
/// Build a sealed relay frame with explicit encoder and semantic field
/// limits. The same limits are applied by the native relay builder.
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn build_encrypted_relay_frame_with_keyring_with_limits(
message_type: &str,
data: JsValue,
signer_id: u64,
final_recipient_id: u64,
next_hop_id: u64,
message_id: &str,
created_at: u64,
encoded_metadata: Option<Vec<u8>>,
keyring_bytes: &[u8],
signature_suite: u8,
metadata_recipient_public_key_bundles: JsValue,
content_recipient_public_key_bundles: JsValue,
limits: 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,
limits,
)
}
@ -891,7 +1018,7 @@ mod tests {
},
};
let bytes = bundle.to_bytes();
let bytes = bundle.try_to_bytes().expect("bundle serialization");
let restored = WasmPublicKeyBundle::from_bytes_unvalidated(&bytes)
.expect("from_bytes_unvalidated failed");
assert_eq!(restored.sig_cl_public_key(), pk);
@ -1098,7 +1225,7 @@ mod tests {
let value = DataValue::Str("signed through wasm".into())
.to_bytes()
.expect("value encoding failed");
let keyring_bytes = keyring.to_bytes();
let keyring_bytes = keyring.try_to_bytes().expect("keyring serialization");
let signed = sign_data_value_with_keyring(
&value,
0xfeed_beef,
@ -1111,7 +1238,7 @@ mod tests {
verify_data_value_with_policy(
&signed,
&bundle.as_bytes(),
&bundle.try_as_bytes().expect("bundle serialization"),
0xfeed_beef,
7,
PROTECTION_SIGNATURE_SUITE_ED25519,
@ -1121,7 +1248,7 @@ mod tests {
assert!(
verify_data_value_with_policy(
&signed,
&wrong_bundle.as_bytes(),
&wrong_bundle.try_as_bytes().expect("bundle serialization"),
0xfeed_beef,
7,
PROTECTION_SIGNATURE_SUITE_ED25519,
@ -1137,21 +1264,29 @@ mod tests {
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");
let recipient_bytes = recipient.try_as_bytes().expect("recipient serialization");
let encrypted =
encrypt_data_value(&value, &recipient_bytes, 9).expect("encrypt_data_value failed");
let keyring_bytes = keyring.try_to_bytes().expect("keyring serialization");
let decrypted =
decrypt_data_value(&encrypted, &keyring_bytes, 9).expect("decrypt_data_value failed");
assert_eq!(decrypted, value);
let second_keyring = Keyring::generate();
let second_recipient = second_keyring.public_key_bundle();
let recipients = js_sys::Array::new();
recipients.push(&js_sys::Uint8Array::from(&recipient.as_bytes()[..]));
recipients.push(&js_sys::Uint8Array::from(&second_recipient.as_bytes()[..]));
let second_recipient_bytes = second_recipient
.try_as_bytes()
.expect("second recipient serialization");
recipients.push(&js_sys::Uint8Array::from(&recipient_bytes[..]));
recipients.push(&js_sys::Uint8Array::from(&second_recipient_bytes[..]));
let multi = encrypt_data_value_for_recipients(&value, recipients.into(), 9)
.expect("multi-recipient encryption failed");
let opened_by_second = decrypt_data_value(&multi, &second_keyring.to_bytes(), 9)
let second_keyring_bytes = second_keyring
.try_to_bytes()
.expect("second keyring serialization");
let opened_by_second = decrypt_data_value(&multi, &second_keyring_bytes, 9)
.expect("second recipient could not decrypt");
assert_eq!(opened_by_second, value);
}

View file

@ -1,9 +1,13 @@
use wasm_bindgen::{JsCast, prelude::*};
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue, PROTOCOL_VERSION};
use mtp_codec::{
CommunicationType, CommunicationValue, DataType, DataValue, DecodeLimits, EncodeLimits,
PROTOCOL_VERSION,
};
use mtp_type_map::TypeMap;
use crate::error::js_error;
use crate::relay::decode_error;
#[wasm_bindgen(typescript_custom_section)]
const PARSED_FRAME_TS: &'static str = r#"
@ -137,7 +141,48 @@ pub(crate) fn data_value_to_js(value: &DataValue, tm: &TypeMap) -> Result<JsValu
}
const MAX_SAFE_INT: f64 = 9007199254740991.0; // 2^53 - 1
struct JsDataValueEncodeContext {
limits: EncodeLimits,
values: usize,
}
impl JsDataValueEncodeContext {
fn visit(&mut self, depth: usize) -> Result<(), JsValue> {
if depth > self.limits.max_depth {
return Err(js_error("MTP DataValue nesting-depth limit exceeded"));
}
self.values = self
.values
.checked_add(1)
.ok_or_else(|| js_error("MTP DataValue value-count limit exceeded"))?;
if self.values > self.limits.max_values {
return Err(js_error("MTP DataValue value-count limit exceeded"));
}
Ok(())
}
}
pub(crate) fn js_to_data_value(value: &JsValue, tm: &TypeMap) -> Result<DataValue, JsValue> {
js_to_data_value_with_limits(value, tm, EncodeLimits::default())
}
pub(crate) fn js_to_data_value_with_limits(
value: &JsValue,
tm: &TypeMap,
limits: EncodeLimits,
) -> Result<DataValue, JsValue> {
let mut context = JsDataValueEncodeContext { limits, values: 0 };
js_to_data_value_with_context(value, tm, &mut context, 0)
}
fn js_to_data_value_with_context(
value: &JsValue,
tm: &TypeMap,
context: &mut JsDataValueEncodeContext,
depth: usize,
) -> Result<DataValue, JsValue> {
context.visit(depth)?;
if value.is_null() || value.is_undefined() {
return Ok(DataValue::Null);
}
@ -152,9 +197,17 @@ pub(crate) fn js_to_data_value(value: &JsValue, tm: &TypeMap) -> Result<DataValu
}
if js_sys::Array::is_array(value) {
let array = js_sys::Array::from(value);
if array.length() as usize > context.limits.max_values {
return Err(js_error("MTP DataValue value-count limit exceeded"));
}
let mut values = Vec::with_capacity(array.length() as usize);
for item in array.iter() {
values.push(js_to_data_value(&item, tm)?);
values.push(js_to_data_value_with_context(
&item,
tm,
context,
depth + 1,
)?);
}
return Ok(DataValue::Array(values));
}
@ -198,6 +251,9 @@ pub(crate) fn js_to_data_value(value: &JsValue, tm: &TypeMap) -> Result<DataValu
if value.is_object() {
let object = js_sys::Object::from(value.clone());
let keys = js_sys::Object::keys(&object);
if keys.length() as usize > context.limits.max_values {
return Err(js_error("MTP DataValue value-count limit exceeded"));
}
let mut entries = Vec::with_capacity(keys.length() as usize);
for key in keys.iter() {
let key = key
@ -212,7 +268,10 @@ pub(crate) fn js_to_data_value(value: &JsValue, tm: &TypeMap) -> Result<DataValu
tm.version
))
})?;
entries.push((id, js_to_data_value(&value, tm)?));
entries.push((
id,
js_to_data_value_with_context(&value, tm, context, depth + 1)?,
));
}
return Ok(DataValue::Container(entries));
}
@ -282,15 +341,16 @@ fn apply_frame_options(
}
pub(crate) fn parse_frame_value(frame: &[u8]) -> Result<JsValue, JsValue> {
parse_frame_value_with_type_map(frame, &TypeMap::latest())
parse_frame_value_with_limits(frame, &TypeMap::latest(), DecodeLimits::default())
}
pub(crate) fn parse_frame_value_with_type_map(
pub(crate) fn parse_frame_value_with_limits(
frame: &[u8],
type_map: &TypeMap,
limits: DecodeLimits,
) -> Result<JsValue, JsValue> {
let comm = CommunicationValue::from_bytes_with(frame, type_map)
.map_err(|e| js_error(format!("parse failed: {}", e)))?;
let comm = CommunicationValue::try_from_bytes_with_type_map_and_limits(frame, type_map, limits)
.map_err(|error| decode_error(error, "parse failed"))?;
let tm = type_map;
let obj = js_sys::Object::new();
@ -355,8 +415,8 @@ pub fn build_ping_frame(
/// Parse an auth response frame into a JS object.
#[wasm_bindgen(unchecked_return_type = "AuthResponse")]
pub fn parse_auth_response(response: &[u8]) -> Result<JsValue, JsValue> {
let comm = CommunicationValue::from_bytes(response)
.map_err(|e| js_error(format!("parse failed: {}", e)))?;
let comm = CommunicationValue::try_from_bytes_with_limits(response, DecodeLimits::default())
.map_err(|error| decode_error(error, "parse failed"))?;
let connected = matches!(
comm.get_data(DataType::Connected),
@ -418,8 +478,8 @@ pub fn parse_auth_response(response: &[u8]) -> Result<JsValue, JsValue> {
/// Parse any MTP frame into the human-readable CommunicationValue display form.
#[wasm_bindgen]
pub fn format_frame(frame: &[u8]) -> Result<String, JsValue> {
let comm = CommunicationValue::from_bytes(frame)
.map_err(|e| js_error(format!("parse failed: {}", e)))?;
let comm = CommunicationValue::try_from_bytes_with_limits(frame, DecodeLimits::default())
.map_err(|error| decode_error(error, "parse failed"))?;
Ok(comm.to_string())
}
@ -429,31 +489,80 @@ pub fn parse_frame(frame: &[u8]) -> Result<JsValue, JsValue> {
parse_frame_value(frame)
}
/// Parse a frame with the caller's bounded receive policy. The compatibility
/// `parse_frame` entry point retains the default policy for existing callers.
#[wasm_bindgen(unchecked_return_type = "ParsedFrame")]
pub fn parse_frame_with_limits(frame: &[u8], limits: JsValue) -> Result<JsValue, JsValue> {
let limits = crate::client::decode_limits_from_js(&limits)?;
parse_frame_value_with_limits(frame, &TypeMap::latest(), limits)
}
/// Parse a standalone serialized `DataValue` into the same structured form
/// used for frame payloads. Protected values remain opaque until the caller
/// explicitly opens and verifies them.
#[wasm_bindgen(unchecked_return_type = "ParsedDataValue")]
pub fn parse_data_value(value: &[u8]) -> Result<JsValue, JsValue> {
let value = DataValue::from_bytes(value).ok_or_else(|| js_error("invalid DataValue"))?;
parse_data_value_with_decode_limits(value, DecodeLimits::default())
}
fn parse_data_value_with_decode_limits(
value: &[u8],
limits: DecodeLimits,
) -> Result<JsValue, JsValue> {
let value = DataValue::try_from_bytes_with_limits(value, limits)
.map_err(|error| decode_error(error, "decode data value failed"))?;
let tm = TypeMap::new(PROTOCOL_VERSION);
data_value_to_js(&value, &tm)
}
/// Parse a standalone serialized `DataValue` with the caller's bounded
/// receive policy. The compatibility `parse_data_value` entry point retains
/// the default policy for existing callers.
#[wasm_bindgen(unchecked_return_type = "ParsedDataValue")]
pub fn parse_data_value_with_limits(value: &[u8], limits: JsValue) -> Result<JsValue, JsValue> {
let limits = crate::client::decode_limits_from_js(&limits)?;
parse_data_value_with_decode_limits(value, limits)
}
/// Encode one standalone `DataValue` using the negotiated/current type map.
#[wasm_bindgen]
pub fn encode_data_value(value: JsValue) -> Result<Vec<u8>, JsValue> {
encode_data_value_with_encode_limits(value, EncodeLimits::default())
}
fn encode_data_value_with_encode_limits(
value: JsValue,
limits: EncodeLimits,
) -> Result<Vec<u8>, JsValue> {
let tm = TypeMap::new(PROTOCOL_VERSION);
js_to_data_value(&value, &tm)?
.to_bytes()
js_to_data_value_with_limits(&value, &tm, limits)?
.to_bytes_with_limits(limits)
.map_err(|e| js_error(format!("encode data value failed: {e}")))
}
/// Encode one standalone `DataValue` using explicit recursion and output
/// limits. The compatibility entry point above keeps the historical default.
#[wasm_bindgen]
pub fn encode_data_value_with_limits(value: JsValue, limits: JsValue) -> Result<Vec<u8>, JsValue> {
let limits = crate::client::encode_limits_from_js(&limits)?;
encode_data_value_with_encode_limits(value, limits)
}
/// Build a typed MTP frame using generated communication/data type names.
#[wasm_bindgen]
pub fn build_frame(
message_type: &str,
data: JsValue,
options: JsValue,
) -> Result<Vec<u8>, JsValue> {
build_frame_with_encode_limits(message_type, data, options, EncodeLimits::default())
}
fn build_frame_with_encode_limits(
message_type: &str,
data: JsValue,
options: JsValue,
limits: EncodeLimits,
) -> Result<Vec<u8>, JsValue> {
let comm_type = CommunicationType::from_name(message_type)
.ok_or_else(|| js_error(format!("unknown communication type: {message_type}")))?;
@ -478,7 +587,7 @@ pub fn build_frame(
))
})?;
msg = msg
.add_data(id, js_to_data_value(&value, &tm)?)
.add_data(id, js_to_data_value_with_limits(&value, &tm, limits)?)
.map_err(|e| js_error(format!("add data failed: {e}")))?;
}
} else if !data.is_null() && !data.is_undefined() {
@ -487,10 +596,24 @@ pub fn build_frame(
));
}
msg.to_bytes()
msg.to_bytes_with_limits(limits)
.map_err(|e| js_error(format!("encode failed: {}", e)))
}
/// Build a typed frame with explicit recursion and complete-frame output
/// limits. High-level SDK sends use this entry point with the transport's
/// admitted message size.
#[wasm_bindgen]
pub fn build_frame_with_limits(
message_type: &str,
data: JsValue,
options: JsValue,
limits: JsValue,
) -> Result<Vec<u8>, JsValue> {
let limits = crate::client::encode_limits_from_js(&limits)?;
build_frame_with_encode_limits(message_type, data, options, limits)
}
/// Build a typed MTP frame around a complete serialized `DataValue` payload.
///
/// Unlike [`build_frame`], this does not interpret the payload as a clear data
@ -501,19 +624,50 @@ pub fn build_frame_with_payload(
message_type: &str,
serialized_payload: &[u8],
options: JsValue,
) -> Result<Vec<u8>, JsValue> {
build_frame_with_payload_with_encode_limits(
message_type,
serialized_payload,
options,
EncodeLimits::default(),
)
}
fn build_frame_with_payload_with_encode_limits(
message_type: &str,
serialized_payload: &[u8],
options: JsValue,
limits: EncodeLimits,
) -> Result<Vec<u8>, JsValue> {
let comm_type = CommunicationType::from_name(message_type)
.ok_or_else(|| js_error(format!("unknown communication type: {message_type}")))?;
let payload = DataValue::from_bytes(serialized_payload)
.ok_or_else(|| js_error("invalid serialized DataValue payload"))?;
let payload = DataValue::try_from_bytes_with_limits(
serialized_payload,
DecodeLimits::for_transport_message_size(limits.max_output_size as u64),
)
.map_err(|error| decode_error(error, "invalid serialized DataValue payload"))?;
let message =
apply_frame_options(CommunicationValue::new(comm_type), &options)?.with_payload(payload);
message
.to_bytes()
.to_bytes_with_limits(limits)
.map_err(|e| js_error(format!("encode failed: {e}")))
}
/// Build a typed frame around a serialized payload with explicit output
/// limits. The payload is also parsed with a policy derived from that limit so
/// an oversized/deep input cannot bypass the bounded builder.
#[wasm_bindgen]
pub fn build_frame_with_payload_with_limits(
message_type: &str,
serialized_payload: &[u8],
options: JsValue,
limits: JsValue,
) -> Result<Vec<u8>, JsValue> {
let limits = crate::client::encode_limits_from_js(&limits)?;
build_frame_with_payload_with_encode_limits(message_type, serialized_payload, options, limits)
}
#[cfg(test)]
#[cfg(target_arch = "wasm32")]
mod tests {

View file

@ -1,7 +1,8 @@
use wasm_bindgen::prelude::*;
use mtp_codec::{
DataValue, ProtectedError, ProtectedMessageBuilder, ProtectedOpenOptions, ProtectionError,
DataValue, DecodeLimits, EncodeLimits, ProtectedError, ProtectedLimits,
ProtectedMessageBuilder, ProtectedOpenOptions, ProtectionError, ProtectionPolicy,
ProtectionPurpose, VerifiedProtectedMessage,
};
@ -9,7 +10,7 @@ use crate::crypto::{
keyrings_from_js, protection_policy_from_suite, public_key_bundles_from_js,
relay_signer_from_keyring,
};
use crate::relay::{decode_frame, structured_error};
use crate::relay::{decode_error, decode_frame_with_limits, structured_error};
const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
@ -45,9 +46,86 @@ fn optional_u64(value: &JsValue, name: &str) -> Result<Option<u64>, JsValue> {
))
}
fn decode_data_value(value: &[u8]) -> Result<DataValue, JsValue> {
DataValue::from_bytes(value)
.ok_or_else(|| structured_error("invalid-data-value", "invalid DataValue"))
fn limit_usize(options: &JsValue, key: &str, default: usize) -> Result<usize, JsValue> {
if options.is_null() || options.is_undefined() {
return Ok(default);
}
let value = js_sys::Reflect::get(options, &JsValue::from_str(key))?;
if value.is_null() || value.is_undefined() {
return Ok(default);
}
let Some(number) = value.as_f64() else {
return Err(structured_error(
"invalid-limit",
format!("{key} must be a number"),
));
};
if !number.is_finite() || number.fract() != 0.0 || number < 0.0 {
return Err(structured_error(
"invalid-limit",
format!("{key} must be a non-negative integer"),
));
}
usize::try_from(number as u64)
.map_err(|_| structured_error("invalid-limit", format!("{key} is out of range")))
}
fn protected_open_options(
expected_receiver_id: Option<u64>,
signature_purpose: u8,
encryption_purpose: u8,
policy: mtp_codec::ProtectionPolicy,
limits: &JsValue,
) -> Result<ProtectedOpenOptions, JsValue> {
let defaults = DecodeLimits::default();
let encode_defaults = EncodeLimits::default();
let protected_defaults = ProtectedLimits::default();
let decode_limits = DecodeLimits {
max_depth: limit_usize(limits, "maxDepth", defaults.max_depth)?,
max_values: limit_usize(limits, "maxValues", defaults.max_values)?,
max_blob_size: limit_usize(limits, "maxBlobSize", defaults.max_blob_size)?,
max_recipients: limit_usize(limits, "maxRecipients", defaults.max_recipients)?,
max_allocated_bytes: limit_usize(
limits,
"maxAllocatedBytes",
defaults.max_allocated_bytes,
)?,
};
let encode_limits = EncodeLimits {
max_depth: limit_usize(limits, "maxDepth", encode_defaults.max_depth)?,
max_values: limit_usize(limits, "maxValues", encode_defaults.max_values)?,
max_output_size: limit_usize(limits, "maxOutputSize", encode_defaults.max_output_size)?,
};
let protected_limits = ProtectedLimits {
max_message_id_bytes: limit_usize(
limits,
"maxMessageIdBytes",
protected_defaults.max_message_id_bytes,
)?,
max_metadata_encoded_bytes: limit_usize(
limits,
"maxMetadataEncodedBytes",
protected_defaults.max_metadata_encoded_bytes,
)?,
max_signer_key_history: limit_usize(
limits,
"maxSignerKeyHistory",
protected_defaults.max_signer_key_history,
)?,
max_decryption_key_history: limit_usize(
limits,
"maxDecryptionKeyHistory",
protected_defaults.max_decryption_key_history,
)?,
};
Ok(ProtectedOpenOptions::new(
expected_receiver_id,
ProtectionPurpose::from(signature_purpose),
ProtectionPurpose::from(encryption_purpose),
policy,
)
.with_limits(decode_limits, protected_limits)
.with_encode_limits(encode_limits))
}
pub(crate) fn protected_error(error: ProtectedError) -> JsValue {
@ -86,6 +164,7 @@ fn protected_error_code(error: &ProtectedError) -> &'static str {
ProtectedError::ExpectedReceiverMismatch => "receiver-id-mismatch",
ProtectedError::ReservedApplicationType(_) => "reserved-application-type",
ProtectedError::Replay => "replay",
ProtectedError::ResourceLimit(_) => "resource-limit",
ProtectedError::ReplayGuard(_) => "replay-guard-error",
ProtectedError::Protection(error) => match error {
ProtectionError::NoMatchingRecipient => "no-matching-recipient",
@ -94,6 +173,7 @@ fn protected_error_code(error: &ProtectedError) -> &'static str {
ProtectionError::PurposeMismatch { .. } => "purpose-mismatch",
ProtectionError::SignerIdMismatch { .. } => "signer-id-mismatch",
ProtectionError::SignerKeyNotFound(_) => "signer-key-not-found",
ProtectionError::ResourceLimit(_) => "resource-limit",
ProtectionError::Crypto(mtp_crypto::CryptoError::InvalidSignature)
| ProtectionError::Crypto(mtp_crypto::CryptoError::VerificationFailed) => {
"invalid-signature"
@ -112,15 +192,6 @@ fn serialize_data_value(value: &DataValue) -> Result<Vec<u8>, JsValue> {
})
}
fn serialize_frame(frame: &mtp_codec::CommunicationValue) -> Result<Vec<u8>, JsValue> {
frame.to_bytes().map_err(|error| {
structured_error(
"invalid-frame",
format!("protected frame encoding failed: {error}"),
)
})
}
#[wasm_bindgen]
pub struct WasmVerifiedProtectedMessage {
inner: VerifiedProtectedMessage,
@ -181,7 +252,58 @@ pub fn build_protected_frame_with_keyring(
expose_sender: bool,
recipient_public_key_bundles: JsValue,
) -> Result<Vec<u8>, JsValue> {
let content = decode_data_value(encoded_content)?;
build_protected_frame_with_keyring_impl(
message_type,
encoded_content,
signer_id,
final_recipient_id,
message_id,
created_at,
signature_purpose,
encryption_purpose,
keyring_bytes,
signature_suite,
frame_id,
expose_sender,
recipient_public_key_bundles,
JsValue::UNDEFINED,
)
}
#[allow(clippy::too_many_arguments)]
fn build_protected_frame_with_keyring_impl(
message_type: &str,
encoded_content: &[u8],
signer_id: u64,
final_recipient_id: u64,
message_id: &str,
created_at: u64,
signature_purpose: u8,
encryption_purpose: u8,
keyring_bytes: &[u8],
signature_suite: u8,
frame_id: Option<u32>,
expose_sender: bool,
recipient_public_key_bundles: JsValue,
limits: JsValue,
) -> Result<Vec<u8>, JsValue> {
let encode_limits = if limits.is_null() || limits.is_undefined() {
EncodeLimits::default()
} else {
crate::client::encode_limits_from_js(&limits)?
};
let open_options = protected_open_options(
None,
signature_purpose,
encryption_purpose,
ProtectionPolicy::any_supported(),
&limits,
)?;
let content = DataValue::try_from_bytes_with_limits(
encoded_content,
DecodeLimits::for_transport_message_size(encode_limits.max_output_size as u64),
)
.map_err(|error| decode_error(error, "DataValue decoding failed"))?;
let keyring = mtp_crypto::Keyring::from_bytes(keyring_bytes).map_err(|error| {
structured_error(
"invalid-keyring",
@ -202,42 +324,149 @@ pub fn build_protected_frame_with_keyring(
.message_id(message_id)
.created_at(created_at)
.recipients(recipients)
.encode_limits(encode_limits)
.protected_limits(open_options.protected_limits)
.expose_sender(expose_sender);
if let Some(frame_id) = frame_id {
builder = builder.frame_id(frame_id);
}
let frame = builder.build().map_err(protected_error)?;
serialize_frame(&frame)
frame.to_bytes_with_limits(encode_limits).map_err(|error| {
structured_error(
"invalid-frame",
format!("protected frame encoding failed: {error}"),
)
})
}
/// Build a complete encrypted protected frame with explicit encoder and
/// semantic protected-field limits.
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn build_protected_frame_with_keyring_with_limits(
message_type: &str,
encoded_content: &[u8],
signer_id: u64,
final_recipient_id: u64,
message_id: &str,
created_at: u64,
signature_purpose: u8,
encryption_purpose: u8,
keyring_bytes: &[u8],
signature_suite: u8,
frame_id: Option<u32>,
expose_sender: bool,
recipient_public_key_bundles: JsValue,
limits: JsValue,
) -> Result<Vec<u8>, JsValue> {
build_protected_frame_with_keyring_impl(
message_type,
encoded_content,
signer_id,
final_recipient_id,
message_id,
created_at,
signature_purpose,
encryption_purpose,
keyring_bytes,
signature_suite,
frame_id,
expose_sender,
recipient_public_key_bundles,
limits,
)
}
/// Read the claimed, unverified signer ID after decrypting the protected
/// payload. The result may only select trusted keys for the same signer ID.
#[wasm_bindgen]
#[deprecated(note = "use protected_claimed_signer_id_with_limits")]
pub fn protected_claimed_signer_id(
frame: &[u8],
keyrings: JsValue,
encryption_purpose: u8,
) -> Result<u64, JsValue> {
let frame = decode_frame(frame)?;
protected_claimed_signer_id_impl(frame, keyrings, encryption_purpose, JsValue::UNDEFINED)
}
fn protected_claimed_signer_id_impl(
frame: &[u8],
keyrings: JsValue,
encryption_purpose: u8,
limits: JsValue,
) -> Result<u64, JsValue> {
let options = protected_open_options(
None,
0,
encryption_purpose,
ProtectionPolicy::any_supported(),
&limits,
)?;
let frame = decode_frame_with_limits(frame, options.decode_limits)?;
let keyrings = keyrings_from_js(&keyrings).map_err(|error| {
structured_error(
"invalid-recipient-keyrings",
error.as_string().unwrap_or_default(),
)
})?;
if keyrings.len() > options.protected_limits.max_decryption_key_history {
return Err(protected_error(ProtectedError::ResourceLimit(
"decryption key history",
)));
}
let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect();
mtp_codec::protected_claimed_signer_id(
mtp_codec::protected_claimed_signer_id_with_options(
&frame,
&references,
ProtectionPurpose::from(encryption_purpose),
options.decode_limits,
options.protected_limits,
)
.map_err(protected_error)
}
/// Open and verify a direct protected message in the native codec using
/// trusted signer-key history supplied by the SDK.
#[wasm_bindgen]
pub fn open_protected_with_keyrings(
pub fn protected_claimed_signer_id_with_limits(
frame: &[u8],
keyrings: JsValue,
encryption_purpose: u8,
limits: JsValue,
) -> Result<u64, JsValue> {
let options = protected_open_options(
None,
0,
encryption_purpose,
ProtectionPolicy::any_supported(),
&limits,
)?;
let frame = decode_frame_with_limits(frame, options.decode_limits)?;
let keyrings = keyrings_from_js(&keyrings).map_err(|error| {
structured_error(
"invalid-recipient-keyrings",
error.as_string().unwrap_or_default(),
)
})?;
if keyrings.len() > options.protected_limits.max_decryption_key_history {
return Err(protected_error(ProtectedError::ResourceLimit(
"decryption key history",
)));
}
let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect();
mtp_codec::protected_claimed_signer_id_with_options(
&frame,
&references,
ProtectionPurpose::from(encryption_purpose),
options.decode_limits,
options.protected_limits,
)
.map_err(protected_error)
}
/// Open a protected value without replay protection. This raw entry point is
/// intended for stored/forensic messages; message-processing callers should
/// apply their replay guard in the SDK or use a checked native API.
#[wasm_bindgen]
pub fn open_protected_with_keyrings_without_replay(
frame: &[u8],
keyrings: JsValue,
expected_signer_id: JsValue,
@ -247,7 +476,30 @@ pub fn open_protected_with_keyrings(
encryption_purpose: u8,
signature_suite: u8,
) -> Result<WasmVerifiedProtectedMessage, JsValue> {
let frame = decode_frame(frame)?;
open_protected_with_keyrings_impl(
frame,
keyrings,
expected_signer_id,
signer_public_key_bundles,
expected_receiver_id,
signature_purpose,
encryption_purpose,
signature_suite,
JsValue::UNDEFINED,
)
}
fn open_protected_with_keyrings_impl(
frame: &[u8],
keyrings: JsValue,
expected_signer_id: JsValue,
signer_public_key_bundles: JsValue,
expected_receiver_id: JsValue,
signature_purpose: u8,
encryption_purpose: u8,
signature_suite: u8,
limits: JsValue,
) -> Result<WasmVerifiedProtectedMessage, JsValue> {
let keyrings = keyrings_from_js(&keyrings).map_err(|error| {
structured_error(
"invalid-recipient-keyrings",
@ -268,23 +520,53 @@ pub fn open_protected_with_keyrings(
error.as_string().unwrap_or_default(),
)
})?;
let message = mtp_codec::open_protected_with_keys(
let options = protected_open_options(
expected_receiver_id,
signature_purpose,
encryption_purpose,
policy,
&limits,
)?;
let frame = decode_frame_with_limits(frame, options.decode_limits)?;
let message = mtp_codec::open_protected_with_keys_without_replay(
&frame,
&references,
expected_signer_id,
&signer_public_keys,
ProtectedOpenOptions::new(
expected_receiver_id,
ProtectionPurpose::from(signature_purpose),
ProtectionPurpose::from(encryption_purpose),
policy,
),
None,
options,
)
.map_err(protected_error)?;
Ok(WasmVerifiedProtectedMessage { inner: message })
}
/// Open a bounded protected value without replay protection. The raw WASM
/// boundary cannot accept a native replay-guard trait, so message-processing
/// callers must use the SDK guard or a native checked API.
#[wasm_bindgen]
pub fn open_protected_with_keyrings_with_limits_without_replay(
frame: &[u8],
keyrings: JsValue,
expected_signer_id: JsValue,
signer_public_key_bundles: JsValue,
expected_receiver_id: JsValue,
signature_purpose: u8,
encryption_purpose: u8,
signature_suite: u8,
limits: JsValue,
) -> Result<WasmVerifiedProtectedMessage, JsValue> {
open_protected_with_keyrings_impl(
frame,
keyrings,
expected_signer_id,
signer_public_key_bundles,
expected_receiver_id,
signature_purpose,
encryption_purpose,
signature_suite,
limits,
)
}
#[cfg(all(test, target_arch = "wasm32"))]
mod tests {
use super::*;
@ -358,9 +640,12 @@ mod tests {
sender: &mtp_crypto::Keyring,
recipient: &mtp_crypto::Keyring,
) -> JsValue {
let recipient_bytes = recipient.to_bytes();
let signer_bundle_bytes = sender.public_key_bundle().as_bytes();
match open_protected_with_keyrings(
let recipient_bytes = recipient.try_to_bytes().expect("recipient serialization");
let signer_bundle_bytes = sender
.public_key_bundle()
.try_as_bytes()
.expect("signer bundle serialization");
match open_protected_with_keyrings_without_replay(
frame,
js_sys::Uint8Array::from(&recipient_bytes[..]).into(),
JsValue::bigint_from_str("7"),
@ -379,8 +664,11 @@ mod tests {
fn protected_builder_returns_the_complete_frame() {
let sender = mtp_crypto::Keyring::generate();
let recipient = mtp_crypto::Keyring::generate();
let sender_bytes = sender.to_bytes();
let recipient_bundle_bytes = recipient.public_key_bundle().as_bytes();
let sender_bytes = sender.try_to_bytes().expect("sender serialization");
let recipient_bundle_bytes = recipient
.public_key_bundle()
.try_as_bytes()
.expect("recipient bundle serialization");
let content = DataValue::Str("complete-frame".into())
.to_bytes()
.expect("encode content");

View file

@ -1,8 +1,8 @@
use wasm_bindgen::prelude::*;
use mtp_codec::{
CommunicationValue, DataValue, ProtectionError, RelayError, VerifiedRelayContent,
VerifiedRelayMetadata,
CommunicationValue, DataValue, DecodeLimits, EncodeLimits, ProtectedLimits, ProtectionError,
ProtectionPolicy, RelayError, RelayOpenOptions, VerifiedRelayContent, VerifiedRelayMetadata,
};
use crate::crypto::{keyrings_from_js, protection_policy_from_suite, public_key_bundles_from_js};
@ -16,6 +16,28 @@ pub(crate) fn structured_error(code: &str, message: impl Into<String>) -> JsValu
value
}
pub(crate) fn decode_error(error: mtp_codec::DecodeError, context: &str) -> JsValue {
let value = structured_error("invalid-frame", format!("{context}: {error}"));
let _ = js_sys::Reflect::set(
&value,
&JsValue::from_str("decodeCode"),
&JsValue::from_str(decode_error_code(&error)),
);
value
}
pub(crate) fn decode_error_code(error: &mtp_codec::DecodeError) -> &'static str {
match error {
mtp_codec::DecodeError::MalformedEncoding => "malformed-encoding",
mtp_codec::DecodeError::DepthLimit => "depth-limit",
mtp_codec::DecodeError::ValueCountLimit => "value-count-limit",
mtp_codec::DecodeError::BlobLimit => "blob-limit",
mtp_codec::DecodeError::AllocationLimit => "allocation-limit",
mtp_codec::DecodeError::RecipientLimit => "recipient-limit",
mtp_codec::DecodeError::DuplicateField => "duplicate-field",
}
}
fn wrapped_input_error(code: &str, error: JsValue) -> JsValue {
let message = error
.as_string()
@ -54,6 +76,7 @@ fn relay_error_code(error: &RelayError) -> &'static str {
RelayError::UnsupportedRelayVersion(_) => "unsupported-relay-version",
RelayError::NotFinalRecipient => "not-final-recipient",
RelayError::Replay => "replay",
RelayError::ResourceLimit(_) => "resource-limit",
RelayError::ReservedApplicationType(_) => "reserved-application-type",
RelayError::ReplayGuard(_) => "replay-guard-error",
RelayError::Protection(error) => match error {
@ -63,6 +86,7 @@ fn relay_error_code(error: &RelayError) -> &'static str {
ProtectionError::PurposeMismatch { .. } => "purpose-mismatch",
ProtectionError::SignerIdMismatch { .. } => "signer-id-mismatch",
ProtectionError::SignerKeyNotFound(_) => "signer-key-not-found",
ProtectionError::ResourceLimit(_) => "resource-limit",
ProtectionError::Crypto(mtp_crypto::CryptoError::InvalidSignature)
| ProtectionError::Crypto(mtp_crypto::CryptoError::VerificationFailed) => {
"invalid-signature"
@ -73,12 +97,15 @@ fn relay_error_code(error: &RelayError) -> &'static str {
}
pub(crate) fn decode_frame(frame: &[u8]) -> Result<CommunicationValue, JsValue> {
CommunicationValue::from_bytes(frame).map_err(|error| {
structured_error(
"invalid-frame",
format!("relay frame decoding failed: {error}"),
)
})
decode_frame_with_limits(frame, DecodeLimits::default())
}
pub(crate) fn decode_frame_with_limits(
frame: &[u8],
limits: DecodeLimits,
) -> Result<CommunicationValue, JsValue> {
CommunicationValue::try_from_bytes_with_limits(frame, limits)
.map_err(|error| decode_error(error, "relay frame decoding failed"))
}
fn optional_u64(value: &JsValue, name: &str) -> Result<Option<u64>, JsValue> {
@ -113,6 +140,79 @@ fn optional_u64(value: &JsValue, name: &str) -> Result<Option<u64>, JsValue> {
))
}
fn limit_usize(options: &JsValue, key: &str, default: usize) -> Result<usize, JsValue> {
if options.is_null() || options.is_undefined() {
return Ok(default);
}
let value = js_sys::Reflect::get(options, &JsValue::from_str(key))?;
if value.is_null() || value.is_undefined() {
return Ok(default);
}
let Some(number) = value.as_f64() else {
return Err(structured_error(
"invalid-limit",
format!("{key} must be a number"),
));
};
if !number.is_finite() || number.fract() != 0.0 || number < 0.0 {
return Err(structured_error(
"invalid-limit",
format!("{key} must be a non-negative integer"),
));
}
usize::try_from(number as u64)
.map_err(|_| structured_error("invalid-limit", format!("{key} is out of range")))
}
pub(crate) fn relay_open_options(
policy: mtp_codec::ProtectionPolicy,
limits: &JsValue,
) -> Result<RelayOpenOptions, JsValue> {
let decode_defaults = DecodeLimits::default();
let encode_defaults = EncodeLimits::default();
let protected_defaults = ProtectedLimits::default();
let options = RelayOpenOptions::new(policy).with_limits(
DecodeLimits {
max_depth: limit_usize(limits, "maxDepth", decode_defaults.max_depth)?,
max_values: limit_usize(limits, "maxValues", decode_defaults.max_values)?,
max_blob_size: limit_usize(limits, "maxBlobSize", decode_defaults.max_blob_size)?,
max_recipients: limit_usize(limits, "maxRecipients", decode_defaults.max_recipients)?,
max_allocated_bytes: limit_usize(
limits,
"maxAllocatedBytes",
decode_defaults.max_allocated_bytes,
)?,
},
ProtectedLimits {
max_message_id_bytes: limit_usize(
limits,
"maxMessageIdBytes",
protected_defaults.max_message_id_bytes,
)?,
max_metadata_encoded_bytes: limit_usize(
limits,
"maxMetadataEncodedBytes",
protected_defaults.max_metadata_encoded_bytes,
)?,
max_signer_key_history: limit_usize(
limits,
"maxSignerKeyHistory",
protected_defaults.max_signer_key_history,
)?,
max_decryption_key_history: limit_usize(
limits,
"maxDecryptionKeyHistory",
protected_defaults.max_decryption_key_history,
)?,
},
);
Ok(options.with_encode_limits(EncodeLimits {
max_depth: limit_usize(limits, "maxDepth", encode_defaults.max_depth)?,
max_values: limit_usize(limits, "maxValues", encode_defaults.max_values)?,
max_output_size: limit_usize(limits, "maxOutputSize", encode_defaults.max_output_size)?,
}))
}
fn serialize_data_value(value: &DataValue) -> Result<Vec<u8>, JsValue> {
value.to_bytes().map_err(|error| {
structured_error(
@ -196,19 +296,49 @@ impl WasmVerifiedRelayContent {
/// versioned relay metadata parser in the JavaScript SDK. The caller must bind
/// this value as the expected signer during the subsequent verification call.
#[wasm_bindgen]
#[deprecated(note = "use relay_metadata_claimed_signer_id_with_limits")]
pub fn relay_metadata_claimed_signer_id(frame: &[u8], keyrings: JsValue) -> Result<u64, JsValue> {
let frame = decode_frame(frame)?;
relay_metadata_claimed_signer_id_impl(frame, keyrings, JsValue::UNDEFINED)
}
fn relay_metadata_claimed_signer_id_impl(
frame: &[u8],
keyrings: JsValue,
limits: JsValue,
) -> Result<u64, JsValue> {
let options = relay_open_options(ProtectionPolicy::any_supported(), &limits)?;
let frame = decode_frame_with_limits(frame, options.decode_limits)?;
let keyrings = keyrings_from_js(&keyrings)
.map_err(|error| wrapped_input_error("invalid-recipient-keyrings", error))?;
if keyrings.len() > options.protected_limits.max_decryption_key_history {
return Err(relay_error(RelayError::ResourceLimit(
"decryption key history",
)));
}
let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect();
mtp_codec::relay_metadata_claimed_signer_id(&frame, &references).map_err(relay_error)
mtp_codec::relay_metadata_claimed_signer_id_with_options(
&frame,
&references,
options.decode_limits,
options.protected_limits,
)
.map_err(relay_error)
}
/// Open and verify relay metadata in the native codec. JavaScript resolves
/// the trusted signing-key history before calling this function, while the
/// codec owns all relay layout and version interpretation.
#[wasm_bindgen]
pub fn open_relay_metadata_with_keyrings(
pub fn relay_metadata_claimed_signer_id_with_limits(
frame: &[u8],
keyrings: JsValue,
limits: JsValue,
) -> Result<u64, JsValue> {
relay_metadata_claimed_signer_id_impl(frame, keyrings, limits)
}
/// Open relay metadata without replay protection. This raw entry point is for
/// stored/forwarded messages; message-processing paths should add a guard in
/// the SDK or use the checked native API.
#[wasm_bindgen]
pub fn open_relay_metadata_with_keyrings_without_replay(
frame: &[u8],
keyrings: JsValue,
expected_signer_id: JsValue,
@ -225,21 +355,54 @@ pub fn open_relay_metadata_with_keyrings(
.ok_or_else(|| structured_error("invalid-option", "expectedSignerId is required"))?;
let policy = protection_policy_from_suite(signature_suite)
.map_err(|error| wrapped_input_error("unsupported-signature-suite", error))?;
let metadata = mtp_codec::open_relay_metadata_with_keys(
let metadata = mtp_codec::open_relay_metadata_with_limits_without_replay(
&frame,
&references,
expected_signer_id,
&signer_public_keys,
policy,
Some(expected_signer_id),
move |_| Some(signer_public_keys),
RelayOpenOptions::new(policy),
)
.map_err(relay_error)?;
Ok(WasmVerifiedRelayMetadata { inner: metadata })
}
/// Open and verify relay content in the native codec using recipient and
/// signer key histories supplied by the SDK.
/// Open bounded relay metadata without replay protection. Use the SDK's
/// message-processing guard or a native checked API for live traffic.
#[wasm_bindgen]
pub fn open_relay_content_with_keyrings(
pub fn open_relay_metadata_with_keyrings_with_limits_without_replay(
frame: &[u8],
keyrings: JsValue,
expected_signer_id: JsValue,
signer_public_key_bundles: JsValue,
signature_suite: u8,
limits: JsValue,
) -> Result<WasmVerifiedRelayMetadata, JsValue> {
let keyrings = keyrings_from_js(&keyrings)
.map_err(|error| wrapped_input_error("invalid-recipient-keyrings", error))?;
let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect();
let signer_public_keys = public_key_bundles_from_js(&signer_public_key_bundles)
.map_err(|error| wrapped_input_error("invalid-signer-keys", error))?;
let expected_signer_id = optional_u64(&expected_signer_id, "expectedSignerId")?
.ok_or_else(|| structured_error("invalid-option", "expectedSignerId is required"))?;
let policy = protection_policy_from_suite(signature_suite)
.map_err(|error| wrapped_input_error("unsupported-signature-suite", error))?;
let options = relay_open_options(policy, &limits)?;
let frame = decode_frame_with_limits(frame, options.decode_limits)?;
let metadata = mtp_codec::open_relay_metadata_with_limits_without_replay(
&frame,
&references,
Some(expected_signer_id),
move |_| Some(signer_public_keys),
options,
)
.map_err(relay_error)?;
Ok(WasmVerifiedRelayMetadata { inner: metadata })
}
/// Open relay content without making a second replay decision. Replay is
/// consumed when live message processing accepts the authenticated metadata.
#[wasm_bindgen]
pub fn open_relay_content_with_keyrings_without_replay(
metadata: &WasmVerifiedRelayMetadata,
keyrings: JsValue,
signer_public_key_bundles: JsValue,
@ -255,12 +418,49 @@ pub fn open_relay_content_with_keyrings(
optional_u64(&expected_final_recipient_id, "expectedFinalRecipientId")?;
let policy = protection_policy_from_suite(signature_suite)
.map_err(|error| wrapped_input_error("unsupported-signature-suite", error))?;
let content = mtp_codec::open_relay_content_with_keyrings(
let content = mtp_codec::open_relay_content_with_limits_without_replay(
&metadata.inner,
&references,
&signer_public_keys,
expected_final_recipient_id,
policy,
RelayOpenOptions {
policy,
decode_limits: metadata.inner.decode_limits(),
encode_limits: metadata.inner.encode_limits(),
protected_limits: metadata.inner.protected_limits(),
},
)
.map_err(relay_error)?;
Ok(WasmVerifiedRelayContent { inner: content })
}
/// Open bounded relay content without replay protection. Replay is consumed
/// when metadata is accepted by the live SDK/native processing boundary.
#[wasm_bindgen]
pub fn open_relay_content_with_keyrings_with_limits_without_replay(
metadata: &WasmVerifiedRelayMetadata,
keyrings: JsValue,
signer_public_key_bundles: JsValue,
expected_final_recipient_id: JsValue,
signature_suite: u8,
limits: JsValue,
) -> Result<WasmVerifiedRelayContent, JsValue> {
let keyrings = keyrings_from_js(&keyrings)
.map_err(|error| wrapped_input_error("invalid-recipient-keyrings", error))?;
let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect();
let signer_public_keys = public_key_bundles_from_js(&signer_public_key_bundles)
.map_err(|error| wrapped_input_error("invalid-signer-keys", error))?;
let expected_final_recipient_id =
optional_u64(&expected_final_recipient_id, "expectedFinalRecipientId")?;
let policy = protection_policy_from_suite(signature_suite)
.map_err(|error| wrapped_input_error("unsupported-signature-suite", error))?;
let options = relay_open_options(policy, &limits)?;
let content = mtp_codec::open_relay_content_with_limits_without_replay(
&metadata.inner,
&references,
&signer_public_keys,
expected_final_recipient_id,
options,
)
.map_err(relay_error)?;
Ok(WasmVerifiedRelayContent { inner: content })

View file

@ -7,8 +7,8 @@ use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use crate::error::js_error;
use crate::frame::parse_frame_value_with_type_map;
use mtp_codec::TypeMap;
use crate::frame::parse_frame_value_with_limits;
use mtp_codec::{DecodeLimits, EncodeLimits, TypeMap};
const CLOSE_FRAME_LEN: u32 = u32::MAX;
@ -133,6 +133,7 @@ pub struct WasmTransport {
/// Serializes stream creation and writes across concurrent callers.
send_lock: Rc<AsyncMutex<()>>,
type_map: Rc<RefCell<TypeMap>>,
decode_limits: Rc<RefCell<DecodeLimits>>,
}
impl WasmTransport {
@ -140,6 +141,15 @@ impl WasmTransport {
url: &str,
cert_hashes: Option<Vec<String>>,
max_message_size: u32,
) -> Result<Self, JsValue> {
Self::connect_with_limits(url, cert_hashes, max_message_size, None).await
}
pub async fn connect_with_limits(
url: &str,
cert_hashes: Option<Vec<String>>,
max_message_size: u32,
configured_limits: Option<DecodeLimits>,
) -> Result<Self, JsValue> {
let ctor = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("WebTransport"))?
.dyn_into::<js_sys::Function>()
@ -188,6 +198,10 @@ impl WasmTransport {
JsFuture::from(ready)
.await
.map_err(|e| js_error(format!("WebTransport ready failed: {:?}", e)))?;
let transport_limits = DecodeLimits::for_transport_message_size(max_message_size as u64);
let decode_limits = configured_limits
.map(|limits| restrict_decode_limits(limits, transport_limits))
.unwrap_or(transport_limits);
Ok(Self {
inner: transport,
max_message_size,
@ -198,6 +212,7 @@ impl WasmTransport {
outgoing_writer: Rc::new(RefCell::new(None)),
send_lock: Rc::new(AsyncMutex::new(())),
type_map: Rc::new(RefCell::new(TypeMap::latest())),
decode_limits: Rc::new(RefCell::new(decode_limits)),
})
}
@ -213,6 +228,17 @@ impl WasmTransport {
self.type_map.borrow().clone()
}
pub fn decode_limits(&self) -> DecodeLimits {
*self.decode_limits.borrow()
}
/// Encoder policy corresponding to the transport's admitted complete
/// frame size. SDK builders use this before constructing a frame so an
/// oversized value is rejected before its serialized buffer is created.
pub fn encode_limits(&self) -> EncodeLimits {
EncodeLimits::for_transport_message_size(self.max_message_size as u64)
}
pub async fn send_frame(&self, frame: &[u8]) -> Result<(), JsValue> {
let _send_guard = self.send_lock.lock().await;
if frame.len() as u64 > self.max_message_size as u64
@ -462,7 +488,7 @@ impl WasmTransport {
match self.next_frame(self.max_message_size).await {
Ok(FrameOutcome::Frame(frame)) => {
let type_map = self.type_map();
match parse_frame_value_with_type_map(&frame, &type_map) {
match parse_frame_value_with_limits(&frame, &type_map, self.decode_limits()) {
Ok(parsed) => {
on_message(parsed);
}
@ -498,15 +524,23 @@ impl WasmTransport {
match self.next_frame(self.max_message_size).await {
Ok(FrameOutcome::Frame(frame)) => {
let type_map = self.type_map();
let decode_limits = self.decode_limits();
let pipe_request_type =
mtp_codec::CommunicationType::PipeRequest.try_to_id(&type_map);
let pipe_response_type =
mtp_codec::CommunicationType::PipeResponse.try_to_id(&type_map);
let is_first = self.new_stream_frame.get();
let comm =
mtp_codec::CommunicationValue::try_from_bytes_with_type_map_and_limits(
&frame,
&type_map,
decode_limits,
)
.ok();
if is_first {
self.new_stream_frame.set(false);
if let Ok(comm) =
mtp_codec::CommunicationValue::from_bytes_with(&frame, &type_map)
if let Some(comm) = comm.as_ref()
&& Some(comm.get_type()) == pipe_request_type
{
let Some(pipe_id) = comm.id().filter(|id| *id != 0) else {
@ -539,8 +573,7 @@ impl WasmTransport {
}
}
if let Ok(comm) =
mtp_codec::CommunicationValue::from_bytes_with(&frame, &type_map)
if let Some(comm) = comm.as_ref()
&& Some(comm.get_type()) == pipe_response_type
&& !matches!(comm.id(), Some(id) if id != 0)
{
@ -551,8 +584,7 @@ impl WasmTransport {
break;
}
if let Ok(comm) =
mtp_codec::CommunicationValue::from_bytes_with(&frame, &type_map)
if let Some(comm) = comm.as_ref()
&& !matches!(comm.id(), Some(id) if id != 0)
&& comm
.get_type_name()
@ -565,7 +597,7 @@ impl WasmTransport {
break;
}
match parse_frame_value_with_type_map(&frame, &type_map) {
match parse_frame_value_with_limits(&frame, &type_map, decode_limits) {
Ok(parsed) => {
on_message(parsed);
}
@ -666,3 +698,13 @@ impl WasmTransport {
}
}
}
fn restrict_decode_limits(left: DecodeLimits, right: DecodeLimits) -> DecodeLimits {
DecodeLimits {
max_depth: left.max_depth.min(right.max_depth),
max_values: left.max_values.min(right.max_values),
max_blob_size: left.max_blob_size.min(right.max_blob_size),
max_recipients: left.max_recipients.min(right.max_recipients),
max_allocated_bytes: left.max_allocated_bytes.min(right.max_allocated_bytes),
}
}

1132
wasm/types/mtp_wasm.d.ts vendored

File diff suppressed because it is too large Load diff