[WIP] Security work While on holiday

This commit is contained in:
Alex 2026-08-12 22:45:28 +02:00
commit 7f0231e3f1
Signed by: alex
SSH key fingerprint: SHA256:D1+Ub8o0v4K5y1JNivW8IxEOelqLSvPmUzBbDIoZkRQ
109 changed files with 19694 additions and 5210 deletions

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -16,6 +16,10 @@ pub fn from_crypto_error(e: mtp_crypto::CryptoError) -> JsValue {
js_error(e.to_string())
}
pub fn from_protection_error(e: mtp_codec::ProtectionError) -> JsValue {
js_error(e.to_string())
}
#[cfg(test)]
#[cfg(target_arch = "wasm32")]
mod tests {

View file

@ -7,12 +7,40 @@ use crate::error::js_error;
#[wasm_bindgen(typescript_custom_section)]
const PARSED_FRAME_TS: &'static str = r#"
export interface ParsedEncryptedValue {
kind: "encrypted";
encryptionType: number;
purpose: number;
recipientCount: number;
encoded: Uint8Array;
}
export interface ParsedSignedValue {
kind: "signed";
signatureType: number;
purpose: number;
signerId: bigint;
value: ParsedDataValue;
}
export type ParsedDataValue =
| boolean
| number
| bigint
| string
| Uint8Array
| ParsedDataValue[]
| { [key: string]: ParsedDataValue }
| ParsedEncryptedValue
| ParsedSignedValue
| null;
export interface ParsedFrame {
id?: number;
type: string;
sender?: bigint;
receiver?: bigint;
data: Record<string, unknown>;
data: ParsedDataValue;
raw: Uint8Array;
}
"#;
@ -28,10 +56,10 @@ fn integer_value(value: &str) -> JsValue {
{
return JsValue::from_f64(number);
}
JsValue::from_str(value)
JsValue::bigint_from_str(value)
}
fn data_value_to_js(value: &DataValue, tm: &TypeMap) -> Result<JsValue, JsValue> {
pub(crate) fn data_value_to_js(value: &DataValue, tm: &TypeMap) -> Result<JsValue, JsValue> {
match value {
DataValue::BoolTrue => Ok(JsValue::TRUE),
DataValue::BoolFalse => Ok(JsValue::FALSE),
@ -59,17 +87,57 @@ fn data_value_to_js(value: &DataValue, tm: &TypeMap) -> Result<JsValue, JsValue>
}
Ok(obj.into())
}
DataValue::EncryptedContainer(bytes)
| DataValue::SignedContainer(bytes)
| DataValue::SignedEncryptedContainer(bytes) => {
Ok(js_sys::Uint8Array::from(&bytes[..]).into())
DataValue::Encrypted(encrypted) => {
let obj = js_sys::Object::new();
set_prop(&obj, "kind", &JsValue::from_str("encrypted"))?;
set_prop(
&obj,
"encryptionType",
&JsValue::from_f64(encrypted.encryption_type.to_byte() as f64),
)?;
set_prop(
&obj,
"purpose",
&JsValue::from_f64(encrypted.purpose as f64),
)?;
set_prop(
&obj,
"recipientCount",
&JsValue::from_f64(encrypted.recipients.len() as f64),
)?;
let encoded = value
.to_bytes()
.map_err(|e| js_error(format!("encode protected value: {e}")))?;
set_prop(
&obj,
"encoded",
&js_sys::Uint8Array::from(&encoded[..]).into(),
)?;
Ok(obj.into())
}
DataValue::Signed(signed) => {
let obj = js_sys::Object::new();
set_prop(&obj, "kind", &JsValue::from_str("signed"))?;
set_prop(
&obj,
"signatureType",
&JsValue::from_f64(signed.algorithm as f64),
)?;
set_prop(&obj, "purpose", &JsValue::from_f64(signed.purpose as f64))?;
set_prop(
&obj,
"signerId",
&JsValue::bigint_from_str(&signed.signer_id.to_string()),
)?;
set_prop(&obj, "value", &data_value_to_js(&signed.value, tm)?)?;
Ok(obj.into())
}
DataValue::Null => Ok(JsValue::NULL),
}
}
const MAX_SAFE_INT: f64 = 9007199254740991.0; // 2^53 - 1
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> {
if value.is_null() || value.is_undefined() {
return Ok(DataValue::Null);
}
@ -91,18 +159,13 @@ fn js_to_data_value(value: &JsValue, tm: &TypeMap) -> Result<DataValue, JsValue>
return Ok(DataValue::Array(values));
}
if let Some(v) = value.as_f64() {
if let Some(v) = value.as_f64() {
if v.is_finite()
&& v.fract() == 0.0
&& (-(MAX_SAFE_INT + 1.0)..=MAX_SAFE_INT).contains(&v)
{
if v >= 0.0 {
return Ok(DataValue::UnsignedNumber(v as u128));
} else {
return Ok(DataValue::SignedNumber(v as i128));
}
if v.is_finite() && v.fract() == 0.0 && (-(MAX_SAFE_INT + 1.0)..=MAX_SAFE_INT).contains(&v)
{
if v >= 0.0 {
return Ok(DataValue::UnsignedNumber(v as u128));
} else {
return Ok(DataValue::SignedNumber(v as i128));
}
return Ok(DataValue::Float(v));
}
return Ok(DataValue::Float(v));
}
@ -115,10 +178,16 @@ fn js_to_data_value(value: &JsValue, tm: &TypeMap) -> Result<DataValue, JsValue>
.as_string()
.ok_or_else(|| js_error("failed to stringify bigint"))?;
if let Some(unsigned) = as_string.strip_prefix('-') {
let n = unsigned
.parse::<i128>()
let magnitude = unsigned
.parse::<u128>()
.map_err(|_| js_error("bigint out of range"))?;
return Ok(DataValue::SignedNumber(-n));
if magnitude > (1u128 << 127) {
return Err(js_error("bigint out of range"));
}
if magnitude == (1u128 << 127) {
return Ok(DataValue::SignedNumber(i128::MIN));
}
return Ok(DataValue::SignedNumber(-(magnitude as i128)));
}
let n = as_string
.parse::<u128>()
@ -159,7 +228,11 @@ fn option_u32(options: &JsValue, key: &str) -> Result<Option<u32>, JsValue> {
let Some(n) = value.as_f64() else {
return Err(js_error(format!("{key} must be a number")));
};
Ok(Some(n as u32))
if !n.is_finite() || n.fract() != 0.0 || !(0.0..=MAX_SAFE_INT).contains(&n) {
return Err(js_error(format!("{key} must be an exact integer")));
}
let n = u32::try_from(n as u64).map_err(|_| js_error(format!("{key} out of range")))?;
Ok(Some(n))
}
fn option_u64(options: &JsValue, key: &str) -> Result<Option<u64>, JsValue> {
@ -168,6 +241,11 @@ fn option_u64(options: &JsValue, key: &str) -> Result<Option<u64>, JsValue> {
return Ok(None);
}
if let Some(n) = value.as_f64() {
if !n.is_finite() || n.fract() != 0.0 || !(0.0..=MAX_SAFE_INT).contains(&n) {
return Err(js_error(format!(
"{key} must be an exact integer number at most 2^53-1 or a bigint"
)));
}
return Ok(Some(n as u64));
}
let type_name = value.js_typeof().as_string().unwrap_or_default();
@ -185,18 +263,39 @@ fn option_u64(options: &JsValue, key: &str) -> Result<Option<u64>, JsValue> {
Err(js_error(format!("{key} must be a number or bigint")))
}
pub(crate) fn parse_frame_value(frame: &[u8]) -> Result<JsValue, JsValue> {
let comm = CommunicationValue::from_bytes(frame)
.map_err(|e| js_error(format!("parse failed: {}", e)))?;
let tm = comm
.type_map()
.cloned()
.unwrap_or_else(|| TypeMap::new(PROTOCOL_VERSION));
let obj = js_sys::Object::new();
let data = js_sys::Object::new();
fn apply_frame_options(
mut message: CommunicationValue,
options: &JsValue,
) -> Result<CommunicationValue, JsValue> {
if !options.is_null() && !options.is_undefined() {
if let Some(id) = option_u32(options, "id")? {
message = message.with_id(id);
}
if let Some(sender) = option_u64(options, "sender")? {
message = message.with_sender(sender);
}
if let Some(receiver) = option_u64(options, "receiver")? {
message = message.with_receiver(receiver);
}
}
Ok(message)
}
if comm.get_id() != 0 {
set_prop(&obj, "id", &JsValue::from_f64(comm.get_id() as f64))?;
pub(crate) fn parse_frame_value(frame: &[u8]) -> Result<JsValue, JsValue> {
parse_frame_value_with_type_map(frame, &TypeMap::latest())
}
pub(crate) fn parse_frame_value_with_type_map(
frame: &[u8],
type_map: &TypeMap,
) -> Result<JsValue, JsValue> {
let comm = CommunicationValue::from_bytes_with(frame, type_map)
.map_err(|e| js_error(format!("parse failed: {}", e)))?;
let tm = type_map;
let obj = js_sys::Object::new();
if let Some(id) = comm.id() {
set_prop(&obj, "id", &JsValue::from_f64(id as f64))?;
}
let frame_type = tm
@ -205,29 +304,22 @@ pub(crate) fn parse_frame_value(frame: &[u8]) -> Result<JsValue, JsValue> {
.unwrap_or_else(|| comm.get_type().0.to_string());
set_prop(&obj, "type", &JsValue::from_str(&frame_type))?;
if comm.get_sender() != 0 {
if let Some(sender) = comm.sender() {
set_prop(
&obj,
"sender",
&JsValue::bigint_from_str(&comm.get_sender().to_string()),
&JsValue::bigint_from_str(&sender.to_string()),
)?;
}
if comm.get_receiver() != 0 {
if let Some(receiver) = comm.receiver() {
set_prop(
&obj,
"receiver",
&JsValue::bigint_from_str(&comm.get_receiver().to_string()),
&JsValue::bigint_from_str(&receiver.to_string()),
)?;
}
for (key, value) in comm.data() {
let name = tm
.data_type_name(key.0)
.map(str::to_string)
.unwrap_or_else(|| key.0.to_string());
set_prop(&data, &name, &data_value_to_js(value, &tm)?)?;
}
set_prop(&obj, "data", &data.into())?;
set_prop(&obj, "data", &data_value_to_js(comm.payload(), &tm)?)?;
set_prop(&obj, "raw", &js_sys::Uint8Array::from(frame).into())?;
Ok(obj.into())
@ -266,25 +358,30 @@ pub fn parse_auth_response(response: &[u8]) -> Result<JsValue, JsValue> {
let comm = CommunicationValue::from_bytes(response)
.map_err(|e| js_error(format!("parse failed: {}", e)))?;
let connected = matches!(comm.get_data(DataType::Connected), DataValue::BoolTrue);
let connected = matches!(
comm.get_data(DataType::Connected),
Some(DataValue::BoolTrue)
);
let client_nonce = match comm.get_data(DataType::ClientNonce) {
DataValue::UnsignedNumber(n) => Some(*n),
Some(DataValue::UnsignedNumber(n)) => Some(*n),
_ => None,
};
let assigned_id = match comm.get_data(DataType::Id) {
DataValue::UnsignedNumber(n) => Some(*n as u64),
Some(DataValue::UnsignedNumber(n)) => {
Some(u64::try_from(*n).map_err(|_| js_error("assigned ID is out of range"))?)
}
_ => None,
};
let timestamp = match comm.get_data(DataType::Timestamp) {
DataValue::UnsignedNumber(n) => Some(*n),
Some(DataValue::UnsignedNumber(n)) => Some(*n),
_ => None,
};
let signature = match comm.get_data(DataType::Signature) {
DataValue::Bytes(b) => Some(b.clone()),
Some(DataValue::Bytes(b)) => Some(b.clone()),
_ => None,
};
@ -332,6 +429,25 @@ pub fn parse_frame(frame: &[u8]) -> Result<JsValue, JsValue> {
parse_frame_value(frame)
}
/// Parse a standalone serialized `DataValue` into the same structured form
/// used for frame payloads. Protected values remain opaque until the caller
/// explicitly opens and verifies them.
#[wasm_bindgen(unchecked_return_type = "ParsedDataValue")]
pub fn parse_data_value(value: &[u8]) -> Result<JsValue, JsValue> {
let value = DataValue::from_bytes(value).ok_or_else(|| js_error("invalid DataValue"))?;
let tm = TypeMap::new(PROTOCOL_VERSION);
data_value_to_js(&value, &tm)
}
/// Encode one standalone `DataValue` using the negotiated/current type map.
#[wasm_bindgen]
pub fn encode_data_value(value: JsValue) -> Result<Vec<u8>, JsValue> {
let tm = TypeMap::new(PROTOCOL_VERSION);
js_to_data_value(&value, &tm)?
.to_bytes()
.map_err(|e| js_error(format!("encode data value failed: {e}")))
}
/// Build a typed MTP frame using generated communication/data type names.
#[wasm_bindgen]
pub fn build_frame(
@ -342,19 +458,7 @@ pub fn build_frame(
let comm_type = CommunicationType::from_name(message_type)
.ok_or_else(|| js_error(format!("unknown communication type: {message_type}")))?;
let tm = TypeMap::new(PROTOCOL_VERSION);
let mut msg = CommunicationValue::new(comm_type);
if !options.is_null() && !options.is_undefined() {
if let Some(id) = option_u32(&options, "id")? {
msg = msg.with_id(id);
}
if let Some(sender) = option_u64(&options, "sender")? {
msg = msg.with_sender(sender);
}
if let Some(receiver) = option_u64(&options, "receiver")? {
msg = msg.with_receiver(receiver);
}
}
let mut msg = apply_frame_options(CommunicationValue::new(comm_type), &options)?;
if data.is_object() && !js_sys::Uint8Array::instanceof(&data) && !js_sys::Array::is_array(&data)
{
@ -373,7 +477,9 @@ pub fn build_frame(
tm.version
))
})?;
msg = msg.add_data(id, js_to_data_value(&value, &tm)?);
msg = msg
.add_data(id, js_to_data_value(&value, &tm)?)
.map_err(|e| js_error(format!("add data failed: {e}")))?;
}
} else if !data.is_null() && !data.is_undefined() {
return Err(js_error(
@ -385,10 +491,37 @@ pub fn build_frame(
.map_err(|e| js_error(format!("encode failed: {}", e)))
}
/// Build a typed MTP frame around a complete serialized `DataValue` payload.
///
/// Unlike [`build_frame`], this does not interpret the payload as a clear data
/// container. It can therefore carry any value supported by the codec,
/// including signed and encrypted protection wrappers.
#[wasm_bindgen]
pub fn build_frame_with_payload(
message_type: &str,
serialized_payload: &[u8],
options: JsValue,
) -> Result<Vec<u8>, JsValue> {
let comm_type = CommunicationType::from_name(message_type)
.ok_or_else(|| js_error(format!("unknown communication type: {message_type}")))?;
let payload = DataValue::from_bytes(serialized_payload)
.ok_or_else(|| js_error("invalid serialized DataValue payload"))?;
let message =
apply_frame_options(CommunicationValue::new(comm_type), &options)?.with_payload(payload);
message
.to_bytes()
.map_err(|e| js_error(format!("encode failed: {e}")))
}
#[cfg(test)]
#[cfg(target_arch = "wasm32")]
mod tests {
use super::*;
use mtp_codec::ProtectionPurpose;
use mtp_crypto::{
Ed25519Signer, HybridKem, PublicKeyBundle, SignaturePqPublicKey, SignaturePublicKey,
};
use wasm_bindgen_test::*;
#[wasm_bindgen_test]
@ -401,14 +534,14 @@ mod tests {
cv.get_type(),
CommunicationType::Ping.try_to_id(&tm).unwrap()
);
assert_eq!(cv.get_sender(), 42);
assert_eq!(cv.sender(), Some(42));
assert_eq!(
cv.get_data(DataType::Description),
&DataValue::Str("test-ping".into())
Some(&DataValue::Str("test-ping".into()))
);
assert_eq!(
cv.get_data(DataType::Timestamp),
&DataValue::UnsignedNumber(1234567890)
Some(&DataValue::UnsignedNumber(1234567890))
);
}
@ -423,18 +556,18 @@ mod tests {
cv.get_type(),
CommunicationType::Ping.try_to_id(&tm).unwrap()
);
assert_eq!(cv.get_sender(), 99);
assert_eq!(cv.sender(), Some(99));
assert_eq!(
cv.get_data(DataType::Description),
&DataValue::Str("with-data".into())
Some(&DataValue::Str("with-data".into()))
);
assert_eq!(
cv.get_data(DataType::Timestamp),
&DataValue::UnsignedNumber(555)
Some(&DataValue::UnsignedNumber(555))
);
assert_eq!(
cv.get_data(DataType::Id),
&DataValue::Bytes(payload.to_vec())
Some(&DataValue::Bytes(payload.to_vec()))
);
}
@ -442,7 +575,273 @@ mod tests {
fn build_ping_frame_client_id_zero() {
let bytes = build_ping_frame(0, "zero-id", 0, &[]).expect("encode failed");
let cv = CommunicationValue::from_bytes(&bytes).expect("decode failed");
assert_eq!(cv.get_sender(), 0);
assert_eq!(cv.sender(), Some(0));
}
#[wasm_bindgen_test]
fn parse_frame_preserves_a_generic_payload() {
let bytes = CommunicationValue::new(CommunicationType::Pong)
.with_payload(DataValue::Bytes(vec![1, 2, 3]))
.to_bytes()
.expect("encode failed");
let parsed = parse_frame_value(&bytes).expect("parse failed");
let data = js_sys::Reflect::get(&parsed, &JsValue::from_str("data"))
.expect("data should be present");
assert_eq!(js_sys::Uint8Array::new(&data).to_vec(), vec![1, 2, 3]);
}
#[wasm_bindgen_test]
fn integer_data_values_round_trip_without_losing_numeric_type() {
let tm = TypeMap::latest();
let cases = [
(DataValue::UnsignedNumber(9_007_199_254_740_991), "number"),
(DataValue::UnsignedNumber(9_007_199_254_740_992), "bigint"),
(DataValue::SignedNumber(-9_007_199_254_740_992), "bigint"),
(DataValue::SignedNumber(i128::MIN), "bigint"),
(DataValue::UnsignedNumber(u128::from(u64::MAX)), "bigint"),
];
for (original, expected_type) in cases {
let javascript = data_value_to_js(&original, &tm).expect("decode value");
assert_eq!(
javascript.js_typeof().as_string().as_deref(),
Some(expected_type)
);
assert_eq!(
js_to_data_value(&javascript, &tm).expect("encode value"),
original
);
}
}
#[wasm_bindgen_test]
fn parse_frame_preserves_signed_value_structure() {
let (signer, _secret_key, _public_key) = Ed25519Signer::generate();
let signed = DataValue::Str("signed payload".into())
.sign(0xfeed_beef, ProtectionPurpose::from(7), &signer)
.expect("signing failed");
let bytes = CommunicationValue::new(CommunicationType::Pong)
.with_payload(signed)
.to_bytes()
.expect("encode failed");
let parsed = parse_frame_value(&bytes).expect("parse failed");
let data = js_sys::Reflect::get(&parsed, &"data".into()).expect("data should be present");
assert_eq!(
js_sys::Reflect::get(&data, &"kind".into())
.expect("kind should be present")
.as_string()
.as_deref(),
Some("signed")
);
assert_eq!(
js_sys::Reflect::get(&data, &"purpose".into())
.expect("purpose should be present")
.as_f64(),
Some(7.0)
);
let signer_id = js_sys::Reflect::get(&data, &"signerId".into())
.expect("signerId should be present")
.unchecked_into::<js_sys::BigInt>()
.to_string(10)
.expect("signerId should stringify")
.as_string();
assert_eq!(signer_id.as_deref(), Some("4276993775"));
assert_eq!(
js_sys::Reflect::get(&data, &"value".into())
.expect("value should be present")
.as_string()
.as_deref(),
Some("signed payload")
);
}
#[wasm_bindgen_test]
fn parse_frame_keeps_encrypted_contents_private() {
let (_secret_key, public_key) = HybridKem::generate_keypair();
let recipient = PublicKeyBundle::new(
public_key,
SignaturePqPublicKey::new(Vec::new()),
SignaturePublicKey::new(Vec::new()),
);
let encrypted = DataValue::Str("secret payload".into())
.encrypt_for(&[recipient], ProtectionPurpose::from(9))
.expect("encryption failed");
let encoded = encrypted.to_bytes().expect("encode protected value failed");
let bytes = CommunicationValue::new(CommunicationType::Pong)
.with_payload(encrypted)
.to_bytes()
.expect("encode failed");
let parsed = parse_frame_value(&bytes).expect("parse failed");
let data = js_sys::Reflect::get(&parsed, &"data".into()).expect("data should be present");
assert_eq!(
js_sys::Reflect::get(&data, &"kind".into())
.expect("kind should be present")
.as_string()
.as_deref(),
Some("encrypted")
);
assert_eq!(
js_sys::Reflect::get(&data, &"recipientCount".into())
.expect("recipientCount should be present")
.as_f64(),
Some(1.0)
);
assert!(!js_sys::Reflect::has(&data, &"value".into()).unwrap_or(false));
assert_eq!(
js_sys::Reflect::get(&data, &"encoded".into())
.expect("encoded should be present")
.unchecked_into::<js_sys::Uint8Array>()
.to_vec(),
encoded
);
}
#[wasm_bindgen_test]
fn parse_frame_preserves_signed_encrypted_composition() {
let (signer, _secret_key, _public_key) = Ed25519Signer::generate();
let (_kem_secret_key, kem_public_key) = HybridKem::generate_keypair();
let recipient = PublicKeyBundle::new(
kem_public_key,
SignaturePqPublicKey::new(Vec::new()),
SignaturePublicKey::new(Vec::new()),
);
let encrypted = DataValue::Container(vec![(
mtp_type_map::DataTypeId(32),
DataValue::Str("secret payload".into()),
)])
.encrypt_for(&[recipient], ProtectionPurpose::from(9))
.expect("encryption failed");
let encrypted_bytes = encrypted.to_bytes().expect("encrypted value should encode");
let signed = encrypted
.sign(0x0102_0304_0506_0708, ProtectionPurpose::from(7), &signer)
.expect("signing failed");
let frame = CommunicationValue::new(CommunicationType::Pong)
.with_payload(signed)
.to_bytes()
.expect("frame should encode");
let parsed = parse_frame_value(&frame).expect("frame should parse");
let signed = js_sys::Reflect::get(&parsed, &"data".into())
.expect("signed payload should be present");
assert_eq!(
js_sys::Reflect::get(&signed, &"kind".into())
.expect("signed kind should be present")
.as_string()
.as_deref(),
Some("signed")
);
let encrypted = js_sys::Reflect::get(&signed, &"value".into())
.expect("encrypted inner value should be present");
assert_eq!(
js_sys::Reflect::get(&encrypted, &"kind".into())
.expect("encrypted kind should be present")
.as_string()
.as_deref(),
Some("encrypted")
);
assert_eq!(
js_sys::Reflect::get(&encrypted, &"encoded".into())
.expect("encrypted encoding should be present")
.unchecked_into::<js_sys::Uint8Array>()
.to_vec(),
encrypted_bytes
);
}
#[wasm_bindgen_test]
fn frame_ids_use_bigints_without_lossy_number_casts() {
let options = js_sys::Object::new();
js_sys::Reflect::set(
&options,
&"sender".into(),
&JsValue::bigint_from_str("18446744073709551615"),
)
.expect("sender option should be set");
let bytes = build_frame("Pong", JsValue::NULL, options.into()).expect("build failed");
let frame = CommunicationValue::from_bytes(&bytes).expect("decode failed");
assert_eq!(frame.sender(), Some(u64::MAX));
let unsafe_number = js_sys::Object::new();
js_sys::Reflect::set(
&unsafe_number,
&"sender".into(),
&JsValue::from_f64(MAX_SAFE_INT + 1.0),
)
.expect("sender option should be set");
assert!(build_frame("Pong", JsValue::NULL, unsafe_number.into()).is_err());
}
#[wasm_bindgen_test]
fn build_frame_with_payload_preserves_clear_and_protected_payloads() {
let (signer, _secret_key, _public_key) = Ed25519Signer::generate();
let (_kem_secret_key, kem_public_key) = HybridKem::generate_keypair();
let recipient = PublicKeyBundle::new(
kem_public_key,
SignaturePqPublicKey::new(Vec::new()),
SignaturePublicKey::new(Vec::new()),
);
let clear = DataValue::Str("generic protected payload".into());
let signed = clear
.clone()
.sign(0x0102_0304_0506_0708, ProtectionPurpose::from(7), &signer)
.expect("signing failed");
let encrypted = clear
.clone()
.encrypt_for(&[recipient.clone()], ProtectionPurpose::from(9))
.expect("encryption failed");
let signed_encrypted = signed
.clone()
.encrypt_for(&[recipient], ProtectionPurpose::from(9))
.expect("signed encryption failed");
for payload in [clear, signed, encrypted, signed_encrypted] {
let serialized = payload.to_bytes().expect("payload encoding failed");
let frame = build_frame_with_payload("Pong", &serialized, JsValue::NULL)
.expect("frame encoding failed");
let decoded = CommunicationValue::from_bytes(&frame).expect("frame decoding failed");
assert_eq!(
decoded
.payload()
.to_bytes()
.expect("payload re-encoding failed"),
serialized
);
}
}
#[wasm_bindgen_test]
fn build_frame_with_payload_applies_frame_options() {
let payload = DataValue::Str("payload".into())
.to_bytes()
.expect("payload encoding failed");
let options = js_sys::Object::new();
js_sys::Reflect::set(&options, &"id".into(), &JsValue::from_f64(17.0))
.expect("id option should be set");
js_sys::Reflect::set(
&options,
&"sender".into(),
&JsValue::bigint_from_str("18446744073709551615"),
)
.expect("sender option should be set");
js_sys::Reflect::set(&options, &"receiver".into(), &JsValue::from_f64(23.0))
.expect("receiver option should be set");
let frame = build_frame_with_payload("Pong", &payload, options.into())
.expect("frame encoding failed");
let decoded = CommunicationValue::from_bytes(&frame).expect("frame decoding failed");
assert_eq!(decoded.id(), Some(17));
assert_eq!(decoded.sender(), Some(u64::MAX));
assert_eq!(decoded.receiver(), Some(23));
assert_eq!(
decoded
.payload()
.to_bytes()
.expect("payload re-encoding failed"),
payload
);
}
#[wasm_bindgen_test]

View file

@ -7,11 +7,14 @@ pub mod error;
pub mod frame;
pub mod logging;
pub mod pipe;
pub mod protected;
pub mod relay;
pub mod subscription;
pub mod transport;
pub use client::WasmClient;
pub use config::{ConnectionConfig, WasmClientConfig};
pub use crypto::{decrypt_data_value, encrypt_data_value, encrypt_data_value_for_recipients};
#[cfg(not(test))]
use wasm_bindgen::prelude::*;

434
wasm/src/protected.rs Normal file
View file

@ -0,0 +1,434 @@
use wasm_bindgen::prelude::*;
use mtp_codec::{
DataValue, ProtectedError, ProtectedMessageBuilder, ProtectionError, ProtectionPurpose,
VerifiedProtectedMessage,
};
use crate::crypto::{
keyrings_from_js, protection_policy_from_suite, public_key_bundles_from_js,
relay_signer_from_keyring,
};
use crate::relay::{decode_frame, structured_error};
const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
fn optional_u64(value: &JsValue, name: &str) -> Result<Option<u64>, JsValue> {
if value.is_null() || value.is_undefined() {
return Ok(None);
}
if let Some(number) = value.as_f64() {
if !number.is_finite()
|| number.fract() != 0.0
|| !(0.0..=MAX_SAFE_INTEGER).contains(&number)
{
return Err(structured_error(
"invalid-option",
format!("{name} must be an exact non-negative integer"),
));
}
return Ok(Some(number as u64));
}
if value.js_typeof().as_string().as_deref() == Some("bigint") {
let bigint = value.clone().unchecked_into::<js_sys::BigInt>();
let text = bigint.to_string(10)?.as_string().ok_or_else(|| {
structured_error("invalid-option", format!("failed to stringify {name}"))
})?;
return text
.parse::<u64>()
.map(Some)
.map_err(|_| structured_error("invalid-option", format!("{name} is out of range")));
}
Err(structured_error(
"invalid-option",
format!("{name} must be a number or bigint"),
))
}
fn decode_data_value(value: &[u8]) -> Result<DataValue, JsValue> {
DataValue::from_bytes(value)
.ok_or_else(|| structured_error("invalid-data-value", "invalid DataValue"))
}
pub(crate) fn protected_error(error: ProtectedError) -> JsValue {
let code = protected_error_code(&error);
let value = structured_error(code, format!("protected opening failed: {error}"));
if let ProtectedError::UnsupportedProtectedVersion(version) = &error {
let _ = js_sys::Reflect::set(
&value,
&JsValue::from_str("protectedVersion"),
&JsValue::bigint_from_str(&version.to_string()),
);
}
if let ProtectedError::ReservedApplicationType(application_type) = &error {
let _ = js_sys::Reflect::set(
&value,
&JsValue::from_str("applicationType"),
&JsValue::from_str(application_type),
);
}
value
}
fn protected_error_code(error: &ProtectedError) -> &'static str {
match error {
ProtectedError::NotApplicationFrame => "not-application-frame",
ProtectedError::MissingReceiver => "missing-receiver",
ProtectedError::PayloadNotEncrypted => "payload-not-encrypted",
ProtectedError::PayloadNotSigned => "payload-not-signed",
ProtectedError::MissingEnvelope => "missing-envelope",
ProtectedError::InvalidLayout(_) => "invalid-layout",
ProtectedError::MissingProtectedVersion => "missing-protected-version",
ProtectedError::UnsupportedProtectedVersion(_) => "unsupported-protected-version",
ProtectedError::MessageTypeMismatch => "message-type-mismatch",
ProtectedError::FinalRecipientMismatch => "final-recipient-mismatch",
ProtectedError::SenderMismatch => "sender-id-mismatch",
ProtectedError::ExpectedReceiverMismatch => "receiver-id-mismatch",
ProtectedError::ReservedApplicationType(_) => "reserved-application-type",
ProtectedError::Replay => "replay",
ProtectedError::ReplayGuard(_) => "replay-guard-error",
ProtectedError::Protection(error) => match error {
ProtectionError::NoMatchingRecipient => "no-matching-recipient",
ProtectionError::InvalidSignature => "invalid-signature",
ProtectionError::SignaturePolicyMismatch { .. } => "signature-policy-mismatch",
ProtectionError::PurposeMismatch { .. } => "purpose-mismatch",
ProtectionError::SignerIdMismatch { .. } => "signer-id-mismatch",
ProtectionError::SignerKeyNotFound(_) => "signer-key-not-found",
ProtectionError::Crypto(mtp_crypto::CryptoError::InvalidSignature)
| ProtectionError::Crypto(mtp_crypto::CryptoError::VerificationFailed) => {
"invalid-signature"
}
_ => "protection-error",
},
}
}
fn serialize_data_value(value: &DataValue) -> Result<Vec<u8>, JsValue> {
value.to_bytes().map_err(|error| {
structured_error(
"invalid-data-value",
format!("protected value encoding failed: {error}"),
)
})
}
fn serialize_frame(frame: &mtp_codec::CommunicationValue) -> Result<Vec<u8>, JsValue> {
frame.to_bytes().map_err(|error| {
structured_error(
"invalid-frame",
format!("protected frame encoding failed: {error}"),
)
})
}
#[wasm_bindgen]
pub struct WasmVerifiedProtectedMessage {
inner: VerifiedProtectedMessage,
}
#[wasm_bindgen]
impl WasmVerifiedProtectedMessage {
pub fn protected_version(&self) -> u64 {
self.inner.protected_version
}
pub fn signer_id(&self) -> u64 {
self.inner.signer_id
}
pub fn final_recipient_id(&self) -> u64 {
self.inner.final_recipient_id
}
pub fn message_id(&self) -> String {
self.inner.message_id.clone()
}
pub fn created_at(&self) -> u64 {
self.inner.created_at
}
pub fn message_type(&self) -> String {
self.inner.message_type.clone()
}
pub fn content(&self) -> Result<Vec<u8>, JsValue> {
serialize_data_value(&self.inner.content)
}
pub fn matched_signer_key_index(&self) -> usize {
self.inner.matched_signer_key_index
}
}
/// Build a complete encrypted direct protected frame in the native codec.
/// The native builder owns both the protected envelope and the clear outer
/// routing fields, including the optional sender exposure and frame ID.
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn build_protected_frame_with_keyring(
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,
) -> Result<Vec<u8>, JsValue> {
let content = decode_data_value(encoded_content)?;
let keyring = mtp_crypto::Keyring::from_bytes(keyring_bytes).map_err(|error| {
structured_error(
"invalid-keyring",
format!("keyring initialization failed: {error}"),
)
})?;
let signer = relay_signer_from_keyring(&keyring, signature_suite)?;
let recipients = public_key_bundles_from_js(&recipient_public_key_bundles)?;
let mut builder = ProtectedMessageBuilder::new(
message_type,
content,
signer_id,
final_recipient_id,
&signer,
ProtectionPurpose::from(signature_purpose),
ProtectionPurpose::from(encryption_purpose),
)
.message_id(message_id)
.created_at(created_at)
.recipients(recipients)
.expose_sender(expose_sender);
if let Some(frame_id) = frame_id {
builder = builder.frame_id(frame_id);
}
let frame = builder.build().map_err(protected_error)?;
serialize_frame(&frame)
}
/// Read the claimed, unverified signer ID after decrypting the protected
/// payload. The result may only select trusted keys for the same signer ID.
#[wasm_bindgen]
pub fn protected_claimed_signer_id(
frame: &[u8],
keyrings: JsValue,
encryption_purpose: u8,
) -> Result<u64, JsValue> {
let frame = decode_frame(frame)?;
let keyrings = keyrings_from_js(&keyrings).map_err(|error| {
structured_error(
"invalid-recipient-keyrings",
error.as_string().unwrap_or_default(),
)
})?;
let references: Vec<&mtp_crypto::Keyring> = keyrings.iter().collect();
mtp_codec::protected_claimed_signer_id(
&frame,
&references,
ProtectionPurpose::from(encryption_purpose),
)
.map_err(protected_error)
}
/// Open and verify a direct protected message in the native codec using
/// trusted signer-key history supplied by the SDK.
#[wasm_bindgen]
pub fn open_protected_with_keyrings(
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,
) -> Result<WasmVerifiedProtectedMessage, JsValue> {
let frame = decode_frame(frame)?;
let keyrings = keyrings_from_js(&keyrings).map_err(|error| {
structured_error(
"invalid-recipient-keyrings",
error.as_string().unwrap_or_default(),
)
})?;
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| {
structured_error("invalid-signer-keys", error.as_string().unwrap_or_default())
})?;
let expected_signer_id = optional_u64(&expected_signer_id, "expectedSignerId")?
.ok_or_else(|| structured_error("invalid-option", "expectedSignerId is required"))?;
let expected_receiver_id = optional_u64(&expected_receiver_id, "expectedReceiverId")?;
let policy = protection_policy_from_suite(signature_suite).map_err(|error| {
structured_error(
"unsupported-signature-suite",
error.as_string().unwrap_or_default(),
)
})?;
let message = mtp_codec::open_protected_with_keys(
&frame,
&references,
expected_signer_id,
&signer_public_keys,
expected_receiver_id,
ProtectionPurpose::from(signature_purpose),
ProtectionPurpose::from(encryption_purpose),
policy,
None,
)
.map_err(protected_error)?;
Ok(WasmVerifiedProtectedMessage { inner: message })
}
#[cfg(all(test, target_arch = "wasm32"))]
mod tests {
use super::*;
use crate::crypto::relay_signer_from_keyring;
use mtp_codec::{CommunicationType, CommunicationValue, DataType, TypeMap};
use wasm_bindgen::JsCast;
use wasm_bindgen_test::*;
const SIGNATURE_PURPOSE: u8 = 0x40;
const ENCRYPTION_PURPOSE: u8 = 0x41;
fn structured_error_code(error: JsValue) -> String {
js_sys::Reflect::get(&error, &JsValue::from_str("code"))
.expect("structured error code")
.as_string()
.expect("structured error code string")
}
fn protected_frame_with_version(
sender: &mtp_crypto::Keyring,
recipient: &mtp_crypto::Keyring,
version: Option<u128>,
) -> Vec<u8> {
let type_map = TypeMap::latest();
let field = |data_type: DataType| data_type.try_to_id(&type_map).expect("field mapping");
let mut fields = Vec::new();
if let Some(version) = version {
fields.push((
field(DataType::ProtectedVersion),
DataValue::UnsignedNumber(version),
));
}
fields.extend([
(
field(DataType::MessageType),
DataValue::Str("ProtectedMessage".into()),
),
(
field(DataType::FinalRecipientId),
DataValue::UnsignedNumber(42),
),
(
field(DataType::MessageId),
DataValue::Str("wasm-structured-error".into()),
),
(field(DataType::CreatedAt), DataValue::UnsignedNumber(123)),
(field(DataType::Content), DataValue::Str("hello".into())),
]);
let signer = relay_signer_from_keyring(sender, 1).expect("Ed25519 signer");
let signed = DataValue::Container(fields)
.sign(7, ProtectionPurpose::from(SIGNATURE_PURPOSE), &signer)
.expect("sign protected envelope");
let encrypted = signed
.encrypt_for(
&[recipient.public_key_bundle()],
ProtectionPurpose::from(ENCRYPTION_PURPOSE),
)
.expect("encrypt protected envelope");
CommunicationValue::new_with_type_map(
CommunicationType::from_name("ProtectedMessage").expect("application type"),
&type_map,
)
.with_receiver(42)
.with_payload(encrypted)
.to_bytes()
.expect("encode protected frame")
}
fn open_for_error(
frame: &[u8],
sender: &mtp_crypto::Keyring,
recipient: &mtp_crypto::Keyring,
) -> JsValue {
let recipient_bytes = recipient.to_bytes();
let signer_bundle_bytes = sender.public_key_bundle().as_bytes();
match open_protected_with_keyrings(
frame,
js_sys::Uint8Array::from(&recipient_bytes[..]).into(),
JsValue::bigint_from_str("7"),
js_sys::Uint8Array::from(&signer_bundle_bytes[..]).into(),
JsValue::bigint_from_str("42"),
SIGNATURE_PURPOSE,
ENCRYPTION_PURPOSE,
1,
) {
Ok(_) => panic!("protected opening should fail"),
Err(error) => error,
}
}
#[wasm_bindgen_test]
fn protected_builder_returns_the_complete_frame() {
let sender = mtp_crypto::Keyring::generate();
let recipient = mtp_crypto::Keyring::generate();
let sender_bytes = sender.to_bytes();
let recipient_bundle_bytes = recipient.public_key_bundle().as_bytes();
let content = DataValue::Str("complete-frame".into())
.to_bytes()
.expect("encode content");
let frame = build_protected_frame_with_keyring(
"ProtectedMessage",
&content,
7,
42,
"wasm-complete-frame",
123,
SIGNATURE_PURPOSE,
ENCRYPTION_PURPOSE,
&sender_bytes,
1,
Some(19),
true,
js_sys::Uint8Array::from(&recipient_bundle_bytes[..]).into(),
)
.expect("build complete protected frame");
let decoded = CommunicationValue::from_bytes(&frame).expect("decode complete frame");
assert_eq!(decoded.id(), Some(19));
assert_eq!(decoded.sender(), Some(7));
assert_eq!(decoded.receiver(), Some(42));
assert!(decoded.payload().as_encrypted().is_some());
}
#[wasm_bindgen_test]
fn protected_opening_maps_missing_and_unsupported_versions() {
let sender = mtp_crypto::Keyring::generate();
let recipient = mtp_crypto::Keyring::generate();
let missing = protected_frame_with_version(&sender, &recipient, None);
assert_eq!(
structured_error_code(open_for_error(&missing, &sender, &recipient)),
"missing-protected-version"
);
let unsupported = protected_frame_with_version(&sender, &recipient, Some(2));
let error = open_for_error(&unsupported, &sender, &recipient);
assert_eq!(
structured_error_code(error.clone()),
"unsupported-protected-version"
);
let version = js_sys::Reflect::get(&error, &JsValue::from_str("protectedVersion"))
.expect("protected version");
let version = version
.unchecked_into::<js_sys::BigInt>()
.to_string(10)
.expect("protected version string")
.as_string()
.expect("protected version text");
assert_eq!(version, "2");
}
}

267
wasm/src/relay.rs Normal file
View file

@ -0,0 +1,267 @@
use wasm_bindgen::prelude::*;
use mtp_codec::{
CommunicationValue, DataValue, ProtectionError, RelayError, VerifiedRelayContent,
VerifiedRelayMetadata,
};
use crate::crypto::{keyrings_from_js, protection_policy_from_suite, public_key_bundles_from_js};
const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
pub(crate) fn structured_error(code: &str, message: impl Into<String>) -> JsValue {
let error = js_sys::Error::new(&message.into());
let value: JsValue = error.into();
let _ = js_sys::Reflect::set(&value, &JsValue::from_str("code"), &JsValue::from_str(code));
value
}
fn wrapped_input_error(code: &str, error: JsValue) -> JsValue {
let message = error
.as_string()
.unwrap_or_else(|| "invalid relay operation input".to_owned());
structured_error(code, message)
}
pub(crate) fn relay_error(error: mtp_codec::RelayError) -> JsValue {
let code = relay_error_code(&error);
let message = format!("relay opening failed: {error}");
let value = structured_error(code, message);
if let RelayError::UnsupportedRelayVersion(version) = &error {
let _ = js_sys::Reflect::set(
&value,
&JsValue::from_str("relayVersion"),
&JsValue::bigint_from_str(&version.to_string()),
);
}
if let RelayError::ReservedApplicationType(application_type) = &error {
let _ = js_sys::Reflect::set(
&value,
&JsValue::from_str("applicationType"),
&JsValue::from_str(application_type),
);
}
value
}
fn relay_error_code(error: &RelayError) -> &'static str {
match error {
RelayError::NotRelay => "not-relay",
RelayError::OuterSenderPresent => "outer-sender-present",
RelayError::MissingNextHop => "missing-next-hop",
RelayError::InvalidLayout(_) => "invalid-layout",
RelayError::MissingRelayVersion => "missing-relay-version",
RelayError::UnsupportedRelayVersion(_) => "unsupported-relay-version",
RelayError::NotFinalRecipient => "not-final-recipient",
RelayError::Replay => "replay",
RelayError::ReservedApplicationType(_) => "reserved-application-type",
RelayError::ReplayGuard(_) => "replay-guard-error",
RelayError::Protection(error) => match error {
ProtectionError::NoMatchingRecipient => "no-matching-recipient",
ProtectionError::InvalidSignature => "invalid-signature",
ProtectionError::SignaturePolicyMismatch { .. } => "signature-policy-mismatch",
ProtectionError::PurposeMismatch { .. } => "purpose-mismatch",
ProtectionError::SignerIdMismatch { .. } => "signer-id-mismatch",
ProtectionError::SignerKeyNotFound(_) => "signer-key-not-found",
ProtectionError::Crypto(mtp_crypto::CryptoError::InvalidSignature)
| ProtectionError::Crypto(mtp_crypto::CryptoError::VerificationFailed) => {
"invalid-signature"
}
_ => "protection-error",
},
}
}
pub(crate) fn decode_frame(frame: &[u8]) -> Result<CommunicationValue, JsValue> {
CommunicationValue::from_bytes(frame).map_err(|error| {
structured_error(
"invalid-frame",
format!("relay frame decoding failed: {error}"),
)
})
}
fn optional_u64(value: &JsValue, name: &str) -> Result<Option<u64>, JsValue> {
if value.is_null() || value.is_undefined() {
return Ok(None);
}
if let Some(number) = value.as_f64() {
if !number.is_finite()
|| number.fract() != 0.0
|| !(0.0..=MAX_SAFE_INTEGER).contains(&number)
{
return Err(structured_error(
"invalid-option",
format!("{name} must be an exact non-negative integer"),
));
}
return Ok(Some(number as u64));
}
if value.js_typeof().as_string().as_deref() == Some("bigint") {
let bigint = value.clone().unchecked_into::<js_sys::BigInt>();
let text = bigint.to_string(10)?.as_string().ok_or_else(|| {
structured_error("invalid-option", format!("failed to stringify {name}"))
})?;
return text
.parse::<u64>()
.map(Some)
.map_err(|_| structured_error("invalid-option", format!("{name} is out of range")));
}
Err(structured_error(
"invalid-option",
format!("{name} must be a number or bigint"),
))
}
fn serialize_data_value(value: &DataValue) -> Result<Vec<u8>, JsValue> {
value.to_bytes().map_err(|error| {
structured_error(
"invalid-data-value",
format!("relay value encoding failed: {error}"),
)
})
}
#[wasm_bindgen]
pub struct WasmVerifiedRelayMetadata {
inner: VerifiedRelayMetadata,
}
#[wasm_bindgen]
impl WasmVerifiedRelayMetadata {
pub fn relay_version(&self) -> u64 {
self.inner.relay_version()
}
pub fn signer_id(&self) -> u64 {
self.inner.signer_id()
}
pub fn final_recipient_id(&self) -> u64 {
self.inner.final_recipient_id()
}
pub fn message_id(&self) -> String {
self.inner.message_id().to_owned()
}
pub fn created_at(&self) -> u64 {
self.inner.created_at()
}
pub fn metadata(&self) -> Result<JsValue, JsValue> {
match self.inner.metadata() {
Some(value) => {
let bytes = serialize_data_value(value)?;
Ok(js_sys::Uint8Array::from(&bytes[..]).into())
}
None => Ok(JsValue::NULL),
}
}
pub fn encrypted_content(&self) -> Result<Vec<u8>, JsValue> {
serialize_data_value(self.inner.encrypted_content())
}
pub fn matched_signer_key_index(&self) -> usize {
self.inner.matched_signer_key_index()
}
}
#[wasm_bindgen]
pub struct WasmVerifiedRelayContent {
inner: VerifiedRelayContent,
}
#[wasm_bindgen]
impl WasmVerifiedRelayContent {
pub fn signer_id(&self) -> u64 {
self.inner.signer_id
}
pub fn final_recipient_id(&self) -> u64 {
self.inner.final_recipient_id
}
pub fn message_type(&self) -> String {
self.inner.message_type.clone()
}
pub fn content(&self) -> Result<Vec<u8>, JsValue> {
serialize_data_value(&self.inner.content)
}
}
/// Read the claimed, unverified signer ID from a relay without duplicating the
/// versioned relay metadata parser in the JavaScript SDK. The caller must bind
/// this value as the expected signer during the subsequent verification call.
#[wasm_bindgen]
pub fn relay_metadata_claimed_signer_id(frame: &[u8], keyrings: JsValue) -> Result<u64, JsValue> {
let frame = decode_frame(frame)?;
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();
mtp_codec::relay_metadata_claimed_signer_id(&frame, &references).map_err(relay_error)
}
/// Open and verify relay metadata in the native codec. JavaScript resolves
/// the trusted signing-key history before calling this function, while the
/// codec owns all relay layout and version interpretation.
#[wasm_bindgen]
pub fn open_relay_metadata_with_keyrings(
frame: &[u8],
keyrings: JsValue,
expected_signer_id: JsValue,
signer_public_key_bundles: JsValue,
signature_suite: u8,
) -> Result<WasmVerifiedRelayMetadata, JsValue> {
let frame = decode_frame(frame)?;
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 metadata = mtp_codec::open_relay_metadata_with_keys(
&frame,
&references,
expected_signer_id,
&signer_public_keys,
policy,
)
.map_err(relay_error)?;
Ok(WasmVerifiedRelayMetadata { inner: metadata })
}
/// Open and verify relay content in the native codec using recipient and
/// signer key histories supplied by the SDK.
#[wasm_bindgen]
pub fn open_relay_content_with_keyrings(
metadata: &WasmVerifiedRelayMetadata,
keyrings: JsValue,
signer_public_key_bundles: JsValue,
expected_final_recipient_id: JsValue,
signature_suite: u8,
) -> 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 content = mtp_codec::open_relay_content_with_keyrings(
&metadata.inner,
&references,
&signer_public_keys,
expected_final_recipient_id,
policy,
)
.map_err(relay_error)?;
Ok(WasmVerifiedRelayContent { inner: content })
}

View file

@ -1,12 +1,14 @@
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use futures_util::lock::Mutex as AsyncMutex;
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use crate::error::js_error;
use crate::frame::parse_frame_value;
use crate::frame::parse_frame_value_with_type_map;
use mtp_codec::TypeMap;
const CLOSE_FRAME_LEN: u32 = u32::MAX;
@ -126,6 +128,11 @@ pub struct WasmTransport {
buffer: Rc<RefCell<Vec<u8>>>,
/// Set to `true` when `open_next_stream` succeeds; cleared after the first frame is parsed.
new_stream_frame: Rc<Cell<bool>>,
/// A single ordered browser send stream shared by all cloned transports.
outgoing_writer: Rc<RefCell<Option<JsValue>>>,
/// Serializes stream creation and writes across concurrent callers.
send_lock: Rc<AsyncMutex<()>>,
type_map: Rc<RefCell<TypeMap>>,
}
impl WasmTransport {
@ -188,6 +195,9 @@ impl WasmTransport {
stream_reader: Rc::new(RefCell::new(None)),
buffer: Rc::new(RefCell::new(Vec::new())),
new_stream_frame: Rc::new(Cell::new(false)),
outgoing_writer: Rc::new(RefCell::new(None)),
send_lock: Rc::new(AsyncMutex::new(())),
type_map: Rc::new(RefCell::new(TypeMap::latest())),
})
}
@ -195,40 +205,48 @@ impl WasmTransport {
&self.inner
}
pub fn set_type_map(&self, type_map: &TypeMap) {
*self.type_map.borrow_mut() = type_map.clone();
}
pub fn type_map(&self) -> TypeMap {
self.type_map.borrow().clone()
}
pub async fn send_frame(&self, frame: &[u8]) -> Result<(), JsValue> {
let _send_guard = self.send_lock.lock().await;
if frame.len() as u64 > self.max_message_size as u64
|| frame.len() as u64 >= CLOSE_FRAME_LEN as u64
{
return Err(js_error("message too large"));
}
let create_stream = js_sys::Reflect::get(
&self.inner,
&JsValue::from_str("createUnidirectionalStream"),
)?
.dyn_into::<js_sys::Function>()
.map_err(|_| js_error("createUnidirectionalStream not a function"))?;
let stream_promise = create_stream
.call0(&self.inner)?
.dyn_into::<js_sys::Promise>()
.map_err(|_| js_error("createUnidirectionalStream did not return a Promise"))?;
let stream = JsFuture::from(stream_promise).await?;
let writable_or_stream = resolve_stream_writable(&stream)?;
let writer_val = js_sys::Reflect::get(&writable_or_stream, &JsValue::from_str("getWriter"))
.map_err(|_| js_error("missing getWriter"))?
let writer_val = if let Some(writer) = self.outgoing_writer.borrow().clone() {
writer
} else {
let create_stream = js_sys::Reflect::get(
&self.inner,
&JsValue::from_str("createUnidirectionalStream"),
)?
.dyn_into::<js_sys::Function>()
.map_err(|_| js_error("getWriter not a function"))?
.call0(&writable_or_stream)
.map_err(|_| js_error("getWriter call failed"))?;
.map_err(|_| js_error("createUnidirectionalStream not a function"))?;
let stream_promise = create_stream
.call0(&self.inner)?
.dyn_into::<js_sys::Promise>()
.map_err(|_| js_error("createUnidirectionalStream did not return a Promise"))?;
let stream = JsFuture::from(stream_promise).await?;
let writable_or_stream = resolve_stream_writable(&stream)?;
let writer = js_sys::Reflect::get(&writable_or_stream, &JsValue::from_str("getWriter"))
.map_err(|_| js_error("missing getWriter"))?
.dyn_into::<js_sys::Function>()
.map_err(|_| js_error("getWriter not a function"))?
.call0(&writable_or_stream)
.map_err(|_| js_error("getWriter call failed"))?;
*self.outgoing_writer.borrow_mut() = Some(writer.clone());
writer
};
let len = frame.len() as u32;
let mut wire = Vec::with_capacity(4 + frame.len());
wire.extend_from_slice(&len.to_be_bytes());
wire.extend_from_slice(frame);
let chunk = js_sys::Uint8Array::from(&wire[..]);
let chunk = js_sys::Uint8Array::from(frame);
let write_fn = js_sys::Reflect::get(&writer_val, &JsValue::from_str("write"))
.map_err(|_| js_error("missing write"))?
@ -239,25 +257,11 @@ impl WasmTransport {
.map_err(|e| js_error(format!("write failed: {:?}", e)))?;
if let Err(e) = JsFuture::from(write_promise.unchecked_into::<js_sys::Promise>()).await {
log_stream_error_code(&e, "send_frame write");
self.outgoing_writer.borrow_mut().take();
release_writer_lock(&writer_val);
return Err(e);
}
let close_fn = js_sys::Reflect::get(&writer_val, &JsValue::from_str("close"))
.map_err(|_| js_error("missing close"))?
.dyn_into::<js_sys::Function>()
.map_err(|_| js_error("close not a function"))?;
let close_promise = close_fn
.call0(&writer_val)
.map_err(|e| js_error(format!("close failed: {:?}", e)))?;
if let Err(e) = JsFuture::from(close_promise.unchecked_into::<js_sys::Promise>()).await {
// Write succeeded; STOP_SENDING on close just means peer stopped reading before FIN.
log_stream_error_code(&e, "send_frame close");
}
// Release the lock so the writer isn't treated as an abort.
release_writer_lock(&writer_val);
Ok(())
}
@ -366,21 +370,21 @@ impl WasmTransport {
if buf.len() < 4 {
return Ok(None);
}
let frame_len = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
if frame_len == CLOSE_FRAME_LEN {
let body_len = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
if body_len == CLOSE_FRAME_LEN {
return Ok(Some(FrameOutcome::Closed));
}
let frame_len = body_len
.checked_add(4)
.ok_or_else(|| js_error("invalid frame length"))?;
if frame_len > max_message_size {
return Err(js_error("message too large"));
}
let frame_len = frame_len as usize;
let Some(frame_end) = 4usize.checked_add(frame_len) else {
return Err(js_error("invalid frame length"));
};
let frame_end = frame_len as usize;
if frame_end > buf.len() {
return Ok(None);
}
let frame = buf[4..frame_end].to_vec();
let frame = buf[..frame_end].to_vec();
drop(buf);
self.buffer.borrow_mut().drain(..frame_end);
Ok(Some(FrameOutcome::Frame(frame)))
@ -456,15 +460,18 @@ impl WasmTransport {
{
loop {
match self.next_frame(self.max_message_size).await {
Ok(FrameOutcome::Frame(frame)) => match parse_frame_value(&frame) {
Ok(parsed) => {
on_message(parsed);
Ok(FrameOutcome::Frame(frame)) => {
let type_map = self.type_map();
match parse_frame_value_with_type_map(&frame, &type_map) {
Ok(parsed) => {
on_message(parsed);
}
Err(e) => {
let message = e.as_string().unwrap_or_else(|| format!("{:?}", e));
let _ = on_error.call1(&JsValue::NULL, &JsValue::from_str(&message));
}
}
Err(e) => {
let message = e.as_string().unwrap_or_else(|| format!("{:?}", e));
let _ = on_error.call1(&JsValue::NULL, &JsValue::from_str(&message));
}
},
}
Ok(FrameOutcome::Closed) | Ok(FrameOutcome::Ended) => break,
Err(e) => {
let _ = on_error.call1(&JsValue::NULL, &e);
@ -487,19 +494,28 @@ impl WasmTransport {
G: FnMut(crate::pipe::PipeReader),
H: FnMut(JsValue),
{
let pipe_request_type =
mtp_codec::CommunicationType::PipeRequest.try_to_id(&mtp_codec::TypeMap::latest());
loop {
match self.next_frame(self.max_message_size).await {
Ok(FrameOutcome::Frame(frame)) => {
let type_map = self.type_map();
let pipe_request_type =
mtp_codec::CommunicationType::PipeRequest.try_to_id(&type_map);
let pipe_response_type =
mtp_codec::CommunicationType::PipeResponse.try_to_id(&type_map);
let is_first = self.new_stream_frame.get();
if is_first {
self.new_stream_frame.set(false);
if let Ok(comm) = mtp_codec::CommunicationValue::from_bytes(&frame)
if let Ok(comm) =
mtp_codec::CommunicationValue::from_bytes_with(&frame, &type_map)
&& Some(comm.get_type()) == pipe_request_type
{
let pipe_id = comm.get_id();
let Some(pipe_id) = comm.id().filter(|id| *id != 0) else {
on_error(JsValue::from_str(
"PipeRequest frame must contain a non-zero id",
));
self.close();
break;
};
let description = comm
.get_str(mtp_codec::DataType::Description)
.unwrap_or("")
@ -523,7 +539,33 @@ impl WasmTransport {
}
}
match parse_frame_value(&frame) {
if let Ok(comm) =
mtp_codec::CommunicationValue::from_bytes_with(&frame, &type_map)
&& Some(comm.get_type()) == pipe_response_type
&& !matches!(comm.id(), Some(id) if id != 0)
{
on_error(JsValue::from_str(
"PipeResponse frame must contain a non-zero id",
));
self.close();
break;
}
if let Ok(comm) =
mtp_codec::CommunicationValue::from_bytes_with(&frame, &type_map)
&& !matches!(comm.id(), Some(id) if id != 0)
&& comm
.get_type_name()
.is_some_and(|name| name.ends_with("Response"))
{
on_error(JsValue::from_str(
"response frame must contain a non-zero id",
));
self.close();
break;
}
match parse_frame_value_with_type_map(&frame, &type_map) {
Ok(parsed) => {
on_message(parsed);
}
@ -550,6 +592,7 @@ impl WasmTransport {
pipe_id: u32,
description: &str,
) -> Result<crate::pipe::PipeWriter, JsValue> {
let _send_guard = self.send_lock.lock().await;
let create_stream = js_sys::Reflect::get(
&self.inner,
&JsValue::from_str("createUnidirectionalStream"),
@ -570,22 +613,21 @@ impl WasmTransport {
.call0(&writable_or_stream)
.map_err(|_| js_error("getWriter call failed"))?;
let request = mtp_codec::CommunicationValue::new(mtp_codec::CommunicationType::PipeRequest)
.with_id(pipe_id)
.add_typed_default(
mtp_codec::DataType::Description,
mtp_codec::DataValue::Str(description.to_string()),
);
let type_map = self.type_map();
let request = mtp_codec::CommunicationValue::new_with_type_map(
mtp_codec::CommunicationType::PipeRequest,
&type_map,
)
.with_id(pipe_id)
.add_typed_default(
mtp_codec::DataType::Description,
mtp_codec::DataValue::Str(description.to_string()),
);
let frame_bytes = request
.to_bytes()
.map_err(|e| js_error(format!("encode failed: {}", e)))?;
let len = frame_bytes.len() as u32;
let mut wire = Vec::with_capacity(4 + frame_bytes.len());
wire.extend_from_slice(&len.to_be_bytes());
wire.extend_from_slice(&frame_bytes);
let chunk = js_sys::Uint8Array::from(&wire[..]);
let chunk = js_sys::Uint8Array::from(&frame_bytes[..]);
let write_fn = js_sys::Reflect::get(&writer_val, &JsValue::from_str("write"))
.map_err(|_| js_error("missing write"))?
.dyn_into::<js_sys::Function>()
@ -603,6 +645,12 @@ impl WasmTransport {
}
pub fn close(&self) {
if let Some(writer) = self.outgoing_writer.borrow_mut().take() {
// The WebTransport session close below terminates the stream. The
// lock must be released first so dropping it is not interpreted as
// an application abort.
release_writer_lock(&writer);
}
// Release reader locks before closing so they aren't treated as cancels.
if let Some(reader) = self.stream_reader.borrow_mut().take() {
release_reader_lock(&reader);