[WIP] Security work While on holiday

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

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

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