[WIP] Security work While on holiday
This commit is contained in:
parent
a81ac4efca
commit
7f0231e3f1
109 changed files with 19694 additions and 5210 deletions
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
434
wasm/src/protected.rs
Normal 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
267
wasm/src/relay.rs
Normal 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 })
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue