Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f6b4f95a6 | |||
|
|
3395b91ad1 |
||
|
|
a7e804c603 |
73 changed files with 11892 additions and 5756 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -1372,6 +1372,7 @@ name = "mtp-crypto"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
|
"argon2",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
|
|
@ -1395,7 +1396,6 @@ dependencies = [
|
||||||
name = "mtp-files"
|
name = "mtp-files"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
|
||||||
"mtp-crypto",
|
"mtp-crypto",
|
||||||
"rand",
|
"rand",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
|
|
@ -1411,6 +1411,7 @@ dependencies = [
|
||||||
"mtp-crypto",
|
"mtp-crypto",
|
||||||
"mtp-transport",
|
"mtp-transport",
|
||||||
"rand",
|
"rand",
|
||||||
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"wtransport",
|
"wtransport",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ use crate::error::AuthState;
|
||||||
use crate::ping::{PingSession, start_ping_session};
|
use crate::ping::{PingSession, start_ping_session};
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
use crate::pipe::PipeRequest;
|
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};
|
use crate::pipe::{PendingRequest, PipeDispatcher, run_dispatcher};
|
||||||
|
|
||||||
pub struct MTPConnection {
|
pub struct MTPConnection {
|
||||||
|
|
@ -134,17 +138,33 @@ impl MTPConnection {
|
||||||
description: &str,
|
description: &str,
|
||||||
) -> Result<crate::pipe::PipeHandle, mtp_common::PipeError> {
|
) -> Result<crate::pipe::PipeHandle, mtp_common::PipeError> {
|
||||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
let token = Arc::new(());
|
||||||
let pipe_id = {
|
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 pipe_id = loop {
|
||||||
let candidate = rand::random::<u32>();
|
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;
|
break candidate;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
pending.insert(pipe_id, tx);
|
pending.insert(
|
||||||
|
pipe_id,
|
||||||
|
PendingCreation {
|
||||||
|
token: token.clone(),
|
||||||
|
sender: tx,
|
||||||
|
},
|
||||||
|
);
|
||||||
pipe_id
|
pipe_id
|
||||||
};
|
};
|
||||||
|
let mut creation_guard =
|
||||||
|
PendingCreationGuard::new(self.pipe_dispatcher.clone(), pipe_id, token.clone());
|
||||||
|
|
||||||
let request = CommunicationValue::new_with_type_map(
|
let request = CommunicationValue::new_with_type_map(
|
||||||
mtp_codec::CommunicationType::PipeRequest,
|
mtp_codec::CommunicationType::PipeRequest,
|
||||||
|
|
@ -154,19 +174,17 @@ impl MTPConnection {
|
||||||
.add_typed_default(DataType::Description, DataValue::Str(description.into()));
|
.add_typed_default(DataType::Description, DataValue::Str(description.into()));
|
||||||
|
|
||||||
if let Err(error) = self.sender.send(&request).await {
|
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));
|
return Err(mtp_common::PipeError::from(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
creation_guard.disarm();
|
||||||
Ok(crate::pipe::PipeHandle {
|
Ok(crate::pipe::PipeHandle {
|
||||||
pipe_id,
|
pipe_id,
|
||||||
description: description.to_string(),
|
description: description.to_string(),
|
||||||
sender: self.sender.clone(),
|
sender: self.sender.clone(),
|
||||||
response_rx: rx,
|
response_rx: rx,
|
||||||
|
dispatcher: self.pipe_dispatcher.clone(),
|
||||||
|
token,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,18 +225,19 @@ pub(crate) async fn connection_from_parts(
|
||||||
|
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
{
|
{
|
||||||
|
let receiver_queue_capacity = config.policy.receiver_queue_capacity.max(1);
|
||||||
let (app_tx, app_rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(
|
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) =
|
let (pipe_req_tx, pipe_req_rx) = mpsc::channel::<PipeRequest>(receiver_queue_capacity);
|
||||||
mpsc::channel::<PipeRequest>(config.policy.receiver_queue_capacity);
|
|
||||||
|
|
||||||
let dispatcher = Arc::new(PipeDispatcher {
|
let dispatcher = Arc::new(PipeDispatcher {
|
||||||
pending_requests: Mutex::new(std::collections::HashMap::new()),
|
pending_requests: Mutex::new(std::collections::HashMap::new()),
|
||||||
expired_requests: Mutex::new(std::collections::HashMap::new()),
|
expired_requests: Mutex::new(std::collections::HashMap::new()),
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
type_map: type_map.clone(),
|
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()),
|
pending_pipes: Mutex::new(std::collections::HashMap::new()),
|
||||||
policy: Arc::new(config.policy),
|
policy: Arc::new(config.policy),
|
||||||
});
|
});
|
||||||
|
|
@ -255,8 +274,9 @@ pub(crate) async fn connection_from_parts(
|
||||||
|
|
||||||
#[cfg(not(feature = "pipes"))]
|
#[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>>(
|
let (app_tx, app_rx) = mpsc::channel::<Result<CommunicationValue, CommunicationError>>(
|
||||||
config.policy.receiver_queue_capacity,
|
receiver_queue_capacity,
|
||||||
);
|
);
|
||||||
let dispatcher = Arc::new(PipeDispatcher {
|
let dispatcher = Arc::new(PipeDispatcher {
|
||||||
pending_requests: Mutex::new(std::collections::HashMap::new()),
|
pending_requests: Mutex::new(std::collections::HashMap::new()),
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,10 @@ impl MTPClient {
|
||||||
sender.set_type_map(&tm).await;
|
sender.set_type_map(&tm).await;
|
||||||
receiver.set_type_map(&tm).await;
|
receiver.set_type_map(&tm).await;
|
||||||
let version_str = format!("{}", PROTOCOL_VERSION);
|
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 =
|
let mut ident =
|
||||||
CommunicationValue::new_with_type_map(CommunicationType::Identification, &tm)
|
CommunicationValue::new_with_type_map(CommunicationType::Identification, &tm)
|
||||||
|
|
@ -233,10 +237,7 @@ impl MTPClient {
|
||||||
// This capability marker lets a non-crypto host reject an
|
// This capability marker lets a non-crypto host reject an
|
||||||
// authentication attempt instead of treating it as a plain
|
// authentication attempt instead of treating it as a plain
|
||||||
// unauthenticated connection.
|
// unauthenticated connection.
|
||||||
.add_typed_default(
|
.add_typed_default(DataType::PublicKeys, DataValue::Bytes(public_key_bytes));
|
||||||
DataType::PublicKeys,
|
|
||||||
DataValue::Bytes(keys.public_key_bundle().as_bytes()),
|
|
||||||
);
|
|
||||||
if let Some(desc) = &config.description {
|
if let Some(desc) = &config.description {
|
||||||
ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone()));
|
ident = ident.add_typed_default(DataType::Description, DataValue::Str(desc.clone()));
|
||||||
}
|
}
|
||||||
|
|
@ -415,7 +416,9 @@ impl MTPClient {
|
||||||
receiver.set_type_map(&tm).await;
|
receiver.set_type_map(&tm).await;
|
||||||
let version_str = format!("{}", PROTOCOL_VERSION);
|
let version_str = format!("{}", PROTOCOL_VERSION);
|
||||||
let pk_bundle = keys.public_key_bundle();
|
let pk_bundle = keys.public_key_bundle();
|
||||||
let pk_bytes = pk_bundle.as_bytes();
|
let pk_bytes = pk_bundle
|
||||||
|
.try_as_bytes()
|
||||||
|
.map_err(|error| CommunicationError::ParseError(error.to_string()))?;
|
||||||
|
|
||||||
let mut register = CommunicationValue::new_with_type_map(CommunicationType::Register, &tm)
|
let mut register = CommunicationValue::new_with_type_map(CommunicationType::Register, &tm)
|
||||||
.add_typed_default(DataType::Version, DataValue::Str(version_str.clone()))
|
.add_typed_default(DataType::Version, DataValue::Str(version_str.clone()))
|
||||||
|
|
@ -601,7 +604,9 @@ mod tests {
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
type_map: mtp_codec::TypeMap::latest(),
|
type_map: mtp_codec::TypeMap::latest(),
|
||||||
#[cfg(feature = "pipes")]
|
#[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")]
|
#[cfg(feature = "pipes")]
|
||||||
pending_pipes: Mutex::new(HashMap::new()),
|
pending_pipes: Mutex::new(HashMap::new()),
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
|
|
@ -639,7 +644,9 @@ mod tests {
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
type_map: mtp_codec::TypeMap::latest(),
|
type_map: mtp_codec::TypeMap::latest(),
|
||||||
#[cfg(feature = "pipes")]
|
#[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")]
|
#[cfg(feature = "pipes")]
|
||||||
pending_pipes: Mutex::new(HashMap::new()),
|
pending_pipes: Mutex::new(HashMap::new()),
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,8 @@ pub(crate) async fn start_ping_session(
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (pong_tx, mut pong_rx) = mpsc::unbounded_channel();
|
let (pong_tx, mut pong_rx) = mpsc::channel(1);
|
||||||
receiver.observe_pongs(pong_tx).await;
|
receiver.observe_pongs_bounded(pong_tx).await;
|
||||||
let last_ping = Arc::new(Mutex::new(None));
|
let last_ping = Arc::new(Mutex::new(None));
|
||||||
let ping_state = last_ping.clone();
|
let ping_state = last_ping.clone();
|
||||||
let interval = config.ping_interval;
|
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 max_missed_pings = config.max_missed_pings;
|
||||||
let ping_timestamp = config.ping_timestamp;
|
let ping_timestamp = config.ping_timestamp;
|
||||||
let type_map = type_map.clone();
|
let type_map = type_map.clone();
|
||||||
|
let ping_receiver = receiver.clone();
|
||||||
let mut close_rx = receiver.handle().subscribe_close();
|
let mut close_rx = receiver.handle().subscribe_close();
|
||||||
|
|
||||||
let task = tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
|
|
@ -91,6 +92,7 @@ pub(crate) async fn start_ping_session(
|
||||||
}
|
}
|
||||||
_ = ticker.tick() => {
|
_ = ticker.tick() => {
|
||||||
let missed_pings = tracker.begin_round();
|
let missed_pings = tracker.begin_round();
|
||||||
|
ping_receiver.set_expected_pong_id(None).await;
|
||||||
if max_missed_pings > 0 && missed_pings >= max_missed_pings {
|
if max_missed_pings > 0 && missed_pings >= max_missed_pings {
|
||||||
sender.close().await;
|
sender.close().await;
|
||||||
break;
|
break;
|
||||||
|
|
@ -121,7 +123,9 @@ pub(crate) async fn start_ping_session(
|
||||||
sender.close().await;
|
sender.close().await;
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
|
ping_receiver.set_expected_pong_id(Some(id)).await;
|
||||||
if sender.send(&ping).await.is_err() {
|
if sender.send(&ping).await.is_err() {
|
||||||
|
ping_receiver.set_expected_pong_id(None).await;
|
||||||
sender.close().await;
|
sender.close().await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ use mtp_common::CommunicationError;
|
||||||
use mtp_transport::Receiver;
|
use mtp_transport::Receiver;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
#[cfg(feature = "pipes")]
|
||||||
|
use std::sync::Mutex as StdMutex;
|
||||||
use tokio::sync::{Mutex, mpsc};
|
use tokio::sync::{Mutex, mpsc};
|
||||||
use tokio::time::{Duration, Instant};
|
use tokio::time::{Duration, Instant};
|
||||||
|
|
||||||
|
|
@ -21,6 +23,8 @@ pub struct PipeHandle {
|
||||||
pub(crate) description: String,
|
pub(crate) description: String,
|
||||||
pub(crate) sender: Sender,
|
pub(crate) sender: Sender,
|
||||||
pub(crate) response_rx: tokio::sync::oneshot::Receiver<Result<bool, PipeError>>,
|
pub(crate) response_rx: tokio::sync::oneshot::Receiver<Result<bool, PipeError>>,
|
||||||
|
pub(crate) dispatcher: Arc<PipeDispatcher>,
|
||||||
|
pub(crate) token: Arc<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
|
|
@ -33,9 +37,11 @@ impl PipeHandle {
|
||||||
&self.description
|
&self.description
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn wait(self) -> Result<Option<mtp_transport::PipeWriter>, PipeError> {
|
pub async fn wait(mut self) -> Result<Option<mtp_transport::PipeWriter>, PipeError> {
|
||||||
match self.response_rx.await {
|
let response =
|
||||||
Ok(Ok(true)) => {
|
tokio::time::timeout(self.dispatcher.policy.read_timeout, &mut self.response_rx).await;
|
||||||
|
match response {
|
||||||
|
Ok(Ok(Ok(true))) => {
|
||||||
let writer = self
|
let writer = self
|
||||||
.sender
|
.sender
|
||||||
.open_pipe(self.pipe_id, &self.description)
|
.open_pipe(self.pipe_id, &self.description)
|
||||||
|
|
@ -43,13 +49,30 @@ impl PipeHandle {
|
||||||
.map_err(PipeError::from)?;
|
.map_err(PipeError::from)?;
|
||||||
Ok(Some(writer))
|
Ok(Some(writer))
|
||||||
}
|
}
|
||||||
Ok(Ok(false)) => Ok(None),
|
Ok(Ok(Ok(false))) => Ok(None),
|
||||||
Ok(Err(e)) => Err(e),
|
Ok(Ok(Err(error))) => {
|
||||||
Err(_) => Err(PipeError::StreamClosed),
|
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")]
|
#[cfg(feature = "pipes")]
|
||||||
pub struct PipeRequest {
|
pub struct PipeRequest {
|
||||||
pub(crate) pipe_id: u32,
|
pub(crate) pipe_id: u32,
|
||||||
|
|
@ -129,14 +152,54 @@ pub(crate) struct PendingRequest {
|
||||||
pub(crate) sender: tokio::sync::oneshot::Sender<Result<CommunicationValue, CommunicationError>>,
|
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) struct PipeDispatcher {
|
||||||
pub(crate) pending_requests: Mutex<HashMap<u32, PendingRequest>>,
|
pub(crate) pending_requests: Mutex<HashMap<u32, PendingRequest>>,
|
||||||
pub(crate) expired_requests: Mutex<HashMap<u32, Instant>>,
|
pub(crate) expired_requests: Mutex<HashMap<u32, Instant>>,
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
pub(crate) type_map: TypeMap,
|
pub(crate) type_map: TypeMap,
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
pub(crate) pending_creations:
|
pub(crate) pending_creations: StdMutex<HashMap<u32, PendingCreation>>,
|
||||||
Mutex<HashMap<u32, tokio::sync::oneshot::Sender<Result<bool, PipeError>>>>,
|
#[cfg(feature = "pipes")]
|
||||||
|
pub(crate) expired_creations: StdMutex<HashMap<u32, Instant>>,
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
pub(crate) pending_pipes:
|
pub(crate) pending_pipes:
|
||||||
Mutex<HashMap<u32, tokio::sync::oneshot::Sender<mtp_transport::PipeReader>>>,
|
Mutex<HashMap<u32, tokio::sync::oneshot::Sender<mtp_transport::PipeReader>>>,
|
||||||
|
|
@ -144,6 +207,90 @@ pub(crate) struct PipeDispatcher {
|
||||||
pub(crate) policy: Arc<Policy>,
|
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(
|
pub(crate) async fn route_message(
|
||||||
msg: CommunicationValue,
|
msg: CommunicationValue,
|
||||||
app_tx: &mpsc::Sender<Result<CommunicationValue, CommunicationError>>,
|
app_tx: &mpsc::Sender<Result<CommunicationValue, CommunicationError>>,
|
||||||
|
|
@ -283,9 +430,15 @@ pub(crate) async fn run_dispatcher(
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let accepted = msg.get_bool(DataType::Accepted).unwrap_or(false);
|
let accepted = msg.get_bool(DataType::Accepted).unwrap_or(false);
|
||||||
let mut pending = dispatcher.pending_creations.lock().await;
|
let pending = dispatcher
|
||||||
if let Some(tx) = pending.remove(&pipe_id) {
|
.pending_creations
|
||||||
let _ = tx.send(Ok(accepted));
|
.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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -303,6 +456,10 @@ pub(crate) async fn run_dispatcher(
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
fail_pending_requests(&dispatcher, e.clone()).await;
|
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;
|
let _ = app_tx.send(Err(e)).await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -325,6 +482,10 @@ pub(crate) async fn run_dispatcher(
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
fail_pending_requests(&dispatcher, e.clone()).await;
|
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;
|
let _ = app_tx.send(Err(e)).await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::io::Cursor;
|
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 crate::rand_u32;
|
||||||
use mtp_common::CodecError;
|
use mtp_common::CodecError;
|
||||||
use mtp_type_map::{
|
use mtp_type_map::{
|
||||||
|
|
@ -260,23 +260,50 @@ impl CommunicationValue {
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn reply_to(&self, comm_type: CommunicationType) -> Self {
|
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.sender = self.receiver;
|
||||||
response.receiver = self.sender;
|
response.receiver = self.sender;
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn merge(&mut self, other: &Self) {
|
/// Merge clear container fields after confirming both values use the same
|
||||||
if self.mapping_error.is_none() {
|
/// negotiated type map.
|
||||||
self.mapping_error.clone_from(&other.mapping_error);
|
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 {
|
let left = self.type_map().ok_or(CodecError::MissingTypeMap)?;
|
||||||
self.mapping_error
|
let right = other.type_map().ok_or(CodecError::MissingTypeMap)?;
|
||||||
.get_or_insert(CodecError::InvalidEncoding);
|
if left.version != right.version {
|
||||||
return;
|
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 {
|
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> {
|
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 {
|
if let Some(error) = &self.mapping_error {
|
||||||
return Err(error.clone());
|
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();
|
let mut body = Vec::new();
|
||||||
body.write_u16::<BigEndian>(self.comm_type.0)
|
body.write_u16::<BigEndian>(self.comm_type.0)
|
||||||
.map_err(|_| CodecError::InvalidEncoding)?;
|
.map_err(|_| CodecError::InvalidEncoding)?;
|
||||||
|
|
@ -344,44 +384,71 @@ impl CommunicationValue {
|
||||||
body.write_u64::<BigEndian>(receiver)
|
body.write_u64::<BigEndian>(receiver)
|
||||||
.map_err(|_| CodecError::InvalidEncoding)?;
|
.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 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)
|
out.write_u32::<BigEndian>(length)
|
||||||
.map_err(|_| CodecError::InvalidEncoding)?;
|
.map_err(|_| CodecError::InvalidEncoding)?;
|
||||||
out.extend_from_slice(&body);
|
out.extend_from_slice(&body);
|
||||||
Ok(out)
|
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> {
|
pub fn from_bytes(bytes: &[u8]) -> Result<Self, CodecError> {
|
||||||
Self::from_bytes_with_limits(bytes, DecodeLimits::default())
|
Self::from_bytes_with_limits(bytes, DecodeLimits::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_bytes_with_limits(bytes: &[u8], limits: DecodeLimits) -> Result<Self, CodecError> {
|
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 mut cursor = Cursor::new(bytes);
|
||||||
let length = cursor
|
let length = cursor
|
||||||
.read_u32::<BigEndian>()
|
.read_u32::<BigEndian>()
|
||||||
.map_err(|_| CodecError::InvalidEncoding)? as usize;
|
.map_err(|_| DecodeError::MalformedEncoding)? as usize;
|
||||||
let end = 4usize
|
let end = 4usize
|
||||||
.checked_add(length)
|
.checked_add(length)
|
||||||
.ok_or(CodecError::InvalidEncoding)?;
|
.ok_or(DecodeError::MalformedEncoding)?;
|
||||||
if end != bytes.len() {
|
if end != bytes.len() {
|
||||||
return Err(CodecError::InvalidEncoding);
|
return Err(DecodeError::MalformedEncoding);
|
||||||
}
|
}
|
||||||
let comm_type = CommunicationTypeId(
|
let comm_type = CommunicationTypeId(
|
||||||
cursor
|
cursor
|
||||||
.read_u16::<BigEndian>()
|
.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 {
|
if flags & !FLAG_KNOWN != 0 {
|
||||||
return Err(CodecError::InvalidEncoding);
|
return Err(DecodeError::MalformedEncoding);
|
||||||
}
|
}
|
||||||
let id = if flags & FLAG_HAS_ID != 0 {
|
let id = if flags & FLAG_HAS_ID != 0 {
|
||||||
Some(
|
Some(
|
||||||
cursor
|
cursor
|
||||||
.read_u32::<BigEndian>()
|
.read_u32::<BigEndian>()
|
||||||
.map_err(|_| CodecError::InvalidEncoding)?,
|
.map_err(|_| DecodeError::MalformedEncoding)?,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|
@ -390,7 +457,7 @@ impl CommunicationValue {
|
||||||
Some(
|
Some(
|
||||||
cursor
|
cursor
|
||||||
.read_u64::<BigEndian>()
|
.read_u64::<BigEndian>()
|
||||||
.map_err(|_| CodecError::InvalidEncoding)?,
|
.map_err(|_| DecodeError::MalformedEncoding)?,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|
@ -399,14 +466,14 @@ impl CommunicationValue {
|
||||||
Some(
|
Some(
|
||||||
cursor
|
cursor
|
||||||
.read_u64::<BigEndian>()
|
.read_u64::<BigEndian>()
|
||||||
.map_err(|_| CodecError::InvalidEncoding)?,
|
.map_err(|_| DecodeError::MalformedEncoding)?,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
None
|
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 {
|
if cursor.position() as usize != end {
|
||||||
return Err(CodecError::InvalidEncoding);
|
return Err(DecodeError::MalformedEncoding);
|
||||||
}
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id,
|
id,
|
||||||
|
|
@ -420,13 +487,36 @@ impl CommunicationValue {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_bytes_with(bytes: &[u8], type_map: &TypeMap) -> Result<Self, CodecError> {
|
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);
|
value.set_type_map(type_map);
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "registry")]
|
#[cfg(feature = "registry")]
|
||||||
pub fn migrate(&self, target: &TypeMap) -> Result<Self, CodecError> {
|
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 {
|
if let Some(error) = &self.mapping_error {
|
||||||
return Err(error.clone());
|
return Err(error.clone());
|
||||||
}
|
}
|
||||||
|
|
@ -441,7 +531,8 @@ impl CommunicationValue {
|
||||||
.comm_id_enum(comm)
|
.comm_id_enum(comm)
|
||||||
.ok_or_else(|| CodecError::UnknownCommunicationType(comm_name.to_string()))?,
|
.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 {
|
Ok(Self {
|
||||||
id: self.id,
|
id: self.id,
|
||||||
comm_type,
|
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")]
|
#[cfg(feature = "registry")]
|
||||||
fn migrate_data_value(
|
fn migrate_data_value(
|
||||||
value: &DataValue,
|
value: &DataValue,
|
||||||
source: &TypeMap,
|
source: &TypeMap,
|
||||||
target: &TypeMap,
|
target: &TypeMap,
|
||||||
|
context: &mut MigrationContext,
|
||||||
) -> Result<DataValue, CodecError> {
|
) -> Result<DataValue, CodecError> {
|
||||||
|
context.value()?;
|
||||||
match value {
|
match value {
|
||||||
DataValue::Container(entries) => {
|
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 {
|
for (old_id, value) in entries {
|
||||||
let name = source
|
let name = source
|
||||||
.data_type_name(old_id.0)
|
.data_type_name(old_id.0)
|
||||||
|
|
@ -474,16 +613,20 @@ fn migrate_data_value(
|
||||||
.data_id_enum(data)
|
.data_id_enum(data)
|
||||||
.ok_or_else(|| CodecError::UnknownDataType(name.to_string()))?,
|
.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))
|
Ok(DataValue::Container(migrated))
|
||||||
}
|
}
|
||||||
DataValue::Array(values) => Ok(DataValue::Array(
|
DataValue::Array(values) => {
|
||||||
values
|
context.enter()?;
|
||||||
.iter()
|
let mut migrated = Vec::with_capacity(values.len());
|
||||||
.map(|value| migrate_data_value(value, source, target))
|
for value in values {
|
||||||
.collect::<Result<Vec<_>, _>>()?,
|
migrated.push(migrate_data_value(value, source, target, context)?);
|
||||||
)),
|
}
|
||||||
|
context.leave();
|
||||||
|
Ok(DataValue::Array(migrated))
|
||||||
|
}
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
DataValue::Signed(_) | DataValue::Encrypted(_) => Err(CodecError::InvalidEncoding),
|
DataValue::Signed(_) | DataValue::Encrypted(_) => Err(CodecError::InvalidEncoding),
|
||||||
scalar => Ok(scalar.clone()),
|
scalar => Ok(scalar.clone()),
|
||||||
|
|
@ -641,6 +784,39 @@ mod tests {
|
||||||
assert_eq!(frame.get_data(DataType::Version), None);
|
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]
|
#[test]
|
||||||
fn generic_payload_roundtrips_without_becoming_a_container() {
|
fn generic_payload_roundtrips_without_becoming_a_container() {
|
||||||
let payload = DataValue::Array(vec![
|
let payload = DataValue::Array(vec![
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -11,20 +11,33 @@ pub use data_value::{
|
||||||
ApplicationProtectionPurpose, EncryptedValue, MtpProtectionPurpose, ProtectionError,
|
ApplicationProtectionPurpose, EncryptedValue, MtpProtectionPurpose, ProtectionError,
|
||||||
ProtectionPolicy, ProtectionPurpose, ProtectionPurposeError, SignaturePolicy, SignedValue,
|
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};
|
pub use mtp_common::{CodecError, TimeError, unix_time_millis};
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
|
#[allow(deprecated)]
|
||||||
pub use protected::{
|
pub use protected::{
|
||||||
CURRENT_PROTECTED_VERSION, InMemoryReplayGuard, ProtectedError, ProtectedMessageBuilder,
|
CURRENT_PROTECTED_VERSION, InMemoryReplayGuard, ProtectedError, ProtectedLimits,
|
||||||
ProtectedOpenOptions, ReplayError, ReplayGuard, VerifiedProtectedMessage, open_protected,
|
ProtectedMessageBuilder, ProtectedOpenOptions, ReplayError, ReplayGuard,
|
||||||
open_protected_with, open_protected_with_keys, protected_claimed_signer_id,
|
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")]
|
#[cfg(feature = "crypto")]
|
||||||
|
#[allow(deprecated)]
|
||||||
pub use relay::{
|
pub use relay::{
|
||||||
CURRENT_RELAY_VERSION, RelayError, SealedRelayBuilder, VerifiedRelayContent,
|
CURRENT_RELAY_VERSION, RelayError, RelayOpenOptions, SealedRelayBuilder, VerifiedRelayContent,
|
||||||
VerifiedRelayMetadata, forward_relay_frame, open_relay_content,
|
VerifiedRelayMetadata, forward_relay_frame, open_relay_content,
|
||||||
open_relay_content_with_keyrings, open_relay_content_with_keys, open_relay_metadata,
|
open_relay_content_with_keyrings, open_relay_content_with_keyrings_and_limits,
|
||||||
open_relay_metadata_with, open_relay_metadata_with_keys, relay_metadata_claimed_signer_id,
|
open_relay_content_with_keys, open_relay_content_with_limits,
|
||||||
|
open_relay_content_with_limits_without_replay, open_relay_metadata_checked,
|
||||||
|
open_relay_metadata_with_checked, open_relay_metadata_with_limits_checked,
|
||||||
|
open_relay_metadata_with_limits_without_replay, open_relay_metadata_with_without_replay,
|
||||||
|
open_relay_metadata_without_replay, relay_metadata_claimed_signer_id,
|
||||||
|
relay_metadata_claimed_signer_id_with_limits, relay_metadata_claimed_signer_id_with_options,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use mtp_type_map::{
|
pub use mtp_type_map::{
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,39 @@
|
||||||
#![cfg(feature = "crypto")]
|
#![cfg(feature = "crypto")]
|
||||||
|
|
||||||
use mtp_crypto::{Keyring, PublicKeyBundle, SignatureScheme};
|
use mtp_crypto::{Keyring, PublicKeyBundle, SignatureScheme};
|
||||||
use mtp_type_map::{CommunicationType, DataType, TypeMap};
|
use mtp_type_map::{CommunicationType, DataType, DataTypeId, TypeMap};
|
||||||
use std::collections::HashSet;
|
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
|
/// The direct protected-message envelope schema version emitted by this
|
||||||
/// codec.
|
/// codec.
|
||||||
pub const CURRENT_PROTECTED_VERSION: u64 = 1;
|
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)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum ProtectedError {
|
pub enum ProtectedError {
|
||||||
#[error("value is not an application communication frame")]
|
#[error("value is not an application communication frame")]
|
||||||
|
|
@ -46,6 +70,8 @@ pub enum ProtectedError {
|
||||||
ReservedApplicationType(String),
|
ReservedApplicationType(String),
|
||||||
#[error("protected message was already accepted")]
|
#[error("protected message was already accepted")]
|
||||||
Replay,
|
Replay,
|
||||||
|
#[error("protected resource limit exceeded: {0}")]
|
||||||
|
ResourceLimit(&'static str),
|
||||||
#[error("protection error: {0}")]
|
#[error("protection error: {0}")]
|
||||||
Protection(#[from] ProtectionError),
|
Protection(#[from] ProtectionError),
|
||||||
#[error("replay guard error: {0}")]
|
#[error("replay guard error: {0}")]
|
||||||
|
|
@ -78,9 +104,35 @@ pub enum ReplayError {
|
||||||
|
|
||||||
/// Small in-memory guard useful for tests and short-lived clients. Production
|
/// Small in-memory guard useful for tests and short-lived clients. Production
|
||||||
/// consumers should implement [`ReplayGuard`] over persistent storage.
|
/// consumers should implement [`ReplayGuard`] over persistent storage.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug)]
|
||||||
pub struct InMemoryReplayGuard {
|
pub struct InMemoryReplayGuard {
|
||||||
accepted: HashSet<(u64, String)>,
|
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 {
|
impl ReplayGuard for InMemoryReplayGuard {
|
||||||
|
|
@ -90,7 +142,21 @@ impl ReplayGuard for InMemoryReplayGuard {
|
||||||
message_id: &str,
|
message_id: &str,
|
||||||
_created_at: u64,
|
_created_at: u64,
|
||||||
) -> Result<bool, ReplayError> {
|
) -> 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>,
|
type_map: Option<TypeMap>,
|
||||||
frame_id: Option<u32>,
|
frame_id: Option<u32>,
|
||||||
expose_sender: bool,
|
expose_sender: bool,
|
||||||
|
limits: ProtectedLimits,
|
||||||
|
encode_limits: EncodeLimits,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> ProtectedMessageBuilder<'a> {
|
impl<'a> ProtectedMessageBuilder<'a> {
|
||||||
|
|
@ -136,6 +204,8 @@ impl<'a> ProtectedMessageBuilder<'a> {
|
||||||
type_map: None,
|
type_map: None,
|
||||||
frame_id: None,
|
frame_id: None,
|
||||||
expose_sender: false,
|
expose_sender: false,
|
||||||
|
limits: ProtectedLimits::default(),
|
||||||
|
encode_limits: EncodeLimits::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,6 +246,16 @@ impl<'a> ProtectedMessageBuilder<'a> {
|
||||||
self
|
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> {
|
pub fn build(self) -> Result<CommunicationValue, ProtectedError> {
|
||||||
let message_id = self.message_id.ok_or(ProtectedError::InvalidLayout(
|
let message_id = self.message_id.ok_or(ProtectedError::InvalidLayout(
|
||||||
"protected builder requires a message ID",
|
"protected builder requires a message ID",
|
||||||
|
|
@ -188,6 +268,9 @@ impl<'a> ProtectedMessageBuilder<'a> {
|
||||||
"protected identifiers must be non-empty",
|
"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() {
|
if self.recipients.is_empty() {
|
||||||
return Err(ProtectedError::InvalidLayout(
|
return Err(ProtectedError::InvalidLayout(
|
||||||
"protected builder requires at least one recipient",
|
"protected builder requires at least one recipient",
|
||||||
|
|
@ -217,8 +300,17 @@ impl<'a> ProtectedMessageBuilder<'a> {
|
||||||
(created_at_id, DataValue::UnsignedNumber(created_at as u128)),
|
(created_at_id, DataValue::UnsignedNumber(created_at as u128)),
|
||||||
(content_id, self.content),
|
(content_id, self.content),
|
||||||
]);
|
]);
|
||||||
let signed = envelope.sign(self.signer_id, self.signature_purpose, self.signer)?;
|
let signed = envelope.sign_with_limits(
|
||||||
let encrypted = signed.encrypt_for(&self.recipients, self.encryption_purpose)?;
|
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)
|
let mut frame = CommunicationValue::new_with_type_map(application_type, &type_map)
|
||||||
.with_receiver(self.final_recipient_id)
|
.with_receiver(self.final_recipient_id)
|
||||||
|
|
@ -258,6 +350,12 @@ pub struct ProtectedOpenOptions {
|
||||||
pub encryption_purpose: ProtectionPurpose,
|
pub encryption_purpose: ProtectionPurpose,
|
||||||
/// Signature algorithms accepted by the receiver.
|
/// Signature algorithms accepted by the receiver.
|
||||||
pub policy: ProtectionPolicy,
|
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 {
|
impl ProtectedOpenOptions {
|
||||||
|
|
@ -272,8 +370,41 @@ impl ProtectedOpenOptions {
|
||||||
signature_purpose,
|
signature_purpose,
|
||||||
encryption_purpose,
|
encryption_purpose,
|
||||||
policy,
|
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(
|
fn protected_field_id(
|
||||||
|
|
@ -328,23 +459,26 @@ fn validate_protected_frame(frame: &CommunicationValue) -> Result<TypeMap, Prote
|
||||||
}
|
}
|
||||||
|
|
||||||
fn field<'a>(
|
fn field<'a>(
|
||||||
value: &'a DataValue,
|
entries: &'a [(DataTypeId, DataValue)],
|
||||||
data_type: DataType,
|
data_type: DataType,
|
||||||
type_map: &TypeMap,
|
type_map: &TypeMap,
|
||||||
) -> Result<&'a DataValue, ProtectedError> {
|
) -> Result<&'a DataValue, ProtectedError> {
|
||||||
value
|
let field_id = protected_field_id(data_type, type_map)?;
|
||||||
.get_field(protected_field_id(data_type, type_map)?)
|
entries
|
||||||
|
.iter()
|
||||||
|
.find(|(id, _)| *id == field_id)
|
||||||
|
.map(|(_, value)| value)
|
||||||
.ok_or(ProtectedError::InvalidLayout(
|
.ok_or(ProtectedError::InvalidLayout(
|
||||||
"required protected field is missing",
|
"required protected field is missing",
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unsigned_field(
|
fn unsigned_field(
|
||||||
value: &DataValue,
|
entries: &[(DataTypeId, DataValue)],
|
||||||
data_type: DataType,
|
data_type: DataType,
|
||||||
type_map: &TypeMap,
|
type_map: &TypeMap,
|
||||||
) -> Result<u128, ProtectedError> {
|
) -> Result<u128, ProtectedError> {
|
||||||
field(value, data_type, type_map)?
|
field(entries, data_type, type_map)?
|
||||||
.as_unsigned_number()
|
.as_unsigned_number()
|
||||||
.ok_or(ProtectedError::InvalidLayout(
|
.ok_or(ProtectedError::InvalidLayout(
|
||||||
"protected field is not unsigned",
|
"protected field is not unsigned",
|
||||||
|
|
@ -352,11 +486,11 @@ fn unsigned_field(
|
||||||
}
|
}
|
||||||
|
|
||||||
fn string_field(
|
fn string_field(
|
||||||
value: &DataValue,
|
entries: &[(DataTypeId, DataValue)],
|
||||||
data_type: DataType,
|
data_type: DataType,
|
||||||
type_map: &TypeMap,
|
type_map: &TypeMap,
|
||||||
) -> Result<String, ProtectedError> {
|
) -> Result<String, ProtectedError> {
|
||||||
field(value, data_type, type_map)?
|
field(entries, data_type, type_map)?
|
||||||
.as_string()
|
.as_string()
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.ok_or(ProtectedError::InvalidLayout(
|
.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_id = protected_field_id(DataType::ProtectedVersion, type_map)?;
|
||||||
let version = value
|
let version = entries
|
||||||
.get_field(version_id)
|
.iter()
|
||||||
|
.find(|(id, _)| *id == version_id)
|
||||||
|
.map(|(_, value)| value)
|
||||||
.ok_or(ProtectedError::MissingProtectedVersion)?
|
.ok_or(ProtectedError::MissingProtectedVersion)?
|
||||||
.as_unsigned_number()
|
.as_unsigned_number()
|
||||||
.ok_or(ProtectedError::InvalidLayout(
|
.ok_or(ProtectedError::InvalidLayout(
|
||||||
|
|
@ -381,10 +533,15 @@ fn decrypt_protected_payload(
|
||||||
frame: &CommunicationValue,
|
frame: &CommunicationValue,
|
||||||
keyrings: &[&Keyring],
|
keyrings: &[&Keyring],
|
||||||
encryption_purpose: ProtectionPurpose,
|
encryption_purpose: ProtectionPurpose,
|
||||||
|
decode_limits: DecodeLimits,
|
||||||
|
max_decryption_key_history: usize,
|
||||||
) -> Result<DataValue, ProtectedError> {
|
) -> Result<DataValue, ProtectedError> {
|
||||||
|
if keyrings.len() > max_decryption_key_history {
|
||||||
|
return Err(ProtectedError::ResourceLimit("decryption key history"));
|
||||||
|
}
|
||||||
frame
|
frame
|
||||||
.payload()
|
.payload()
|
||||||
.decrypt_with_keyrings(keyrings, encryption_purpose)
|
.decrypt_with_keyrings_and_limits(keyrings, encryption_purpose, decode_limits)
|
||||||
.map_err(|error| match error {
|
.map_err(|error| match error {
|
||||||
ProtectionError::NotEncrypted => ProtectedError::PayloadNotEncrypted,
|
ProtectionError::NotEncrypted => ProtectedError::PayloadNotEncrypted,
|
||||||
other => ProtectedError::Protection(other),
|
other => ProtectedError::Protection(other),
|
||||||
|
|
@ -394,22 +551,63 @@ fn decrypt_protected_payload(
|
||||||
/// Return the claimed signer ID after decryption, without verifying its
|
/// Return the claimed signer ID after decryption, without verifying its
|
||||||
/// signature. The value is untrusted and may only select the key history that
|
/// 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.
|
/// 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(
|
pub fn protected_claimed_signer_id(
|
||||||
frame: &CommunicationValue,
|
frame: &CommunicationValue,
|
||||||
keyrings: &[&Keyring],
|
keyrings: &[&Keyring],
|
||||||
encryption_purpose: ProtectionPurpose,
|
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> {
|
) -> Result<u64, ProtectedError> {
|
||||||
validate_protected_frame(frame)?;
|
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
|
let signed = decrypted
|
||||||
.as_signed()
|
.as_signed()
|
||||||
.ok_or(ProtectedError::PayloadNotSigned)?;
|
.ok_or(ProtectedError::PayloadNotSigned)?;
|
||||||
Ok(signed.signer_id)
|
Ok(signed.signer_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a direct protected message using a resolver for trusted signer keys.
|
fn open_protected_with_impl<F>(
|
||||||
/// The resolver receives a claimed, unverified signer ID only as a lookup key.
|
|
||||||
pub fn open_protected_with<F>(
|
|
||||||
frame: &CommunicationValue,
|
frame: &CommunicationValue,
|
||||||
keyrings: &[&Keyring],
|
keyrings: &[&Keyring],
|
||||||
expected_signer_id: Option<u64>,
|
expected_signer_id: Option<u64>,
|
||||||
|
|
@ -422,7 +620,13 @@ where
|
||||||
{
|
{
|
||||||
validate_protected_frame(frame)?;
|
validate_protected_frame(frame)?;
|
||||||
let type_map = frame.type_map().cloned().unwrap_or_else(TypeMap::latest);
|
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
|
let signed = decrypted
|
||||||
.as_signed()
|
.as_signed()
|
||||||
.ok_or(ProtectedError::PayloadNotSigned)?;
|
.ok_or(ProtectedError::PayloadNotSigned)?;
|
||||||
|
|
@ -437,13 +641,54 @@ where
|
||||||
}
|
}
|
||||||
let signer_keys = resolve_signer_keys(signed.signer_id)
|
let signer_keys = resolve_signer_keys(signed.signer_id)
|
||||||
.ok_or(ProtectionError::SignerKeyNotFound(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_decrypted_protected(frame, type_map, signed, &signer_keys, options, replay_guard)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a direct protected message against already resolved trusted signer
|
pub fn open_protected_with_checked<F>(
|
||||||
/// keys. The signer ID is mandatory so a key history cannot be applied to a
|
frame: &CommunicationValue,
|
||||||
/// different claimed identity.
|
keyrings: &[&Keyring],
|
||||||
pub fn open_protected_with_keys(
|
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,
|
frame: &CommunicationValue,
|
||||||
keyrings: &[&Keyring],
|
keyrings: &[&Keyring],
|
||||||
expected_signer_id: u64,
|
expected_signer_id: u64,
|
||||||
|
|
@ -452,7 +697,13 @@ pub fn open_protected_with_keys(
|
||||||
replay_guard: Option<&mut dyn ReplayGuard>,
|
replay_guard: Option<&mut dyn ReplayGuard>,
|
||||||
) -> Result<VerifiedProtectedMessage, ProtectedError> {
|
) -> Result<VerifiedProtectedMessage, ProtectedError> {
|
||||||
let type_map = validate_protected_frame(frame)?;
|
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
|
let signed = decrypted
|
||||||
.as_signed()
|
.as_signed()
|
||||||
.ok_or(ProtectedError::PayloadNotSigned)?;
|
.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
|
pub fn open_protected_with_keys_checked(
|
||||||
/// public key are already known.
|
frame: &CommunicationValue,
|
||||||
pub fn open_protected(
|
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,
|
frame: &CommunicationValue,
|
||||||
keyring: &Keyring,
|
keyring: &Keyring,
|
||||||
expected_signer_id: u64,
|
expected_signer_id: u64,
|
||||||
signer_public_key: &PublicKeyBundle,
|
signer_public_key: &PublicKeyBundle,
|
||||||
options: ProtectedOpenOptions,
|
options: ProtectedOpenOptions,
|
||||||
replay_guard: Option<&mut dyn ReplayGuard>,
|
replay_guard: &mut dyn ReplayGuard,
|
||||||
) -> Result<VerifiedProtectedMessage, ProtectedError> {
|
) -> Result<VerifiedProtectedMessage, ProtectedError> {
|
||||||
open_protected_with_keys(
|
open_protected_with_keys_impl(
|
||||||
frame,
|
frame,
|
||||||
std::slice::from_ref(&keyring),
|
std::slice::from_ref(&keyring),
|
||||||
expected_signer_id,
|
expected_signer_id,
|
||||||
std::slice::from_ref(signer_public_key),
|
std::slice::from_ref(signer_public_key),
|
||||||
options,
|
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,
|
options: ProtectedOpenOptions,
|
||||||
mut replay_guard: Option<&mut dyn ReplayGuard>,
|
mut replay_guard: Option<&mut dyn ReplayGuard>,
|
||||||
) -> Result<VerifiedProtectedMessage, ProtectedError> {
|
) -> 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,
|
signed.signer_id,
|
||||||
signer_public_keys,
|
signer_public_keys,
|
||||||
options.signature_purpose,
|
options.signature_purpose,
|
||||||
options.policy,
|
options.policy,
|
||||||
|
options.encode_limits,
|
||||||
)?;
|
)?;
|
||||||
let receiver_id = frame.receiver().ok_or(ProtectedError::MissingReceiver)?;
|
let receiver_id = frame.receiver().ok_or(ProtectedError::MissingReceiver)?;
|
||||||
if options
|
if options
|
||||||
|
|
@ -520,22 +825,23 @@ fn open_decrypted_protected(
|
||||||
{
|
{
|
||||||
return Err(ProtectedError::SenderMismatch);
|
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
|
let envelope = signed
|
||||||
.value
|
.value
|
||||||
.as_container()
|
.container_entries()
|
||||||
.ok_or(ProtectedError::MissingEnvelope)?;
|
.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 {
|
if version != CURRENT_PROTECTED_VERSION {
|
||||||
return Err(ProtectedError::UnsupportedProtectedVersion(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)?;
|
let application_type = validate_application_message_type(&message_type, &type_map)?;
|
||||||
if frame.get_comm_type_enum() != Some(application_type) {
|
if frame.get_comm_type_enum() != Some(application_type) {
|
||||||
return Err(ProtectedError::MessageTypeMismatch);
|
return Err(ProtectedError::MessageTypeMismatch);
|
||||||
}
|
}
|
||||||
let final_recipient_id = u64::try_from(unsigned_field(
|
let final_recipient_id = u64::try_from(unsigned_field(
|
||||||
&envelope,
|
envelope,
|
||||||
DataType::FinalRecipientId,
|
DataType::FinalRecipientId,
|
||||||
&type_map,
|
&type_map,
|
||||||
)?)
|
)?)
|
||||||
|
|
@ -543,10 +849,14 @@ fn open_decrypted_protected(
|
||||||
if final_recipient_id != receiver_id {
|
if final_recipient_id != receiver_id {
|
||||||
return Err(ProtectedError::FinalRecipientMismatch);
|
return Err(ProtectedError::FinalRecipientMismatch);
|
||||||
}
|
}
|
||||||
let message_id = string_field(&envelope, DataType::MessageId, &type_map)?;
|
let message_id = string_field_ref(envelope, DataType::MessageId, &type_map)?;
|
||||||
let created_at = u64::try_from(unsigned_field(&envelope, DataType::CreatedAt, &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"))?;
|
.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()
|
if let Some(guard) = replay_guard.as_mut()
|
||||||
&& !guard.accept(signed.signer_id, &message_id, created_at)?
|
&& !guard.accept(signed.signer_id, &message_id, created_at)?
|
||||||
|
|
@ -569,7 +879,7 @@ fn open_decrypted_protected(
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use mtp_crypto::{Ed25519Signer, Keyring};
|
use mtp_crypto::{Ed25519Signer, Keyring, PublicKeyBundle};
|
||||||
use mtp_type_map::{DataType, DataTypeId};
|
use mtp_type_map::{DataType, DataTypeId};
|
||||||
|
|
||||||
const SIGNATURE_PURPOSE: ProtectionPurpose = ProtectionPurpose(0x40);
|
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)]
|
#[derive(Default)]
|
||||||
struct RecordingReplayGuard {
|
struct RecordingReplayGuard {
|
||||||
created_at: Option<u64>,
|
created_at: Option<u64>,
|
||||||
accepted: bool,
|
accepted: bool,
|
||||||
|
calls: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReplayGuard for RecordingReplayGuard {
|
impl ReplayGuard for RecordingReplayGuard {
|
||||||
|
|
@ -597,6 +995,7 @@ mod tests {
|
||||||
_message_id: &str,
|
_message_id: &str,
|
||||||
created_at: u64,
|
created_at: u64,
|
||||||
) -> Result<bool, ReplayError> {
|
) -> Result<bool, ReplayError> {
|
||||||
|
self.calls += 1;
|
||||||
self.created_at = Some(created_at);
|
self.created_at = Some(created_at);
|
||||||
if self.accepted {
|
if self.accepted {
|
||||||
Ok(false)
|
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 {
|
fn protected_field(data_type: DataType, type_map: &TypeMap) -> DataTypeId {
|
||||||
data_type
|
data_type
|
||||||
.try_to_id(type_map)
|
.try_to_id(type_map)
|
||||||
|
|
@ -743,6 +1174,7 @@ mod tests {
|
||||||
assert_eq!(opened.message_type, "ProtectedMessage");
|
assert_eq!(opened.message_type, "ProtectedMessage");
|
||||||
assert_eq!(opened.message_id, "protected-test");
|
assert_eq!(opened.message_id, "protected-test");
|
||||||
assert_eq!(guard.created_at, Some(1_700_000_000_000));
|
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_eq!(opened.content, DataValue::Str("hello".into()));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
open_protected(
|
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]
|
#[test]
|
||||||
fn builder_owns_outer_sender_and_frame_id() {
|
fn builder_owns_outer_sender_and_frame_id() {
|
||||||
let sender = Keyring::generate();
|
let sender = Keyring::generate();
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ use mtp_common::CodecError;
|
||||||
use mtp_type_map::{PROTOCOL_VERSION, TypeMap, Version};
|
use mtp_type_map::{PROTOCOL_VERSION, TypeMap, Version};
|
||||||
|
|
||||||
use crate::CommunicationValue;
|
use crate::CommunicationValue;
|
||||||
|
use crate::EncodeLimits;
|
||||||
|
|
||||||
pub use mtp_type_map::Registry;
|
pub use mtp_type_map::Registry;
|
||||||
|
|
||||||
|
|
@ -42,7 +43,41 @@ impl VersionedCodec {
|
||||||
|
|
||||||
/// Encode a value using the codec's negotiated framing rules.
|
/// Encode a value using the codec's negotiated framing rules.
|
||||||
pub fn encode(&self, value: &CommunicationValue) -> Result<Vec<u8>, CodecError> {
|
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.
|
/// Decode a frame and retain the negotiated type map for typed access.
|
||||||
|
|
@ -58,3 +93,34 @@ impl VersionedCodec {
|
||||||
&self.registry
|
&self.registry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::DataValue;
|
||||||
|
use mtp_type_map::{CommunicationType, Version};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_rejects_a_value_from_another_negotiated_map() {
|
||||||
|
let mut registry = Registry::new();
|
||||||
|
let version_a = Version::new(3, 0);
|
||||||
|
let version_b = Version::new(4, 0);
|
||||||
|
registry.register(TypeMap::new(version_a.clone()));
|
||||||
|
registry.register(TypeMap::new(version_b.clone()));
|
||||||
|
|
||||||
|
let codec = VersionedCodec::for_version(registry, version_b).expect("codec version");
|
||||||
|
let value = CommunicationValue::new_with_type_map(
|
||||||
|
CommunicationType::Ping,
|
||||||
|
&TypeMap::new(version_a.clone()),
|
||||||
|
)
|
||||||
|
.with_payload(DataValue::Null);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
codec.encode(&value),
|
||||||
|
Err(CodecError::TypeMapMismatch {
|
||||||
|
expected: "4.0".into(),
|
||||||
|
actual: "3.0".into(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@
|
||||||
#![cfg(feature = "crypto")]
|
#![cfg(feature = "crypto")]
|
||||||
|
|
||||||
use mtp_crypto::{Keyring, PublicKeyBundle, SignatureScheme};
|
use mtp_crypto::{Keyring, PublicKeyBundle, SignatureScheme};
|
||||||
use mtp_type_map::{CommunicationType, DataType, TypeMap};
|
use mtp_type_map::{CommunicationType, DataType, DataTypeId, TypeMap};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CommunicationValue, DataValue, MtpProtectionPurpose, ProtectionError, ProtectionPolicy,
|
CommunicationValue, DataValue, DecodeLimits, EncodeLimits, MtpProtectionPurpose,
|
||||||
ReplayError, ReplayGuard,
|
ProtectedLimits, ProtectionError, ProtectionPolicy, ReplayError, ReplayGuard,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The relay metadata schema emitted by [`SealedRelayBuilder`].
|
/// The relay metadata schema emitted by [`SealedRelayBuilder`].
|
||||||
|
|
@ -37,6 +37,8 @@ pub enum RelayError {
|
||||||
NotFinalRecipient,
|
NotFinalRecipient,
|
||||||
#[error("relay message was already accepted")]
|
#[error("relay message was already accepted")]
|
||||||
Replay,
|
Replay,
|
||||||
|
#[error("relay resource limit exceeded: {0}")]
|
||||||
|
ResourceLimit(&'static str),
|
||||||
#[error("relay application message type is reserved: {0}")]
|
#[error("relay application message type is reserved: {0}")]
|
||||||
ReservedApplicationType(String),
|
ReservedApplicationType(String),
|
||||||
#[error("protection error: {0}")]
|
#[error("protection error: {0}")]
|
||||||
|
|
@ -62,6 +64,9 @@ pub struct VerifiedRelayMetadata {
|
||||||
encrypted_content: DataValue,
|
encrypted_content: DataValue,
|
||||||
type_map: TypeMap,
|
type_map: TypeMap,
|
||||||
matched_signer_key_index: usize,
|
matched_signer_key_index: usize,
|
||||||
|
decode_limits: DecodeLimits,
|
||||||
|
encode_limits: EncodeLimits,
|
||||||
|
protected_limits: ProtectedLimits,
|
||||||
// There is intentionally no public constructor. This marker documents
|
// There is intentionally no public constructor. This marker documents
|
||||||
// that the fields originate from a successful authenticated open.
|
// that the fields originate from a successful authenticated open.
|
||||||
_verified: VerifiedMarker,
|
_verified: VerifiedMarker,
|
||||||
|
|
@ -84,6 +89,18 @@ impl VerifiedRelayMetadata {
|
||||||
self.final_recipient_id
|
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 {
|
pub fn message_id(&self) -> &str {
|
||||||
&self.message_id
|
&self.message_id
|
||||||
}
|
}
|
||||||
|
|
@ -120,6 +137,40 @@ pub struct VerifiedRelayContent {
|
||||||
pub content: DataValue,
|
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.
|
/// Native sealed-relay builder shared by non-WASM applications.
|
||||||
///
|
///
|
||||||
/// The browser SDK and this builder intentionally produce the same reserved
|
/// The browser SDK and this builder intentionally produce the same reserved
|
||||||
|
|
@ -139,6 +190,8 @@ pub struct SealedRelayBuilder<'a> {
|
||||||
metadata_recipients: Vec<PublicKeyBundle>,
|
metadata_recipients: Vec<PublicKeyBundle>,
|
||||||
content_recipients: Vec<PublicKeyBundle>,
|
content_recipients: Vec<PublicKeyBundle>,
|
||||||
type_map: Option<TypeMap>,
|
type_map: Option<TypeMap>,
|
||||||
|
limits: ProtectedLimits,
|
||||||
|
encode_limits: EncodeLimits,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> SealedRelayBuilder<'a> {
|
impl<'a> SealedRelayBuilder<'a> {
|
||||||
|
|
@ -163,6 +216,8 @@ impl<'a> SealedRelayBuilder<'a> {
|
||||||
metadata_recipients: Vec::new(),
|
metadata_recipients: Vec::new(),
|
||||||
content_recipients: Vec::new(),
|
content_recipients: Vec::new(),
|
||||||
type_map: None,
|
type_map: None,
|
||||||
|
limits: ProtectedLimits::default(),
|
||||||
|
encode_limits: EncodeLimits::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,6 +247,16 @@ impl<'a> SealedRelayBuilder<'a> {
|
||||||
self
|
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
|
/// Build the reserved relay fields against a negotiated type map. The
|
||||||
/// default is the current map, but native callers handling an older
|
/// default is the current map, but native callers handling an older
|
||||||
/// negotiated frame should pass that map explicitly.
|
/// negotiated frame should pass that map explicitly.
|
||||||
|
|
@ -212,11 +277,17 @@ impl<'a> SealedRelayBuilder<'a> {
|
||||||
"relay builder identifiers must be non-empty",
|
"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() {
|
if self.metadata_recipients.is_empty() || self.content_recipients.is_empty() {
|
||||||
return Err(RelayError::InvalidLayout(
|
return Err(RelayError::InvalidLayout(
|
||||||
"relay builder requires metadata and content recipients",
|
"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);
|
let type_map = self.type_map.unwrap_or_else(TypeMap::latest);
|
||||||
validate_application_message_type(&self.message_type, &type_map)?;
|
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)),
|
(message_type_id, DataValue::Str(self.message_type)),
|
||||||
(content_id, self.content),
|
(content_id, self.content),
|
||||||
]);
|
]);
|
||||||
let signed_content = content.sign(
|
let signed_content = content.sign_with_limits(
|
||||||
self.signer_id,
|
self.signer_id,
|
||||||
MtpProtectionPurpose::RelayContentSignature.into(),
|
MtpProtectionPurpose::RelayContentSignature.into(),
|
||||||
self.signer,
|
self.signer,
|
||||||
|
self.encode_limits,
|
||||||
)?;
|
)?;
|
||||||
let encrypted_content = signed_content.encrypt_for(
|
let encrypted_content = signed_content.encrypt_for_with_limits(
|
||||||
&self.content_recipients,
|
&self.content_recipients,
|
||||||
MtpProtectionPurpose::RelayContentEncryption.into(),
|
MtpProtectionPurpose::RelayContentEncryption.into(),
|
||||||
|
self.encode_limits,
|
||||||
)?;
|
)?;
|
||||||
let mut metadata_fields = vec![
|
let mut metadata_fields = vec![
|
||||||
(
|
(
|
||||||
|
|
@ -258,14 +331,16 @@ impl<'a> SealedRelayBuilder<'a> {
|
||||||
metadata_fields.push((metadata_id, application_metadata));
|
metadata_fields.push((metadata_id, application_metadata));
|
||||||
}
|
}
|
||||||
let metadata = DataValue::Container(metadata_fields);
|
let metadata = DataValue::Container(metadata_fields);
|
||||||
let signed_metadata = metadata.sign(
|
let signed_metadata = metadata.sign_with_limits(
|
||||||
self.signer_id,
|
self.signer_id,
|
||||||
MtpProtectionPurpose::RelayMetadataSignature.into(),
|
MtpProtectionPurpose::RelayMetadataSignature.into(),
|
||||||
self.signer,
|
self.signer,
|
||||||
|
self.encode_limits,
|
||||||
)?;
|
)?;
|
||||||
let encrypted_metadata = signed_metadata.encrypt_for(
|
let encrypted_metadata = signed_metadata.encrypt_for_with_limits(
|
||||||
&self.metadata_recipients,
|
&self.metadata_recipients,
|
||||||
MtpProtectionPurpose::RelayMetadataEncryption.into(),
|
MtpProtectionPurpose::RelayMetadataEncryption.into(),
|
||||||
|
self.encode_limits,
|
||||||
)?;
|
)?;
|
||||||
Ok(
|
Ok(
|
||||||
CommunicationValue::new_with_type_map(CommunicationType::Relay, &type_map)
|
CommunicationValue::new_with_type_map(CommunicationType::Relay, &type_map)
|
||||||
|
|
@ -305,22 +380,44 @@ fn validate_application_message_type(
|
||||||
Ok(())
|
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>(
|
fn field<'a>(
|
||||||
value: &'a DataValue,
|
entries: &'a [(DataTypeId, DataValue)],
|
||||||
data_type: DataType,
|
data_type: DataType,
|
||||||
type_map: &TypeMap,
|
type_map: &TypeMap,
|
||||||
) -> Result<&'a DataValue, RelayError> {
|
) -> Result<&'a DataValue, RelayError> {
|
||||||
value
|
let field_id = relay_field(data_type, type_map)?;
|
||||||
.get_field(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"))
|
.ok_or(RelayError::InvalidLayout("required relay field is missing"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn string_field(
|
fn string_field(
|
||||||
value: &DataValue,
|
entries: &[(DataTypeId, DataValue)],
|
||||||
data_type: DataType,
|
data_type: DataType,
|
||||||
type_map: &TypeMap,
|
type_map: &TypeMap,
|
||||||
) -> Result<String, RelayError> {
|
) -> Result<String, RelayError> {
|
||||||
field(value, data_type, type_map)?
|
field(entries, data_type, type_map)?
|
||||||
.as_string()
|
.as_string()
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.ok_or(RelayError::InvalidLayout(
|
.ok_or(RelayError::InvalidLayout(
|
||||||
|
|
@ -328,21 +425,36 @@ fn string_field(
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn optional_metadata_field(
|
fn string_field_ref<'a>(
|
||||||
value: &DataValue,
|
entries: &'a [(DataTypeId, DataValue)],
|
||||||
|
data_type: DataType,
|
||||||
type_map: &TypeMap,
|
type_map: &TypeMap,
|
||||||
) -> Result<Option<DataValue>, RelayError> {
|
) -> Result<&'a str, RelayError> {
|
||||||
Ok(value
|
field(entries, data_type, type_map)?
|
||||||
.get_field(relay_field(DataType::Metadata, type_map)?)
|
.as_str()
|
||||||
.cloned())
|
.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(
|
fn unsigned_field(
|
||||||
value: &DataValue,
|
entries: &[(DataTypeId, DataValue)],
|
||||||
data_type: DataType,
|
data_type: DataType,
|
||||||
type_map: &TypeMap,
|
type_map: &TypeMap,
|
||||||
) -> Result<u128, RelayError> {
|
) -> Result<u128, RelayError> {
|
||||||
field(value, data_type, type_map)?
|
field(entries, data_type, type_map)?
|
||||||
.as_unsigned_number()
|
.as_unsigned_number()
|
||||||
.ok_or(RelayError::InvalidLayout("relay field is not unsigned"))
|
.ok_or(RelayError::InvalidLayout("relay field is not unsigned"))
|
||||||
}
|
}
|
||||||
|
|
@ -356,55 +468,86 @@ struct RelayMetadataV1 {
|
||||||
encrypted_content: DataValue,
|
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_id = relay_field(DataType::RelayVersion, type_map)?;
|
||||||
let version = value
|
let version = entries
|
||||||
.get_field(version_id)
|
.iter()
|
||||||
|
.find(|(id, _)| *id == version_id)
|
||||||
|
.map(|(_, value)| value)
|
||||||
.ok_or(RelayError::MissingRelayVersion)?
|
.ok_or(RelayError::MissingRelayVersion)?
|
||||||
.as_unsigned_number()
|
.as_unsigned_number()
|
||||||
.ok_or(RelayError::InvalidLayout("relay version is not unsigned"))?;
|
.ok_or(RelayError::InvalidLayout("relay version is not unsigned"))?;
|
||||||
u64::try_from(version).map_err(|_| RelayError::InvalidLayout("relay version is out of range"))
|
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> {
|
fn parse_relay_v1(
|
||||||
let final_recipient_id =
|
entries: &[(DataTypeId, DataValue)],
|
||||||
u64::try_from(unsigned_field(value, DataType::FinalRecipientId, type_map)?)
|
type_map: &TypeMap,
|
||||||
.map_err(|_| RelayError::InvalidLayout("final recipient ID is out of range"))?;
|
limits: &ProtectedLimits,
|
||||||
let created_at = u64::try_from(unsigned_field(value, DataType::CreatedAt, type_map)?)
|
) -> 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"))?;
|
.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() {
|
if encrypted_content.as_encrypted().is_none() {
|
||||||
return Err(RelayError::InvalidLayout("content is not encrypted"));
|
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 {
|
Ok(RelayMetadataV1 {
|
||||||
final_recipient_id,
|
final_recipient_id,
|
||||||
message_id: string_field(value, DataType::MessageId, type_map)?,
|
message_id: message_id.to_owned(),
|
||||||
created_at,
|
created_at,
|
||||||
metadata: optional_metadata_field(value, type_map)?,
|
metadata: metadata.cloned(),
|
||||||
encrypted_content,
|
encrypted_content,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt and verify relay metadata, without opening its content.
|
pub fn open_relay_metadata_checked(
|
||||||
///
|
|
||||||
/// `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(
|
|
||||||
frame: &CommunicationValue,
|
frame: &CommunicationValue,
|
||||||
keyring: &Keyring,
|
keyring: &Keyring,
|
||||||
expected_signer_id: u64,
|
expected_signer_id: u64,
|
||||||
signer_public_key: &PublicKeyBundle,
|
signer_public_key: &PublicKeyBundle,
|
||||||
policy: ProtectionPolicy,
|
options: RelayOpenOptions,
|
||||||
|
replay_guard: &mut dyn ReplayGuard,
|
||||||
) -> Result<VerifiedRelayMetadata, RelayError> {
|
) -> Result<VerifiedRelayMetadata, RelayError> {
|
||||||
open_relay_metadata_with(
|
open_relay_metadata_with_limits_checked(
|
||||||
frame,
|
frame,
|
||||||
std::slice::from_ref(&keyring),
|
std::slice::from_ref(&keyring),
|
||||||
Some(expected_signer_id),
|
Some(expected_signer_id),
|
||||||
|_| Some(vec![signer_public_key.clone()]),
|
|_| Some(vec![signer_public_key.clone()]),
|
||||||
policy,
|
options,
|
||||||
None,
|
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
|
/// signature or interpreting the versioned relay schema. The result is
|
||||||
/// untrusted and may only select the key history that is then bound to the
|
/// untrusted and may only select the key history that is then bound to the
|
||||||
/// same signer ID during [`open_relay_metadata_with_keys`].
|
/// 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(
|
pub fn relay_metadata_claimed_signer_id(
|
||||||
frame: &CommunicationValue,
|
frame: &CommunicationValue,
|
||||||
keyrings: &[&Keyring],
|
keyrings: &[&Keyring],
|
||||||
) -> Result<u64, RelayError> {
|
) -> 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,
|
keyrings,
|
||||||
MtpProtectionPurpose::RelayMetadataEncryption.into(),
|
MtpProtectionPurpose::RelayMetadataEncryption.into(),
|
||||||
|
decode_limits,
|
||||||
)?;
|
)?;
|
||||||
let signed = decrypted
|
let signed = decrypted
|
||||||
.as_signed()
|
.as_signed()
|
||||||
|
|
@ -441,49 +614,118 @@ pub fn relay_metadata_claimed_signer_id(
|
||||||
Ok(signed.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
|
/// Decrypt and verify relay metadata using recipient-key history and a
|
||||||
/// signer-key resolver. The resolver receives a claimed, unverified signer
|
/// signer-key resolver. The resolver receives a claimed, unverified signer
|
||||||
/// ID used only as a trusted-key lookup key. The ID becomes authenticated
|
/// ID used only as a trusted-key lookup key. The ID becomes authenticated
|
||||||
/// only after signature verification. This is the native counterpart of the
|
/// only after signature verification. This is the native counterpart of the
|
||||||
/// browser relay API and supports sealed sender plus signing-key rotation.
|
/// 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,
|
frame: &CommunicationValue,
|
||||||
keyrings: &[&Keyring],
|
keyrings: &[&Keyring],
|
||||||
expected_signer_id: Option<u64>,
|
expected_signer_id: Option<u64>,
|
||||||
resolve_signer_keys: F,
|
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>,
|
mut replay_guard: Option<&mut dyn ReplayGuard>,
|
||||||
) -> Result<VerifiedRelayMetadata, RelayError>
|
) -> Result<VerifiedRelayMetadata, RelayError>
|
||||||
where
|
where
|
||||||
F: Fn(u64) -> Option<Vec<PublicKeyBundle>>,
|
F: FnOnce(u64) -> Option<Vec<PublicKeyBundle>>,
|
||||||
{
|
{
|
||||||
validate_relay_frame(frame)?;
|
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 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,
|
keyrings,
|
||||||
MtpProtectionPurpose::RelayMetadataEncryption.into(),
|
MtpProtectionPurpose::RelayMetadataEncryption.into(),
|
||||||
|
options.decode_limits,
|
||||||
)?;
|
)?;
|
||||||
let signed = decrypted
|
let signed = decrypted
|
||||||
.as_signed()
|
.as_signed()
|
||||||
|
|
@ -499,23 +741,30 @@ where
|
||||||
}
|
}
|
||||||
let signer_keys = resolve_signer_keys(signed.signer_id)
|
let signer_keys = resolve_signer_keys(signed.signer_id)
|
||||||
.ok_or(ProtectionError::SignerKeyNotFound(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,
|
signed.signer_id,
|
||||||
&signer_keys,
|
&signer_keys,
|
||||||
MtpProtectionPurpose::RelayMetadataSignature.into(),
|
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
|
let metadata = signed
|
||||||
.value
|
.value
|
||||||
.as_container()
|
.container_entries()
|
||||||
.ok_or(RelayError::InvalidLayout("metadata is not a container"))?;
|
.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 {
|
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)),
|
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 {
|
let result = VerifiedRelayMetadata {
|
||||||
relay_version,
|
relay_version,
|
||||||
signer_id: signed.signer_id,
|
signer_id: signed.signer_id,
|
||||||
|
|
@ -526,6 +775,9 @@ where
|
||||||
encrypted_content: parsed.encrypted_content,
|
encrypted_content: parsed.encrypted_content,
|
||||||
type_map,
|
type_map,
|
||||||
matched_signer_key_index,
|
matched_signer_key_index,
|
||||||
|
decode_limits: options.decode_limits,
|
||||||
|
encode_limits: options.encode_limits,
|
||||||
|
protected_limits: options.protected_limits,
|
||||||
_verified: VerifiedMarker,
|
_verified: VerifiedMarker,
|
||||||
};
|
};
|
||||||
if let Some(guard) = replay_guard.as_mut()
|
if let Some(guard) = replay_guard.as_mut()
|
||||||
|
|
@ -537,6 +789,7 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open and verify content after metadata has been authenticated.
|
/// Open and verify content after metadata has been authenticated.
|
||||||
|
#[deprecated(note = "use open_relay_content_with_limits_without_replay")]
|
||||||
pub fn open_relay_content(
|
pub fn open_relay_content(
|
||||||
metadata: &VerifiedRelayMetadata,
|
metadata: &VerifiedRelayMetadata,
|
||||||
keyring: &Keyring,
|
keyring: &Keyring,
|
||||||
|
|
@ -544,17 +797,23 @@ pub fn open_relay_content(
|
||||||
expected_recipient_id: u64,
|
expected_recipient_id: u64,
|
||||||
policy: ProtectionPolicy,
|
policy: ProtectionPolicy,
|
||||||
) -> Result<VerifiedRelayContent, RelayError> {
|
) -> Result<VerifiedRelayContent, RelayError> {
|
||||||
open_relay_content_with_keys(
|
open_relay_content_with_limits_without_replay(
|
||||||
metadata,
|
metadata,
|
||||||
keyring,
|
std::slice::from_ref(&keyring),
|
||||||
std::slice::from_ref(signer_public_key),
|
std::slice::from_ref(signer_public_key),
|
||||||
expected_recipient_id,
|
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 trusted signing-key history for the metadata's
|
/// Open relay content against trusted signing-key history for the metadata's
|
||||||
/// authenticated signer ID.
|
/// authenticated signer ID.
|
||||||
|
#[deprecated(note = "use open_relay_content_with_limits_without_replay")]
|
||||||
pub fn open_relay_content_with_keys(
|
pub fn open_relay_content_with_keys(
|
||||||
metadata: &VerifiedRelayMetadata,
|
metadata: &VerifiedRelayMetadata,
|
||||||
keyring: &Keyring,
|
keyring: &Keyring,
|
||||||
|
|
@ -562,18 +821,24 @@ pub fn open_relay_content_with_keys(
|
||||||
expected_recipient_id: u64,
|
expected_recipient_id: u64,
|
||||||
policy: ProtectionPolicy,
|
policy: ProtectionPolicy,
|
||||||
) -> Result<VerifiedRelayContent, RelayError> {
|
) -> Result<VerifiedRelayContent, RelayError> {
|
||||||
open_relay_content_with_keyrings(
|
open_relay_content_with_limits_without_replay(
|
||||||
metadata,
|
metadata,
|
||||||
std::slice::from_ref(&keyring),
|
std::slice::from_ref(&keyring),
|
||||||
signer_public_keys,
|
signer_public_keys,
|
||||||
Some(expected_recipient_id),
|
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
|
/// Open relay content against recipient-key history and trusted signing-key
|
||||||
/// history. The expected final recipient is optional for callers that only
|
/// history. The expected final recipient is optional for callers that only
|
||||||
/// have decryption material and do not have a local identity ID.
|
/// 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(
|
pub fn open_relay_content_with_keyrings(
|
||||||
metadata: &VerifiedRelayMetadata,
|
metadata: &VerifiedRelayMetadata,
|
||||||
keyrings: &[&Keyring],
|
keyrings: &[&Keyring],
|
||||||
|
|
@ -581,15 +846,96 @@ pub fn open_relay_content_with_keyrings(
|
||||||
expected_recipient_id: Option<u64>,
|
expected_recipient_id: Option<u64>,
|
||||||
policy: ProtectionPolicy,
|
policy: ProtectionPolicy,
|
||||||
) -> Result<VerifiedRelayContent, RelayError> {
|
) -> 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) {
|
if expected_recipient_id.is_some_and(|id| metadata.final_recipient_id != id) {
|
||||||
return Err(RelayError::NotFinalRecipient);
|
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 type_map = &metadata.type_map;
|
||||||
let decrypted = metadata.encrypted_content.decrypt_with_keyrings(
|
let decrypted = metadata
|
||||||
keyrings,
|
.encrypted_content
|
||||||
MtpProtectionPurpose::RelayContentEncryption.into(),
|
.decrypt_with_keyrings_and_limits(
|
||||||
)?;
|
keyrings,
|
||||||
|
MtpProtectionPurpose::RelayContentEncryption.into(),
|
||||||
|
options.decode_limits,
|
||||||
|
)?;
|
||||||
let signed = decrypted
|
let signed = decrypted
|
||||||
.as_signed()
|
.as_signed()
|
||||||
.ok_or(RelayError::InvalidLayout("content is not 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
|
// Content is a separately signed value and must not inherit a weaker
|
||||||
// metadata policy.
|
// metadata policy.
|
||||||
signed.verify_with_key_history(
|
signed.verify_with_key_history_and_limits(
|
||||||
metadata.signer_id,
|
metadata.signer_id,
|
||||||
signer_public_keys,
|
signer_public_keys,
|
||||||
MtpProtectionPurpose::RelayContentSignature.into(),
|
MtpProtectionPurpose::RelayContentSignature.into(),
|
||||||
policy,
|
options.policy,
|
||||||
|
options.encode_limits,
|
||||||
)?;
|
)?;
|
||||||
let content = signed
|
let content = signed
|
||||||
.value
|
.value
|
||||||
.as_container()
|
.container_entries()
|
||||||
.ok_or(RelayError::InvalidLayout("content is not a container"))?;
|
.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)?;
|
validate_application_message_type(&message_type, type_map)?;
|
||||||
Ok(VerifiedRelayContent {
|
Ok(VerifiedRelayContent {
|
||||||
signer_id: signed.signer_id,
|
signer_id: signed.signer_id,
|
||||||
final_recipient_id: metadata.final_recipient_id,
|
final_recipient_id: metadata.final_recipient_id,
|
||||||
message_type,
|
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.
|
/// 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
|
/// The authenticated encrypted payload is cloned byte-for-byte, so a relay
|
||||||
/// cannot alter the final recipient or message metadata while forwarding.
|
/// cannot alter the final recipient or message metadata while forwarding.
|
||||||
|
|
@ -636,13 +1015,78 @@ pub fn forward_relay_frame(
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::InMemoryReplayGuard;
|
use crate::InMemoryReplayGuard;
|
||||||
use mtp_crypto::{Ed25519Signer, Keyring};
|
use mtp_crypto::{Ed25519Signer, Keyring, PublicKeyBundle};
|
||||||
use mtp_type_map::DataTypeId;
|
use mtp_type_map::DataTypeId;
|
||||||
|
|
||||||
fn ed_signer(keyring: &Keyring) -> Ed25519Signer {
|
fn ed_signer(keyring: &Keyring) -> Ed25519Signer {
|
||||||
Ed25519Signer::new(&keyring.sig_cl_secret_key).expect("Ed25519 signer")
|
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(
|
fn relay_frame_with_version(
|
||||||
version: Option<u64>,
|
version: Option<u64>,
|
||||||
include_v1_fields: bool,
|
include_v1_fields: bool,
|
||||||
|
|
@ -874,6 +1318,22 @@ mod tests {
|
||||||
assert_eq!(metadata.created_at(), 123);
|
assert_eq!(metadata.created_at(), 123);
|
||||||
assert_eq!(metadata.metadata(), Some(&application_metadata));
|
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
|
// A final recipient may be included in the metadata recipient set and
|
||||||
// therefore open both authenticated layers directly.
|
// therefore open both authenticated layers directly.
|
||||||
let final_metadata = open_relay_metadata(
|
let final_metadata = open_relay_metadata(
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,10 @@ pub enum CodecError {
|
||||||
InvalidEncoding,
|
InvalidEncoding,
|
||||||
#[error("Too many entries to encode")]
|
#[error("Too many entries to encode")]
|
||||||
TooManyEntries,
|
TooManyEntries,
|
||||||
|
#[error("Missing negotiated type map")]
|
||||||
|
MissingTypeMap,
|
||||||
|
#[error("Type-map mismatch: expected {expected}, actual {actual}")]
|
||||||
|
TypeMapMismatch { expected: String, actual: String },
|
||||||
#[error("Crypto failed: {0}")]
|
#[error("Crypto failed: {0}")]
|
||||||
CryptoFailed(String),
|
CryptoFailed(String),
|
||||||
#[error("Missing required field: {0}")]
|
#[error("Missing required field: {0}")]
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ rand = "0.10.2"
|
||||||
getrandom = "0.4.3"
|
getrandom = "0.4.3"
|
||||||
mlkem-tls = { version = "0.2", optional = true }
|
mlkem-tls = { version = "0.2", optional = true }
|
||||||
ml-dsa = { version = "0.1.1", optional = true }
|
ml-dsa = { version = "0.1.1", optional = true }
|
||||||
|
argon2 = { version = "0.5", optional = true }
|
||||||
serde = { version = "1", optional = true, features = ["derive"] }
|
serde = { version = "1", optional = true, features = ["derive"] }
|
||||||
rcgen = { version = "0.14", optional = true }
|
rcgen = { version = "0.14", optional = true }
|
||||||
time = { version = "0.3", optional = true }
|
time = { version = "0.3", optional = true }
|
||||||
|
|
@ -43,3 +44,4 @@ hkdf = ["dep:hkdf", "dep:sha2"]
|
||||||
sha2 = ["dep:sha2"]
|
sha2 = ["dep:sha2"]
|
||||||
tls = ["dep:rcgen", "dep:time"]
|
tls = ["dep:rcgen", "dep:time"]
|
||||||
parallel = ["dep:tokio"]
|
parallel = ["dep:tokio"]
|
||||||
|
password-kdf = ["dep:argon2"]
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ pub enum CryptoError {
|
||||||
EncryptionFailed,
|
EncryptionFailed,
|
||||||
#[error("decryption failed")]
|
#[error("decryption failed")]
|
||||||
DecryptionFailed,
|
DecryptionFailed,
|
||||||
|
#[error("decryption output exceeds the caller's allocation limit")]
|
||||||
|
AllocationLimit,
|
||||||
#[error("malformed encryption envelope")]
|
#[error("malformed encryption envelope")]
|
||||||
MalformedEnvelope,
|
MalformedEnvelope,
|
||||||
#[error("no encryption recipients")]
|
#[error("no encryption recipients")]
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,114 @@ pub struct MultiEncryptedMessage {
|
||||||
pub ciphertext: Vec<u8>,
|
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 {
|
impl MultiEncryptedMessage {
|
||||||
/// Serialize the envelope body without redundant per-recipient lengths.
|
/// Serialize the envelope body without redundant per-recipient lengths.
|
||||||
pub fn to_bytes(&self) -> Result<Vec<u8>, CryptoError> {
|
pub fn to_bytes(&self) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
|
@ -72,50 +180,7 @@ impl MultiEncryptedMessage {
|
||||||
|
|
||||||
/// Parse the canonical envelope body.
|
/// Parse the canonical envelope body.
|
||||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, CryptoError> {
|
pub fn from_bytes(bytes: &[u8]) -> Result<Self, CryptoError> {
|
||||||
if bytes.len() < 4 {
|
Ok(MultiEncryptedMessageRef::from_bytes(bytes)?.to_owned())
|
||||||
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(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,20 +262,51 @@ pub fn decrypt_multi_for(
|
||||||
purpose: u8,
|
purpose: u8,
|
||||||
keyring: &Keyring,
|
keyring: &Keyring,
|
||||||
) -> Result<Vec<u8>, CryptoError> {
|
) -> Result<Vec<u8>, CryptoError> {
|
||||||
if message.recipients.is_empty()
|
decrypt_multi_for_parts(
|
||||||
|| message.recipients.len() > MAX_RECIPIENTS
|
message.encryption_type,
|
||||||
|| message.purpose != purpose
|
message.purpose,
|
||||||
|| message.ciphertext.len() < message.encryption_type.minimum_ciphertext_len()
|
&message.recipients,
|
||||||
|| message.recipients.iter().any(|recipient| {
|
&message.ciphertext,
|
||||||
recipient.kem_ciphertext.len() != message.encryption_type.kem_ciphertext_len()
|
purpose,
|
||||||
|| recipient.encrypted_key.len() != message.encryption_type.wrapped_key_len()
|
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);
|
return Err(CryptoError::MalformedEnvelope);
|
||||||
}
|
}
|
||||||
|
|
||||||
let payload_aad = payload_aad(message)?;
|
let count = u16::try_from(recipients.len()).map_err(|_| CryptoError::MalformedEnvelope)?;
|
||||||
for entry in &message.recipients {
|
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 =
|
let shared_secret =
|
||||||
match HybridKem::decapsulate(&keyring.kem_secret_key, &entry.kem_ciphertext) {
|
match HybridKem::decapsulate(&keyring.kem_secret_key, &entry.kem_ciphertext) {
|
||||||
Ok(secret) => secret,
|
Ok(secret) => secret,
|
||||||
|
|
@ -219,30 +315,51 @@ pub fn decrypt_multi_for(
|
||||||
let wrap_key = Zeroizing::new(derive_encryption_key(
|
let wrap_key = Zeroizing::new(derive_encryption_key(
|
||||||
&shared_secret,
|
&shared_secret,
|
||||||
KEY_WRAP_DOMAIN,
|
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 aad = wrap_aad(encryption_type, purpose, &entry.kem_ciphertext);
|
||||||
let cek = match open_with_key(
|
let cek = match open_with_key(encryption_type, *wrap_key, &entry.encrypted_key, &aad) {
|
||||||
message.encryption_type,
|
|
||||||
*wrap_key,
|
|
||||||
&entry.encrypted_key,
|
|
||||||
&aad,
|
|
||||||
) {
|
|
||||||
Ok(key) => key,
|
Ok(key) => key,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
let cek: [u8; 32] = cek.try_into().map_err(|_| CryptoError::DecryptionFailed)?;
|
let cek: [u8; 32] = cek.try_into().map_err(|_| CryptoError::DecryptionFailed)?;
|
||||||
return open_with_key(
|
return open_with_key(encryption_type, cek, ciphertext, &payload_aad);
|
||||||
message.encryption_type,
|
|
||||||
cek,
|
|
||||||
&message.ciphertext,
|
|
||||||
&payload_aad,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(CryptoError::NoMatchingRecipient)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -325,4 +442,30 @@ mod tests {
|
||||||
Err(CryptoError::MalformedEnvelope)
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,3 +36,29 @@ pub fn derive_encryption_key(
|
||||||
out.copy_from_slice(&key);
|
out.copy_from_slice(&key);
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "password-kdf")]
|
||||||
|
pub fn derive_password_key(
|
||||||
|
passphrase: &[u8],
|
||||||
|
salt: &[u8],
|
||||||
|
memory_kib: u32,
|
||||||
|
iterations: u32,
|
||||||
|
lanes: u32,
|
||||||
|
) -> Result<[u8; 32], CryptoError> {
|
||||||
|
if passphrase.is_empty()
|
||||||
|
|| salt.len() < 16
|
||||||
|
|| !(8 * 1024..=256 * 1024).contains(&memory_kib)
|
||||||
|
|| !(1..=10).contains(&iterations)
|
||||||
|
|| !(1..=8).contains(&lanes)
|
||||||
|
{
|
||||||
|
return Err(CryptoError::KdfError);
|
||||||
|
}
|
||||||
|
let params = argon2::Params::new(memory_kib, iterations, lanes, Some(32))
|
||||||
|
.map_err(|_| CryptoError::KdfError)?;
|
||||||
|
let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
argon
|
||||||
|
.hash_password_into(passphrase, salt, &mut key)
|
||||||
|
.map_err(|_| CryptoError::KdfError)?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -340,9 +340,9 @@ impl Keyring {
|
||||||
Ok(out)
|
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()
|
self.try_to_bytes()
|
||||||
.expect("key material length exceeds wire limit")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, crate::error::CryptoError> {
|
pub fn from_bytes(bytes: &[u8]) -> Result<Self, crate::error::CryptoError> {
|
||||||
|
|
@ -383,16 +383,26 @@ impl Keyring {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_hex(&self) -> String {
|
#[deprecated(note = "use try_to_hex for the primary fallible serializer")]
|
||||||
bytes_to_hex(&self.to_bytes())
|
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> {
|
pub fn from_hex(s: &str) -> Result<Self, crate::error::CryptoError> {
|
||||||
Self::from_bytes(&hex_to_bytes(s)?)
|
Self::from_bytes(&hex_to_bytes(s)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_base64(&self) -> String {
|
#[deprecated(note = "use try_to_base64 for the primary fallible serializer")]
|
||||||
bytes_to_base64(&self.to_bytes())
|
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> {
|
pub fn from_base64(s: &str) -> Result<Self, crate::error::CryptoError> {
|
||||||
|
|
@ -507,9 +517,9 @@ impl PublicKeyBundle {
|
||||||
Ok(out)
|
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()
|
self.try_as_bytes()
|
||||||
.expect("public key bundle field exceeds wire limit")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a complete suite-compatible public bundle.
|
/// Parse a complete suite-compatible public bundle.
|
||||||
|
|
@ -582,8 +592,13 @@ impl PublicKeyBundle {
|
||||||
Self::from_bytes(bytes)
|
Self::from_bytes(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_base64(&self) -> String {
|
#[deprecated(note = "use try_to_base64 for the primary fallible serializer")]
|
||||||
bytes_to_base64(&self.as_bytes())
|
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> {
|
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 {
|
impl fmt::Debug for PublicKeyBundle {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
f.debug_struct("PublicKeyBundle")
|
f.debug_struct("PublicKeyBundle")
|
||||||
|
|
@ -629,7 +638,7 @@ mod tests {
|
||||||
let cl = SignaturePublicKey::new(vec![3u8; 32]);
|
let cl = SignaturePublicKey::new(vec![3u8; 32]);
|
||||||
|
|
||||||
let bundle = PublicKeyBundle::new(kem, pq, cl);
|
let bundle = PublicKeyBundle::new(kem, pq, cl);
|
||||||
let bytes = bundle.as_bytes();
|
let bytes = bundle.try_as_bytes()?;
|
||||||
let recovered = PublicKeyBundle::from_bytes_unvalidated(&bytes)?;
|
let recovered = PublicKeyBundle::from_bytes_unvalidated(&bytes)?;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -672,9 +681,9 @@ mod tests {
|
||||||
SignaturePqPublicKey::new(vec![0xCDu8; 96]),
|
SignaturePqPublicKey::new(vec![0xCDu8; 96]),
|
||||||
SignaturePublicKey::new(vec![0xEFu8; 32]),
|
SignaturePublicKey::new(vec![0xEFu8; 32]),
|
||||||
);
|
);
|
||||||
let bytes: Vec<u8> = Vec::from(&bundle);
|
let bytes = bundle.try_as_bytes()?;
|
||||||
let recovered = PublicKeyBundle::from_bytes_unvalidated(bytes.as_slice())?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -688,8 +697,8 @@ mod tests {
|
||||||
SignaturePublicKey::new(vec![5u8; 32]),
|
SignaturePublicKey::new(vec![5u8; 32]),
|
||||||
SignaturePrivateKey::new(vec![6u8; 32]),
|
SignaturePrivateKey::new(vec![6u8; 32]),
|
||||||
);
|
);
|
||||||
let bytes = keyring.to_bytes();
|
let bytes = keyring.try_to_bytes()?;
|
||||||
let recovered = Keyring::from_bytes(&bytes)?;
|
let recovered = Keyring::from_bytes(bytes.as_slice())?;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
keyring.kem_public_key.as_bytes(),
|
keyring.kem_public_key.as_bytes(),
|
||||||
recovered.kem_public_key.as_bytes()
|
recovered.kem_public_key.as_bytes()
|
||||||
|
|
@ -722,7 +731,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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(
|
let keyring = Keyring::new(
|
||||||
KemPublicKey::new(vec![1u8; 16]),
|
KemPublicKey::new(vec![1u8; 16]),
|
||||||
KemPrivateKey::new(vec![2u8; 16]),
|
KemPrivateKey::new(vec![2u8; 16]),
|
||||||
|
|
@ -731,25 +740,40 @@ mod tests {
|
||||||
SignaturePublicKey::new(vec![5u8; 16]),
|
SignaturePublicKey::new(vec![5u8; 16]),
|
||||||
SignaturePrivateKey::new(vec![6u8; 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);
|
keyring_bytes.push(0xAA);
|
||||||
assert!(Keyring::from_bytes(&keyring_bytes).is_err());
|
assert!(Keyring::from_bytes(&keyring_bytes).is_err());
|
||||||
|
|
||||||
let bundle = keyring.public_key_bundle();
|
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);
|
bundle_bytes.push(0xBB);
|
||||||
assert!(PublicKeyBundle::from_bytes(&bundle_bytes).is_err());
|
assert!(PublicKeyBundle::from_bytes(&bundle_bytes).is_err());
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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(
|
let bundle = PublicKeyBundle::new(
|
||||||
KemPublicKey::new(vec![1u8; 32]),
|
KemPublicKey::new(vec![1u8; 32]),
|
||||||
SignaturePqPublicKey::new(vec![2u8; 64]),
|
SignaturePqPublicKey::new(vec![2u8; 64]),
|
||||||
SignaturePublicKey::new(vec![3u8; 32]),
|
SignaturePublicKey::new(vec![3u8; 32]),
|
||||||
);
|
);
|
||||||
assert!(bundle.validate().is_err());
|
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]
|
#[test]
|
||||||
|
|
@ -762,9 +786,9 @@ mod tests {
|
||||||
SignaturePublicKey::new(vec![4u8; 16]),
|
SignaturePublicKey::new(vec![4u8; 16]),
|
||||||
SignaturePrivateKey::new(vec![5u8; 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())?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -788,9 +812,9 @@ mod tests {
|
||||||
SignaturePublicKey::new(vec![5u8; 16]),
|
SignaturePublicKey::new(vec![5u8; 16]),
|
||||||
SignaturePrivateKey::new(vec![6u8; 16]),
|
SignaturePrivateKey::new(vec![6u8; 16]),
|
||||||
);
|
);
|
||||||
let hex = keyring.to_hex();
|
let hex = keyring.try_to_hex()?;
|
||||||
let recovered = Keyring::from_hex(&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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -804,9 +828,9 @@ mod tests {
|
||||||
SignaturePublicKey::new(vec![5u8; 16]),
|
SignaturePublicKey::new(vec![5u8; 16]),
|
||||||
SignaturePrivateKey::new(vec![6u8; 16]),
|
SignaturePrivateKey::new(vec![6u8; 16]),
|
||||||
);
|
);
|
||||||
let b64 = keyring.to_base64();
|
let b64 = keyring.try_to_base64()?;
|
||||||
let recovered = Keyring::from_base64(&b64)?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -817,9 +841,9 @@ mod tests {
|
||||||
SignaturePqPublicKey::new(vec![2u8; 64]),
|
SignaturePqPublicKey::new(vec![2u8; 64]),
|
||||||
SignaturePublicKey::new(vec![3u8; 32]),
|
SignaturePublicKey::new(vec![3u8; 32]),
|
||||||
);
|
);
|
||||||
let b64 = bundle.to_base64();
|
let b64 = bundle.try_to_base64()?;
|
||||||
let recovered = PublicKeyBundle::from_base64_unvalidated(&b64)?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@ pub use sign::{DualSignature, DualSigner, sign_dual};
|
||||||
#[cfg(feature = "sha2")]
|
#[cfg(feature = "sha2")]
|
||||||
pub use hash::{Sha256Hasher, sha256, sha256_double};
|
pub use hash::{Sha256Hasher, sha256, sha256_double};
|
||||||
|
|
||||||
|
#[cfg(feature = "password-kdf")]
|
||||||
|
pub use kdf::derive_password_key;
|
||||||
#[cfg(feature = "hkdf")]
|
#[cfg(feature = "hkdf")]
|
||||||
pub use kdf::{derive_encryption_key, hkdf_expand, hkdf_extract};
|
pub use kdf::{derive_encryption_key, hkdf_expand, hkdf_extract};
|
||||||
|
|
||||||
|
|
@ -83,7 +85,9 @@ pub use helper::{ENCRYPT_DOMAIN, KEY_WRAP_DOMAIN};
|
||||||
|
|
||||||
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
#[cfg(all(feature = "mlkem-tls", feature = "hkdf"))]
|
||||||
pub use helper::{
|
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 ================================ */
|
/* ================================ TESTS ================================ */
|
||||||
|
|
@ -311,7 +315,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn keyring_serialize_roundtrip() {
|
fn keyring_serialize_roundtrip() {
|
||||||
let kr = Keyring::generate();
|
let kr = Keyring::generate();
|
||||||
let bytes = kr.to_bytes();
|
let bytes = kr
|
||||||
|
.try_to_bytes()
|
||||||
|
.expect("keyring serialization should succeed");
|
||||||
let loaded = Keyring::from_bytes(&bytes).expect("keyring roundtrip should succeed");
|
let loaded = Keyring::from_bytes(&bytes).expect("keyring roundtrip should succeed");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
kr.kem_public_key.as_bytes(),
|
kr.kem_public_key.as_bytes(),
|
||||||
|
|
@ -332,7 +338,9 @@ mod tests {
|
||||||
fn public_key_bundle_serialize_roundtrip() {
|
fn public_key_bundle_serialize_roundtrip() {
|
||||||
let kr = Keyring::generate();
|
let kr = Keyring::generate();
|
||||||
let bundle = kr.public_key_bundle();
|
let bundle = kr.public_key_bundle();
|
||||||
let bytes = bundle.as_bytes();
|
let bytes = bundle
|
||||||
|
.try_as_bytes()
|
||||||
|
.expect("bundle serialization should succeed");
|
||||||
let loaded = PublicKeyBundle::from_bytes(&bytes).expect("bundle roundtrip should succeed");
|
let loaded = PublicKeyBundle::from_bytes(&bytes).expect("bundle roundtrip should succeed");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bundle.kem_public_key.as_bytes(),
|
bundle.kem_public_key.as_bytes(),
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ let conn = MTPClient::auth_register(config, &keyring, &host_pk).await?;
|
||||||
|
|
||||||
// Save for next session
|
// Save for next session
|
||||||
let id = conn.client_id;
|
let id = conn.client_id;
|
||||||
let keyring_bytes = keyring.to_bytes();
|
let keyring_bytes = keyring.try_to_bytes()?;
|
||||||
```
|
```
|
||||||
|
|
||||||
When callers already know whether a saved client ID exists, the convenience helper uses `Some(id)` for login and `None` for registration:
|
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>`
|
- Deserialise: `Keyring::from_bytes(&bytes)` -> `Result<Keyring, CryptoError>`
|
||||||
- Get public half: `keyring.public_key_bundle()` -> `PublicKeyBundle`
|
- Get public half: `keyring.public_key_bundle()` -> `PublicKeyBundle`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
let host_keyring = Keyring::new(kem_pk, kem_sk, sig_pq_pk, sig_pq_sk, sig_pk, sig_sk);
|
||||||
|
|
||||||
// Save to disk
|
// Save to disk
|
||||||
let bytes = host_keyring.to_bytes();
|
let bytes = host_keyring.try_to_bytes()?;
|
||||||
std::fs::write("host_keys.bin", bytes)?;
|
std::fs::write("host_keys.bin", bytes)?;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,28 @@ Verified SDK results expose the authenticated `protectedVersion` and
|
||||||
`finalRecipientId` alongside the application content.
|
`finalRecipientId` alongside the application content.
|
||||||
|
|
||||||
Native applications use the same schema through `ProtectedMessageBuilder` and
|
Native applications use the same schema through `ProtectedMessageBuilder` and
|
||||||
`open_protected`; language bindings delegate envelope construction and opening
|
the replay-explicit `open_protected_checked` or `open_protected_without_replay`
|
||||||
to this codec boundary.
|
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
|
## 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.
|
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`.
|
`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
|
## Version Negotiation
|
||||||
|
|
|
||||||
|
|
@ -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
|
dispatching application content. Transport frame IDs must not be used for
|
||||||
this purpose.
|
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
|
`VerifiedRelayMetadata` is an authenticated capability rather than a caller
|
||||||
constructed data transfer object. Rust fields are private and the browser
|
constructed data transfer object. Rust fields are private and the browser
|
||||||
implementation keeps authenticated state behind a branded class. Content
|
implementation keeps authenticated state behind a branded class. Content
|
||||||
|
|
@ -174,9 +182,11 @@ fallback.
|
||||||
Verification takes a receiver-side `SignaturePolicy`/`ProtectionPolicy`.
|
Verification takes a receiver-side `SignaturePolicy`/`ProtectionPolicy`.
|
||||||
`AnySupported` is useful for compatibility at the low-level codec boundary,
|
`AnySupported` is useful for compatibility at the low-level codec boundary,
|
||||||
but protocol receivers should select `Ed25519` or `Dual`. The browser SDK uses
|
but protocol receivers should select `Ed25519` or `Dual`. The browser SDK uses
|
||||||
an explicit `ed25519` default and permits an operation or client override. It
|
an explicit `ed25519` default and permits an operation or client override. Its
|
||||||
never derives receive policy from the recipient keyring. The sender's
|
`MTPSecurityProfile` resolves protected-message sender/receiver suites,
|
||||||
signature suite remains a separate choice. Signature policy must be applied
|
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.
|
independently to relay metadata, relay content, and pipe session establishment.
|
||||||
|
|
||||||
### Key history and rotation
|
### 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.
|
Applications remain responsible for storage at rest. The `files` feature writes passphrase-protected keyrings to `.mk` files and public bundles to `.mpkb` files. Protected `.mk` files store the Argon2id identifier, parameters, salt, and AEAD ciphertext; they do not derive their key with HKDF. On Unix, keyring files are created with owner-only `0600` permissions.
|
||||||
Restrict those files to the owning account and protect backups. Browser applications should treat the configured credential storage as sensitive application data.
|
Restrict those files to the owning account and protect backups. Browser applications should treat the configured credential storage as sensitive application data.
|
||||||
|
|
||||||
|
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
|
## Resource Limits and Operational Controls
|
||||||
|
|
||||||
`Policy::default()` sets a 16 MiB application message limit and a 64 KiB handshake message limit. It also sets a 30 second read timeout, a 30 second maximum idle timeout, a receiver queue capacity of 1000, and a maximum of 128 concurrent stream tasks. Tune these values for the deployment and peer trust level.
|
`Policy::default()` sets a 16 MiB application message limit and a 64 KiB handshake message limit. It also sets a 30 second read timeout, a 30 second maximum idle timeout, a receiver queue capacity of 1000, and a maximum of 128 concurrent stream tasks. Tune these values for the deployment and peer trust level.
|
||||||
|
|
||||||
The recursive codec applies additional defaults while parsing untrusted values:
|
The recursive codec applies additional defaults while parsing untrusted values:
|
||||||
maximum nesting depth 64, 65,536 value nodes, 16 MiB per blob or envelope,
|
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.
|
The host applies an authentication-attempt limiter before storage lookups,
|
||||||
Deploy authentication endpoints behind a rate-limiting proxy or add admission control through the host callbacks, including `GuestIdGenerator` where guest connections are permitted.
|
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
|
## Security Limitations
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
# Type Map
|
# 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).
|
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
|
## 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.
|
Protection nesting directly represents both signer-visibility choices: `Encrypted(Signed(Container))` keeps signer metadata private, while `Signed(Encrypted(Container))` exposes it. A frame with no outer sender and an `Encrypted(Signed(Container))` payload uses sealed sender. Sealed sender adds no flag or distinct wire type.
|
||||||
|
|
||||||
|
### Container ordering and signatures
|
||||||
|
|
||||||
|
Container entries are ordered sequences in the current format. Insertion order
|
||||||
|
is therefore semantic: two containers with the same field/value pairs in a
|
||||||
|
different order have different serialized bytes and different signatures. The
|
||||||
|
decoder rejects duplicate field IDs. Applications that need map semantics must
|
||||||
|
canonicalize their own input before signing; a future canonical map encoding
|
||||||
|
requires a protocol-format version and cannot be inferred by a receiver.
|
||||||
|
|
||||||
## TypeMap & Compile-Time Type Safety
|
## TypeMap & Compile-Time Type Safety
|
||||||
|
|
||||||
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.
|
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
|
```rust
|
||||||
use mtp::codec::registry::{Registry, VersionedCodec};
|
use mtp::codec::registry::{Registry, VersionedCodec};
|
||||||
|
use mtp::codec::{CommunicationType, CommunicationValue, DataValue};
|
||||||
|
use mtp_type_map::Version;
|
||||||
|
|
||||||
let registry = Registry::builtin();
|
let registry = Registry::builtin();
|
||||||
let codec = VersionedCodec::new(registry);
|
let codec = VersionedCodec::for_version(registry, Version(3, 0)).unwrap();
|
||||||
|
let value = CommunicationValue::new_with_type_map(
|
||||||
|
CommunicationType::Ping,
|
||||||
|
codec.type_map(),
|
||||||
|
).with_payload(DataValue::Null);
|
||||||
|
|
||||||
// Encode with a specific version
|
// The value must retain the negotiated map used to construct it.
|
||||||
let bytes = codec.encode(&value, Version(3, 0)).unwrap();
|
let bytes = codec.encode(&value).unwrap();
|
||||||
|
|
||||||
// Decode with a specific version
|
let decoded = codec.decode(&bytes).unwrap();
|
||||||
let decoded = codec.decode(&bytes, Version(3, 0)).unwrap();
|
|
||||||
|
// A clear value can be migrated explicitly when the application has chosen
|
||||||
|
// that behavior. Protected values are not silently remapped.
|
||||||
|
let migrated = codec.encode_migrating(&value).unwrap();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`VersionedCodec::encode` compares the retained map identity (its protocol
|
||||||
|
version) and returns `CodecError::MissingTypeMap` or
|
||||||
|
`CodecError::TypeMapMismatch` on failure. `reply_to` retains the request's
|
||||||
|
map, while `try_merge` rejects frames from different maps before copying any
|
||||||
|
fields. The deprecated `merge` method records the error for compatibility; new
|
||||||
|
code should migrate to `try_merge` and handle the result.
|
||||||
|
|
||||||
## Customizing Type Maps in Downstream Projects
|
## Customizing Type Maps in Downstream Projects
|
||||||
|
|
||||||
External projects must provide their own type map configuration. Browser projects use the Vite plugin from [Defining Type Maps](#defining-type-maps) and do not need to publish, fork, or copy a generated WASM package.
|
External projects must provide their own type map configuration. Browser projects use the Vite plugin from [Defining Type Maps](#defining-type-maps) and do not need to publish, fork, or copy a generated WASM package.
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ use std::time::{Duration, Instant};
|
||||||
use mtp::client::MTPConnection;
|
use mtp::client::MTPConnection;
|
||||||
use mtp::codec::{
|
use mtp::codec::{
|
||||||
CommunicationType, DataType, DataValue, ProtectionPolicy, ProtectedMessageBuilder,
|
CommunicationType, DataType, DataValue, ProtectionPolicy, ProtectedMessageBuilder,
|
||||||
ProtectionPurpose, SealedRelayBuilder, SignaturePolicy, TypeMap, open_relay_content,
|
ProtectionPurpose, SealedRelayBuilder, SignaturePolicy, TypeMap,
|
||||||
open_relay_metadata,
|
open_relay_content_with_limits_without_replay,
|
||||||
|
open_relay_metadata_without_replay,
|
||||||
};
|
};
|
||||||
use mtp::common::unix_time_millis;
|
use mtp::common::unix_time_millis;
|
||||||
use mtp::crypto::{Ed25519Signer, Keyring, PublicKeyBundle};
|
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());
|
return Err("relay forwarding changed the sealed-sender boundary".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let metadata = open_relay_metadata(
|
let metadata = open_relay_metadata_without_replay(
|
||||||
&forwarded,
|
&forwarded,
|
||||||
&final_recipient_keyring,
|
&final_recipient_keyring,
|
||||||
signer_id,
|
signer_id,
|
||||||
|
|
@ -169,7 +170,7 @@ pub async fn send_sealed_relay(
|
||||||
let application_metadata = metadata
|
let application_metadata = metadata
|
||||||
.metadata()
|
.metadata()
|
||||||
.ok_or("forwarded relay metadata was missing")?;
|
.ok_or("forwarded relay metadata was missing")?;
|
||||||
let content = open_relay_content(
|
let content = open_relay_content_with_limits_without_replay(
|
||||||
&metadata,
|
&metadata,
|
||||||
&final_recipient_keyring,
|
&final_recipient_keyring,
|
||||||
&signer_keyring.public_key_bundle(),
|
&signer_keyring.public_key_bundle(),
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,22 @@ fn main() -> Result<(), files::FileError> {
|
||||||
/* Read both back to confirm the files round-trip through the on-disk format. */
|
/* Read both back to confirm the files round-trip through the on-disk format. */
|
||||||
let loaded_keyring = load_keyring_raw(&keyring_path)?;
|
let loaded_keyring = load_keyring_raw(&keyring_path)?;
|
||||||
let loaded_bundle = load_public_key_bundle(&bundle_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!(
|
assert_eq!(
|
||||||
keyring.public_key_bundle().as_bytes(),
|
bundle_bytes,
|
||||||
loaded_bundle.as_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 keyring -> {}", keyring_path.display());
|
||||||
println!("Wrote bundle -> {}", bundle_path.display());
|
println!("Wrote bundle -> {}", bundle_path.display());
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ use std::collections::HashMap;
|
||||||
use mtp::codec::{
|
use mtp::codec::{
|
||||||
CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, InMemoryReplayGuard,
|
CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, InMemoryReplayGuard,
|
||||||
ProtectedOpenOptions, ProtectionPolicy, ProtectionPurpose, SignaturePolicy, TypeMap,
|
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};
|
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,
|
msg,
|
||||||
std::slice::from_ref(&host_keyring),
|
std::slice::from_ref(&host_keyring),
|
||||||
None,
|
None,
|
||||||
|
|
@ -76,7 +78,7 @@ fn process_direct_protected(
|
||||||
ProtectionPurpose::from(DIRECT_ENCRYPTION_PURPOSE),
|
ProtectionPurpose::from(DIRECT_ENCRYPTION_PURPOSE),
|
||||||
SIGNATURE_POLICY,
|
SIGNATURE_POLICY,
|
||||||
),
|
),
|
||||||
Some(accepted_messages),
|
accepted_messages,
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("direct protected message could not be authenticated: {e}"))?;
|
.map_err(|e| format!("direct protected message could not be authenticated: {e}"))?;
|
||||||
let signer_id = opened.signer_id;
|
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,
|
msg,
|
||||||
std::slice::from_ref(&host_keyring),
|
std::slice::from_ref(&host_keyring),
|
||||||
None,
|
None,
|
||||||
|
|
@ -141,7 +143,7 @@ fn process_sealed_relay(
|
||||||
resolve_signer_key(signer_id, registered_clients).map(|key| vec![key])
|
resolve_signer_key(signer_id, registered_clients).map(|key| vec![key])
|
||||||
},
|
},
|
||||||
SIGNATURE_POLICY,
|
SIGNATURE_POLICY,
|
||||||
Some(accepted_messages),
|
accepted_messages,
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("metadata relay could not authenticate metadata: {e}"))?;
|
.map_err(|e| format!("metadata relay could not authenticate metadata: {e}"))?;
|
||||||
println!(
|
println!(
|
||||||
|
|
@ -160,7 +162,7 @@ fn process_sealed_relay(
|
||||||
.len()
|
.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
let content_result = open_relay_content(
|
let content_result = open_relay_content_with_limits_without_replay(
|
||||||
&metadata,
|
&metadata,
|
||||||
host_keyring,
|
host_keyring,
|
||||||
&resolve_signer_key(metadata.signer_id(), registered_clients)
|
&resolve_signer_key(metadata.signer_id(), registered_clients)
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ pub async fn export_host_public_keys(
|
||||||
save_public_key_bundle(&bundle, "host.mpkb")?;
|
save_public_key_bundle(&bundle, "host.mpkb")?;
|
||||||
|
|
||||||
/* The web client fetches the bundle as hex over HTTP. */
|
/* 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::write("host_public_key_bundle.hex", &bundle_hex).await?;
|
||||||
fs::create_dir_all("web-client/public").await?;
|
fs::create_dir_all("web-client/public").await?;
|
||||||
fs::write("web-client/public/host_public_key_bundle.hex", &bundle_hex).await?;
|
fs::write("web-client/public/host_public_key_bundle.hex", &bundle_hex).await?;
|
||||||
|
|
|
||||||
|
|
@ -111,8 +111,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
}) as Pin<Box<dyn Future<Output = u64> + Send>>
|
}) as Pin<Box<dyn Future<Output = u64> + Send>>
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let decrypt_keyring_bytes = host_keyring.try_to_bytes()?;
|
||||||
let decrypt_keyring = Arc::new(
|
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,
|
Ok(keyring) => keyring,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return Err(format!("failed to re-load host keyring for decryption: {e}").into());
|
return Err(format!("failed to re-load host keyring for decryption: {e}").into());
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,7 @@ edition = "2024"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Only the plain key types (`Keyring`, `PublicKeyBundle`, `CryptoError`) are
|
# Only the plain key types (`Keyring`, `PublicKeyBundle`, `CryptoError`) are
|
||||||
# needed here; those are always compiled, so no crypto features are required.
|
# needed here; those are always compiled, so no crypto features are required.
|
||||||
mtp-crypto = { version = "0.3.0", path = "../crypto", default-features = false, features = ["chacha20poly1305", "hkdf"] }
|
mtp-crypto = { version = "0.3.0", path = "../crypto", default-features = false, features = ["chacha20poly1305", "hkdf", "password-kdf"] }
|
||||||
argon2 = "0.5"
|
|
||||||
rand = "0.10.2"
|
rand = "0.10.2"
|
||||||
|
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,20 @@ fn temporary_path(path: &Path, attempt: u64) -> io::Result<PathBuf> {
|
||||||
|
|
||||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
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<()> {
|
fn write_secret_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
|
|
@ -146,7 +160,7 @@ fn write_secret_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||||
let _ = fs::remove_file(&temporary);
|
let _ = fs::remove_file(&temporary);
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
Ok(())
|
sync_parent_directory(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn derive_key(
|
fn derive_key(
|
||||||
|
|
@ -156,21 +170,12 @@ fn derive_key(
|
||||||
iterations: u32,
|
iterations: u32,
|
||||||
lanes: u32,
|
lanes: u32,
|
||||||
) -> Result<Zeroizing<[u8; 32]>, FileError> {
|
) -> Result<Zeroizing<[u8; 32]>, FileError> {
|
||||||
if salt.len() != SALT_LEN
|
if salt.len() != SALT_LEN {
|
||||||
|| !(8 * 1024..=256 * 1024).contains(&memory_kib)
|
|
||||||
|| !(1..=10).contains(&iterations)
|
|
||||||
|| !(1..=8).contains(&lanes)
|
|
||||||
{
|
|
||||||
return Err(FileError::Crypto(CryptoError::KdfError));
|
return Err(FileError::Crypto(CryptoError::KdfError));
|
||||||
}
|
}
|
||||||
let params = argon2::Params::new(memory_kib, iterations, lanes, Some(32))
|
Ok(Zeroizing::new(mtp_crypto::derive_password_key(
|
||||||
.map_err(|_| FileError::Crypto(CryptoError::KdfError))?;
|
passphrase, salt, memory_kib, iterations, lanes,
|
||||||
let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
|
)?))
|
||||||
let mut key = Zeroizing::new([0u8; 32]);
|
|
||||||
argon
|
|
||||||
.hash_password_into(passphrase, salt, key.as_mut())
|
|
||||||
.map_err(|_| FileError::Crypto(CryptoError::KdfError))?;
|
|
||||||
Ok(key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn protected_header_aad(parameters: &[u8]) -> Vec<u8> {
|
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(&ARGON2_LANES.to_be_bytes());
|
||||||
parameters.extend_from_slice(&salt);
|
parameters.extend_from_slice(&salt);
|
||||||
let cipher = ChaCha20Poly1305::new(*key);
|
let cipher = ChaCha20Poly1305::new(*key);
|
||||||
let plaintext = keyring.to_bytes();
|
let plaintext = keyring.try_to_bytes()?;
|
||||||
let encrypted = cipher.encrypt(&plaintext, &protected_header_aad(¶meters))?;
|
let encrypted = cipher.encrypt(&plaintext, &protected_header_aad(¶meters))?;
|
||||||
let mut payload = Vec::with_capacity(PROTECTED_PARAMS_LEN + encrypted.len());
|
let mut payload = Vec::with_capacity(PROTECTED_PARAMS_LEN + encrypted.len());
|
||||||
payload.extend_from_slice(¶meters);
|
payload.extend_from_slice(¶meters);
|
||||||
|
|
@ -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.
|
/// Explicitly save the legacy plaintext format for tests and development.
|
||||||
#[cfg(any(test, feature = "raw"))]
|
#[cfg(any(test, feature = "raw"))]
|
||||||
pub fn save_keyring_raw(keyring: &Keyring, path: impl AsRef<Path>) -> Result<(), FileError> {
|
pub fn save_keyring_raw(keyring: &Keyring, path: impl AsRef<Path>) -> Result<(), FileError> {
|
||||||
let payload = keyring.to_bytes();
|
let payload = keyring.try_to_bytes()?;
|
||||||
let bytes = Zeroizing::new(encode(KEYRING_MAGIC, RAW_FORMAT_VERSION, &payload));
|
let bytes = Zeroizing::new(encode(KEYRING_MAGIC, RAW_FORMAT_VERSION, &payload));
|
||||||
write_secret_atomic(path.as_ref(), &bytes)?;
|
write_secret_atomic(path.as_ref(), &bytes)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -286,7 +291,8 @@ pub fn save_public_key_bundle(
|
||||||
bundle: &PublicKeyBundle,
|
bundle: &PublicKeyBundle,
|
||||||
path: impl AsRef<Path>,
|
path: impl AsRef<Path>,
|
||||||
) -> Result<(), FileError> {
|
) -> 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)?;
|
fs::write(path, bytes)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -339,7 +345,7 @@ mod tests {
|
||||||
let keyring = sample_keyring();
|
let keyring = sample_keyring();
|
||||||
save_keyring(&keyring, &path, b"correct horse battery staple")?;
|
save_keyring(&keyring, &path, b"correct horse battery staple")?;
|
||||||
let loaded = load_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);
|
let _ = fs::remove_file(&path);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -350,7 +356,7 @@ mod tests {
|
||||||
let bundle = Keyring::generate().public_key_bundle();
|
let bundle = Keyring::generate().public_key_bundle();
|
||||||
save_public_key_bundle(&bundle, &path)?;
|
save_public_key_bundle(&bundle, &path)?;
|
||||||
let loaded = load_public_key_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);
|
let _ = fs::remove_file(&path);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -414,7 +420,7 @@ mod tests {
|
||||||
Err(FileError::UnprotectedKeyring)
|
Err(FileError::UnprotectedKeyring)
|
||||||
));
|
));
|
||||||
let loaded = load_keyring_raw(&path)?;
|
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);
|
let _ = fs::remove_file(&path);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -423,7 +429,7 @@ mod tests {
|
||||||
fn protected_keyring_is_not_plaintext() -> Result<(), Box<dyn std::error::Error>> {
|
fn protected_keyring_is_not_plaintext() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let path = temp_path(KEYRING_EXTENSION);
|
let path = temp_path(KEYRING_EXTENSION);
|
||||||
let keyring = sample_keyring();
|
let keyring = sample_keyring();
|
||||||
let serialized = keyring.to_bytes();
|
let serialized = keyring.try_to_bytes()?;
|
||||||
save_keyring(&keyring, &path, b"passphrase")?;
|
save_keyring(&keyring, &path, b"passphrase")?;
|
||||||
let stored = fs::read(&path)?;
|
let stored = fs::read(&path)?;
|
||||||
assert!(
|
assert!(
|
||||||
|
|
|
||||||
|
|
@ -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-transport = { version = "0.3.0", path = "../transport", features = ["host"] }
|
||||||
mtp-crypto = { version = "0.3.0", path = "../crypto", optional = true }
|
mtp-crypto = { version = "0.3.0", path = "../crypto", optional = true }
|
||||||
rand = "0.10"
|
rand = "0.10"
|
||||||
|
thiserror = "2"
|
||||||
tokio = { version = "1", features = ["macros", "rt", "time", "sync"] }
|
tokio = { version = "1", features = ["macros", "rt", "time", "sync"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
wtransport = "0.7"
|
wtransport = "0.7"
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,14 @@ use std::collections::HashMap;
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
|
use std::time::{Duration as StdDuration, Instant};
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
use tokio::time::Duration;
|
use tokio::time::Duration;
|
||||||
|
|
||||||
pub use mtp_transport::Policy;
|
pub use mtp_transport::Policy;
|
||||||
|
|
@ -82,6 +86,158 @@ pub enum AuthenticationPolicy {
|
||||||
Unauthenticated,
|
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 struct HostConfig {
|
||||||
pub ip: IpAddr,
|
pub ip: IpAddr,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
|
|
@ -113,6 +269,10 @@ pub struct HostConfig {
|
||||||
pub complete_register: CompleteRegister,
|
pub complete_register: CompleteRegister,
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
pub find_registered_client: Option<FindRegisteredClient>,
|
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 {
|
impl HostConfig {
|
||||||
|
|
@ -153,6 +313,13 @@ impl HostConfig {
|
||||||
complete_register: Box::new(|_, _| Box::pin(async { 0 })),
|
complete_register: Box::new(|_, _| Box::pin(async { 0 })),
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
find_registered_client: None,
|
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.find_registered_client = Some(lookup);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub fn with_authentication_limiter(
|
||||||
|
mut self,
|
||||||
|
limiter: Arc<dyn AuthenticationAttemptLimiter>,
|
||||||
|
) -> Self {
|
||||||
|
self.auth_limiter = limiter;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub fn with_authentication_identity_concealment(mut self, conceal: bool) -> Self {
|
||||||
|
self.conceal_authentication_identities = conceal;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "crypto"))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authentication_attempt_limiter_rejects_repeated_attempts() {
|
||||||
|
let limiter = InMemoryAuthenticationAttemptLimiter::new(1, StdDuration::from_secs(60))
|
||||||
|
.with_keys(false, true, false, false);
|
||||||
|
let attempt = AuthenticationAttempt {
|
||||||
|
peer_network_identity: None,
|
||||||
|
connection_id: 9,
|
||||||
|
claimed_client_id: Some(42),
|
||||||
|
registration: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(limiter.allow(&attempt).expect("first attempt decision"));
|
||||||
|
assert!(!limiter.allow(&attempt).expect("second attempt decision"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authentication_attempt_limiter_can_scope_registration_separately() {
|
||||||
|
let limiter = InMemoryAuthenticationAttemptLimiter::new(2, StdDuration::from_secs(60))
|
||||||
|
.with_keys(false, false, false, true);
|
||||||
|
let login = AuthenticationAttempt {
|
||||||
|
peer_network_identity: None,
|
||||||
|
connection_id: 1,
|
||||||
|
claimed_client_id: None,
|
||||||
|
registration: false,
|
||||||
|
};
|
||||||
|
let registration = AuthenticationAttempt {
|
||||||
|
registration: true,
|
||||||
|
..login.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(limiter.allow(&login).expect("login attempt decision"));
|
||||||
|
assert!(
|
||||||
|
limiter
|
||||||
|
.allow(®istration)
|
||||||
|
.expect("registration attempt decision")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!limiter
|
||||||
|
.allow(®istration)
|
||||||
|
.expect("repeated registration decision")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ use tokio::sync::{Mutex, mpsc};
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
use crate::error::random_client_id;
|
use crate::error::random_client_id;
|
||||||
#[cfg(feature = "pipes")]
|
#[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")]
|
#[cfg(feature = "pipes")]
|
||||||
use mtp_transport::Policy;
|
use mtp_transport::Policy;
|
||||||
|
|
||||||
|
|
@ -145,10 +148,12 @@ where
|
||||||
remote_addr: Option<SocketAddr>,
|
remote_addr: Option<SocketAddr>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let policy = Arc::new(Policy::default());
|
let policy = Arc::new(Policy::default());
|
||||||
let (app_tx, app_rx) = mpsc::channel(policy.receiver_queue_capacity);
|
let receiver_queue_capacity = policy.receiver_queue_capacity.max(1);
|
||||||
let (pipe_req_tx, pipe_req_rx) = mpsc::channel(policy.receiver_queue_capacity);
|
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 {
|
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()),
|
pending_pipes: Mutex::new(std::collections::HashMap::new()),
|
||||||
policy,
|
policy,
|
||||||
type_map: codec.type_map().clone(),
|
type_map: codec.type_map().clone(),
|
||||||
|
|
@ -198,10 +203,12 @@ where
|
||||||
remote_addr: Option<SocketAddr>,
|
remote_addr: Option<SocketAddr>,
|
||||||
policy: Arc<Policy>,
|
policy: Arc<Policy>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let (app_tx, app_rx) = mpsc::channel(policy.receiver_queue_capacity);
|
let receiver_queue_capacity = policy.receiver_queue_capacity.max(1);
|
||||||
let (pipe_req_tx, pipe_req_rx) = mpsc::channel(policy.receiver_queue_capacity);
|
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 {
|
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()),
|
pending_pipes: Mutex::new(std::collections::HashMap::new()),
|
||||||
policy,
|
policy,
|
||||||
type_map: codec.type_map().clone(),
|
type_map: codec.type_map().clone(),
|
||||||
|
|
@ -321,19 +328,37 @@ where
|
||||||
pub async fn create_pipe(
|
pub async fn create_pipe(
|
||||||
&self,
|
&self,
|
||||||
description: &str,
|
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 (response_tx, response_rx) = tokio::sync::oneshot::channel();
|
||||||
let pipe_id = {
|
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 pipe_id = loop {
|
||||||
let candidate = rand::random::<u32>();
|
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;
|
break candidate;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
pending.insert(pipe_id, response_tx);
|
let token = Arc::new(());
|
||||||
pipe_id
|
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(
|
let request = CommunicationValue::new_with_type_map(
|
||||||
CommunicationType::PipeRequest,
|
CommunicationType::PipeRequest,
|
||||||
|
|
@ -342,19 +367,17 @@ where
|
||||||
.with_id(pipe_id)
|
.with_id(pipe_id)
|
||||||
.add_typed_default(DataType::Description, DataValue::Str(description.into()));
|
.add_typed_default(DataType::Description, DataValue::Str(description.into()));
|
||||||
if let Err(error) = self.sender.send_pipe_message(&request).await {
|
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));
|
return Err(mtp_common::PipeError::from(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
creation_guard.disarm();
|
||||||
Ok(crate::pipe::PipeHandle {
|
Ok(crate::pipe::PipeHandle {
|
||||||
pipe_id,
|
pipe_id,
|
||||||
description: description.to_owned(),
|
description: description.to_owned(),
|
||||||
sender: self.sender.clone(),
|
sender: self.sender.clone(),
|
||||||
response_rx,
|
response_rx,
|
||||||
|
dispatcher: self.pipe_dispatcher.clone(),
|
||||||
|
token,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
//! and the web server's `MTPWebServer` to perform the MTP opening handshake,
|
//! and the web server's `MTPWebServer` to perform the MTP opening handshake,
|
||||||
//! version negotiation, authentication, and guest assignment.
|
//! version negotiation, authentication, and guest assignment.
|
||||||
|
|
||||||
use crate::config::HostConfig;
|
use crate::config::{AuthenticationContext, HostConfig};
|
||||||
use crate::error::AcceptError;
|
use crate::error::AcceptError;
|
||||||
use mtp_codec::{
|
use mtp_codec::{
|
||||||
CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, Version,
|
CommunicationType, CommunicationValue, DataType, DataValue, TypeMap, Version,
|
||||||
|
|
@ -127,19 +127,32 @@ impl HandshakeEngine {
|
||||||
&self,
|
&self,
|
||||||
sender: &S,
|
sender: &S,
|
||||||
receiver: &R,
|
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> {
|
) -> Result<HandshakeResult, AcceptError> {
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
{
|
{
|
||||||
self.accept_until(
|
self.accept_until_with_context(
|
||||||
sender,
|
sender,
|
||||||
receiver,
|
receiver,
|
||||||
tokio::time::Instant::now() + self.config.auth_timeout,
|
tokio::time::Instant::now() + self.config.auth_timeout,
|
||||||
|
context,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "crypto"))]
|
#[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() {
|
if result.is_err() {
|
||||||
sender.close();
|
sender.close();
|
||||||
}
|
}
|
||||||
|
|
@ -159,7 +172,22 @@ impl HandshakeEngine {
|
||||||
receiver: &R,
|
receiver: &R,
|
||||||
deadline: tokio::time::Instant,
|
deadline: tokio::time::Instant,
|
||||||
) -> Result<HandshakeResult, AcceptError> {
|
) -> 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) => {
|
Ok(result) => {
|
||||||
if result.is_err() {
|
if result.is_err() {
|
||||||
sender.close();
|
sender.close();
|
||||||
|
|
@ -186,6 +214,7 @@ impl HandshakeEngine {
|
||||||
&self,
|
&self,
|
||||||
sender: &S,
|
sender: &S,
|
||||||
receiver: &R,
|
receiver: &R,
|
||||||
|
_authentication_context: &AuthenticationContext,
|
||||||
) -> Result<HandshakeResult, AcceptError> {
|
) -> Result<HandshakeResult, AcceptError> {
|
||||||
let mut first_msg = receiver.receive().await.map_err(AcceptError::Receive)?;
|
let mut first_msg = receiver.receive().await.map_err(AcceptError::Receive)?;
|
||||||
|
|
||||||
|
|
@ -257,6 +286,42 @@ impl HandshakeEngine {
|
||||||
|
|
||||||
#[cfg(feature = "crypto")]
|
#[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 {
|
match self.config.authentication_policy {
|
||||||
crate::config::AuthenticationPolicy::ForceAuthentication => {
|
crate::config::AuthenticationPolicy::ForceAuthentication => {
|
||||||
self.force_auth_handshake(
|
self.force_auth_handshake(
|
||||||
|
|
@ -408,7 +473,14 @@ impl HandshakeEngine {
|
||||||
return Err(error);
|
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
|
return self
|
||||||
.complete_auth_handshake(
|
.complete_auth_handshake(
|
||||||
sender,
|
sender,
|
||||||
|
|
@ -453,6 +525,28 @@ impl HandshakeEngine {
|
||||||
// Unknown or zero ID: an Identification carrying PublicKeys is an
|
// Unknown or zero ID: an Identification carrying PublicKeys is an
|
||||||
// explicit authentication attempt, not a guest connection.
|
// explicit authentication attempt, not a guest connection.
|
||||||
if first_msg.get_data(DataType::PublicKeys).is_some() {
|
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(
|
let error = AcceptError::AuthenticationFailed(
|
||||||
"unknown authenticated client identity".into(),
|
"unknown authenticated client identity".into(),
|
||||||
);
|
);
|
||||||
|
|
@ -525,20 +619,29 @@ impl HandshakeEngine {
|
||||||
let bundle = match (self.config.get_existing_client)(cid, description.clone()).await {
|
let bundle = match (self.config.get_existing_client)(cid, description.clone()).await {
|
||||||
Some(b) => b,
|
Some(b) => b,
|
||||||
None => {
|
None => {
|
||||||
let rejection = CommunicationValue::new_with_type_map(
|
if self.config.conceal_authentication_identities {
|
||||||
CommunicationType::IdentificationResponse,
|
// Use a valid fixed-cost dummy identity so an unknown
|
||||||
tm,
|
// client follows the same challenge/proof sequence as
|
||||||
)
|
// a registered client. The host public bundle is
|
||||||
.add_typed_default(DataType::Connected, DataValue::BoolFalse)
|
// already public and the peer cannot produce its
|
||||||
.add_typed_default(
|
// private-key proof.
|
||||||
DataType::ErrorMessage,
|
self.config.host_keyring.public_key_bundle()
|
||||||
DataValue::Str("unknown client id".into()),
|
} else {
|
||||||
);
|
let rejection = CommunicationValue::new_with_type_map(
|
||||||
let _ = sender.send(&rejection).await;
|
CommunicationType::IdentificationResponse,
|
||||||
sender.close();
|
tm,
|
||||||
return Err(AcceptError::AuthenticationFailed(
|
)
|
||||||
"unknown client id".into(),
|
.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);
|
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 },
|
Flow::Register { bundle, pk_bytes },
|
||||||
CommunicationType::RegisterResponse,
|
CommunicationType::RegisterResponse,
|
||||||
|
|
@ -787,7 +897,14 @@ impl HandshakeEngine {
|
||||||
Flow::Login { id, bundle } => (id, bundle),
|
Flow::Login { id, bundle } => (id, bundle),
|
||||||
Flow::Register { bundle, .. } => {
|
Flow::Register { bundle, .. } => {
|
||||||
let _registration_guard = self.config.registration_lock.lock().await;
|
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
|
let cached_id = self
|
||||||
.config
|
.config
|
||||||
.registration_ids
|
.registration_ids
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ use std::time::Instant;
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::config::HostConfig;
|
use crate::config::{AuthenticationContext, HostConfig};
|
||||||
use crate::connection::MTPConnection;
|
use crate::connection::MTPConnection;
|
||||||
use crate::engine::HandshakeEngine;
|
use crate::engine::HandshakeEngine;
|
||||||
use crate::error::AcceptError;
|
use crate::error::AcceptError;
|
||||||
|
|
@ -135,7 +135,16 @@ impl HandshakeContext {
|
||||||
receiver: Receiver,
|
receiver: Receiver,
|
||||||
) -> Result<Option<MTPConnection>, AcceptError> {
|
) -> Result<Option<MTPConnection>, AcceptError> {
|
||||||
let engine = HandshakeEngine::new(self.registry.clone(), self.config.clone());
|
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")]
|
#[cfg(feature = "crypto")]
|
||||||
{
|
{
|
||||||
Ok(Some(self.connection_from_handshake_result(
|
Ok(Some(self.connection_from_handshake_result(
|
||||||
|
|
@ -171,12 +180,13 @@ impl HandshakeContext {
|
||||||
receiver.respond_to_pings(sender.clone());
|
receiver.respond_to_pings(sender.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let (app_tx, app_rx) = mpsc::channel(self.config.policy.receiver_queue_capacity);
|
let receiver_queue_capacity = self.config.policy.receiver_queue_capacity.max(1);
|
||||||
let (pipe_req_tx, pipe_req_rx) =
|
let (app_tx, app_rx) = mpsc::channel(receiver_queue_capacity);
|
||||||
mpsc::channel(self.config.policy.receiver_queue_capacity);
|
let (pipe_req_tx, pipe_req_rx) = mpsc::channel(receiver_queue_capacity);
|
||||||
|
|
||||||
let dispatcher = Arc::new(PipeDispatcher {
|
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()),
|
pending_pipes: tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
policy: Arc::new(self.config.policy),
|
policy: Arc::new(self.config.policy),
|
||||||
type_map: type_map.clone(),
|
type_map: type_map.clone(),
|
||||||
|
|
@ -260,12 +270,13 @@ impl HandshakeContext {
|
||||||
receiver.respond_to_pings(sender.clone());
|
receiver.respond_to_pings(sender.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let (app_tx, app_rx) = mpsc::channel(self.config.policy.receiver_queue_capacity);
|
let receiver_queue_capacity = self.config.policy.receiver_queue_capacity.max(1);
|
||||||
let (pipe_req_tx, pipe_req_rx) =
|
let (app_tx, app_rx) = mpsc::channel(receiver_queue_capacity);
|
||||||
mpsc::channel(self.config.policy.receiver_queue_capacity);
|
let (pipe_req_tx, pipe_req_rx) = mpsc::channel(receiver_queue_capacity);
|
||||||
|
|
||||||
let dispatcher = Arc::new(PipeDispatcher {
|
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()),
|
pending_pipes: tokio::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
policy: Arc::new(self.config.policy),
|
policy: Arc::new(self.config.policy),
|
||||||
type_map,
|
type_map,
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,9 @@ pub use mtp_codec::registry::Registry;
|
||||||
|
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
pub use config::{
|
pub use config::{
|
||||||
AuthenticationPolicy, CompleteRegister, FindRegisteredClient, GetExistingClient,
|
AuthenticationAttempt, AuthenticationAttemptLimiter, AuthenticationContext,
|
||||||
GuestIdGenerator,
|
AuthenticationLimitError, AuthenticationPolicy, CompleteRegister, FindRegisteredClient,
|
||||||
|
GetExistingClient, GuestIdGenerator, InMemoryAuthenticationAttemptLimiter,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
pub use error::AuthState;
|
pub use error::AuthState;
|
||||||
|
|
|
||||||
185
host/src/pipe.rs
185
host/src/pipe.rs
|
|
@ -3,6 +3,7 @@ use mtp_common::{CommunicationError, PipeError};
|
||||||
use mtp_transport::{PipeReader, PipeWriter, Policy, TransportEvent};
|
use mtp_transport::{PipeReader, PipeWriter, Policy, TransportEvent};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::Mutex as StdMutex;
|
||||||
use tokio::sync::{Mutex, mpsc};
|
use tokio::sync::{Mutex, mpsc};
|
||||||
|
|
||||||
/// The sender operations needed by the transport-independent pipe protocol.
|
/// 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) pipe_id: u32,
|
||||||
pub(crate) description: String,
|
pub(crate) description: String,
|
||||||
pub(crate) sender: S,
|
pub(crate) sender: S,
|
||||||
pub(crate) response_rx: tokio::sync::oneshot::Receiver<Result<bool, PipeError>>,
|
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 {
|
pub fn pipe_id(&self) -> u32 {
|
||||||
self.pipe_id
|
self.pipe_id
|
||||||
}
|
}
|
||||||
|
|
@ -109,21 +116,42 @@ impl<S: PipeSender> PipeHandle<S> {
|
||||||
&self.description
|
&self.description
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn wait(self) -> Result<Option<PipeWriter<S::Writer>>, PipeError> {
|
pub async fn wait(mut self) -> Result<Option<PipeWriter<S::Writer>>, PipeError> {
|
||||||
match self.response_rx.await {
|
let response =
|
||||||
Ok(Ok(true)) => self
|
tokio::time::timeout(self.dispatcher.policy.read_timeout, &mut self.response_rx).await;
|
||||||
|
match response {
|
||||||
|
Ok(Ok(Ok(true))) => self
|
||||||
.sender
|
.sender
|
||||||
.open_pipe_stream(self.pipe_id, &self.description)
|
.open_pipe_stream(self.pipe_id, &self.description)
|
||||||
.await
|
.await
|
||||||
.map(Some)
|
.map(Some)
|
||||||
.map_err(PipeError::from),
|
.map_err(PipeError::from),
|
||||||
Ok(Ok(false)) => Ok(None),
|
Ok(Ok(Ok(false))) => Ok(None),
|
||||||
Ok(Err(error)) => Err(error),
|
Ok(Ok(Err(error))) => {
|
||||||
Err(_) => Err(PipeError::StreamClosed),
|
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 struct PipeRequest<S, P> {
|
||||||
pub(crate) pipe_id: u32,
|
pub(crate) pipe_id: u32,
|
||||||
pub(crate) description: String,
|
pub(crate) description: String,
|
||||||
|
|
@ -203,13 +231,132 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct PipeDispatcher<P> {
|
pub(crate) struct PipeDispatcher<P> {
|
||||||
pub(crate) pending_creations:
|
pub(crate) pending_creations: StdMutex<HashMap<u32, PendingCreation>>,
|
||||||
Mutex<HashMap<u32, tokio::sync::oneshot::Sender<Result<bool, PipeError>>>>,
|
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) pending_pipes: Mutex<HashMap<u32, tokio::sync::oneshot::Sender<PipeReader<P>>>>,
|
||||||
pub(crate) policy: Arc<Policy>,
|
pub(crate) policy: Arc<Policy>,
|
||||||
pub(crate) type_map: TypeMap,
|
pub(crate) 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>(
|
pub(crate) async fn run_dispatcher<S, R, P>(
|
||||||
receiver: R,
|
receiver: R,
|
||||||
sender: S,
|
sender: S,
|
||||||
|
|
@ -256,10 +403,17 @@ pub(crate) async fn run_dispatcher<S, R, P>(
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let mut pending = dispatcher.pending_creations.lock().await;
|
let pending = dispatcher
|
||||||
if let Some(reply) = pending.remove(&pipe_id) {
|
.pending_creations
|
||||||
let _ =
|
.lock()
|
||||||
reply.send(Ok(message.get_bool(DataType::Accepted).unwrap_or(false)));
|
.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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -297,9 +451,12 @@ pub(crate) async fn run_dispatcher<S, R, P>(
|
||||||
let _ = pipe_req_tx.send(request).await;
|
let _ = pipe_req_tx.send(request).await;
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
fail_pending_creations(&dispatcher, &error);
|
||||||
|
fail_pending_pipes(&dispatcher).await;
|
||||||
if app_tx.send(Err(error)).await.is_err() {
|
if app_tx.send(Err(error)).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,11 @@ impl H3TransportConnection {
|
||||||
pub(crate) fn remote_addr(&self) -> std::net::SocketAddr {
|
pub(crate) fn remote_addr(&self) -> std::net::SocketAddr {
|
||||||
self.quinn.remote_address()
|
self.quinn.remote_address()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
pub(crate) fn connection_id(&self) -> u64 {
|
||||||
|
self.quinn.stable_id() as u64
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
|
|
@ -283,6 +288,8 @@ async fn accept_web_connection_inner(
|
||||||
let max_message_size = policy.max_message_size;
|
let max_message_size = policy.max_message_size;
|
||||||
let transport = H3TransportConnection::new(session, quinn);
|
let transport = H3TransportConnection::new(session, quinn);
|
||||||
let remote_addr = transport.remote_addr();
|
let remote_addr = transport.remote_addr();
|
||||||
|
#[cfg(feature = "crypto")]
|
||||||
|
let connection_id = transport.connection_id();
|
||||||
let policy = Arc::new(policy);
|
let policy = Arc::new(policy);
|
||||||
let sender = WebMtpSender::new(transport.clone(), policy.clone());
|
let sender = WebMtpSender::new(transport.clone(), policy.clone());
|
||||||
let receiver = WebMtpReceiver::new(transport, 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);
|
let engine = mtp_host::HandshakeEngine::new(Registry::builtin(), host_config);
|
||||||
#[cfg(feature = "crypto")]
|
#[cfg(feature = "crypto")]
|
||||||
let result = engine
|
let result = engine
|
||||||
.accept_until(
|
.accept_until_with_context(
|
||||||
&sender,
|
&sender,
|
||||||
&receiver,
|
&receiver,
|
||||||
deadline.expect("crypto WebTransport handshakes have a deadline"),
|
deadline.expect("crypto WebTransport handshakes have a deadline"),
|
||||||
|
mtp_host::AuthenticationContext {
|
||||||
|
peer_network_identity: Some(remote_addr.to_string()),
|
||||||
|
connection_id,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
#[cfg(not(feature = "crypto"))]
|
#[cfg(not(feature = "crypto"))]
|
||||||
|
|
|
||||||
|
|
@ -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 .",
|
"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:e2e": "tsc && node test/e2ee.mjs",
|
||||||
"test:secrets": "tsc && node --test --test-isolation=none test/encrypted-secret.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:types": "tsc -p tsconfig.type-tests.json --noEmit",
|
||||||
"test:vite": "tsc && node test/vite-type-map.mjs",
|
"test:vite": "tsc && node test/vite-type-map.mjs",
|
||||||
"test:boundary": "node --test test/package-boundary.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": {
|
"devDependencies": {
|
||||||
"@types/node": "^26.0.1",
|
"@types/node": "^26.0.1",
|
||||||
|
|
|
||||||
2635
src/sdk/client.ts
Normal file
2635
src/sdk/client.ts
Normal file
File diff suppressed because it is too large
Load diff
959
src/sdk/codec.ts
Normal file
959
src/sdk/codec.ts
Normal 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
26
src/sdk/credentials.ts
Normal 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);
|
||||||
|
}
|
||||||
3150
src/sdk/index.ts
3150
src/sdk/index.ts
File diff suppressed because it is too large
Load diff
33
src/sdk/passphrase-worker.ts
Normal file
33
src/sdk/passphrase-worker.ts
Normal 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
258
src/sdk/protection.ts
Normal 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
204
src/sdk/relay.ts
Normal 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 };
|
||||||
|
|
@ -92,8 +92,14 @@ export function signatureVerificationPolicyValue(
|
||||||
return bindings.mtp_protection_signature_suite_ed25519();
|
return bindings.mtp_protection_signature_suite_ed25519();
|
||||||
case "dual":
|
case "dual":
|
||||||
return bindings.mtp_protection_signature_suite_dual();
|
return bindings.mtp_protection_signature_suite_dual();
|
||||||
case "any-supported":
|
case "any-supported": {
|
||||||
return 0;
|
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
27
src/sdk/timeout.ts
Normal 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
27
src/sdk/wasm-init.ts
Normal 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
20
test/wasm-init.mjs
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
use crate::ConnectionHandle;
|
use crate::ConnectionHandle;
|
||||||
|
use crate::framing::RetryClassifier;
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
use crate::pipe::PipeReader;
|
use crate::pipe::PipeReader;
|
||||||
use mtp_codec::{CommunicationValue, DecodeLimits, TypeMap};
|
use mtp_codec::{CommunicationValue, DecodeError, DecodeLimits, EncodeLimits, TypeMap};
|
||||||
use mtp_common::CommunicationError;
|
use mtp_common::CommunicationError;
|
||||||
|
use std::ops::Deref;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use tokio::sync::{Mutex, Notify, RwLock, Semaphore, mpsc};
|
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 tracing::{debug, info, instrument, trace, warn};
|
||||||
use wtransport::Connection;
|
use wtransport::Connection;
|
||||||
|
|
||||||
|
|
@ -19,6 +21,63 @@ pub enum TransportEvent<R = wtransport::RecvStream> {
|
||||||
|
|
||||||
const APPLICATION_CLOSE_REASON: &str = "mtp-close";
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum SendMode {
|
pub enum SendMode {
|
||||||
PersistentStream,
|
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 {
|
enum ReceivedFrame {
|
||||||
Message(CommunicationValue),
|
Message(CommunicationValue),
|
||||||
ClosedByPeer,
|
ClosedByPeer,
|
||||||
Idle,
|
Idle,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum SenderState {
|
||||||
|
Open,
|
||||||
|
Closing,
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Sender {
|
pub struct Sender {
|
||||||
send_guard: Arc<Mutex<()>>,
|
send_guard: Arc<Mutex<()>>,
|
||||||
stream_guard: Arc<Mutex<Option<wtransport::SendStream>>>,
|
stream_guard: Arc<Mutex<Option<wtransport::SendStream>>>,
|
||||||
|
state: Arc<Mutex<SenderState>>,
|
||||||
handle: Arc<ConnectionHandle>,
|
handle: Arc<ConnectionHandle>,
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
policy: Arc<Policy>,
|
policy: Arc<RuntimePolicy>,
|
||||||
type_map: Arc<RwLock<TypeMap>>,
|
type_map: Arc<RwLock<TypeMap>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Sender {
|
impl Sender {
|
||||||
pub fn new(connection: Connection, handle: Arc<ConnectionHandle>, policy: Arc<Policy>) -> Self {
|
pub fn new(connection: Connection, handle: Arc<ConnectionHandle>, policy: Arc<Policy>) -> Self {
|
||||||
|
let policy = Arc::new(RuntimePolicy::from_public(&policy));
|
||||||
Self {
|
Self {
|
||||||
send_guard: Arc::new(Mutex::new(())),
|
send_guard: Arc::new(Mutex::new(())),
|
||||||
stream_guard: Arc::new(Mutex::new(None)),
|
stream_guard: Arc::new(Mutex::new(None)),
|
||||||
|
state: Arc::new(Mutex::new(SenderState::Open)),
|
||||||
handle,
|
handle,
|
||||||
connection,
|
connection,
|
||||||
policy,
|
policy,
|
||||||
|
|
@ -174,7 +268,11 @@ impl Sender {
|
||||||
data: &CommunicationValue,
|
data: &CommunicationValue,
|
||||||
policy: &Policy,
|
policy: &Policy,
|
||||||
) -> Result<(), CommunicationError> {
|
) -> 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
|
if bytes.len() as u64 > policy.max_message_size
|
||||||
|| bytes.len() as u64 >= policy.close_frame_len as u64
|
|| bytes.len() as u64 >= policy.close_frame_len as u64
|
||||||
{
|
{
|
||||||
|
|
@ -269,11 +367,11 @@ impl Sender {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let err = res.err().unwrap_or(CommunicationError::StreamError);
|
let err = match res {
|
||||||
if !matches!(
|
Ok(()) => return Ok(()),
|
||||||
err,
|
Err(error) => error,
|
||||||
CommunicationError::StreamError | CommunicationError::StreamClosed
|
};
|
||||||
) {
|
if !RetryClassifier::retry_persistent_stream(&err) {
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
*stream_opt = None;
|
*stream_opt = None;
|
||||||
|
|
@ -356,20 +454,24 @@ impl Sender {
|
||||||
|
|
||||||
#[instrument(skip(self, data), level = "trace")]
|
#[instrument(skip(self, data), level = "trace")]
|
||||||
pub async fn send(&self, data: &CommunicationValue) -> Result<(), CommunicationError> {
|
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 _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() {
|
if self.connection.quic_connection().close_reason().is_some() {
|
||||||
let reason = self
|
let reason = self
|
||||||
.handle
|
.handle
|
||||||
.close_reason()
|
.close_reason()
|
||||||
.unwrap_or(CommunicationError::StreamClosed);
|
.unwrap_or(CommunicationError::StreamClosed);
|
||||||
|
*self.state.lock().await = SenderState::Closed;
|
||||||
self.handle.close(Some(reason.clone()));
|
self.handle.close(Some(reason.clone()));
|
||||||
return Err(reason);
|
return Err(reason);
|
||||||
}
|
}
|
||||||
|
|
@ -402,6 +504,7 @@ impl Sender {
|
||||||
if self.connection.quic_connection().close_reason().is_some()
|
if self.connection.quic_connection().close_reason().is_some()
|
||||||
|| matches!(normalized, CommunicationError::StreamClosed)
|
|| matches!(normalized, CommunicationError::StreamClosed)
|
||||||
{
|
{
|
||||||
|
*self.state.lock().await = SenderState::Closed;
|
||||||
self.handle.close(Some(normalized.clone()));
|
self.handle.close(Some(normalized.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -413,6 +516,12 @@ impl Sender {
|
||||||
#[instrument(skip(self), level = "trace")]
|
#[instrument(skip(self), level = "trace")]
|
||||||
pub async fn finish_stream(&self) -> Result<(), CommunicationError> {
|
pub async fn finish_stream(&self) -> Result<(), CommunicationError> {
|
||||||
let _send_lock = self.send_guard.lock().await;
|
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;
|
let mut stream_opt = self.stream_guard.lock().await;
|
||||||
if let Some(mut stream) = stream_opt.take() {
|
if let Some(mut stream) = stream_opt.take() {
|
||||||
match timeout(self.policy.write_timeout, stream.finish()).await {
|
match timeout(self.policy.write_timeout, stream.finish()).await {
|
||||||
|
|
@ -445,11 +554,12 @@ impl Sender {
|
||||||
pipe_id: u32,
|
pipe_id: u32,
|
||||||
description: &str,
|
description: &str,
|
||||||
) -> Result<crate::pipe::PipeWriter, CommunicationError> {
|
) -> 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
|
return Err(self
|
||||||
.handle
|
.handle
|
||||||
.close_reason()
|
.close_reason()
|
||||||
.unwrap_or(CommunicationError::UseAfterClosed));
|
.unwrap_or(CommunicationError::StreamClosed));
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.connection.quic_connection().close_reason().is_some() {
|
if self.connection.quic_connection().close_reason().is_some() {
|
||||||
|
|
@ -486,31 +596,47 @@ impl Sender {
|
||||||
let handle = self.handle.clone();
|
let handle = self.handle.clone();
|
||||||
let policy = self.policy.clone();
|
let policy = self.policy.clone();
|
||||||
let stream_guard = self.stream_guard.clone();
|
let stream_guard = self.stream_guard.clone();
|
||||||
|
let send_guard = self.send_guard.clone();
|
||||||
|
let state = self.state.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
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() {
|
if connection.quic_connection().close_reason().is_some() || handle.is_closed() {
|
||||||
|
*state.lock().await = SenderState::Closed;
|
||||||
handle.close(Some(CommunicationError::StreamClosed));
|
handle.close(Some(CommunicationError::StreamClosed));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(mut stream) = stream_guard.lock().await.take() {
|
{
|
||||||
match timeout(policy.write_timeout, stream.finish()).await {
|
if let Some(mut stream) = stream_guard.lock().await.take() {
|
||||||
Ok(Ok(())) => {}
|
match timeout(policy.write_timeout, stream.finish()).await {
|
||||||
Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => warn!(
|
Ok(Ok(())) => {}
|
||||||
"[Sender] persistent stream finish failed: peer sent STOP_SENDING (error code {code})"
|
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}")
|
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;
|
let _ = Self::send_close_frame(&connection, &policy).await;
|
||||||
|
|
||||||
|
*state.lock().await = SenderState::Closed;
|
||||||
handle.close(Some(CommunicationError::StreamClosed));
|
handle.close(Some(CommunicationError::StreamClosed));
|
||||||
info!(target = "mtp.transport", "connection closed");
|
info!(target = "mtp.transport", "connection closed");
|
||||||
|
|
||||||
|
drop(_send_lock);
|
||||||
sleep(policy.force_close_delay).await;
|
sleep(policy.force_close_delay).await;
|
||||||
if connection.quic_connection().close_reason().is_none() {
|
if connection.quic_connection().close_reason().is_none() {
|
||||||
connection.quic_connection().close(
|
connection.quic_connection().close(
|
||||||
|
|
@ -528,35 +654,48 @@ impl Sender {
|
||||||
let connection = self.connection.clone();
|
let connection = self.connection.clone();
|
||||||
let handle = self.handle.clone();
|
let handle = self.handle.clone();
|
||||||
let policy = self.policy.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() {
|
if connection.quic_connection().close_reason().is_some() || handle.is_closed() {
|
||||||
|
*self.state.lock().await = SenderState::Closed;
|
||||||
handle.close(Some(CommunicationError::StreamClosed));
|
handle.close(Some(CommunicationError::StreamClosed));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(mut stream) = stream_opt.take() {
|
{
|
||||||
let close_bytes = policy.close_frame_len.to_be_bytes();
|
let mut stream_opt = self.stream_guard.lock().await;
|
||||||
let close_write = async {
|
if let Some(mut stream) = stream_opt.take() {
|
||||||
stream.write_all(&close_bytes).await?;
|
let close_bytes = policy.close_frame_len.to_be_bytes();
|
||||||
stream.finish().await
|
let close_write = async {
|
||||||
};
|
stream.write_all(&close_bytes).await?;
|
||||||
|
stream.finish().await
|
||||||
|
};
|
||||||
|
|
||||||
match timeout(policy.write_timeout, close_write).await {
|
match timeout(policy.write_timeout, close_write).await {
|
||||||
Ok(Ok(())) => {}
|
Ok(Ok(())) => {}
|
||||||
Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => {
|
Ok(Err(wtransport::error::StreamWriteError::Stopped(code))) => {
|
||||||
warn!("[Sender] close failed: peer sent STOP_SENDING (error code {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}"),
|
} else {
|
||||||
Err(_) => warn!("[Sender] close timed out"),
|
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));
|
handle.close(Some(CommunicationError::StreamClosed));
|
||||||
info!(target = "mtp.transport", "connection closed");
|
info!(target = "mtp.transport", "connection closed");
|
||||||
|
|
||||||
|
drop(_send_lock);
|
||||||
sleep(policy.force_close_delay).await;
|
sleep(policy.force_close_delay).await;
|
||||||
if connection.quic_connection().close_reason().is_none() {
|
if connection.quic_connection().close_reason().is_none() {
|
||||||
connection.quic_connection().close(
|
connection.quic_connection().close(
|
||||||
|
|
@ -605,6 +744,7 @@ struct ReceiverInner {
|
||||||
queue_notify: Arc<Notify>,
|
queue_notify: Arc<Notify>,
|
||||||
max_message_size: Arc<AtomicU64>,
|
max_message_size: Arc<AtomicU64>,
|
||||||
type_map: Arc<RwLock<TypeMap>>,
|
type_map: Arc<RwLock<TypeMap>>,
|
||||||
|
decode_rejections: Arc<DecodeRejectionCounters>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Clone for Receiver {
|
impl Clone for Receiver {
|
||||||
|
|
@ -626,12 +766,26 @@ impl Drop for Receiver {
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
struct PingControl {
|
struct PingControl {
|
||||||
pong_sender: Option<Sender>,
|
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 {
|
impl Receiver {
|
||||||
pub fn new(connection: Connection, handle: Arc<ConnectionHandle>, policy: Arc<Policy>) -> Self {
|
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")]
|
#[cfg(feature = "host")]
|
||||||
|
|
@ -640,6 +794,7 @@ impl Receiver {
|
||||||
handle: Arc<ConnectionHandle>,
|
handle: Arc<ConnectionHandle>,
|
||||||
policy: Arc<Policy>,
|
policy: Arc<Policy>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let policy = Arc::new(RuntimePolicy::from_public(&policy));
|
||||||
let initial_max = policy
|
let initial_max = policy
|
||||||
.handshake_max_message_size
|
.handshake_max_message_size
|
||||||
.min(policy.max_message_size);
|
.min(policy.max_message_size);
|
||||||
|
|
@ -649,7 +804,7 @@ impl Receiver {
|
||||||
fn new_with_max_message_size(
|
fn new_with_max_message_size(
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
handle: Arc<ConnectionHandle>,
|
handle: Arc<ConnectionHandle>,
|
||||||
policy: Arc<Policy>,
|
policy: Arc<RuntimePolicy>,
|
||||||
initial_max_message_size: u64,
|
initial_max_message_size: u64,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
|
|
@ -674,6 +829,8 @@ impl Receiver {
|
||||||
let accept_max_message_size = max_message_size.clone();
|
let accept_max_message_size = max_message_size.clone();
|
||||||
let type_map = Arc::new(RwLock::new(TypeMap::latest()));
|
let type_map = Arc::new(RwLock::new(TypeMap::latest()));
|
||||||
let accept_type_map = type_map.clone();
|
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 stream_limit = Arc::new(Semaphore::new(policy.max_concurrent_stream_tasks.max(1)));
|
||||||
let accept_stream_limit = stream_limit.clone();
|
let accept_stream_limit = stream_limit.clone();
|
||||||
debug!(
|
debug!(
|
||||||
|
|
@ -742,6 +899,7 @@ impl Receiver {
|
||||||
let stream_ping_control = accept_ping_control.clone();
|
let stream_ping_control = accept_ping_control.clone();
|
||||||
let stream_max_message_size = accept_max_message_size.clone();
|
let stream_max_message_size = accept_max_message_size.clone();
|
||||||
let stream_type_map = accept_type_map.clone();
|
let stream_type_map = accept_type_map.clone();
|
||||||
|
let stream_decode_rejections = accept_decode_rejections.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
|
|
@ -761,7 +919,14 @@ impl Receiver {
|
||||||
}
|
}
|
||||||
|
|
||||||
let frame_limit = stream_max_message_size.load(Ordering::Relaxed);
|
let frame_limit = stream_max_message_size.load(Ordering::Relaxed);
|
||||||
match Self::read_one_frame(&mut s, &stream_policy, frame_limit).await {
|
match Self::read_one_frame(
|
||||||
|
&mut s,
|
||||||
|
&stream_policy,
|
||||||
|
frame_limit,
|
||||||
|
&stream_decode_rejections,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(ReceivedFrame::Message(mut msg)) => {
|
Ok(ReceivedFrame::Message(mut msg)) => {
|
||||||
let negotiated_type_map =
|
let negotiated_type_map =
|
||||||
stream_type_map.read().await.clone();
|
stream_type_map.read().await.clone();
|
||||||
|
|
@ -805,24 +970,17 @@ impl Receiver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let control = {
|
let pong_sender = if msg.is_type(mtp_codec::CommunicationType::Ping) {
|
||||||
let control = stream_ping_control.read().await;
|
stream_ping_control
|
||||||
if msg.is_type(mtp_codec::CommunicationType::Ping) {
|
.read()
|
||||||
control
|
.await
|
||||||
.pong_sender
|
.pong_sender
|
||||||
.clone()
|
.clone()
|
||||||
.map(|sender| (Some(sender), None))
|
} else {
|
||||||
} else if msg.is_type(mtp_codec::CommunicationType::Pong) {
|
None
|
||||||
control
|
|
||||||
.pong_observer
|
|
||||||
.clone()
|
|
||||||
.map(|observer| (None, Some(observer)))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some((Some(sender), _)) = control {
|
if let Some(sender) = pong_sender {
|
||||||
let mut pong = CommunicationValue::new_with_type_map(
|
let mut pong = CommunicationValue::new_with_type_map(
|
||||||
mtp_codec::CommunicationType::Pong,
|
mtp_codec::CommunicationType::Pong,
|
||||||
&negotiated_type_map,
|
&negotiated_type_map,
|
||||||
|
|
@ -844,8 +1002,18 @@ impl Receiver {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((_, Some(observer))) = control {
|
if msg.is_type(mtp_codec::CommunicationType::Pong) {
|
||||||
let _ = observer.send(msg);
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -942,6 +1110,7 @@ impl Receiver {
|
||||||
queue_notify,
|
queue_notify,
|
||||||
max_message_size,
|
max_message_size,
|
||||||
type_map,
|
type_map,
|
||||||
|
decode_rejections,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -958,6 +1127,14 @@ impl Receiver {
|
||||||
*self.inner.type_map.write().await = type_map.clone();
|
*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. */
|
/* Respond to reserved Ping frames without exposing them to application I/O. */
|
||||||
pub fn respond_to_pings(&self, sender: Sender) {
|
pub fn respond_to_pings(&self, sender: Sender) {
|
||||||
if let Ok(mut control) = self.inner.ping_control.try_write() {
|
if let Ok(mut control) = self.inner.ping_control.try_write() {
|
||||||
|
|
@ -967,16 +1144,21 @@ impl Receiver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Route reserved Pong frames to a connection-level observer. */
|
/* Route only the currently expected reserved Pong through a bounded observer. */
|
||||||
pub async fn observe_pongs(&self, observer: mpsc::UnboundedSender<CommunicationValue>) {
|
pub async fn observe_pongs_bounded(&self, observer: mpsc::Sender<CommunicationValue>) {
|
||||||
self.inner.ping_control.write().await.pong_observer = Some(observer);
|
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(
|
async fn read_one_frame(
|
||||||
stream: &mut wtransport::RecvStream,
|
stream: &mut wtransport::RecvStream,
|
||||||
policy: &Policy,
|
policy: &RuntimePolicy,
|
||||||
max_message_size: u64,
|
max_message_size: u64,
|
||||||
|
decode_rejections: &DecodeRejectionCounters,
|
||||||
) -> Result<ReceivedFrame, CommunicationError> {
|
) -> Result<ReceivedFrame, CommunicationError> {
|
||||||
use wtransport::error::{StreamReadError, StreamReadExactError};
|
use wtransport::error::{StreamReadError, StreamReadExactError};
|
||||||
|
|
||||||
|
|
@ -1011,6 +1193,7 @@ impl Receiver {
|
||||||
if len == policy.close_frame_len {
|
if len == policy.close_frame_len {
|
||||||
return Ok(ReceivedFrame::ClosedByPeer);
|
return Ok(ReceivedFrame::ClosedByPeer);
|
||||||
}
|
}
|
||||||
|
let deadline = Instant::now() + policy.read_timeout;
|
||||||
|
|
||||||
let body_len = len as usize;
|
let body_len = len as usize;
|
||||||
let frame_len = body_len
|
let frame_len = body_len
|
||||||
|
|
@ -1020,29 +1203,29 @@ impl Receiver {
|
||||||
return Err(CommunicationError::MessageTooLarge);
|
return Err(CommunicationError::MessageTooLarge);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grow in bounded chunks instead of trusting the peer's length prefix
|
// The length has already been checked against the admitted frame
|
||||||
// enough to allocate the complete frame up front.
|
// limit, so reserve one bounded framing buffer and decode it without a
|
||||||
let mut buf = Vec::new();
|
// second prefix-plus-body allocation/copy.
|
||||||
buf.try_reserve(body_len.min(16 * 1024))
|
let mut frame = Vec::new();
|
||||||
|
frame
|
||||||
|
.try_reserve_exact(frame_len)
|
||||||
.map_err(|_| CommunicationError::MessageTooLarge)?;
|
.map_err(|_| CommunicationError::MessageTooLarge)?;
|
||||||
while buf.len() < body_len {
|
frame.extend_from_slice(&len_buf);
|
||||||
let chunk_len = (body_len - buf.len()).min(16 * 1024);
|
frame.resize(frame_len, 0);
|
||||||
let mut chunk = [0u8; 16 * 1024];
|
let mut body_offset = 4usize;
|
||||||
match timeout(
|
while body_offset < frame_len {
|
||||||
policy.read_timeout,
|
let chunk_len = (frame_len - body_offset).min(16 * 1024);
|
||||||
stream.read_exact(&mut chunk[..chunk_len]),
|
match timeout_at(
|
||||||
|
deadline,
|
||||||
|
stream.read_exact(&mut frame[body_offset..body_offset + chunk_len]),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Ok(())) => {
|
Ok(Ok(())) => body_offset += chunk_len,
|
||||||
buf.try_reserve(chunk_len)
|
|
||||||
.map_err(|_| CommunicationError::MessageTooLarge)?;
|
|
||||||
buf.extend_from_slice(&chunk[..chunk_len]);
|
|
||||||
}
|
|
||||||
Ok(Err(StreamReadExactError::FinishedEarly(n))) => {
|
Ok(Err(StreamReadExactError::FinishedEarly(n))) => {
|
||||||
warn!(
|
warn!(
|
||||||
"[Receiver] body read ended early ({}/{body_len} bytes): stream closed by peer",
|
"[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);
|
return Err(CommunicationError::StreamError);
|
||||||
}
|
}
|
||||||
|
|
@ -1063,14 +1246,21 @@ impl Receiver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut frame = Vec::with_capacity(frame_len);
|
let message = match CommunicationValue::try_from_bytes_with_limits(
|
||||||
frame.extend_from_slice(&len_buf);
|
|
||||||
frame.extend_from_slice(&buf);
|
|
||||||
let message = CommunicationValue::from_bytes_with_limits(
|
|
||||||
&frame,
|
&frame,
|
||||||
DecodeLimits::for_transport_message_size(max_message_size),
|
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))
|
Ok(ReceivedFrame::Message(message))
|
||||||
}
|
}
|
||||||
|
|
@ -1267,4 +1457,61 @@ mod tests {
|
||||||
let debug_str = format!("{:?}", p);
|
let debug_str = format!("{:?}", p);
|
||||||
assert!(debug_str.contains("Policy"));
|
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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,26 @@ use mtp_common::CommunicationError;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
Arc,
|
Arc,
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||||
};
|
};
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ConnectionHandle {
|
pub struct ConnectionHandle {
|
||||||
|
connection_id: u64,
|
||||||
closed: AtomicBool,
|
closed: AtomicBool,
|
||||||
close_tx: watch::Sender<Option<CommunicationError>>,
|
close_tx: watch::Sender<Option<CommunicationError>>,
|
||||||
close_rx: watch::Receiver<Option<CommunicationError>>,
|
close_rx: watch::Receiver<Option<CommunicationError>>,
|
||||||
remote_addr: Option<SocketAddr>,
|
remote_addr: Option<SocketAddr>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static NEXT_CONNECTION_ID: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
impl ConnectionHandle {
|
impl ConnectionHandle {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let (close_tx, close_rx) = watch::channel(None);
|
let (close_tx, close_rx) = watch::channel(None);
|
||||||
Self {
|
Self {
|
||||||
|
connection_id: NEXT_CONNECTION_ID.fetch_add(1, Ordering::Relaxed).max(1),
|
||||||
closed: AtomicBool::new(false),
|
closed: AtomicBool::new(false),
|
||||||
close_tx,
|
close_tx,
|
||||||
close_rx,
|
close_rx,
|
||||||
|
|
@ -35,6 +39,11 @@ impl ConnectionHandle {
|
||||||
self.remote_addr
|
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 {
|
pub fn is_open(&self) -> bool {
|
||||||
!self.closed.load(Ordering::SeqCst)
|
!self.closed.load(Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,21 @@
|
||||||
use crate::{Policy, TransportSendStream};
|
use crate::{Policy, TransportSendStream};
|
||||||
use mtp_codec::CommunicationValue;
|
use mtp_codec::{CommunicationValue, EncodeLimits};
|
||||||
use mtp_common::CommunicationError;
|
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.
|
/// Writes the canonical self-framed MTP value used by every transport.
|
||||||
///
|
///
|
||||||
/// `CommunicationValue` already begins with the four-byte body length. The
|
/// `CommunicationValue` already begins with the four-byte body length. The
|
||||||
|
|
@ -12,7 +26,11 @@ pub(crate) async fn write_frame<S: TransportSendStream>(
|
||||||
value: &CommunicationValue,
|
value: &CommunicationValue,
|
||||||
policy: &Policy,
|
policy: &Policy,
|
||||||
) -> Result<(), CommunicationError> {
|
) -> 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
|
if bytes.len() as u64 > policy.max_message_size
|
||||||
|| bytes.len() as u64 >= policy.close_frame_len as u64
|
|| bytes.len() as u64 >= policy.close_frame_len as u64
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,21 +5,23 @@
|
||||||
//! wrappers while the framing implementation below is shared by adapters.
|
//! wrappers while the framing implementation below is shared by adapters.
|
||||||
|
|
||||||
use crate::{
|
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_codec::{CommunicationValue, DecodeLimits, TypeMap};
|
||||||
use mtp_common::CommunicationError;
|
use mtp_common::CommunicationError;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use tokio::sync::{Mutex, RwLock, Semaphore, mpsc};
|
use tokio::sync::{Mutex, Notify, RwLock, Semaphore, mpsc};
|
||||||
use tokio::time::timeout;
|
use tokio::time::{Instant, timeout, timeout_at};
|
||||||
|
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
use crate::pipe::{PipeReader, PipeWriter};
|
use crate::pipe::{PipeReader, PipeWriter};
|
||||||
|
|
||||||
pub struct GenericSender<C: TransportConnection> {
|
pub struct GenericSender<C: TransportConnection> {
|
||||||
connection: C,
|
connection: C,
|
||||||
policy: Arc<Policy>,
|
policy: Arc<RuntimePolicy>,
|
||||||
persistent: Arc<Mutex<Option<C::SendStream>>>,
|
persistent: Arc<Mutex<Option<C::SendStream>>>,
|
||||||
send_lock: Arc<Mutex<()>>,
|
send_lock: Arc<Mutex<()>>,
|
||||||
type_map: Arc<RwLock<TypeMap>>,
|
type_map: Arc<RwLock<TypeMap>>,
|
||||||
|
|
@ -39,6 +41,7 @@ impl<C: TransportConnection> Clone for GenericSender<C> {
|
||||||
|
|
||||||
impl<C: TransportConnection> GenericSender<C> {
|
impl<C: TransportConnection> GenericSender<C> {
|
||||||
pub fn new(connection: C, policy: Arc<Policy>) -> Self {
|
pub fn new(connection: C, policy: Arc<Policy>) -> Self {
|
||||||
|
let policy = Arc::new(RuntimePolicy::from_public(&policy));
|
||||||
Self {
|
Self {
|
||||||
connection,
|
connection,
|
||||||
policy,
|
policy,
|
||||||
|
|
@ -84,20 +87,30 @@ impl<C: TransportConnection> GenericSender<C> {
|
||||||
if stream.is_none() {
|
if stream.is_none() {
|
||||||
*stream = Some(self.open().await?);
|
*stream = Some(self.open().await?);
|
||||||
}
|
}
|
||||||
let result = timeout(
|
let result = match stream.as_mut() {
|
||||||
self.policy.write_timeout,
|
Some(stream) => timeout(
|
||||||
write_frame(stream.as_mut().unwrap(), value, &self.policy),
|
self.policy.write_timeout,
|
||||||
)
|
write_frame(stream, value, &self.policy),
|
||||||
.await
|
)
|
||||||
.map_err(|_| CommunicationError::StreamError)
|
.await
|
||||||
.and_then(|r| r);
|
.map_err(|_| CommunicationError::StreamError)
|
||||||
|
.and_then(|result| result),
|
||||||
|
None => Err(CommunicationError::StreamError),
|
||||||
|
};
|
||||||
if result.is_ok() {
|
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;
|
*stream = None;
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
if attempts > self.policy.persistent_stream_max_retries {
|
if attempts > self.policy.persistent_stream_max_retries {
|
||||||
return result;
|
return Err(error);
|
||||||
}
|
}
|
||||||
tokio::time::sleep(
|
tokio::time::sleep(
|
||||||
self.policy.persistent_stream_retry_backoff * attempts as u32,
|
self.policy.persistent_stream_retry_backoff * attempts as u32,
|
||||||
|
|
@ -114,6 +127,7 @@ impl<C: TransportConnection> GenericSender<C> {
|
||||||
pipe_id: u32,
|
pipe_id: u32,
|
||||||
description: &str,
|
description: &str,
|
||||||
) -> Result<PipeWriter<C::SendStream>, CommunicationError> {
|
) -> Result<PipeWriter<C::SendStream>, CommunicationError> {
|
||||||
|
let _send_lock = self.send_lock.lock().await;
|
||||||
if self.connection.close_reason().is_some() {
|
if self.connection.close_reason().is_some() {
|
||||||
return Err(CommunicationError::StreamClosed);
|
return Err(CommunicationError::StreamClosed);
|
||||||
}
|
}
|
||||||
|
|
@ -179,6 +193,8 @@ pub struct GenericReceiver<C: TransportConnection> {
|
||||||
ping_sender: Arc<RwLock<Option<GenericSender<C>>>>,
|
ping_sender: Arc<RwLock<Option<GenericSender<C>>>>,
|
||||||
max_message_size: Arc<AtomicU64>,
|
max_message_size: Arc<AtomicU64>,
|
||||||
type_map: Arc<RwLock<TypeMap>>,
|
type_map: Arc<RwLock<TypeMap>>,
|
||||||
|
queue_notify: Arc<Notify>,
|
||||||
|
decode_rejections: Arc<DecodeRejectionCounters>,
|
||||||
_accept_task: Arc<tokio::task::JoinHandle<()>>,
|
_accept_task: Arc<tokio::task::JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,6 +208,8 @@ impl<C: TransportConnection> Clone for GenericReceiver<C> {
|
||||||
ping_sender: self.ping_sender.clone(),
|
ping_sender: self.ping_sender.clone(),
|
||||||
max_message_size: self.max_message_size.clone(),
|
max_message_size: self.max_message_size.clone(),
|
||||||
type_map: self.type_map.clone(),
|
type_map: self.type_map.clone(),
|
||||||
|
queue_notify: self.queue_notify.clone(),
|
||||||
|
decode_rejections: self.decode_rejections.clone(),
|
||||||
_accept_task: self._accept_task.clone(),
|
_accept_task: self._accept_task.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -207,6 +225,7 @@ impl<C: TransportConnection> Drop for GenericReceiver<C> {
|
||||||
|
|
||||||
impl<C: TransportConnection> GenericReceiver<C> {
|
impl<C: TransportConnection> GenericReceiver<C> {
|
||||||
pub fn new(connection: C, policy: Arc<Policy>) -> Self {
|
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);
|
let (tx, rx) = mpsc::channel(policy.receiver_queue_capacity);
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
let (pipe_tx, pipe_rx) = mpsc::channel(policy.receiver_queue_capacity);
|
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 task_max_message_size = max_message_size.clone();
|
||||||
let type_map = Arc::new(RwLock::new(TypeMap::latest()));
|
let type_map = Arc::new(RwLock::new(TypeMap::latest()));
|
||||||
let task_type_map = type_map.clone();
|
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();
|
let task_accept_task_tx = tx.clone();
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
let task_accept_task_pipe_tx = pipe_tx.clone();
|
let task_accept_task_pipe_tx = pipe_tx.clone();
|
||||||
|
|
@ -237,8 +260,10 @@ impl<C: TransportConnection> GenericReceiver<C> {
|
||||||
#[cfg(not(feature = "pipes"))]
|
#[cfg(not(feature = "pipes"))]
|
||||||
let cap_full = task_accept_task_tx.capacity() == 0;
|
let cap_full = task_accept_task_tx.capacity() == 0;
|
||||||
|
|
||||||
|
let notified = task_queue_notify.notified();
|
||||||
|
tokio::pin!(notified);
|
||||||
if cap_full {
|
if cap_full {
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
|
notified.await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -277,11 +302,12 @@ impl<C: TransportConnection> GenericReceiver<C> {
|
||||||
let ping_sender = task_ping_sender.clone();
|
let ping_sender = task_ping_sender.clone();
|
||||||
let connection = task_connection.clone();
|
let connection = task_connection.clone();
|
||||||
let type_map = task_type_map.clone();
|
let type_map = task_type_map.clone();
|
||||||
|
let decode_rejections = task_decode_rejections.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
let mut stream = stream;
|
let mut stream = stream;
|
||||||
let mut frames = 0usize;
|
let mut frames = 0usize;
|
||||||
loop {
|
'stream: loop {
|
||||||
if policy
|
if policy
|
||||||
.max_frames_per_stream
|
.max_frames_per_stream
|
||||||
.is_some_and(|max| frames >= max)
|
.is_some_and(|max| frames >= max)
|
||||||
|
|
@ -304,7 +330,7 @@ impl<C: TransportConnection> GenericReceiver<C> {
|
||||||
policy.application_close_code,
|
policy.application_close_code,
|
||||||
b"frame header read error",
|
b"frame header read error",
|
||||||
);
|
);
|
||||||
break;
|
break 'stream;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
break;
|
break;
|
||||||
|
|
@ -314,6 +340,7 @@ impl<C: TransportConnection> GenericReceiver<C> {
|
||||||
if len == policy.close_frame_len {
|
if len == policy.close_frame_len {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
let deadline = Instant::now() + policy.read_timeout;
|
||||||
let frame_limit = max_message_size.load(Ordering::Relaxed);
|
let frame_limit = max_message_size.load(Ordering::Relaxed);
|
||||||
let body_len = len as usize;
|
let body_len = len as usize;
|
||||||
let frame_len = match body_len.checked_add(4) {
|
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");
|
connection.close(policy.application_close_code, b"frame too large");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let target_len = body_len;
|
let mut frame = Vec::new();
|
||||||
let mut body = Vec::new();
|
if frame.try_reserve_exact(frame_len).is_err() {
|
||||||
if body.try_reserve(target_len.min(16 * 1024)).is_err() {
|
tracing::warn!(frame_len, "MTP receive stream could not reserve frame");
|
||||||
tracing::warn!(
|
|
||||||
target_len,
|
|
||||||
"MTP receive stream could not reserve frame body"
|
|
||||||
);
|
|
||||||
let _ = tx.send(Err(CommunicationError::MessageTooLarge)).await;
|
let _ = tx.send(Err(CommunicationError::MessageTooLarge)).await;
|
||||||
connection
|
connection
|
||||||
.close(policy.application_close_code, b"frame allocation failed");
|
.close(policy.application_close_code, b"frame allocation failed");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
while body.len() < target_len {
|
frame.extend_from_slice(&len.to_be_bytes());
|
||||||
let chunk_len = (target_len - body.len()).min(16 * 1024);
|
frame.resize(frame_len, 0);
|
||||||
let mut chunk = [0u8; 16 * 1024];
|
let mut body_offset = 4usize;
|
||||||
let body_read = tokio::time::timeout(
|
while body_offset < frame_len {
|
||||||
policy.read_timeout,
|
let chunk_len = (frame_len - body_offset).min(16 * 1024);
|
||||||
stream.read_exact(&mut chunk[..chunk_len]),
|
let body_read = timeout_at(
|
||||||
|
deadline,
|
||||||
|
stream.read_exact(&mut frame[body_offset..body_offset + chunk_len]),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if !matches!(&body_read, Ok(Ok(())))
|
if !matches!(&body_read, Ok(Ok(()))) {
|
||||||
|| body.try_reserve(chunk_len).is_err()
|
|
||||||
{
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
pipe_chunk_len = chunk_len,
|
pipe_chunk_len = chunk_len,
|
||||||
?body_read,
|
?body_read,
|
||||||
|
|
@ -361,24 +384,23 @@ impl<C: TransportConnection> GenericReceiver<C> {
|
||||||
let _ = tx.send(Err(CommunicationError::StreamError)).await;
|
let _ = tx.send(Err(CommunicationError::StreamError)).await;
|
||||||
connection
|
connection
|
||||||
.close(policy.application_close_code, b"frame body read error");
|
.close(policy.application_close_code, b"frame body read error");
|
||||||
break;
|
break 'stream;
|
||||||
}
|
}
|
||||||
body.extend_from_slice(&chunk[..chunk_len]);
|
body_offset += chunk_len;
|
||||||
}
|
|
||||||
if body.len() != target_len {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
frames += 1;
|
frames += 1;
|
||||||
let mut frame = Vec::with_capacity(frame_len);
|
let mut message = match CommunicationValue::try_from_bytes_with_limits(
|
||||||
frame.extend_from_slice(&len.to_be_bytes());
|
|
||||||
frame.extend_from_slice(&body);
|
|
||||||
let mut message = match CommunicationValue::from_bytes_with_limits(
|
|
||||||
&frame,
|
&frame,
|
||||||
DecodeLimits::for_transport_message_size(frame_limit),
|
DecodeLimits::for_transport_message_size(frame_limit),
|
||||||
) {
|
) {
|
||||||
Ok(message) => message,
|
Ok(message) => message,
|
||||||
Err(_) => {
|
Err(error) => {
|
||||||
tracing::warn!("MTP receive stream contained an invalid frame");
|
tracing::warn!(
|
||||||
|
?error,
|
||||||
|
class = ?classify_decode_error(&error),
|
||||||
|
"MTP receive stream rejected by bounded decode"
|
||||||
|
);
|
||||||
|
decode_rejections.record(&error);
|
||||||
let _ = tx
|
let _ = tx
|
||||||
.send(Err(CommunicationError::ParseCommunicationValue))
|
.send(Err(CommunicationError::ParseCommunicationValue))
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -463,6 +485,8 @@ impl<C: TransportConnection> GenericReceiver<C> {
|
||||||
ping_sender,
|
ping_sender,
|
||||||
max_message_size,
|
max_message_size,
|
||||||
type_map,
|
type_map,
|
||||||
|
queue_notify,
|
||||||
|
decode_rejections,
|
||||||
_accept_task: Arc::new(accept_task),
|
_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) {
|
pub async fn set_type_map(&self, type_map: &TypeMap) {
|
||||||
*self.type_map.write().await = type_map.clone();
|
*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> {
|
pub async fn receive(&self) -> Result<CommunicationValue, CommunicationError> {
|
||||||
self.incoming
|
let result = self
|
||||||
|
.incoming
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.recv()
|
.recv()
|
||||||
.await
|
.await
|
||||||
.unwrap_or(Err(CommunicationError::StreamClosed))
|
.unwrap_or(Err(CommunicationError::StreamClosed));
|
||||||
|
if result.is_ok() {
|
||||||
|
self.queue_notify.notify_one();
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
|
|
@ -498,7 +532,10 @@ impl<C: TransportConnection> GenericReceiver<C> {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
msg = incoming.recv() => {
|
msg = incoming.recv() => {
|
||||||
match msg {
|
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),
|
Some(Err(e)) => Err(e),
|
||||||
None => Err(self
|
None => Err(self
|
||||||
.connection
|
.connection
|
||||||
|
|
@ -508,7 +545,10 @@ impl<C: TransportConnection> GenericReceiver<C> {
|
||||||
}
|
}
|
||||||
pipe = pipes.recv() => {
|
pipe = pipes.recv() => {
|
||||||
match pipe {
|
match pipe {
|
||||||
Some(reader) => Ok(crate::TransportEvent::Pipe(reader)),
|
Some(reader) => {
|
||||||
|
self.queue_notify.notify_one();
|
||||||
|
Ok(crate::TransportEvent::Pipe(reader))
|
||||||
|
}
|
||||||
None => Err(self
|
None => Err(self
|
||||||
.connection
|
.connection
|
||||||
.close_reason()
|
.close_reason()
|
||||||
|
|
@ -520,12 +560,17 @@ impl<C: TransportConnection> GenericReceiver<C> {
|
||||||
|
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
pub async fn receive_pipe(&self) -> Result<PipeReader<C::RecvStream>, CommunicationError> {
|
pub async fn receive_pipe(&self) -> Result<PipeReader<C::RecvStream>, CommunicationError> {
|
||||||
self.pipes
|
let result = self
|
||||||
|
.pipes
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.recv()
|
.recv()
|
||||||
.await
|
.await
|
||||||
.ok_or(CommunicationError::StreamClosed)
|
.ok_or(CommunicationError::StreamClosed);
|
||||||
|
if result.is_ok() {
|
||||||
|
self.queue_notify.notify_one();
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
|
|
@ -534,7 +579,10 @@ impl<C: TransportConnection> GenericReceiver<C> {
|
||||||
) -> Result<Option<PipeReader<C::RecvStream>>, CommunicationError> {
|
) -> Result<Option<PipeReader<C::RecvStream>>, CommunicationError> {
|
||||||
match self.pipes.try_lock() {
|
match self.pipes.try_lock() {
|
||||||
Ok(mut rx) => match rx.try_recv() {
|
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::Empty) => Ok(None),
|
||||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||||
Err(CommunicationError::StreamClosed)
|
Err(CommunicationError::StreamClosed)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ pub mod encrypted_pipe;
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
pub mod pipe;
|
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};
|
pub use generic_connection::{GenericReceiver, GenericSender};
|
||||||
|
|
||||||
#[cfg(feature = "pipes")]
|
#[cfg(feature = "pipes")]
|
||||||
|
|
|
||||||
|
|
@ -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 sender = GenericSender::new(conn_a, policy.clone());
|
||||||
let receiver = GenericReceiver::new(conn_b, policy);
|
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?;
|
sender.send(&msg).await?;
|
||||||
|
|
||||||
let _pipe_writer = sender.open_pipe(1, "mixed-pipe").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?;
|
let received = receiver.receive().await?;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
received.get_type(),
|
received.get_type(),
|
||||||
mtp_codec::CommunicationType::Pong
|
mtp_codec::CommunicationType::BadRequest
|
||||||
.try_to_id(&mtp_codec::TypeMap::latest())
|
.try_to_id(&mtp_codec::TypeMap::latest())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -127,12 +127,12 @@ async fn test_send_receive_roundtrip() -> Result<(), Box<dyn std::error::Error>>
|
||||||
assert_numbered_message(&received, CommunicationType::Ping, 42, &tm);
|
assert_numbered_message(&received, CommunicationType::Ping, 42, &tm);
|
||||||
|
|
||||||
// Host sends a response
|
// 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?;
|
host_tx.send(&resp).await?;
|
||||||
|
|
||||||
// Client receives it
|
// Client receives it
|
||||||
let client_received = client_rx.receive().await?;
|
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
|
// Close both sides
|
||||||
client_tx.close().await;
|
client_tx.close().await;
|
||||||
|
|
@ -179,13 +179,13 @@ async fn test_concurrent_messages() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
||||||
// Send 3 responses back
|
// Send 3 responses back
|
||||||
for i in 0..3u128 {
|
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?;
|
client_tx.send(&msg).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
for i in 0..3u128 {
|
for i in 0..3u128 {
|
||||||
let received = host_rx.receive().await?;
|
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;
|
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 tm = TypeMap::latest();
|
||||||
|
|
||||||
let resp = numbered_message(CommunicationType::Pong, 7, &tm);
|
let resp = numbered_message(CommunicationType::BadRequest, 7, &tm);
|
||||||
host_tx.send(&resp).await?;
|
host_tx.send(&resp).await?;
|
||||||
|
|
||||||
let got = client_rx.receive().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;
|
client_tx.close().await;
|
||||||
host_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?;
|
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?;
|
client_tx.send(&msg2).await?;
|
||||||
let received2 = host_rx.receive().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;
|
client_tx.close().await;
|
||||||
host_tx.close().await;
|
host_tx.close().await;
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ getrandom-v04 = { package = "getrandom", version = "0.4.3", features = ["wasm_js
|
||||||
mtp-common = { version = "0.3.0", path = "../common" }
|
mtp-common = { version = "0.3.0", path = "../common" }
|
||||||
mtp-type-map = { version = "0.3.0", path = "../type-map" }
|
mtp-type-map = { version = "0.3.0", path = "../type-map" }
|
||||||
mtp-codec = { version = "0.3.0", path = "../codec", features = ["crypto", "pipes", "registry"] }
|
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"
|
zeroize = "1.9"
|
||||||
wasm-bindgen-test = "0.3.76"
|
wasm-bindgen-test = "0.3.76"
|
||||||
|
|
||||||
|
|
|
||||||
1389
wasm/src/client.rs
1389
wasm/src/client.rs
File diff suppressed because it is too large
Load diff
630
wasm/src/client/authentication.rs
Normal file
630
wasm/src/client/authentication.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
102
wasm/src/client/connection.rs
Normal file
102
wasm/src/client/connection.rs
Normal 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
184
wasm/src/client/dispatch.rs
Normal 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
299
wasm/src/client/mod.rs
Normal 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
76
wasm/src/client/pipes.rs
Normal 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
328
wasm/src/client/receive.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,10 @@ use std::collections::HashMap;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use futures_channel::oneshot;
|
use futures_channel::oneshot;
|
||||||
|
use futures_util::{FutureExt, pin_mut, select};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
use wasm_bindgen_futures::JsFuture;
|
||||||
|
|
||||||
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
use mtp_codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||||
|
|
||||||
|
|
@ -21,12 +23,17 @@ pub(crate) struct PendingRequest {
|
||||||
|
|
||||||
pub(crate) struct PendingPipeCreation {
|
pub(crate) struct PendingPipeCreation {
|
||||||
pub(crate) generation: u32,
|
pub(crate) generation: u32,
|
||||||
|
pub(crate) token: Rc<()>,
|
||||||
pub(crate) sender: oneshot::Sender<Result<bool, JsValue>>,
|
pub(crate) sender: oneshot::Sender<Result<bool, JsValue>>,
|
||||||
}
|
}
|
||||||
pub(crate) type PendingPipeCreations = Rc<RefCell<HashMap<u32, PendingPipeCreation>>>;
|
pub(crate) type PendingPipeCreations = Rc<RefCell<HashMap<u32, PendingPipeCreation>>>;
|
||||||
type PipeResponseReceiver = oneshot::Receiver<Result<bool, JsValue>>;
|
type PipeResponseReceiver = oneshot::Receiver<Result<bool, JsValue>>;
|
||||||
type PipeResponseCell = Rc<RefCell<Option<PipeResponseReceiver>>>;
|
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) struct PendingPipe {
|
||||||
pub(crate) generation: u32,
|
pub(crate) generation: u32,
|
||||||
pub(crate) sender: oneshot::Sender<Result<PipeReader, JsValue>>,
|
pub(crate) sender: oneshot::Sender<Result<PipeReader, JsValue>>,
|
||||||
|
|
@ -114,6 +121,10 @@ pub struct WasmPipeHandle {
|
||||||
description: String,
|
description: String,
|
||||||
transport: WasmTransport,
|
transport: WasmTransport,
|
||||||
response_rx: PipeResponseCell,
|
response_rx: PipeResponseCell,
|
||||||
|
pending: PendingPipeCreations,
|
||||||
|
expired: Rc<RefCell<HashMap<u32, f64>>>,
|
||||||
|
generation: u32,
|
||||||
|
token: Rc<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
|
|
@ -125,9 +136,37 @@ impl WasmPipeHandle {
|
||||||
.take()
|
.take()
|
||||||
.ok_or_else(|| js_error("handle already consumed"))?;
|
.ok_or_else(|| js_error("handle already consumed"))?;
|
||||||
|
|
||||||
let accepted = rx
|
let response = rx.fuse();
|
||||||
.await
|
let timeout = wait_for_timeout(DEFAULT_PIPE_CREATION_TIMEOUT_MS).fuse();
|
||||||
.map_err(|_| js_error("pipe handle channel closed"))?;
|
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 {
|
match accepted {
|
||||||
Ok(true) => {
|
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> {
|
pub(crate) fn random_pipe_id() -> Result<u32, JsValue> {
|
||||||
let mut bytes = [0u8; 4];
|
let mut bytes = [0u8; 4];
|
||||||
getrandom_v04::fill(&mut bytes).map_err(|_| js_error("rng failed"))?;
|
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) {
|
pub(crate) fn reject_pending_pipes(pending: &PendingPipes, message: &str) {
|
||||||
let pending = std::mem::take(&mut *pending.borrow_mut());
|
let pending = std::mem::take(&mut *pending.borrow_mut());
|
||||||
for (_, entry) in pending {
|
for (_, entry) in pending {
|
||||||
|
|
@ -178,19 +369,26 @@ pub(crate) async fn wasm_create_pipe(
|
||||||
description: &str,
|
description: &str,
|
||||||
pipe_id: u32,
|
pipe_id: u32,
|
||||||
pending_pipe_creations: &PendingPipeCreations,
|
pending_pipe_creations: &PendingPipeCreations,
|
||||||
|
expired_pipe_creations: &Rc<RefCell<HashMap<u32, f64>>>,
|
||||||
generation: u32,
|
generation: u32,
|
||||||
current_generation: &Rc<std::cell::Cell<u32>>,
|
current_generation: &Rc<std::cell::Cell<u32>>,
|
||||||
) -> Result<WasmPipeHandle, JsValue> {
|
) -> Result<WasmPipeHandle, JsValue> {
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
|
let token = Rc::new(());
|
||||||
let mut pipe_id = pipe_id;
|
let mut pipe_id = pipe_id;
|
||||||
for _ in 0..128 {
|
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 {
|
if !occupied {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
pipe_id = random_pipe_id()?;
|
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"));
|
return Err(js_error("could not allocate a unique pipe id"));
|
||||||
}
|
}
|
||||||
let type_map = transport.type_map();
|
let type_map = transport.type_map();
|
||||||
|
|
@ -207,9 +405,17 @@ pub(crate) async fn wasm_create_pipe(
|
||||||
pipe_id,
|
pipe_id,
|
||||||
PendingPipeCreation {
|
PendingPipeCreation {
|
||||||
generation,
|
generation,
|
||||||
|
token: token.clone(),
|
||||||
sender: tx,
|
sender: tx,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
let mut creation_guard = PendingPipeCreationGuard::new(
|
||||||
|
pending_pipe_creations.clone(),
|
||||||
|
expired_pipe_creations.clone(),
|
||||||
|
pipe_id,
|
||||||
|
generation,
|
||||||
|
token.clone(),
|
||||||
|
);
|
||||||
debug!(
|
debug!(
|
||||||
target = "mtp.wasm",
|
target = "mtp.wasm",
|
||||||
pipe_id,
|
pipe_id,
|
||||||
|
|
@ -218,31 +424,22 @@ pub(crate) async fn wasm_create_pipe(
|
||||||
"sending pipe request"
|
"sending pipe request"
|
||||||
);
|
);
|
||||||
if let Err(error) = transport.send_frame(&request_bytes).await {
|
if let Err(error) = transport.send_frame(&request_bytes).await {
|
||||||
let mut pending = pending_pipe_creations.borrow_mut();
|
|
||||||
if pending
|
|
||||||
.get(&pipe_id)
|
|
||||||
.is_some_and(|entry| entry.generation == generation)
|
|
||||||
{
|
|
||||||
pending.remove(&pipe_id);
|
|
||||||
}
|
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
if current_generation.get() != generation {
|
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"));
|
return Err(js_error("connection attempt superseded"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
creation_guard.disarm();
|
||||||
Ok(WasmPipeHandle {
|
Ok(WasmPipeHandle {
|
||||||
pipe_id,
|
pipe_id,
|
||||||
description: description.to_string(),
|
description: description.to_string(),
|
||||||
transport: transport.clone(),
|
transport: transport.clone(),
|
||||||
response_rx: Rc::new(RefCell::new(Some(rx))),
|
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!(
|
debug!(
|
||||||
target = "mtp.wasm",
|
target = "mtp.wasm",
|
||||||
|
|
@ -291,28 +489,42 @@ pub(crate) async fn wasm_accept_pipe(
|
||||||
"sending pipe response"
|
"sending pipe response"
|
||||||
);
|
);
|
||||||
if let Err(error) = transport.send_frame(&resp_bytes).await {
|
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);
|
return Err(error);
|
||||||
}
|
}
|
||||||
if current_generation.get() != generation {
|
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"));
|
return Err(js_error("connection attempt superseded"));
|
||||||
}
|
}
|
||||||
|
|
||||||
rx.await
|
let response = rx.fuse();
|
||||||
.map_err(|_| js_error("pipe closed before stream arrived"))?
|
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> {
|
pub(crate) async fn wasm_deny_pipe(transport: &WasmTransport, pipe_id: u32) -> Result<(), JsValue> {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub struct ConnectionConfig {
|
pub struct ConnectionConfig {
|
||||||
pub(crate) url: String,
|
pub(crate) url: String,
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ use wasm_bindgen::prelude::*;
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use mtp_codec::{
|
use mtp_codec::{
|
||||||
DataValue, MtpProtectionPurpose, PROTOCOL_VERSION, ProtectionPolicy, ProtectionPurpose,
|
DataValue, DecodeLimits, EncodeLimits, MtpProtectionPurpose, PROTOCOL_VERSION,
|
||||||
SealedRelayBuilder, SignaturePolicy, TypeMap,
|
ProtectionPolicy, ProtectionPurpose, SealedRelayBuilder, SignaturePolicy, TypeMap,
|
||||||
};
|
};
|
||||||
use mtp_crypto::{
|
use mtp_crypto::{
|
||||||
AeadDecrypt, AeadEncrypt, DualSigner, Ed25519Signer, HybridKem, KemPrivateKey, KemPublicKey,
|
AeadDecrypt, AeadEncrypt, DualSigner, Ed25519Signer, HybridKem, KemPrivateKey, KemPublicKey,
|
||||||
|
|
@ -12,10 +12,18 @@ use mtp_crypto::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::error::{from_protection_error, js_error};
|
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> {
|
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(
|
fn decode_public_key_bundle(
|
||||||
|
|
@ -74,10 +82,19 @@ pub struct WasmKeyring {
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
impl WasmKeyring {
|
impl WasmKeyring {
|
||||||
/// Serialise the keyring to bytes.
|
/// Serialise the keyring to bytes and report malformed caller-owned
|
||||||
|
/// material as a JavaScript exception.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn to_bytes(&self) -> Vec<u8> {
|
pub fn to_bytes(&self) -> Result<Vec<u8>, JsValue> {
|
||||||
self.inner.to_bytes().to_vec()
|
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.
|
/// Deserialise a keyring from bytes.
|
||||||
|
|
@ -118,8 +135,17 @@ impl WasmKeyring {
|
||||||
|
|
||||||
/// Generate a full keyring with KEM, ML-DSA, and Ed25519 keys.
|
/// Generate a full keyring with KEM, ML-DSA, and Ed25519 keys.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn keyring_generate() -> Vec<u8> {
|
pub fn keyring_generate() -> Result<Vec<u8>, JsValue> {
|
||||||
Keyring::generate().to_bytes().to_vec()
|
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).
|
/// 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()),
|
SignaturePublicKey::new(public_key.to_vec()),
|
||||||
SignaturePrivateKey::new(secret_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]
|
#[wasm_bindgen]
|
||||||
pub fn to_bytes(&self) -> Vec<u8> {
|
pub fn to_bytes(&self) -> Result<Vec<u8>, JsValue> {
|
||||||
self.inner.as_bytes()
|
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]
|
#[wasm_bindgen]
|
||||||
|
|
@ -428,6 +464,14 @@ pub fn wasm_sha256_double(data: &[u8]) -> Vec<u8> {
|
||||||
// KDF
|
// 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`.
|
/// HKDF-expand: derive `len` bytes from `ikm` with `salt` and `info`.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn wasm_hkdf_expand(
|
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)))
|
.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.
|
/// Signature suites accepted by high-level protected-value APIs.
|
||||||
pub const PROTECTION_SIGNATURE_SUITE_ED25519: u8 = 0x01;
|
pub const PROTECTION_SIGNATURE_SUITE_ED25519: u8 = 0x01;
|
||||||
pub const PROTECTION_SIGNATURE_SUITE_DUAL: u8 = 0x03;
|
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 value = decode_data_value(value)?;
|
||||||
let bundle = decode_public_key_bundle(public_key_bundle, None)?;
|
let bundle = decode_public_key_bundle(public_key_bundle, None)?;
|
||||||
let result = if signature_suite == 0 {
|
let result = if signature_suite == 0 {
|
||||||
value.verify(
|
value.verify_with_policy(
|
||||||
expected_signer_id,
|
expected_signer_id,
|
||||||
&bundle,
|
&bundle,
|
||||||
ProtectionPurpose::from(expected_purpose),
|
ProtectionPurpose::from(expected_purpose),
|
||||||
|
ProtectionPolicy::any_supported(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
value.verify_with_policy(
|
value.verify_with_policy(
|
||||||
|
|
@ -650,7 +710,11 @@ pub fn decrypt_data_value_with_keyrings(
|
||||||
let keyrings = keyrings_from_js(&keyrings)?;
|
let keyrings = keyrings_from_js(&keyrings)?;
|
||||||
let references: Vec<&Keyring> = keyrings.iter().collect();
|
let references: Vec<&Keyring> = keyrings.iter().collect();
|
||||||
value
|
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)?
|
.map_err(from_protection_error)?
|
||||||
.to_bytes()
|
.to_bytes()
|
||||||
.map_err(|e| js_error(format!("decryption failed: {e}")))
|
.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
|
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
|
/// Forward a sealed relay frame to another clear next hop without opening or
|
||||||
/// re-encoding its authenticated encrypted payload.
|
/// re-encoding its authenticated encrypted payload.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
|
|
@ -776,12 +847,27 @@ fn build_encrypted_relay_frame_impl(
|
||||||
signer: &dyn SignatureScheme,
|
signer: &dyn SignatureScheme,
|
||||||
metadata_recipient_public_key_bundles: JsValue,
|
metadata_recipient_public_key_bundles: JsValue,
|
||||||
content_recipient_public_key_bundles: JsValue,
|
content_recipient_public_key_bundles: JsValue,
|
||||||
|
limits: JsValue,
|
||||||
) -> Result<Vec<u8>, JsValue> {
|
) -> Result<Vec<u8>, JsValue> {
|
||||||
let tm = TypeMap::new(PROTOCOL_VERSION);
|
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
|
let application_metadata = encoded_metadata
|
||||||
.as_deref()
|
.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()?;
|
.transpose()?;
|
||||||
let content_recipients = public_key_bundles_from_js(&content_recipient_public_key_bundles)?;
|
let content_recipients = public_key_bundles_from_js(&content_recipient_public_key_bundles)?;
|
||||||
let metadata_recipients = public_key_bundles_from_js(&metadata_recipient_public_key_bundles)?;
|
let 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)
|
.created_at(created_at)
|
||||||
.metadata_recipients(metadata_recipients)
|
.metadata_recipients(metadata_recipients)
|
||||||
.content_recipients(content_recipients)
|
.content_recipients(content_recipients)
|
||||||
|
.encode_limits(encode_limits)
|
||||||
|
.protected_limits(relay_options.protected_limits)
|
||||||
.type_map(&tm);
|
.type_map(&tm);
|
||||||
let builder = match application_metadata {
|
let builder = match application_metadata {
|
||||||
Some(metadata) => builder.metadata(metadata),
|
Some(metadata) => builder.metadata(metadata),
|
||||||
|
|
@ -807,7 +895,7 @@ fn build_encrypted_relay_frame_impl(
|
||||||
builder
|
builder
|
||||||
.build()
|
.build()
|
||||||
.map_err(relay_error)?
|
.map_err(relay_error)?
|
||||||
.to_bytes()
|
.to_bytes_with_limits(encode_limits)
|
||||||
.map_err(|e| js_error(format!("relay frame encoding failed: {e}")))
|
.map_err(|e| js_error(format!("relay frame encoding failed: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -844,6 +932,45 @@ pub fn build_encrypted_relay_frame_with_keyring(
|
||||||
&signer,
|
&signer,
|
||||||
metadata_recipient_public_key_bundles,
|
metadata_recipient_public_key_bundles,
|
||||||
content_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)
|
let restored = WasmPublicKeyBundle::from_bytes_unvalidated(&bytes)
|
||||||
.expect("from_bytes_unvalidated failed");
|
.expect("from_bytes_unvalidated failed");
|
||||||
assert_eq!(restored.sig_cl_public_key(), pk);
|
assert_eq!(restored.sig_cl_public_key(), pk);
|
||||||
|
|
@ -1098,7 +1225,7 @@ mod tests {
|
||||||
let value = DataValue::Str("signed through wasm".into())
|
let value = DataValue::Str("signed through wasm".into())
|
||||||
.to_bytes()
|
.to_bytes()
|
||||||
.expect("value encoding failed");
|
.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(
|
let signed = sign_data_value_with_keyring(
|
||||||
&value,
|
&value,
|
||||||
0xfeed_beef,
|
0xfeed_beef,
|
||||||
|
|
@ -1111,7 +1238,7 @@ mod tests {
|
||||||
|
|
||||||
verify_data_value_with_policy(
|
verify_data_value_with_policy(
|
||||||
&signed,
|
&signed,
|
||||||
&bundle.as_bytes(),
|
&bundle.try_as_bytes().expect("bundle serialization"),
|
||||||
0xfeed_beef,
|
0xfeed_beef,
|
||||||
7,
|
7,
|
||||||
PROTECTION_SIGNATURE_SUITE_ED25519,
|
PROTECTION_SIGNATURE_SUITE_ED25519,
|
||||||
|
|
@ -1121,7 +1248,7 @@ mod tests {
|
||||||
assert!(
|
assert!(
|
||||||
verify_data_value_with_policy(
|
verify_data_value_with_policy(
|
||||||
&signed,
|
&signed,
|
||||||
&wrong_bundle.as_bytes(),
|
&wrong_bundle.try_as_bytes().expect("bundle serialization"),
|
||||||
0xfeed_beef,
|
0xfeed_beef,
|
||||||
7,
|
7,
|
||||||
PROTECTION_SIGNATURE_SUITE_ED25519,
|
PROTECTION_SIGNATURE_SUITE_ED25519,
|
||||||
|
|
@ -1137,21 +1264,29 @@ mod tests {
|
||||||
let value = DataValue::Array(vec![DataValue::BoolTrue, DataValue::UnsignedNumber(42)])
|
let value = DataValue::Array(vec![DataValue::BoolTrue, DataValue::UnsignedNumber(42)])
|
||||||
.to_bytes()
|
.to_bytes()
|
||||||
.expect("value encoding failed");
|
.expect("value encoding failed");
|
||||||
let encrypted = encrypt_data_value(&value, &recipient.as_bytes(), 9)
|
let recipient_bytes = recipient.try_as_bytes().expect("recipient serialization");
|
||||||
.expect("encrypt_data_value failed");
|
let encrypted =
|
||||||
let decrypted = decrypt_data_value(&encrypted, &keyring.to_bytes(), 9)
|
encrypt_data_value(&value, &recipient_bytes, 9).expect("encrypt_data_value failed");
|
||||||
.expect("decrypt_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);
|
assert_eq!(decrypted, value);
|
||||||
|
|
||||||
let second_keyring = Keyring::generate();
|
let second_keyring = Keyring::generate();
|
||||||
let second_recipient = second_keyring.public_key_bundle();
|
let second_recipient = second_keyring.public_key_bundle();
|
||||||
let recipients = js_sys::Array::new();
|
let recipients = js_sys::Array::new();
|
||||||
recipients.push(&js_sys::Uint8Array::from(&recipient.as_bytes()[..]));
|
let second_recipient_bytes = second_recipient
|
||||||
recipients.push(&js_sys::Uint8Array::from(&second_recipient.as_bytes()[..]));
|
.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)
|
let multi = encrypt_data_value_for_recipients(&value, recipients.into(), 9)
|
||||||
.expect("multi-recipient encryption failed");
|
.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");
|
.expect("second recipient could not decrypt");
|
||||||
assert_eq!(opened_by_second, value);
|
assert_eq!(opened_by_second, value);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
use wasm_bindgen::{JsCast, prelude::*};
|
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 mtp_type_map::TypeMap;
|
||||||
|
|
||||||
use crate::error::js_error;
|
use crate::error::js_error;
|
||||||
|
use crate::relay::decode_error;
|
||||||
|
|
||||||
#[wasm_bindgen(typescript_custom_section)]
|
#[wasm_bindgen(typescript_custom_section)]
|
||||||
const PARSED_FRAME_TS: &'static str = r#"
|
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
|
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> {
|
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() {
|
if value.is_null() || value.is_undefined() {
|
||||||
return Ok(DataValue::Null);
|
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) {
|
if js_sys::Array::is_array(value) {
|
||||||
let array = js_sys::Array::from(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);
|
let mut values = Vec::with_capacity(array.length() as usize);
|
||||||
for item in array.iter() {
|
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));
|
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() {
|
if value.is_object() {
|
||||||
let object = js_sys::Object::from(value.clone());
|
let object = js_sys::Object::from(value.clone());
|
||||||
let keys = js_sys::Object::keys(&object);
|
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);
|
let mut entries = Vec::with_capacity(keys.length() as usize);
|
||||||
for key in keys.iter() {
|
for key in keys.iter() {
|
||||||
let key = key
|
let key = key
|
||||||
|
|
@ -212,7 +268,10 @@ pub(crate) fn js_to_data_value(value: &JsValue, tm: &TypeMap) -> Result<DataValu
|
||||||
tm.version
|
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));
|
return Ok(DataValue::Container(entries));
|
||||||
}
|
}
|
||||||
|
|
@ -282,15 +341,16 @@ fn apply_frame_options(
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn parse_frame_value(frame: &[u8]) -> Result<JsValue, JsValue> {
|
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],
|
frame: &[u8],
|
||||||
type_map: &TypeMap,
|
type_map: &TypeMap,
|
||||||
|
limits: DecodeLimits,
|
||||||
) -> Result<JsValue, JsValue> {
|
) -> Result<JsValue, JsValue> {
|
||||||
let comm = CommunicationValue::from_bytes_with(frame, type_map)
|
let comm = CommunicationValue::try_from_bytes_with_type_map_and_limits(frame, type_map, limits)
|
||||||
.map_err(|e| js_error(format!("parse failed: {}", e)))?;
|
.map_err(|error| decode_error(error, "parse failed"))?;
|
||||||
let tm = type_map;
|
let tm = type_map;
|
||||||
let obj = js_sys::Object::new();
|
let obj = js_sys::Object::new();
|
||||||
|
|
||||||
|
|
@ -355,8 +415,8 @@ pub fn build_ping_frame(
|
||||||
/// Parse an auth response frame into a JS object.
|
/// Parse an auth response frame into a JS object.
|
||||||
#[wasm_bindgen(unchecked_return_type = "AuthResponse")]
|
#[wasm_bindgen(unchecked_return_type = "AuthResponse")]
|
||||||
pub fn parse_auth_response(response: &[u8]) -> Result<JsValue, JsValue> {
|
pub fn parse_auth_response(response: &[u8]) -> Result<JsValue, JsValue> {
|
||||||
let comm = CommunicationValue::from_bytes(response)
|
let comm = CommunicationValue::try_from_bytes_with_limits(response, DecodeLimits::default())
|
||||||
.map_err(|e| js_error(format!("parse failed: {}", e)))?;
|
.map_err(|error| decode_error(error, "parse failed"))?;
|
||||||
|
|
||||||
let connected = matches!(
|
let connected = matches!(
|
||||||
comm.get_data(DataType::Connected),
|
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.
|
/// Parse any MTP frame into the human-readable CommunicationValue display form.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn format_frame(frame: &[u8]) -> Result<String, JsValue> {
|
pub fn format_frame(frame: &[u8]) -> Result<String, JsValue> {
|
||||||
let comm = CommunicationValue::from_bytes(frame)
|
let comm = CommunicationValue::try_from_bytes_with_limits(frame, DecodeLimits::default())
|
||||||
.map_err(|e| js_error(format!("parse failed: {}", e)))?;
|
.map_err(|error| decode_error(error, "parse failed"))?;
|
||||||
Ok(comm.to_string())
|
Ok(comm.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -429,31 +489,80 @@ pub fn parse_frame(frame: &[u8]) -> Result<JsValue, JsValue> {
|
||||||
parse_frame_value(frame)
|
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
|
/// Parse a standalone serialized `DataValue` into the same structured form
|
||||||
/// used for frame payloads. Protected values remain opaque until the caller
|
/// used for frame payloads. Protected values remain opaque until the caller
|
||||||
/// explicitly opens and verifies them.
|
/// explicitly opens and verifies them.
|
||||||
#[wasm_bindgen(unchecked_return_type = "ParsedDataValue")]
|
#[wasm_bindgen(unchecked_return_type = "ParsedDataValue")]
|
||||||
pub fn parse_data_value(value: &[u8]) -> Result<JsValue, JsValue> {
|
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);
|
let tm = TypeMap::new(PROTOCOL_VERSION);
|
||||||
data_value_to_js(&value, &tm)
|
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.
|
/// Encode one standalone `DataValue` using the negotiated/current type map.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn encode_data_value(value: JsValue) -> Result<Vec<u8>, JsValue> {
|
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);
|
let tm = TypeMap::new(PROTOCOL_VERSION);
|
||||||
js_to_data_value(&value, &tm)?
|
js_to_data_value_with_limits(&value, &tm, limits)?
|
||||||
.to_bytes()
|
.to_bytes_with_limits(limits)
|
||||||
.map_err(|e| js_error(format!("encode data value failed: {e}")))
|
.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.
|
/// Build a typed MTP frame using generated communication/data type names.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn build_frame(
|
pub fn build_frame(
|
||||||
message_type: &str,
|
message_type: &str,
|
||||||
data: JsValue,
|
data: JsValue,
|
||||||
options: 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> {
|
) -> Result<Vec<u8>, JsValue> {
|
||||||
let comm_type = CommunicationType::from_name(message_type)
|
let comm_type = CommunicationType::from_name(message_type)
|
||||||
.ok_or_else(|| js_error(format!("unknown communication type: {message_type}")))?;
|
.ok_or_else(|| js_error(format!("unknown communication type: {message_type}")))?;
|
||||||
|
|
@ -478,7 +587,7 @@ pub fn build_frame(
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
msg = msg
|
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}")))?;
|
.map_err(|e| js_error(format!("add data failed: {e}")))?;
|
||||||
}
|
}
|
||||||
} else if !data.is_null() && !data.is_undefined() {
|
} 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)))
|
.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.
|
/// Build a typed MTP frame around a complete serialized `DataValue` payload.
|
||||||
///
|
///
|
||||||
/// Unlike [`build_frame`], this does not interpret the payload as a clear data
|
/// 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,
|
message_type: &str,
|
||||||
serialized_payload: &[u8],
|
serialized_payload: &[u8],
|
||||||
options: JsValue,
|
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> {
|
) -> Result<Vec<u8>, JsValue> {
|
||||||
let comm_type = CommunicationType::from_name(message_type)
|
let comm_type = CommunicationType::from_name(message_type)
|
||||||
.ok_or_else(|| js_error(format!("unknown communication type: {message_type}")))?;
|
.ok_or_else(|| js_error(format!("unknown communication type: {message_type}")))?;
|
||||||
let payload = DataValue::from_bytes(serialized_payload)
|
let payload = DataValue::try_from_bytes_with_limits(
|
||||||
.ok_or_else(|| js_error("invalid serialized DataValue payload"))?;
|
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 =
|
let message =
|
||||||
apply_frame_options(CommunicationValue::new(comm_type), &options)?.with_payload(payload);
|
apply_frame_options(CommunicationValue::new(comm_type), &options)?.with_payload(payload);
|
||||||
|
|
||||||
message
|
message
|
||||||
.to_bytes()
|
.to_bytes_with_limits(limits)
|
||||||
.map_err(|e| js_error(format!("encode failed: {e}")))
|
.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(test)]
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
use mtp_codec::{
|
use mtp_codec::{
|
||||||
DataValue, ProtectedError, ProtectedMessageBuilder, ProtectedOpenOptions, ProtectionError,
|
DataValue, DecodeLimits, EncodeLimits, ProtectedError, ProtectedLimits,
|
||||||
|
ProtectedMessageBuilder, ProtectedOpenOptions, ProtectionError, ProtectionPolicy,
|
||||||
ProtectionPurpose, VerifiedProtectedMessage,
|
ProtectionPurpose, VerifiedProtectedMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -9,7 +10,7 @@ use crate::crypto::{
|
||||||
keyrings_from_js, protection_policy_from_suite, public_key_bundles_from_js,
|
keyrings_from_js, protection_policy_from_suite, public_key_bundles_from_js,
|
||||||
relay_signer_from_keyring,
|
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;
|
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> {
|
fn limit_usize(options: &JsValue, key: &str, default: usize) -> Result<usize, JsValue> {
|
||||||
DataValue::from_bytes(value)
|
if options.is_null() || options.is_undefined() {
|
||||||
.ok_or_else(|| structured_error("invalid-data-value", "invalid DataValue"))
|
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 {
|
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::ExpectedReceiverMismatch => "receiver-id-mismatch",
|
||||||
ProtectedError::ReservedApplicationType(_) => "reserved-application-type",
|
ProtectedError::ReservedApplicationType(_) => "reserved-application-type",
|
||||||
ProtectedError::Replay => "replay",
|
ProtectedError::Replay => "replay",
|
||||||
|
ProtectedError::ResourceLimit(_) => "resource-limit",
|
||||||
ProtectedError::ReplayGuard(_) => "replay-guard-error",
|
ProtectedError::ReplayGuard(_) => "replay-guard-error",
|
||||||
ProtectedError::Protection(error) => match error {
|
ProtectedError::Protection(error) => match error {
|
||||||
ProtectionError::NoMatchingRecipient => "no-matching-recipient",
|
ProtectionError::NoMatchingRecipient => "no-matching-recipient",
|
||||||
|
|
@ -94,6 +173,7 @@ fn protected_error_code(error: &ProtectedError) -> &'static str {
|
||||||
ProtectionError::PurposeMismatch { .. } => "purpose-mismatch",
|
ProtectionError::PurposeMismatch { .. } => "purpose-mismatch",
|
||||||
ProtectionError::SignerIdMismatch { .. } => "signer-id-mismatch",
|
ProtectionError::SignerIdMismatch { .. } => "signer-id-mismatch",
|
||||||
ProtectionError::SignerKeyNotFound(_) => "signer-key-not-found",
|
ProtectionError::SignerKeyNotFound(_) => "signer-key-not-found",
|
||||||
|
ProtectionError::ResourceLimit(_) => "resource-limit",
|
||||||
ProtectionError::Crypto(mtp_crypto::CryptoError::InvalidSignature)
|
ProtectionError::Crypto(mtp_crypto::CryptoError::InvalidSignature)
|
||||||
| ProtectionError::Crypto(mtp_crypto::CryptoError::VerificationFailed) => {
|
| ProtectionError::Crypto(mtp_crypto::CryptoError::VerificationFailed) => {
|
||||||
"invalid-signature"
|
"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]
|
#[wasm_bindgen]
|
||||||
pub struct WasmVerifiedProtectedMessage {
|
pub struct WasmVerifiedProtectedMessage {
|
||||||
inner: VerifiedProtectedMessage,
|
inner: VerifiedProtectedMessage,
|
||||||
|
|
@ -181,7 +252,58 @@ pub fn build_protected_frame_with_keyring(
|
||||||
expose_sender: bool,
|
expose_sender: bool,
|
||||||
recipient_public_key_bundles: JsValue,
|
recipient_public_key_bundles: JsValue,
|
||||||
) -> Result<Vec<u8>, 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| {
|
let keyring = mtp_crypto::Keyring::from_bytes(keyring_bytes).map_err(|error| {
|
||||||
structured_error(
|
structured_error(
|
||||||
"invalid-keyring",
|
"invalid-keyring",
|
||||||
|
|
@ -202,42 +324,149 @@ pub fn build_protected_frame_with_keyring(
|
||||||
.message_id(message_id)
|
.message_id(message_id)
|
||||||
.created_at(created_at)
|
.created_at(created_at)
|
||||||
.recipients(recipients)
|
.recipients(recipients)
|
||||||
|
.encode_limits(encode_limits)
|
||||||
|
.protected_limits(open_options.protected_limits)
|
||||||
.expose_sender(expose_sender);
|
.expose_sender(expose_sender);
|
||||||
if let Some(frame_id) = frame_id {
|
if let Some(frame_id) = frame_id {
|
||||||
builder = builder.frame_id(frame_id);
|
builder = builder.frame_id(frame_id);
|
||||||
}
|
}
|
||||||
let frame = builder.build().map_err(protected_error)?;
|
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
|
/// Read the claimed, unverified signer ID after decrypting the protected
|
||||||
/// payload. The result may only select trusted keys for the same signer ID.
|
/// payload. The result may only select trusted keys for the same signer ID.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
|
#[deprecated(note = "use protected_claimed_signer_id_with_limits")]
|
||||||
pub fn protected_claimed_signer_id(
|
pub fn protected_claimed_signer_id(
|
||||||
frame: &[u8],
|
frame: &[u8],
|
||||||
keyrings: JsValue,
|
keyrings: JsValue,
|
||||||
encryption_purpose: u8,
|
encryption_purpose: u8,
|
||||||
) -> Result<u64, JsValue> {
|
) -> 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| {
|
let keyrings = keyrings_from_js(&keyrings).map_err(|error| {
|
||||||
structured_error(
|
structured_error(
|
||||||
"invalid-recipient-keyrings",
|
"invalid-recipient-keyrings",
|
||||||
error.as_string().unwrap_or_default(),
|
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();
|
let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect();
|
||||||
mtp_codec::protected_claimed_signer_id(
|
mtp_codec::protected_claimed_signer_id_with_options(
|
||||||
&frame,
|
&frame,
|
||||||
&references,
|
&references,
|
||||||
ProtectionPurpose::from(encryption_purpose),
|
ProtectionPurpose::from(encryption_purpose),
|
||||||
|
options.decode_limits,
|
||||||
|
options.protected_limits,
|
||||||
)
|
)
|
||||||
.map_err(protected_error)
|
.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]
|
#[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],
|
frame: &[u8],
|
||||||
keyrings: JsValue,
|
keyrings: JsValue,
|
||||||
expected_signer_id: JsValue,
|
expected_signer_id: JsValue,
|
||||||
|
|
@ -247,7 +476,30 @@ pub fn open_protected_with_keyrings(
|
||||||
encryption_purpose: u8,
|
encryption_purpose: u8,
|
||||||
signature_suite: u8,
|
signature_suite: u8,
|
||||||
) -> Result<WasmVerifiedProtectedMessage, JsValue> {
|
) -> 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| {
|
let keyrings = keyrings_from_js(&keyrings).map_err(|error| {
|
||||||
structured_error(
|
structured_error(
|
||||||
"invalid-recipient-keyrings",
|
"invalid-recipient-keyrings",
|
||||||
|
|
@ -268,23 +520,53 @@ pub fn open_protected_with_keyrings(
|
||||||
error.as_string().unwrap_or_default(),
|
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,
|
&frame,
|
||||||
&references,
|
&references,
|
||||||
expected_signer_id,
|
expected_signer_id,
|
||||||
&signer_public_keys,
|
&signer_public_keys,
|
||||||
ProtectedOpenOptions::new(
|
options,
|
||||||
expected_receiver_id,
|
|
||||||
ProtectionPurpose::from(signature_purpose),
|
|
||||||
ProtectionPurpose::from(encryption_purpose),
|
|
||||||
policy,
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
)
|
||||||
.map_err(protected_error)?;
|
.map_err(protected_error)?;
|
||||||
Ok(WasmVerifiedProtectedMessage { inner: message })
|
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"))]
|
#[cfg(all(test, target_arch = "wasm32"))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -358,9 +640,12 @@ mod tests {
|
||||||
sender: &mtp_crypto::Keyring,
|
sender: &mtp_crypto::Keyring,
|
||||||
recipient: &mtp_crypto::Keyring,
|
recipient: &mtp_crypto::Keyring,
|
||||||
) -> JsValue {
|
) -> JsValue {
|
||||||
let recipient_bytes = recipient.to_bytes();
|
let recipient_bytes = recipient.try_to_bytes().expect("recipient serialization");
|
||||||
let signer_bundle_bytes = sender.public_key_bundle().as_bytes();
|
let signer_bundle_bytes = sender
|
||||||
match open_protected_with_keyrings(
|
.public_key_bundle()
|
||||||
|
.try_as_bytes()
|
||||||
|
.expect("signer bundle serialization");
|
||||||
|
match open_protected_with_keyrings_without_replay(
|
||||||
frame,
|
frame,
|
||||||
js_sys::Uint8Array::from(&recipient_bytes[..]).into(),
|
js_sys::Uint8Array::from(&recipient_bytes[..]).into(),
|
||||||
JsValue::bigint_from_str("7"),
|
JsValue::bigint_from_str("7"),
|
||||||
|
|
@ -379,8 +664,11 @@ mod tests {
|
||||||
fn protected_builder_returns_the_complete_frame() {
|
fn protected_builder_returns_the_complete_frame() {
|
||||||
let sender = mtp_crypto::Keyring::generate();
|
let sender = mtp_crypto::Keyring::generate();
|
||||||
let recipient = mtp_crypto::Keyring::generate();
|
let recipient = mtp_crypto::Keyring::generate();
|
||||||
let sender_bytes = sender.to_bytes();
|
let sender_bytes = sender.try_to_bytes().expect("sender serialization");
|
||||||
let recipient_bundle_bytes = recipient.public_key_bundle().as_bytes();
|
let recipient_bundle_bytes = recipient
|
||||||
|
.public_key_bundle()
|
||||||
|
.try_as_bytes()
|
||||||
|
.expect("recipient bundle serialization");
|
||||||
let content = DataValue::Str("complete-frame".into())
|
let content = DataValue::Str("complete-frame".into())
|
||||||
.to_bytes()
|
.to_bytes()
|
||||||
.expect("encode content");
|
.expect("encode content");
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
use mtp_codec::{
|
use mtp_codec::{
|
||||||
CommunicationValue, DataValue, ProtectionError, RelayError, VerifiedRelayContent,
|
CommunicationValue, DataValue, DecodeLimits, EncodeLimits, ProtectedLimits, ProtectionError,
|
||||||
VerifiedRelayMetadata,
|
ProtectionPolicy, RelayError, RelayOpenOptions, VerifiedRelayContent, VerifiedRelayMetadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::crypto::{keyrings_from_js, protection_policy_from_suite, public_key_bundles_from_js};
|
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
|
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 {
|
fn wrapped_input_error(code: &str, error: JsValue) -> JsValue {
|
||||||
let message = error
|
let message = error
|
||||||
.as_string()
|
.as_string()
|
||||||
|
|
@ -54,6 +76,7 @@ fn relay_error_code(error: &RelayError) -> &'static str {
|
||||||
RelayError::UnsupportedRelayVersion(_) => "unsupported-relay-version",
|
RelayError::UnsupportedRelayVersion(_) => "unsupported-relay-version",
|
||||||
RelayError::NotFinalRecipient => "not-final-recipient",
|
RelayError::NotFinalRecipient => "not-final-recipient",
|
||||||
RelayError::Replay => "replay",
|
RelayError::Replay => "replay",
|
||||||
|
RelayError::ResourceLimit(_) => "resource-limit",
|
||||||
RelayError::ReservedApplicationType(_) => "reserved-application-type",
|
RelayError::ReservedApplicationType(_) => "reserved-application-type",
|
||||||
RelayError::ReplayGuard(_) => "replay-guard-error",
|
RelayError::ReplayGuard(_) => "replay-guard-error",
|
||||||
RelayError::Protection(error) => match error {
|
RelayError::Protection(error) => match error {
|
||||||
|
|
@ -63,6 +86,7 @@ fn relay_error_code(error: &RelayError) -> &'static str {
|
||||||
ProtectionError::PurposeMismatch { .. } => "purpose-mismatch",
|
ProtectionError::PurposeMismatch { .. } => "purpose-mismatch",
|
||||||
ProtectionError::SignerIdMismatch { .. } => "signer-id-mismatch",
|
ProtectionError::SignerIdMismatch { .. } => "signer-id-mismatch",
|
||||||
ProtectionError::SignerKeyNotFound(_) => "signer-key-not-found",
|
ProtectionError::SignerKeyNotFound(_) => "signer-key-not-found",
|
||||||
|
ProtectionError::ResourceLimit(_) => "resource-limit",
|
||||||
ProtectionError::Crypto(mtp_crypto::CryptoError::InvalidSignature)
|
ProtectionError::Crypto(mtp_crypto::CryptoError::InvalidSignature)
|
||||||
| ProtectionError::Crypto(mtp_crypto::CryptoError::VerificationFailed) => {
|
| ProtectionError::Crypto(mtp_crypto::CryptoError::VerificationFailed) => {
|
||||||
"invalid-signature"
|
"invalid-signature"
|
||||||
|
|
@ -73,12 +97,15 @@ fn relay_error_code(error: &RelayError) -> &'static str {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn decode_frame(frame: &[u8]) -> Result<CommunicationValue, JsValue> {
|
pub(crate) fn decode_frame(frame: &[u8]) -> Result<CommunicationValue, JsValue> {
|
||||||
CommunicationValue::from_bytes(frame).map_err(|error| {
|
decode_frame_with_limits(frame, DecodeLimits::default())
|
||||||
structured_error(
|
}
|
||||||
"invalid-frame",
|
|
||||||
format!("relay frame decoding failed: {error}"),
|
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> {
|
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> {
|
fn serialize_data_value(value: &DataValue) -> Result<Vec<u8>, JsValue> {
|
||||||
value.to_bytes().map_err(|error| {
|
value.to_bytes().map_err(|error| {
|
||||||
structured_error(
|
structured_error(
|
||||||
|
|
@ -196,19 +296,49 @@ impl WasmVerifiedRelayContent {
|
||||||
/// versioned relay metadata parser in the JavaScript SDK. The caller must bind
|
/// versioned relay metadata parser in the JavaScript SDK. The caller must bind
|
||||||
/// this value as the expected signer during the subsequent verification call.
|
/// this value as the expected signer during the subsequent verification call.
|
||||||
#[wasm_bindgen]
|
#[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> {
|
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)
|
let keyrings = keyrings_from_js(&keyrings)
|
||||||
.map_err(|error| wrapped_input_error("invalid-recipient-keyrings", error))?;
|
.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();
|
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]
|
#[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],
|
frame: &[u8],
|
||||||
keyrings: JsValue,
|
keyrings: JsValue,
|
||||||
expected_signer_id: 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"))?;
|
.ok_or_else(|| structured_error("invalid-option", "expectedSignerId is required"))?;
|
||||||
let policy = protection_policy_from_suite(signature_suite)
|
let policy = protection_policy_from_suite(signature_suite)
|
||||||
.map_err(|error| wrapped_input_error("unsupported-signature-suite", error))?;
|
.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,
|
&frame,
|
||||||
&references,
|
&references,
|
||||||
expected_signer_id,
|
Some(expected_signer_id),
|
||||||
&signer_public_keys,
|
move |_| Some(signer_public_keys),
|
||||||
policy,
|
RelayOpenOptions::new(policy),
|
||||||
)
|
)
|
||||||
.map_err(relay_error)?;
|
.map_err(relay_error)?;
|
||||||
Ok(WasmVerifiedRelayMetadata { inner: metadata })
|
Ok(WasmVerifiedRelayMetadata { inner: metadata })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open and verify relay content in the native codec using recipient and
|
/// Open bounded relay metadata without replay protection. Use the SDK's
|
||||||
/// signer key histories supplied by the SDK.
|
/// message-processing guard or a native checked API for live traffic.
|
||||||
#[wasm_bindgen]
|
#[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,
|
metadata: &WasmVerifiedRelayMetadata,
|
||||||
keyrings: JsValue,
|
keyrings: JsValue,
|
||||||
signer_public_key_bundles: JsValue,
|
signer_public_key_bundles: JsValue,
|
||||||
|
|
@ -255,12 +418,49 @@ pub fn open_relay_content_with_keyrings(
|
||||||
optional_u64(&expected_final_recipient_id, "expectedFinalRecipientId")?;
|
optional_u64(&expected_final_recipient_id, "expectedFinalRecipientId")?;
|
||||||
let policy = protection_policy_from_suite(signature_suite)
|
let policy = protection_policy_from_suite(signature_suite)
|
||||||
.map_err(|error| wrapped_input_error("unsupported-signature-suite", error))?;
|
.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,
|
&metadata.inner,
|
||||||
&references,
|
&references,
|
||||||
&signer_public_keys,
|
&signer_public_keys,
|
||||||
expected_final_recipient_id,
|
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)?;
|
.map_err(relay_error)?;
|
||||||
Ok(WasmVerifiedRelayContent { inner: content })
|
Ok(WasmVerifiedRelayContent { inner: content })
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ use wasm_bindgen::prelude::*;
|
||||||
use wasm_bindgen_futures::JsFuture;
|
use wasm_bindgen_futures::JsFuture;
|
||||||
|
|
||||||
use crate::error::js_error;
|
use crate::error::js_error;
|
||||||
use crate::frame::parse_frame_value_with_type_map;
|
use crate::frame::parse_frame_value_with_limits;
|
||||||
use mtp_codec::TypeMap;
|
use mtp_codec::{DecodeLimits, EncodeLimits, TypeMap};
|
||||||
|
|
||||||
const CLOSE_FRAME_LEN: u32 = u32::MAX;
|
const CLOSE_FRAME_LEN: u32 = u32::MAX;
|
||||||
|
|
||||||
|
|
@ -133,6 +133,7 @@ pub struct WasmTransport {
|
||||||
/// Serializes stream creation and writes across concurrent callers.
|
/// Serializes stream creation and writes across concurrent callers.
|
||||||
send_lock: Rc<AsyncMutex<()>>,
|
send_lock: Rc<AsyncMutex<()>>,
|
||||||
type_map: Rc<RefCell<TypeMap>>,
|
type_map: Rc<RefCell<TypeMap>>,
|
||||||
|
decode_limits: Rc<RefCell<DecodeLimits>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WasmTransport {
|
impl WasmTransport {
|
||||||
|
|
@ -140,6 +141,15 @@ impl WasmTransport {
|
||||||
url: &str,
|
url: &str,
|
||||||
cert_hashes: Option<Vec<String>>,
|
cert_hashes: Option<Vec<String>>,
|
||||||
max_message_size: u32,
|
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> {
|
) -> Result<Self, JsValue> {
|
||||||
let ctor = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("WebTransport"))?
|
let ctor = js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("WebTransport"))?
|
||||||
.dyn_into::<js_sys::Function>()
|
.dyn_into::<js_sys::Function>()
|
||||||
|
|
@ -188,6 +198,10 @@ impl WasmTransport {
|
||||||
JsFuture::from(ready)
|
JsFuture::from(ready)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| js_error(format!("WebTransport ready failed: {:?}", e)))?;
|
.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 {
|
Ok(Self {
|
||||||
inner: transport,
|
inner: transport,
|
||||||
max_message_size,
|
max_message_size,
|
||||||
|
|
@ -198,6 +212,7 @@ impl WasmTransport {
|
||||||
outgoing_writer: Rc::new(RefCell::new(None)),
|
outgoing_writer: Rc::new(RefCell::new(None)),
|
||||||
send_lock: Rc::new(AsyncMutex::new(())),
|
send_lock: Rc::new(AsyncMutex::new(())),
|
||||||
type_map: Rc::new(RefCell::new(TypeMap::latest())),
|
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()
|
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> {
|
pub async fn send_frame(&self, frame: &[u8]) -> Result<(), JsValue> {
|
||||||
let _send_guard = self.send_lock.lock().await;
|
let _send_guard = self.send_lock.lock().await;
|
||||||
if frame.len() as u64 > self.max_message_size as u64
|
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 {
|
match self.next_frame(self.max_message_size).await {
|
||||||
Ok(FrameOutcome::Frame(frame)) => {
|
Ok(FrameOutcome::Frame(frame)) => {
|
||||||
let type_map = self.type_map();
|
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) => {
|
Ok(parsed) => {
|
||||||
on_message(parsed);
|
on_message(parsed);
|
||||||
}
|
}
|
||||||
|
|
@ -498,15 +524,23 @@ impl WasmTransport {
|
||||||
match self.next_frame(self.max_message_size).await {
|
match self.next_frame(self.max_message_size).await {
|
||||||
Ok(FrameOutcome::Frame(frame)) => {
|
Ok(FrameOutcome::Frame(frame)) => {
|
||||||
let type_map = self.type_map();
|
let type_map = self.type_map();
|
||||||
|
let decode_limits = self.decode_limits();
|
||||||
let pipe_request_type =
|
let pipe_request_type =
|
||||||
mtp_codec::CommunicationType::PipeRequest.try_to_id(&type_map);
|
mtp_codec::CommunicationType::PipeRequest.try_to_id(&type_map);
|
||||||
let pipe_response_type =
|
let pipe_response_type =
|
||||||
mtp_codec::CommunicationType::PipeResponse.try_to_id(&type_map);
|
mtp_codec::CommunicationType::PipeResponse.try_to_id(&type_map);
|
||||||
let is_first = self.new_stream_frame.get();
|
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 {
|
if is_first {
|
||||||
self.new_stream_frame.set(false);
|
self.new_stream_frame.set(false);
|
||||||
if let Ok(comm) =
|
if let Some(comm) = comm.as_ref()
|
||||||
mtp_codec::CommunicationValue::from_bytes_with(&frame, &type_map)
|
|
||||||
&& Some(comm.get_type()) == pipe_request_type
|
&& Some(comm.get_type()) == pipe_request_type
|
||||||
{
|
{
|
||||||
let Some(pipe_id) = comm.id().filter(|id| *id != 0) else {
|
let Some(pipe_id) = comm.id().filter(|id| *id != 0) else {
|
||||||
|
|
@ -539,8 +573,7 @@ impl WasmTransport {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(comm) =
|
if let Some(comm) = comm.as_ref()
|
||||||
mtp_codec::CommunicationValue::from_bytes_with(&frame, &type_map)
|
|
||||||
&& Some(comm.get_type()) == pipe_response_type
|
&& Some(comm.get_type()) == pipe_response_type
|
||||||
&& !matches!(comm.id(), Some(id) if id != 0)
|
&& !matches!(comm.id(), Some(id) if id != 0)
|
||||||
{
|
{
|
||||||
|
|
@ -551,8 +584,7 @@ impl WasmTransport {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(comm) =
|
if let Some(comm) = comm.as_ref()
|
||||||
mtp_codec::CommunicationValue::from_bytes_with(&frame, &type_map)
|
|
||||||
&& !matches!(comm.id(), Some(id) if id != 0)
|
&& !matches!(comm.id(), Some(id) if id != 0)
|
||||||
&& comm
|
&& comm
|
||||||
.get_type_name()
|
.get_type_name()
|
||||||
|
|
@ -565,7 +597,7 @@ impl WasmTransport {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
match parse_frame_value_with_type_map(&frame, &type_map) {
|
match parse_frame_value_with_limits(&frame, &type_map, decode_limits) {
|
||||||
Ok(parsed) => {
|
Ok(parsed) => {
|
||||||
on_message(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
1132
wasm/types/mtp_wasm.d.ts
vendored
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue