diff --git a/Cargo.lock b/Cargo.lock index f5223d28e1..2fb76c2c47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -707,7 +707,7 @@ dependencies = [ "alloy-rlp", "alloy-serde", "alloy-sol-types", - "itertools 0.14.0", + "itertools 0.13.0", "serde", "serde_json", "serde_with", @@ -863,7 +863,7 @@ checksum = "eed3ed3300a998f88639ed619fdbbd88bd82865e00c6a8ecb796c99eb12358f6" dependencies = [ "alloy-json-rpc", "alloy-transport", - "itertools 0.14.0", + "itertools 0.13.0", "reqwest 0.13.3", "serde_json", "tower", @@ -1004,7 +1004,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1015,7 +1015,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2638,7 +2638,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -3482,7 +3482,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3805,7 +3805,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6621,7 +6621,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7809,7 +7809,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8339,7 +8339,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openmls" version = "0.8.1" -source = "git+https://github.com/xmtp/openmls?rev=e125674303a715b00bf133ceba903862304eac83#e125674303a715b00bf133ceba903862304eac83" +source = "git+https://github.com/xmtp/openmls?rev=9cdd5e798cb9217ef246964885e87d371ea553b3#9cdd5e798cb9217ef246964885e87d371ea553b3" dependencies = [ "backtrace", "getrandom 0.3.4", @@ -8366,7 +8366,7 @@ dependencies = [ [[package]] name = "openmls_basic_credential" version = "0.5.0" -source = "git+https://github.com/xmtp/openmls?rev=e125674303a715b00bf133ceba903862304eac83#e125674303a715b00bf133ceba903862304eac83" +source = "git+https://github.com/xmtp/openmls?rev=9cdd5e798cb9217ef246964885e87d371ea553b3#9cdd5e798cb9217ef246964885e87d371ea553b3" dependencies = [ "ed25519-dalek", "openmls_traits", @@ -8380,7 +8380,7 @@ dependencies = [ [[package]] name = "openmls_libcrux_crypto" version = "0.3.1" -source = "git+https://github.com/xmtp/openmls?rev=e125674303a715b00bf133ceba903862304eac83#e125674303a715b00bf133ceba903862304eac83" +source = "git+https://github.com/xmtp/openmls?rev=9cdd5e798cb9217ef246964885e87d371ea553b3#9cdd5e798cb9217ef246964885e87d371ea553b3" dependencies = [ "hpke-rs", "hpke-rs-crypto", @@ -8401,7 +8401,7 @@ dependencies = [ [[package]] name = "openmls_memory_storage" version = "0.5.0" -source = "git+https://github.com/xmtp/openmls?rev=e125674303a715b00bf133ceba903862304eac83#e125674303a715b00bf133ceba903862304eac83" +source = "git+https://github.com/xmtp/openmls?rev=9cdd5e798cb9217ef246964885e87d371ea553b3#9cdd5e798cb9217ef246964885e87d371ea553b3" dependencies = [ "hex", "log", @@ -8414,7 +8414,7 @@ dependencies = [ [[package]] name = "openmls_rust_crypto" version = "0.5.1" -source = "git+https://github.com/xmtp/openmls?rev=e125674303a715b00bf133ceba903862304eac83#e125674303a715b00bf133ceba903862304eac83" +source = "git+https://github.com/xmtp/openmls?rev=9cdd5e798cb9217ef246964885e87d371ea553b3#9cdd5e798cb9217ef246964885e87d371ea553b3" dependencies = [ "aes-gcm", "chacha20poly1305", @@ -8438,7 +8438,7 @@ dependencies = [ [[package]] name = "openmls_test" version = "0.2.1" -source = "git+https://github.com/xmtp/openmls?rev=e125674303a715b00bf133ceba903862304eac83#e125674303a715b00bf133ceba903862304eac83" +source = "git+https://github.com/xmtp/openmls?rev=9cdd5e798cb9217ef246964885e87d371ea553b3#9cdd5e798cb9217ef246964885e87d371ea553b3" dependencies = [ "ansi_term", "openmls_rust_crypto", @@ -8453,7 +8453,7 @@ dependencies = [ [[package]] name = "openmls_traits" version = "0.5.0" -source = "git+https://github.com/xmtp/openmls?rev=e125674303a715b00bf133ceba903862304eac83#e125674303a715b00bf133ceba903862304eac83" +source = "git+https://github.com/xmtp/openmls?rev=9cdd5e798cb9217ef246964885e87d371ea553b3#9cdd5e798cb9217ef246964885e87d371ea553b3" dependencies = [ "serde", "tls_codec", @@ -8520,7 +8520,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -9290,8 +9290,8 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.5.0", - "itertools 0.14.0", + "heck 0.4.1", + "itertools 0.10.5", "log", "multimap", "petgraph", @@ -9312,7 +9312,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -10129,7 +10129,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -10186,7 +10186,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -11014,7 +11014,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.117", @@ -11037,7 +11037,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11395,7 +11395,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -12311,7 +12311,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -13249,7 +13249,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -14152,7 +14152,7 @@ dependencies = [ "hyper-util", "idna", "indexmap 2.14.0", - "itertools 0.14.0", + "itertools 0.13.0", "k256", "libc", "libcrux-ed25519", @@ -14672,7 +14672,11 @@ version = "1.11.0-dev" dependencies = [ "bon", "const-hex", + "hpke-rs", "openmls", + "openmls_libcrux_crypto", + "openmls_rust_crypto", + "openmls_traits", "parking_lot", "proptest", "prost", diff --git a/Cargo.toml b/Cargo.toml index c46791e647..97ce8970da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,15 +85,15 @@ lru = "0.18" mockall = { version = "0.14" } mockall_double = "0.3.1" once_cell = "1.21.4" -openmls = { git = "https://github.com/xmtp/openmls", rev = "e125674303a715b00bf133ceba903862304eac83", default-features = false, features = [ +openmls = { git = "https://github.com/xmtp/openmls", rev = "9cdd5e798cb9217ef246964885e87d371ea553b3", default-features = false, features = [ "extensions-draft-08", ] } -openmls_basic_credential = { git = "https://github.com/xmtp/openmls", rev = "e125674303a715b00bf133ceba903862304eac83" } -openmls_libcrux_crypto = { git = "https://github.com/xmtp/openmls", rev = "e125674303a715b00bf133ceba903862304eac83", features = [ +openmls_basic_credential = { git = "https://github.com/xmtp/openmls", rev = "9cdd5e798cb9217ef246964885e87d371ea553b3" } +openmls_libcrux_crypto = { git = "https://github.com/xmtp/openmls", rev = "9cdd5e798cb9217ef246964885e87d371ea553b3", features = [ "extensions-draft-08", ] } -openmls_rust_crypto = { git = "https://github.com/xmtp/openmls", rev = "e125674303a715b00bf133ceba903862304eac83" } -openmls_traits = { git = "https://github.com/xmtp/openmls", rev = "e125674303a715b00bf133ceba903862304eac83", features = [ +openmls_rust_crypto = { git = "https://github.com/xmtp/openmls", rev = "9cdd5e798cb9217ef246964885e87d371ea553b3" } +openmls_traits = { git = "https://github.com/xmtp/openmls", rev = "9cdd5e798cb9217ef246964885e87d371ea553b3", features = [ "extensions-draft-08", ] } owo-colors = { version = "4.1" } diff --git a/bindings/node/src/client/external_invite.rs b/bindings/node/src/client/external_invite.rs new file mode 100644 index 0000000000..07eac34c92 --- /dev/null +++ b/bindings/node/src/client/external_invite.rs @@ -0,0 +1,65 @@ +use napi::bindgen_prelude::{Result, Uint8Array}; +use napi_derive::napi; + +use super::Client; +use crate::ErrorWrapper; +use crate::conversation::Conversation; + +/// Output of [`Client::join_group_by_external_invite`]. +/// +/// Exposes the newly-joined group (via the `conversation` getter) and the +/// post-commit re-encrypted GroupInfo blob (`refreshedEncryptedGroupInfo`). +/// The application should upload `refreshedEncryptedGroupInfo` back to its +/// invite service under the same `groupIdHash` key the original blob used +/// (overwrite semantics) so the next joiner reads a GroupInfo at the new +/// epoch — otherwise their external commit would race a stale ratchet tree. +#[napi] +pub struct JoinGroupByExternalInviteOutput { + conversation: Conversation, + refreshed_encrypted_group_info: Vec, +} + +#[napi] +impl JoinGroupByExternalInviteOutput { + #[napi(getter)] + pub fn conversation(&self) -> Conversation { + self.conversation.clone() + } + + #[napi(getter)] + pub fn refreshed_encrypted_group_info(&self) -> Uint8Array { + self.refreshed_encrypted_group_info.as_slice().into() + } +} + +#[napi] +impl Client { + /// Join a group via an external invite (atomic external commit). + /// + /// `invitePayload` is the serialized `ExternalInvitePayload` proto carried + /// by the QR-code or link. `encryptedGroupInfo` is the encrypted blob the + /// application fetched from its invite service using the payload's + /// `groupIdHash` as the lookup key. + /// + /// On success returns the joined [`Conversation`] together with a + /// post-commit `refreshedEncryptedGroupInfo` blob that the caller should + /// ship back to the invite service so the next joiner reads a GroupInfo + /// at the new epoch. + #[napi] + pub async fn join_group_by_external_invite( + &self, + invite_payload: Uint8Array, + encrypted_group_info: Uint8Array, + ) -> Result { + let output = self + .inner_client + .join_group_by_external_invite(invite_payload.as_ref(), encrypted_group_info.as_ref()) + .await + .map_err(ErrorWrapper::from)?; + + Ok(JoinGroupByExternalInviteOutput { + conversation: output.group.into(), + refreshed_encrypted_group_info: output.refreshed_encrypted_group_info, + }) + } +} diff --git a/bindings/node/src/client/mod.rs b/bindings/node/src/client/mod.rs index 6c0a22d953..575ce1bfa8 100644 --- a/bindings/node/src/client/mod.rs +++ b/bindings/node/src/client/mod.rs @@ -12,6 +12,7 @@ use xmtp_mls::groups::MlsGroup; pub mod backend; mod consent_state; pub mod create_client; +mod external_invite; pub(crate) mod gateway_auth; mod identity; mod inbox_state; diff --git a/crates/xmtp-workspace-hack/Cargo.toml b/crates/xmtp-workspace-hack/Cargo.toml index 27a94df00d..6022265ad3 100644 --- a/crates/xmtp-workspace-hack/Cargo.toml +++ b/crates/xmtp-workspace-hack/Cargo.toml @@ -55,18 +55,18 @@ hpke-rs = { git = "https://github.com/xmtp/hpke-rs", rev = "3425025bb43b68bdef28 hyper = { version = "1", features = ["http1", "http2", "server"] } idna = { version = "1" } indexmap = { version = "2", features = ["serde"] } -itertools = { version = "0.14" } +itertools = { version = "0.13" } k256 = { version = "0.13", default-features = false, features = ["ecdh", "ecdsa", "std"] } libcrux-ed25519 = { version = "0.0.7", default-features = false, features = ["rand"] } log = { version = "0.4", default-features = false, features = ["std"] } memchr = { version = "2" } nu-ansi-term = { version = "0.50" } num-traits = { version = "0.2", features = ["libm"] } -openmls = { git = "https://github.com/xmtp/openmls", rev = "e125674303a715b00bf133ceba903862304eac83", default-features = false, features = ["extensions-draft-08", "test-utils"] } -openmls_basic_credential = { git = "https://github.com/xmtp/openmls", rev = "e125674303a715b00bf133ceba903862304eac83", default-features = false, features = ["clonable", "test-utils"] } -openmls_memory_storage = { git = "https://github.com/xmtp/openmls", rev = "e125674303a715b00bf133ceba903862304eac83", default-features = false, features = ["extensions-draft-08", "test-utils"] } -openmls_rust_crypto = { git = "https://github.com/xmtp/openmls", rev = "e125674303a715b00bf133ceba903862304eac83", default-features = false, features = ["test-utils"] } -openmls_traits = { git = "https://github.com/xmtp/openmls", rev = "e125674303a715b00bf133ceba903862304eac83", features = ["extensions-draft-08", "test-utils"] } +openmls = { git = "https://github.com/xmtp/openmls", rev = "9cdd5e798cb9217ef246964885e87d371ea553b3", default-features = false, features = ["extensions-draft-08", "test-utils"] } +openmls_basic_credential = { git = "https://github.com/xmtp/openmls", rev = "9cdd5e798cb9217ef246964885e87d371ea553b3", default-features = false, features = ["clonable", "test-utils"] } +openmls_memory_storage = { git = "https://github.com/xmtp/openmls", rev = "9cdd5e798cb9217ef246964885e87d371ea553b3", default-features = false, features = ["extensions-draft-08", "test-utils"] } +openmls_rust_crypto = { git = "https://github.com/xmtp/openmls", rev = "9cdd5e798cb9217ef246964885e87d371ea553b3", default-features = false, features = ["test-utils"] } +openmls_traits = { git = "https://github.com/xmtp/openmls", rev = "9cdd5e798cb9217ef246964885e87d371ea553b3", features = ["extensions-draft-08", "test-utils"] } p256 = { version = "0.13", features = ["ecdh", "jwk"] } percent-encoding = { version = "2" } rand-274715c4dabd11b0 = { package = "rand", version = "0.9", features = ["serde"] } @@ -147,18 +147,18 @@ hpke-rs = { git = "https://github.com/xmtp/hpke-rs", rev = "3425025bb43b68bdef28 hyper = { version = "1", features = ["http1", "http2", "server"] } idna = { version = "1" } indexmap = { version = "2", features = ["serde"] } -itertools = { version = "0.14" } +itertools = { version = "0.13" } k256 = { version = "0.13", default-features = false, features = ["ecdh", "ecdsa", "std"] } libcrux-ed25519 = { version = "0.0.7", default-features = false, features = ["rand"] } log = { version = "0.4", default-features = false, features = ["std"] } memchr = { version = "2" } nu-ansi-term = { version = "0.50" } num-traits = { version = "0.2", features = ["libm"] } -openmls = { git = "https://github.com/xmtp/openmls", rev = "e125674303a715b00bf133ceba903862304eac83", default-features = false, features = ["extensions-draft-08", "test-utils"] } -openmls_basic_credential = { git = "https://github.com/xmtp/openmls", rev = "e125674303a715b00bf133ceba903862304eac83", default-features = false, features = ["clonable", "test-utils"] } -openmls_memory_storage = { git = "https://github.com/xmtp/openmls", rev = "e125674303a715b00bf133ceba903862304eac83", default-features = false, features = ["extensions-draft-08", "test-utils"] } -openmls_rust_crypto = { git = "https://github.com/xmtp/openmls", rev = "e125674303a715b00bf133ceba903862304eac83", default-features = false, features = ["test-utils"] } -openmls_traits = { git = "https://github.com/xmtp/openmls", rev = "e125674303a715b00bf133ceba903862304eac83", features = ["extensions-draft-08", "test-utils"] } +openmls = { git = "https://github.com/xmtp/openmls", rev = "9cdd5e798cb9217ef246964885e87d371ea553b3", default-features = false, features = ["extensions-draft-08", "test-utils"] } +openmls_basic_credential = { git = "https://github.com/xmtp/openmls", rev = "9cdd5e798cb9217ef246964885e87d371ea553b3", default-features = false, features = ["clonable", "test-utils"] } +openmls_memory_storage = { git = "https://github.com/xmtp/openmls", rev = "9cdd5e798cb9217ef246964885e87d371ea553b3", default-features = false, features = ["extensions-draft-08", "test-utils"] } +openmls_rust_crypto = { git = "https://github.com/xmtp/openmls", rev = "9cdd5e798cb9217ef246964885e87d371ea553b3", default-features = false, features = ["test-utils"] } +openmls_traits = { git = "https://github.com/xmtp/openmls", rev = "9cdd5e798cb9217ef246964885e87d371ea553b3", features = ["extensions-draft-08", "test-utils"] } p256 = { version = "0.13", features = ["ecdh", "jwk"] } percent-encoding = { version = "2" } proc-macro2 = { version = "1", features = ["span-locations"] } diff --git a/crates/xmtp_configuration/src/common/mls.rs b/crates/xmtp_configuration/src/common/mls.rs index f9edef4913..128aee257c 100644 --- a/crates/xmtp_configuration/src/common/mls.rs +++ b/crates/xmtp_configuration/src/common/mls.rs @@ -11,6 +11,18 @@ pub const MLS_PROTOCOL_VERSION: ProtocolVersion = ProtocolVersion::Mls10; pub const WELCOME_HPKE_LABEL: &str = "MLS_WELCOME"; +/// HPKE domain-separation label for external-invite GroupInfo payloads +/// wrapped via [`payload_encryption::wrap_payload_hpke`]. Distinct from +/// [`WELCOME_HPKE_LABEL`] to prevent cross-protocol oracle attacks. +/// +/// The v1 external-invite flow uses symmetric AEAD encryption only (see +/// `xmtp_mls_common::invite::encrypted_group_info`), but the label is +/// reserved here so a future HPKE-based external-invite path can adopt it +/// without churning the public API. +/// +/// [`payload_encryption::wrap_payload_hpke`]: https://docs.rs/xmtp_mls_common/latest/xmtp_mls_common/mls_ext/payload_encryption/fn.wrap_payload_hpke.html +pub const XMTP_EXTERNAL_INVITE_LABEL: &str = "XMTP_EXTERNAL_INVITE"; + pub const MAX_GROUP_SYNC_RETRIES: usize = 3; pub const MAX_INTENT_PUBLISH_ATTEMPTS: usize = 3; diff --git a/crates/xmtp_mls/benches/crypto.rs b/crates/xmtp_mls/benches/crypto.rs index 874c5fe7cd..90a848e9da 100644 --- a/crates/xmtp_mls/benches/crypto.rs +++ b/crates/xmtp_mls/benches/crypto.rs @@ -3,7 +3,7 @@ use openmls_rust_crypto::RustCrypto; use openmls_traits::{crypto::OpenMlsCrypto, random::OpenMlsRand}; use rand::{TryRng, rngs::SysRng}; use xmtp_configuration::{CIPHERSUITE, POST_QUANTUM_CIPHERSUITE}; -use xmtp_mls::utils::bench::re_export::{WrapperAlgorithm, wrap_welcome}; +use xmtp_mls::utils::bench::re_export::{WELCOME_HPKE_LABEL, WrapperAlgorithm, wrap_payload_hpke}; const BENCH_SIZES: [usize; 24] = [ 16, @@ -50,7 +50,15 @@ fn bench_encrypt_welcome_curve25519(c: &mut Criterion) { SysRng.try_fill_bytes(payload.as_mut_slice()).unwrap(); (payload, keypair.public) }, - |(payload, key)| wrap_welcome(&payload, &[], &key, WrapperAlgorithm::Curve25519), + |(payload, key)| { + wrap_payload_hpke( + &payload, + &[], + &key, + WrapperAlgorithm::Curve25519, + WELCOME_HPKE_LABEL, + ) + }, BatchSize::SmallInput, ) }); @@ -80,7 +88,13 @@ fn bench_encrypt_welcome_post_quantum(c: &mut Criterion) { (payload, keypair.public) }, |(payload, key)| { - wrap_welcome(&payload, &[], &key, WrapperAlgorithm::XWingMLKEM768Draft6) + wrap_payload_hpke( + &payload, + &[], + &key, + WrapperAlgorithm::XWingMLKEM768Draft6, + WELCOME_HPKE_LABEL, + ) }, BatchSize::SmallInput, ) diff --git a/crates/xmtp_mls/src/client.rs b/crates/xmtp_mls/src/client.rs index 788740a4fe..e9d8f2adf2 100644 --- a/crates/xmtp_mls/src/client.rs +++ b/crates/xmtp_mls/src/client.rs @@ -1,3 +1,5 @@ +pub mod external_invite; + use crate::{ builder::DeviceSyncMode, context::XmtpSharedContext, diff --git a/crates/xmtp_mls/src/client/external_invite.rs b/crates/xmtp_mls/src/client/external_invite.rs new file mode 100644 index 0000000000..0891e73fca --- /dev/null +++ b/crates/xmtp_mls/src/client/external_invite.rs @@ -0,0 +1,753 @@ +//! Consumer-side QR-invite flow: [`Client::join_group_by_external_invite`]. +//! +//! Given a decoded [`ExternalInvitePayload`] and the matching encrypted +//! GroupInfo blob fetched from the external service, this module joins the +//! target group via an MLS external commit, atomically adds the joiner's +//! other installations, registers the joiner in the group's membership +//! component, and returns a freshly-encrypted GroupInfo at the new epoch so +//! the service can rotate its stored blob. +//! +//! Atomicity is required: the validator-side rejects external commits that +//! don't simultaneously add ALL installations of the joiner's inbox AND +//! register the inbox in the membership component (the AppDataUpdate +//! proposal). +//! +//! [`ExternalInvitePayload`]: xmtp_proto::xmtp::mls::message_contents::ExternalInvitePayload + +use hmac::{Hmac, Mac}; +use openmls::{ + extensions::{ApplicationIdExtension, Extension, ExtensionType, Extensions}, + framing::{MlsMessageBodyIn, MlsMessageIn}, + group::{MlsGroupJoinConfig, WireFormatPolicy}, + messages::proposals::{AppDataUpdateProposal, Proposal, ProposalType}, + prelude::{ + Capabilities, CredentialWithKey, LeafNode, LeafNodeParameters, tls_codec::Deserialize as _, + tls_codec::Serialize as _, + }, +}; +use openmls_traits::OpenMlsProvider as _; +use prost::Message as _; +use sha2::Sha256; +use thiserror::Error; +use tls_codec::VLBytes; +use xmtp_configuration::{ + GROUP_MEMBERSHIP_EXTENSION_ID, GROUP_PERMISSIONS_EXTENSION_ID, MAX_PAST_EPOCHS, + MUTABLE_METADATA_EXTENSION_ID, WELCOME_HPKE_LABEL, + WELCOME_POINTEE_ENCRYPTION_AEAD_TYPES_EXTENSION_ID, WELCOME_WRAPPER_ENCRYPTION_EXTENSION_ID, +}; +use xmtp_cryptography::configuration::CIPHERSUITE; +use xmtp_db::prelude::*; +use xmtp_db::xmtp_openmls_provider::XmtpOpenMlsProviderRef; +use xmtp_id::associations::{MemberIdentifier, ident}; +use xmtp_mls_common::invite::{ + encrypted_group_info::{EncryptedGroupInfoError, unwrap_group_info, wrap_group_info}, + payload::{InvitePayloadError, SYMMETRIC_KEY_LEN, validate}, +}; +use xmtp_mls_common::mls_ext::payload_encryption::wrap_payload_hpke; +use xmtp_mls_common::{ + app_data::{ + component_id::ComponentId, components::tls_map_components::GroupMembershipComponent, + typed::Component, + }, + inbox_id::InboxId as VersionedInboxId, + tls_map::TlsMapDelta, +}; +use xmtp_proto::xmtp::mls::api::v1::{ + GroupMessageInput, WelcomeMessageInput, WelcomeMetadata, + group_message_input::{V1 as GroupMessageInputV1, Version as GroupMessageInputVersion}, + welcome_message_input::{V1 as WelcomeMessageInputV1, Version as WelcomeMessageInputVersion}, +}; +use xmtp_proto::xmtp::mls::message_contents::{ + EncryptedGroupInfoBlob as EncryptedGroupInfoBlobProto, + ExternalInvitePayload as ExternalInvitePayloadProto, GroupMembershipEntry, + group_membership_entry, +}; + +use crate::{ + Client, + client::ClientError, + context::XmtpSharedContext, + groups::{MlsGroup, intents::Installation}, + identity_updates::load_identity_updates, +}; + +/// Result returned by [`Client::join_group_by_external_invite`]. +/// +/// `group` is the freshly-joined [`MlsGroup`] view of the target group with +/// the joiner now a member at the post-commit epoch. The underlying MLS +/// state has already been persisted to local storage by openmls's +/// `ExternalCommitBuilder::finalize`; a libxmtp-side `StoredGroup` row is +/// also written. +/// +/// `refreshed_encrypted_group_info` is the post-commit GroupInfo blob +/// re-encrypted with the SAME symmetric key from the invite (and a fresh +/// nonce, per the ChaCha20Poly1305 nonce-uniqueness requirement). Callers +/// should ship this back to the external invite service so the next joiner +/// reads a GroupInfo at the new epoch and their external commit doesn't +/// race a stale ratchet tree. +pub struct JoinByExternalInviteOutput { + /// The MlsGroup view of the freshly-joined group, already persisted. + pub group: MlsGroup, + /// Post-commit GroupInfo blob re-encrypted with the invite's + /// symmetric key. Upload back to the external invite service. + pub refreshed_encrypted_group_info: Vec, +} + +impl std::fmt::Debug for JoinByExternalInviteOutput +where + Context: XmtpSharedContext, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("JoinByExternalInviteOutput") + .field("group", &self.group) + .field( + "refreshed_encrypted_group_info", + &format!("<{} bytes>", self.refreshed_encrypted_group_info.len()), + ) + .finish() + } +} + +/// Errors returned by [`Client::join_group_by_external_invite`]. +#[derive(Debug, Error)] +pub enum JoinByExternalInviteError { + /// The invite payload proto failed prost decoding. + #[error("invite payload proto decode failed: {0}")] + InvitePayloadDecode(#[source] prost::DecodeError), + /// The invite payload didn't validate (unsupported version, wrong + /// symmetric_key length, too-short external_group_id). + #[error(transparent)] + InvitePayload(#[from] InvitePayloadError), + /// The encrypted GroupInfo blob proto failed prost decoding. + #[error("encrypted GroupInfo blob proto decode failed: {0}")] + EncryptedBlobDecode(#[source] prost::DecodeError), + /// The encrypted GroupInfo envelope was malformed (wrong version, + /// short nonce, …) or AEAD decryption failed (wrong key, tampered + /// ciphertext). + #[error(transparent)] + EncryptedBlob(#[from] EncryptedGroupInfoError), + /// The decrypted plaintext failed to parse as a TLS-serialized + /// MlsMessageIn carrying a GroupInfo body. + #[error("decrypted plaintext is not an MlsMessage(GroupInfo)")] + GroupInfoNotGroupInfo, + /// The decrypted plaintext failed TLS decoding entirely. + #[error("decrypted GroupInfo TLS decode failed: {0}")] + GroupInfoTlsDecode(#[from] tls_codec::Error), + /// Post-join correlation check failed: the just-joined group's + /// `EXTERNAL_COMMIT_POLICY.external_group_id` doesn't match the + /// invite payload's `external_group_id`. Indicates the QR is stale + /// (admin rotated the policy + re-issued) or the service returned a + /// GroupInfo for a different group than the QR pointed at. + #[error("post-join external_group_id mismatch — QR is stale or service returned wrong group")] + ExternalGroupIdMismatch, + /// The freshly-joined group has `EXTERNAL_COMMIT_POLICY` absent or + /// with `allow_external_commit = false`. Should be impossible + /// (validator-side enforces this before the commit is accepted) but + /// surface a clear error rather than panic. + #[error("post-join: EXTERNAL_COMMIT_POLICY missing or disabled")] + PostJoinPolicyMissing, + /// Failed to fetch one or more installation key packages for the + /// joiner's other installations. Without ALL co-resident key + /// packages we can't atomically add them, and the validator would + /// reject a partial-membership external commit. + #[error("failed to fetch key packages for joiner's other installations: {missing} missing")] + MissingInstallations { missing: usize }, + /// The openmls external commit builder returned an error during the + /// `build_group` / `add_proposals` / `build` / `finalize` pipeline. + #[error("external commit builder failed: {0}")] + ExternalCommitBuilderFailed(String), + /// AppDataUpdate proposal payload construction failed. + #[error("failed to build AppDataUpdate(GROUP_MEMBERSHIP) payload: {0}")] + BuildAppDataUpdatePayload(String), + /// Re-export of the post-commit GroupInfo failed. + #[error("post-commit GroupInfo export failed: {0}")] + PostCommitGroupInfoExport(String), + /// Re-encryption of the post-commit GroupInfo failed. + #[error("post-commit GroupInfo re-encrypt failed: {0}")] + PostCommitGroupInfoEncrypt(#[source] EncryptedGroupInfoError), +} + +impl From for ClientError { + fn from(value: JoinByExternalInviteError) -> Self { + ClientError::Generic(format!("join_group_by_external_invite: {value}")) + } +} + +impl Client +where + Context: XmtpSharedContext, +{ + /// Join the group referenced by `invite_payload_bytes` + + /// `encrypted_group_info_bytes` via an MLS external commit. + /// + /// The flow: + /// + /// 1. Decode + validate the [`ExternalInvitePayload`] (version, expiry, + /// symmetric key length). + /// 2. Decode + decrypt the [`EncryptedGroupInfoBlob`] using the + /// payload's symmetric key. + /// 3. Parse the plaintext as an `MlsMessage(GroupInfo)` and extract the + /// [`VerifiableGroupInfo`]. + /// 4. Defense-in-depth: verify + /// `sha256(group_id) == payload.group_id_hash`. + /// 5. Enumerate the joiner's OTHER installations from the latest + /// association state and fetch their key packages. + /// 6. Build an external commit that: + /// - introduces the joiner via the `CredentialWithKey` in the + /// `ExternalInit` proposal, + /// - inlines one `Add` proposal per other installation (so all + /// co-resident leaves land in the same commit), and + /// - inlines an `AppDataUpdate(GROUP_MEMBERSHIP)` proposal that + /// registers the joiner's inbox in the membership component. + /// 7. Finalize the commit; openmls persists the joined-group state to + /// the workspace MLS storage. + /// 8. Publish the `PublicMessage` commit to XMTP delivery. + /// 9. Send HPKE-wrapped Welcomes to each non-primary installation. + /// 10. Re-export and re-encrypt the post-commit GroupInfo so the + /// external service can serve the new epoch to the next joiner. + /// + /// [`ExternalInvitePayload`]: xmtp_proto::xmtp::mls::message_contents::ExternalInvitePayload + /// [`EncryptedGroupInfoBlob`]: xmtp_proto::xmtp::mls::message_contents::EncryptedGroupInfoBlob + /// [`VerifiableGroupInfo`]: openmls::messages::group_info::VerifiableGroupInfo + #[tracing::instrument(level = "trace", skip_all, fields(inbox_id = %self.context.inbox_id()))] + pub async fn join_group_by_external_invite( + &self, + invite_payload_bytes: &[u8], + encrypted_group_info_bytes: &[u8], + ) -> Result, ClientError> { + // 1. Decode + validate the invite payload. `validate` checks the + // version oneof, symmetric_key length, and + // external_group_id min length in one pass. + let payload = ExternalInvitePayloadProto::decode(invite_payload_bytes) + .map_err(JoinByExternalInviteError::InvitePayloadDecode)?; + let payload_v1 = validate(&payload).map_err(JoinByExternalInviteError::from)?; + + let mut key = [0u8; SYMMETRIC_KEY_LEN]; + key.copy_from_slice(&payload_v1.symmetric_key); + let payload_external_group_id = payload_v1.external_group_id.clone(); + + // 2. Decode + decrypt the encrypted GroupInfo blob. Expiry now + // lives on the blob envelope (the service controls TTL), not + // on the payload — `unwrap_group_info` enforces it when + // `now_ns` is supplied. + let blob = EncryptedGroupInfoBlobProto::decode(encrypted_group_info_bytes) + .map_err(JoinByExternalInviteError::EncryptedBlobDecode)?; + let now = xmtp_common::time::now_ns() as u64; + let (plaintext, _blob_v1) = + unwrap_group_info(&blob, &key, Some(now)).map_err(JoinByExternalInviteError::from)?; + + // 3. Parse plaintext as MlsMessage(GroupInfo). + let mls_message = MlsMessageIn::tls_deserialize(&mut plaintext.as_slice()) + .map_err(JoinByExternalInviteError::GroupInfoTlsDecode)?; + let verifiable_group_info = match mls_message.extract() { + MlsMessageBodyIn::GroupInfo(vgi) => vgi, + _ => return Err(JoinByExternalInviteError::GroupInfoNotGroupInfo.into()), + }; + // Defense-in-depth payload-vs-GroupInfo group-id binding is no + // longer present in the wire format (`payload.external_group_id` + // is a service slot id, intentionally decoupled from the MLS + // group_id). The post-join correlation check at step 11 catches + // service swaps: we read EXTERNAL_COMMIT_POLICY off the joined + // group and confirm its `external_group_id` matches the payload. + + // 5. Enumerate the joiner's OTHER installations from the latest + // association state and fetch their key packages. We hit the + // network for identity updates first so we never miss a recently + // rotated installation — a partial enumeration would make the + // validator reject the external commit. + let inbox_id = self.context.inbox_id().to_string(); + let my_installation_id = self.context.installation_id(); + let db = self.context.db(); + load_identity_updates(self.context.api(), &db, &[inbox_id.as_str()]) + .await + .map_err(|e| { + ClientError::Generic(format!( + "join_group_by_external_invite: load identity updates: {e}" + )) + })?; + // `IdentityUpdates::get_latest_association_state` takes a + // `&DbConnection`; the generic context here only gives us back a + // `DbQuery` (which, on native, IS a DbConnection — but the + // compiler can't see through the associated type). We've already + // refreshed identity updates above, so reading the in-memory + // association state via `get_association_state` (which takes + // `&impl DbQuery`) is exactly equivalent. + let association_state = self + .identity_updates() + .get_association_state(&db, &inbox_id, None) + .await?; + let my_installation_slice: &[u8] = &*my_installation_id; + let other_installation_ids: Vec> = association_state + .members() + .into_iter() + .filter_map(|member| match member.identifier { + MemberIdentifier::Installation(ident::Installation(id)) + if id.as_slice() != my_installation_slice => + { + Some(id) + } + _ => None, + }) + .collect(); + + let (other_key_packages, other_installations) = if other_installation_ids.is_empty() { + (Vec::new(), Vec::new()) + } else { + let kps_map = self + .mls_store() + .get_key_packages_for_installation_ids(other_installation_ids.clone()) + .await?; + let mut kps: Vec = + Vec::with_capacity(other_installation_ids.len()); + let mut installations: Vec = + Vec::with_capacity(other_installation_ids.len()); + let mut missing = 0usize; + for id in &other_installation_ids { + match kps_map.get(id) { + Some(Ok(verified_kp)) => { + kps.push(verified_kp.inner.clone()); + installations.push( + Installation::from_verified_key_package(verified_kp).map_err(|e| { + ClientError::Generic(format!( + "Installation::from_verified_key_package: {e}" + )) + })?, + ); + } + Some(Err(_)) | None => missing += 1, + } + } + if missing > 0 { + return Err(JoinByExternalInviteError::MissingInstallations { missing }.into()); + } + (kps, installations) + }; + + // 6. Build the AppDataUpdate(GROUP_MEMBERSHIP) payload that + // registers the joiner's inbox in the membership component. The + // shape mirrors `build_group_membership_app_data_payload` on the + // existing commit-path: a `TlsMapDelta` with + // one Insert(joiner_inbox_id, encode(V1{sequence_id, failed=[]})) + // mutation, encoded via `Component::encode_mutation` so the + // receiver decodes through the same path. + let joiner_inbox_id = VersionedInboxId::from_hex(&inbox_id).map_err(|e| { + JoinByExternalInviteError::BuildAppDataUpdatePayload(format!( + "inbox_id from_hex failed: {e}" + )) + })?; + let sequence_id = db + .get_latest_sequence_id(&[inbox_id.as_str()]) + .map_err(|e| { + ClientError::Generic(format!( + "join_group_by_external_invite: get_latest_sequence_id failed: {e}" + )) + })? + .get(inbox_id.as_str()) + .copied() + .unwrap_or(0) as u64; + let entry = GroupMembershipEntry { + version: Some(group_membership_entry::Version::V1( + group_membership_entry::V1 { + sequence_id, + failed_installations: vec![], + }, + )), + }; + let mut delta: TlsMapDelta = TlsMapDelta::new(); + delta = delta.insert(joiner_inbox_id, VLBytes::new(entry.encode_to_vec())); + let app_data_payload = ::encode_mutation(&delta) + .map_err(|e| { + JoinByExternalInviteError::BuildAppDataUpdatePayload(format!( + "encode_mutation failed: {e}" + )) + })?; + let app_data_update_proposal = + Proposal::AppDataUpdate(Box::new(AppDataUpdateProposal::update( + ComponentId::GROUP_MEMBERSHIP.as_u16(), + app_data_payload.clone(), + ))); + + // 7. Build the external commit. Per L-9 the openmls fork accepts + // by-value `Proposal::Add` and `Proposal::AppDataUpdate` inline + // for external commits (validator carve-out for non-member joins + // that need to atomically introduce co-resident leaves AND + // register membership in the AppData dictionary). + let provider = XmtpOpenMlsProviderRef::new(self.context.mls_storage()); + let signer = &self.context.identity().installation_keys; + let credential_with_key = CredentialWithKey { + credential: self.context.identity().credential.clone(), + signature_key: self + .context + .identity() + .installation_keys + .public_slice() + .into(), + }; + // Same shape as `groups::mls_ext::decrypted_welcome::build_group_join_config`, + // inlined here because that module is private to `groups`. + let join_config = MlsGroupJoinConfig::builder() + .wire_format_policy(WireFormatPolicy::default()) + .max_past_epochs(MAX_PAST_EPOCHS) + .use_ratchet_tree_extension(true) + .build(); + + // Build the joiner's leaf-node parameters. We must advertise + // every extension type the target group's RequiredCapabilities + // demand AND the `AppDataUpdate` proposal type so the validator + // accepts our by-value AppDataUpdate(GROUP_MEMBERSHIP) proposal. + // This mirrors `XmtpKeyPackage::build`'s capability set in + // `crates/xmtp_mls/src/identity.rs:842-888` — kept in sync by + // construction (both lists advertise the same XMTP-flavored + // extension catalogue + proposals). + let leaf_node_parameters = + build_external_join_leaf_node_parameters(credential_with_key.clone(), &inbox_id)?; + + let mut builder = openmls::group::MlsGroup::external_commit_builder() + .with_config(join_config) + .build_group(&provider, verifiable_group_info, credential_with_key) + .map_err(|e| { + JoinByExternalInviteError::ExternalCommitBuilderFailed(format!("build_group: {e}")) + })? + .leaf_node_parameters(leaf_node_parameters); + // Inline one Add proposal per other installation (by-value, per + // the L-9 validator carve-out). Then inline the AppDataUpdate + // membership proposal. Order matters: per L-7's + // validate_app_data_update_proposals_and_group_context, an + // AppDataUpdate must NOT appear before a GroupContextExtensions + // proposal — we emit no GCE here, so any ordering with Add + // proposals is fine. + if !other_key_packages.is_empty() { + builder = builder.propose_adds(other_key_packages); + } + builder = builder.add_proposal(app_data_update_proposal); + + let mut builder = builder.load_psks(provider.storage()).map_err(|e| { + JoinByExternalInviteError::ExternalCommitBuilderFailed(format!("load_psks: {e}")) + })?; + + // 7a. Compute the AppDataDictionary updates that openmls expects + // to accompany our AppDataUpdate(GROUP_MEMBERSHIP) proposal. The + // proposal carries a *delta* (`TlsMapDelta`); + // openmls needs the resulting absolute value (`Vec` of the + // post-commit `TlsMap`) so the commit's + // confirmation tag agrees with what receivers will compute from + // their own apply path. Read the joining-epoch value of the + // GROUP_MEMBERSHIP component from the builder's view of the + // group's app_data_dict, then run our delta through + // `GroupMembershipComponent::apply_update_payload` to materialize + // the new absolute value. This mirrors the receive-side + // `accumulate_app_data_updates` logic, just collapsed for the + // single-proposal case typical of an external-invite join. + let mut updater = builder.app_data_dictionary_updater(); + let old_group_membership_bytes: Option> = updater + .old_value(ComponentId::GROUP_MEMBERSHIP.as_u16()) + .map(<[u8]>::to_vec); + let new_group_membership_bytes = + ::apply_update_payload( + &app_data_payload, + old_group_membership_bytes.as_deref(), + ) + .map_err(|e| { + JoinByExternalInviteError::BuildAppDataUpdatePayload(format!( + "apply_update_payload failed: {e}" + )) + })?; + updater.set(openmls::component::ComponentData::from_parts( + ComponentId::GROUP_MEMBERSHIP.as_u16(), + VLBytes::new(new_group_membership_bytes), + )); + builder.with_app_data_dictionary_updates(updater.changes()); + + let (mls_group, bundle) = builder + .build(provider.rand(), provider.crypto(), signer, |_| true) + .map_err(|e| { + JoinByExternalInviteError::ExternalCommitBuilderFailed(format!("build: {e}")) + })? + .finalize(&provider) + .map_err(|e| { + JoinByExternalInviteError::ExternalCommitBuilderFailed(format!("finalize: {e}")) + })?; + + // 7b. Persist a libxmtp StoredGroup row for the freshly-joined + // group so libxmtp's higher-level group APIs (load, sync, send, + // find_groups) can see it. openmls has already persisted its own + // MLS state via the finalize call above; the StoredGroup wrapper + // carries libxmtp-side metadata. Mirrors the welcome-side + // semantics: membership_state=Pending until the next sync + // confirms our join. + let group_id = xmtp_proto::types::GroupId::try_from(mls_group.group_id()).map_err(|e| { + ClientError::Generic(format!( + "join_group_by_external_invite: group_id conversion failed: {e}" + )) + })?; + persist_joined_group(&self.context, group_id)?; + let created_at_ns = xmtp_common::time::now_ns(); + let new_group = MlsGroup::new_from_arc( + self.context.clone(), + group_id, + None, + xmtp_db::group::ConversationType::Group, + created_at_ns, + ); + + // 8. Publish the PublicMessage external commit to XMTP delivery. + // Bundle gives us a borrowed view of the commit; serialize and + // wrap into the same `GroupMessageInput.V1` shape the existing + // commit-publish path uses (an HMAC-of-payload binding to the + // current epoch's sender HMAC key, even for an external joiner + // — receivers use the HMAC for dedup, not authentication). + let commit_bytes = bundle + .commit() + .tls_serialize_detached() + .map_err(JoinByExternalInviteError::GroupInfoTlsDecode)?; + let group_message = build_group_message_input(&new_group, &commit_bytes)?; + self.context + .api() + .send_group_messages(vec![group_message]) + .await + .map_err(|e| ClientError::PublishError(format!("external commit publish: {e}")))?; + + // 9. HPKE-wrap + ship Welcomes to each non-primary installation. + // The Welcome carries the freshly-minted ratchet tree at the + // post-commit epoch so each co-resident installation can + // reconstruct group state and decrypt subsequent messages. We + // inline the v1 HPKE-wrap (one welcome per installation) rather + // than reuse `MlsGroup::send_welcomes`, which is `pub(super)` + // inside `groups` and additionally branches into the + // welcome-pointer optimization for groups above + // INSTALLATION_THRESHOLD_FOR_WELCOME_POINTER_SENDING — irrelevant + // for the small (≤ ~few) other-installation case typical of a + // QR-invite join. + if !other_installations.is_empty() + && let Some(welcome_msg) = bundle.clone().into_welcome_msg() + { + let welcome_bytes = welcome_msg + .tls_serialize_detached() + .map_err(JoinByExternalInviteError::GroupInfoTlsDecode)?; + let welcome_metadata_bytes = WelcomeMetadata { message_cursor: 0 }.encode_to_vec(); + let mut welcomes: Vec = + Vec::with_capacity(other_installations.len()); + for installation in &other_installations { + let algorithm = installation.welcome_wrapper_algorithm; + let (data, welcome_metadata) = wrap_payload_hpke( + &welcome_bytes, + &welcome_metadata_bytes, + &installation.hpke_public_key, + algorithm, + WELCOME_HPKE_LABEL, + ) + .map_err(|e| { + ClientError::Generic(format!( + "join_group_by_external_invite: wrap_payload_hpke failed: {e}" + )) + })?; + welcomes.push(WelcomeMessageInput { + version: Some(WelcomeMessageInputVersion::V1(WelcomeMessageInputV1 { + installation_key: installation.installation_key.clone(), + data, + hpke_public_key: installation.hpke_public_key.clone(), + wrapper_algorithm: i32::from(algorithm), + welcome_metadata, + })), + }); + } + self.context + .api() + .send_welcome_messages(&welcomes) + .await + .map_err(|e| { + ClientError::PublishError(format!("external commit welcome publish: {e}")) + })?; + } + + // 10. Read the freshly-joined group's EXTERNAL_COMMIT_POLICY. + // a) Correlation check: payload.external_group_id MUST equal + // the policy's external_group_id. Catches stale QRs + // (admin rotated policy + re-issued) and service swaps + // (service returned a GroupInfo for the wrong group). + // b) Derive the refreshed-blob's `expires_at_ns` from the + // policy so re-uploads carry the admin's intended TTL. + let policy = crate::groups::external_commit_policy::load_external_commit_policy(&mls_group) + .map_err(|e| { + ClientError::Generic(format!( + "join_group_by_external_invite: load EXTERNAL_COMMIT_POLICY: {e}" + )) + })? + .ok_or(JoinByExternalInviteError::PostJoinPolicyMissing)?; + if !policy.allow_external_commit { + return Err(JoinByExternalInviteError::PostJoinPolicyMissing.into()); + } + if policy.external_group_id.as_slice() != payload_external_group_id.as_slice() { + return Err(JoinByExternalInviteError::ExternalGroupIdMismatch.into()); + } + let refreshed_expires_at_ns = compute_refreshed_blob_expiry(&policy, now); + + // 11. Re-export the GroupInfo at the post-commit epoch and + // re-encrypt with the SAME symmetric key + a fresh nonce + // (wrap_group_info generates one internally). The external + // service atomically swaps its stored blob for this + // refreshed payload so the next joiner reads the right + // epoch. The blob carries epoch + state hash so the service + // can total-order uploads + reject same-epoch fork uploads. + let post_commit_group_info_msg = mls_group + .export_group_info(provider.crypto(), signer, /* with_ratchet_tree */ true) + .map_err(|e| JoinByExternalInviteError::PostCommitGroupInfoExport(format!("{e}")))?; + let post_commit_plaintext = post_commit_group_info_msg + .tls_serialize_detached() + .map_err(JoinByExternalInviteError::GroupInfoTlsDecode)?; + let post_commit_epoch = mls_group.epoch().as_u64(); + let post_commit_state_hash = mls_group.epoch_authenticator().as_slice().to_vec(); + let refreshed_blob = wrap_group_info( + &post_commit_plaintext, + &key, + post_commit_epoch, + post_commit_state_hash, + refreshed_expires_at_ns, + ) + .map_err(JoinByExternalInviteError::PostCommitGroupInfoEncrypt)?; + let refreshed_encrypted_group_info = refreshed_blob.encode_to_vec(); + + // Belt-and-suspenders: zeroize our copy of the symmetric key. + key.fill(0); + + Ok(JoinByExternalInviteOutput { + group: new_group, + refreshed_encrypted_group_info, + }) + } +} + +/// Derive `expires_at_ns` for a refreshed-blob upload from the +/// post-join policy. Tightest active rule wins. `0` anywhere means +/// "this rule contributes nothing"; absent contributors produce a `0` +/// (no expiry) blob. +fn compute_refreshed_blob_expiry( + policy: &xmtp_proto::xmtp::mls::message_contents::ExternalCommitPolicyV1, + now_ns: u64, +) -> u64 { + let mut candidates: Vec = Vec::new(); + if policy.expires_at_ns != 0 { + candidates.push(policy.expires_at_ns); + } + if policy.expire_in_ns != 0 { + candidates.push(now_ns.saturating_add(policy.expire_in_ns)); + } + candidates.into_iter().min().unwrap_or(0) +} + +/// Build the joiner's [`LeafNodeParameters`] for an external commit. +/// +/// The capability set mirrors `XmtpKeyPackage::build` in +/// `crates/xmtp_mls/src/identity.rs:842-888`: advertise every +/// XMTP-flavored extension type and the `AppDataUpdate` / +/// `GroupContextExtensions` proposal types so the validator-side +/// accepts our by-value AppDataUpdate proposal and the leaf passes +/// the group's RequiredCapabilities check. +/// +/// We also attach the `ApplicationId` leaf-node extension so receivers +/// can recover the joiner's inbox_id from the leaf node directly (same +/// as the welcome-side leaf builds). +fn build_external_join_leaf_node_parameters( + credential_with_key: CredentialWithKey, + inbox_id: &str, +) -> Result { + let capability_extensions = [ + ExtensionType::LastResort, + ExtensionType::ApplicationId, + ExtensionType::ImmutableMetadata, + ExtensionType::AppDataDictionary, + ExtensionType::Unknown(GROUP_PERMISSIONS_EXTENSION_ID), + ExtensionType::Unknown(MUTABLE_METADATA_EXTENSION_ID), + ExtensionType::Unknown(GROUP_MEMBERSHIP_EXTENSION_ID), + ExtensionType::Unknown(WELCOME_WRAPPER_ENCRYPTION_EXTENSION_ID), + ExtensionType::Unknown(WELCOME_POINTEE_ENCRYPTION_AEAD_TYPES_EXTENSION_ID), + ]; + let capabilities = Capabilities::new( + None, + Some(&[CIPHERSUITE]), + Some(&capability_extensions), + Some(&[ + ProposalType::GroupContextExtensions, + ProposalType::AppDataUpdate, + ]), + None, + ); + + let application_id = Extension::ApplicationId(ApplicationIdExtension::new(inbox_id.as_bytes())); + let leaf_node_extensions = Extensions::::single(application_id).map_err(|e| { + ClientError::Generic(format!( + "join_group_by_external_invite: leaf node extensions build failed: {e}" + )) + })?; + + Ok(LeafNodeParameters::builder() + .with_credential_with_key(credential_with_key) + .with_capabilities(capabilities) + .with_extensions(leaf_node_extensions) + .build()) +} + +/// Build a `GroupMessageInput.V1` carrying `payload` and the current +/// epoch's sender HMAC. Mirrors `MlsGroup::prepare_group_messages` (which +/// is `pub(super)` inside `groups`); receivers use the HMAC for dedup so +/// any binding from the current epoch's key is sufficient. +fn build_group_message_input( + group: &MlsGroup, + payload: &[u8], +) -> Result { + let hmac_key = group + .hmac_keys(0..=0) + .map_err(ClientError::from)? + .pop() + .ok_or_else(|| ClientError::Generic("hmac_keys returned empty range".to_string()))?; + let mut mac = + as Mac>::new_from_slice(&hmac_key.key).expect("HMAC accepts any key length"); + mac.update(payload); + let sender_hmac = mac.finalize().into_bytes().to_vec(); + + Ok(GroupMessageInput { + version: Some(GroupMessageInputVersion::V1(GroupMessageInputV1 { + data: payload.to_vec(), + sender_hmac, + should_push: false, + })), + }) +} + +/// Persist a StoredGroup row for a group joined via external commit. +/// +/// Mirrors the welcome-side `insert_or_replace_group` flow but with +/// `added_by_inbox_id` set to the joiner's own inbox (external joins are +/// self-initiated) and a `Pending` membership state — the next sync will +/// surface the validator's verdict on the external commit and flip us to +/// `Allowed`. +fn persist_joined_group( + context: &Context, + group_id: xmtp_proto::types::GroupId, +) -> Result<(), ClientError> { + use xmtp_db::group::{ConversationType, GroupMembershipState, StoredGroup}; + use xmtp_db::prelude::*; + + let creator = context.inbox_id().to_string(); + let stored_group = StoredGroup::builder() + .id(group_id) + .created_at_ns(xmtp_common::time::now_ns()) + .membership_state(GroupMembershipState::Pending) + .conversation_type(ConversationType::Group) + .added_by_inbox_id(creator) + .should_publish_commit_log(false) + .build() + .map_err(|e| { + ClientError::Generic(format!( + "join_group_by_external_invite: StoredGroup build failed: {e}" + )) + })?; + let _ = stored_group.store_or_ignore(&context.db()); + Ok(()) +} + +// End-to-end test coverage for this entry point lives in the QR-invite +// integration test (T-1, libxmtp-integration-test). The join path +// requires the producer-side `set_allow_external_commit` + the +// validator-side carve-out to be wired up end-to-end, both of which are +// outside this module's scope. diff --git a/crates/xmtp_mls/src/groups/app_data/component_source.rs b/crates/xmtp_mls/src/groups/app_data/component_source.rs index 43427348e4..67beab926a 100644 --- a/crates/xmtp_mls/src/groups/app_data/component_source.rs +++ b/crates/xmtp_mls/src/groups/app_data/component_source.rs @@ -274,6 +274,11 @@ pub(crate) fn component_type(id: ComponentId) -> Option { | ComponentId::MESSAGE_DISAPPEAR_IN_NS | ComponentId::COMMIT_LOG_SIGNER => Some(ComponentType::Bytes), + // External-commit policy: proto-encoded ExternalCommitPolicyEntry, + // replaced atomically via the generic AppDataUpdate intent. No + // per-id Component impl needed; helpers decode bytes via prost. + ComponentId::EXTERNAL_COMMIT_POLICY => Some(ComponentType::Bytes), + // Immutable metadata (not flowable through AppDataUpdate writes, // but we still advertise the type for completeness). ComponentId::CONVERSATION_TYPE diff --git a/crates/xmtp_mls/src/groups/error.rs b/crates/xmtp_mls/src/groups/error.rs index 54a52eb450..a7872f0c12 100644 --- a/crates/xmtp_mls/src/groups/error.rs +++ b/crates/xmtp_mls/src/groups/error.rs @@ -1,5 +1,4 @@ use super::group_permissions::GroupMutablePermissionsError; -use super::mls_ext::{UnwrapWelcomeError, WrapWelcomeError}; use super::mls_sync::GroupMessageProcessingError; use super::summary::SyncSummary; use super::{intents::IntentError, validated_commit::CommitValidationError}; @@ -28,6 +27,8 @@ use xmtp_db::NotFound; use xmtp_db::sql_key_store; use xmtp_mls_common::group_metadata::GroupMetadataError; use xmtp_mls_common::group_mutable_metadata::GroupMutableMetadataError; +use xmtp_mls_common::invite::encrypted_group_info::EncryptedGroupInfoError; +use xmtp_mls_common::mls_ext::payload_encryption::{UnwrapPayloadError, WrapPayloadError}; /// Wraps multiple message processing errors from a single receive operation. /// @@ -407,12 +408,12 @@ pub enum GroupError { /// /// Failed to wrap welcome message. Not retryable. #[error(transparent)] - WrapWelcome(#[from] WrapWelcomeError), + WrapWelcome(#[from] WrapPayloadError), /// Unwrap welcome error. /// /// Failed to unwrap welcome message. Not retryable. #[error(transparent)] - UnwrapWelcome(#[from] UnwrapWelcomeError), + UnwrapWelcome(#[from] UnwrapPayloadError), /// Welcome data not found. /// /// Welcome data missing from topic. Not retryable. @@ -443,6 +444,33 @@ pub enum GroupError { /// Device sync operation failed. May be retryable. #[error(transparent)] DeviceSync(#[from] Box), + /// Export group info error. + /// + /// OpenMLS failed to export a GroupInfo object for the current epoch + /// (used by the external-invite producer path). Not retryable. + #[error("export group info: {0}")] + ExportGroupInfo(#[from] openmls::group::ExportGroupInfoError), + /// Encrypted group info wrap/unwrap error. + /// + /// Wrapping or unwrapping an [`EncryptedGroupInfoBlob`] failed. + /// Not retryable. + /// + /// [`EncryptedGroupInfoBlob`]: xmtp_proto::xmtp::mls::message_contents::EncryptedGroupInfoBlob + #[error(transparent)] + EncryptedGroupInfo(#[from] EncryptedGroupInfoError), + /// Tried to issue or accept an external commit on a group whose + /// `EXTERNAL_COMMIT_POLICY` has `allow_external_commit = false` or + /// is absent entirely. Not retryable; admin must enable the policy + /// via [`MlsGroup::set_allow_external_commit`] first. + #[error("external commit is not allowed for this group")] + ExternalCommitNotAllowed, + /// `EXTERNAL_COMMIT_POLICY.v1` is present but the in-group key + /// material doesn't meet the lifecycle invariants + /// (`symmetric_key.len() == 32`, `external_group_id.len() >= 4`). + /// Indicates corruption or a malicious / buggy peer that wrote a + /// malformed policy. Not retryable. + #[error("EXTERNAL_COMMIT_POLICY malformed: {0}")] + ExternalCommitPolicyMalformed(String), } #[derive(Error, Debug)] @@ -637,6 +665,10 @@ impl RetryableError for GroupError { | Self::NoWelcomesToSend | Self::WelcomeDataNotFound(_) | Self::UninitializedField(_) + | Self::ExportGroupInfo(_) + | Self::EncryptedGroupInfo(_) + | Self::ExternalCommitNotAllowed + | Self::ExternalCommitPolicyMalformed(_) | Self::UninitializedResult => false, } } diff --git a/crates/xmtp_mls/src/groups/external_commit_policy.rs b/crates/xmtp_mls/src/groups/external_commit_policy.rs new file mode 100644 index 0000000000..49c31fc15a --- /dev/null +++ b/crates/xmtp_mls/src/groups/external_commit_policy.rs @@ -0,0 +1,223 @@ +//! External-commit policy lookup helpers. +//! +//! Two layers gate an incoming MLS External Commit (RFC 9420 §12.4.3.2): +//! +//! 1. **Master switch** — the `EXTERNAL_COMMIT_POLICY` well-known +//! component, decoded into [`ExternalCommitPolicyV1`]. Carries +//! `allow_external_commit` plus the time-window controls +//! (`expires_at_ns`, `expire_in_ns`). +//! 2. **Per-component declarative permissions** — each component's +//! `ComponentMetadata.external_committer_permissions` block. Sibling +//! of the existing `permissions` block; governs what external +//! committers may do to *this* component. +//! +//! Both layers default to "deny" when absent — this module surfaces +//! `Option<…>`/`bool` from "absent" rather than synthesizing a default +//! struct, so callers can route on whether the admin has ever opted in. +//! +//! The MLS-spec invariants (exactly one ExternalInit, joiner credential +//! binding on Adds, no by-reference proposals, no SelfRemove) are +//! hardcoded in the validator (see L-7); this module only covers the +//! AppData-resident policy. + +use openmls::group::MlsGroup as OpenMlsGroup; +use prost::Message; +use xmtp_mls_common::app_data::component_id::ComponentId; +use xmtp_proto::xmtp::mls::message_contents::{ + ComponentPermissions, ExternalCommitPolicyEntry, ExternalCommitPolicyV1, + external_commit_policy_entry::Version as ExternalCommitPolicyVersion, +}; + +use crate::groups::app_data::{component_source::ComponentSourceError, load_component_registry}; + +/// Read the `EXTERNAL_COMMIT_POLICY` component from the group's AppData +/// dictionary. Returns: +/// +/// - `Ok(Some(policy))` — entry is present and decoded. +/// - `Ok(None)` — entry is absent, or the dict has no recognizable +/// version variant (defensive: unknown variants treated as absent). +/// - `Err(_)` — registry / extension decode failed. +// +// Consumed by the L-7 validator (`ValidatedCommit::from_external_commit`). +// Stays dead-allowed at this PR until L-7 lands. +#[allow(dead_code)] +pub(crate) fn load_external_commit_policy( + mls_group: &OpenMlsGroup, +) -> Result, ComponentSourceError> { + let Some(bytes) = mls_group + .extensions() + .app_data_dictionary() + .and_then(|ext| { + ext.dictionary() + .get(&ComponentId::EXTERNAL_COMMIT_POLICY.as_u16()) + }) + else { + return Ok(None); + }; + + let entry = ExternalCommitPolicyEntry::decode(bytes).map_err(|e| { + ComponentSourceError::MalformedComponentValue { + component_id: ComponentId::EXTERNAL_COMMIT_POLICY, + reason: format!("ExternalCommitPolicyEntry decode: {e}"), + } + })?; + + // Unknown future variant — treat as default-disabled rather than + // failing hard. Newer clients understand the variant; older ones + // fail closed. + Ok(entry + .version + .map(|ExternalCommitPolicyVersion::V1(v1)| v1)) +} + +/// Convenience: true iff the group has opted into accepting external +/// commits via `EXTERNAL_COMMIT_POLICY.v1.allow_external_commit`. +/// +/// This is the cheap first-line check the validator runs before any +/// per-proposal evaluation. It does NOT enforce the time-window fields +/// (`expires_at_ns` / `expire_in_ns`) — the validator consults the full +/// policy via [`load_external_commit_policy`] for those, because they +/// require additional context (wall-clock time and GroupInfo export +/// timestamp) the helper itself doesn't have. +/// +/// Returns `false` on absent entry, decode failure, or any policy +/// shape that doesn't set the bit. Fails closed. +// +// Consumed by the L-7 validator. Dead-allowed until L-7 lands. +#[allow(dead_code)] +pub(crate) fn is_external_commit_allowed(mls_group: &OpenMlsGroup) -> bool { + load_external_commit_policy(mls_group) + .ok() + .flatten() + .map(|policy| policy.allow_external_commit) + .unwrap_or(false) +} + +/// Read the `external_committer_permissions` block from the +/// `ComponentMetadata` of the given component in the registry. +/// +/// Returns: +/// +/// - `Ok(Some(perms))` — component has an `external_committer_permissions` +/// block. The caller evaluates each proposal's effect against the +/// relevant policy slot. +/// - `Ok(None)` — component is in the registry but has no +/// `external_committer_permissions` block, OR component isn't in the +/// registry at all. In both cases the validator treats this as +/// all-Deny: external committers may not touch this component. +/// - `Err(_)` — registry decode failed. +// +// Consumed by the L-7 validator. Dead-allowed until L-7 lands. +#[allow(dead_code)] +pub(crate) fn external_committer_permissions_for( + mls_group: &OpenMlsGroup, + component_id: ComponentId, +) -> Result, ComponentSourceError> { + let registry = load_component_registry(mls_group)?; + let Some(meta) = registry.get(&component_id).ok().flatten() else { + return Ok(None); + }; + Ok(meta.external_committer_permissions) +} + +#[cfg(test)] +mod tests { + //! Round-trip + absence coverage for the policy lookup helpers. + use super::*; + use openmls::extensions::{ + AppDataDictionary, AppDataDictionaryExtension, Extension, Extensions, + }; + use xmtp_proto::xmtp::mls::message_contents::ComponentMetadata; + + fn encode_policy(v1: ExternalCommitPolicyV1) -> Vec { + ExternalCommitPolicyEntry { + version: Some(ExternalCommitPolicyVersion::V1(v1)), + } + .encode_to_vec() + } + + fn extensions_with_policy_bytes(bytes: Vec) -> Extensions { + let mut dict = AppDataDictionary::new(); + let _ = dict.insert(ComponentId::EXTERNAL_COMMIT_POLICY.as_u16(), bytes); + Extensions::from_vec(vec![Extension::AppDataDictionary( + AppDataDictionaryExtension::new(dict), + )]) + .expect("AppDataDictionary is a valid GroupContext extension") + } + + #[xmtp_common::test(unwrap_try = true)] + fn empty_dict_treated_as_disabled() { + let extensions: Extensions = + Extensions::from_vec(vec![]).unwrap(); + let dict_entry = extensions.app_data_dictionary().and_then(|ext| { + ext.dictionary() + .get(&ComponentId::EXTERNAL_COMMIT_POLICY.as_u16()) + }); + assert!(dict_entry.is_none(), "no dict entry should be present"); + } + + #[xmtp_common::test(unwrap_try = true)] + fn malformed_entry_surfaces_decode_error() { + let extensions = extensions_with_policy_bytes(vec![0xFF; 16]); + let bytes = extensions + .app_data_dictionary() + .and_then(|ext| { + ext.dictionary() + .get(&ComponentId::EXTERNAL_COMMIT_POLICY.as_u16()) + }) + .unwrap(); + let err = ExternalCommitPolicyEntry::decode(bytes); + assert!(err.is_err(), "malformed bytes must fail to decode"); + } + + #[xmtp_common::test(unwrap_try = true)] + fn round_trip_allows_external_commit() { + let v1 = ExternalCommitPolicyV1 { + allow_external_commit: true, + expires_at_ns: 1_700_000_000_000_000_000, + expire_in_ns: 60_000_000_000, + symmetric_key: vec![0x11u8; 32], + external_group_id: vec![0x22u8; 16], + }; + let bytes = encode_policy(v1.clone()); + let decoded = ExternalCommitPolicyEntry::decode(bytes.as_ref()).unwrap(); + match decoded.version { + Some(ExternalCommitPolicyVersion::V1(v)) => { + assert!(v.allow_external_commit); + assert_eq!(v.expires_at_ns, v1.expires_at_ns); + assert_eq!(v.expire_in_ns, v1.expire_in_ns); + assert_eq!(v.symmetric_key, v1.symmetric_key); + assert_eq!(v.external_group_id, v1.external_group_id); + } + None => panic!("decoded entry has no version variant"), + } + } + + #[xmtp_common::test(unwrap_try = true)] + fn round_trip_default_disabled() { + // Zero-valued ExternalCommitPolicyV1 must decode back unchanged. + let v1 = ExternalCommitPolicyV1::default(); + let bytes = encode_policy(v1); + let decoded = ExternalCommitPolicyEntry::decode(bytes.as_ref()).unwrap(); + match decoded.version { + Some(ExternalCommitPolicyVersion::V1(v)) => { + assert!(!v.allow_external_commit); + assert_eq!(v.expires_at_ns, 0); + assert_eq!(v.expire_in_ns, 0); + } + None => panic!("decoded entry has no version variant"), + } + } + + #[xmtp_common::test(unwrap_try = true)] + fn component_metadata_without_external_block_is_treated_as_deny() { + // ComponentMetadata with no external_committer_permissions field + // is treated as all-Deny by the validator. + let meta = ComponentMetadata { + component_type: 1, + permissions: None, + external_committer_permissions: None, + }; + assert!(meta.external_committer_permissions.is_none()); + } +} diff --git a/crates/xmtp_mls/src/groups/external_invite.rs b/crates/xmtp_mls/src/groups/external_invite.rs new file mode 100644 index 0000000000..dee21481c8 --- /dev/null +++ b/crates/xmtp_mls/src/groups/external_invite.rs @@ -0,0 +1,274 @@ +//! Producer-side API for the external-invite ("QR-invite") flow. +//! +//! Any current member calls [`MlsGroup::create_external_invite`] to +//! produce two opaque byte blobs: +//! +//! 1. an [`ExternalInvitePayload`] proto wrapping a V1 variant carrying +//! the application-supplied `service_pointer`, plus the +//! `symmetric_key` and `external_group_id` read from the group's +//! [`EXTERNAL_COMMIT_POLICY`] component; and +//! 2. an [`EncryptedGroupInfoBlob`] proto wrapping a TLS-serialized +//! MLS `GroupInfo` for the current epoch (with the ratchet tree +//! embedded) under that same symmetric key plus a fresh nonce, +//! annotated with `epoch`, `group_state_hash`, and `expires_at_ns`. +//! +//! The symmetric key and the external group id live in the group (as +//! `EXTERNAL_COMMIT_POLICY.v1`), so they're stable across re-uploads +//! and across members: a just-joined external committer can re-export +//! and re-upload under the same key without re-issuing the QR. +//! +//! The application is responsible for transporting the payload (e.g. +//! via a QR code, deep link, or NFC tap) and for uploading the +//! encrypted blob to its own service indexed by the payload's +//! `external_group_id`. After every successful join, the joiner +//! re-exports and re-uploads under the same key with a fresh nonce. +//! +//! Crypto primitives live in `xmtp_mls_common`; this module only +//! orchestrates them. +//! +//! [`ExternalInvitePayload`]: xmtp_proto::xmtp::mls::message_contents::ExternalInvitePayload +//! [`EncryptedGroupInfoBlob`]: xmtp_proto::xmtp::mls::message_contents::EncryptedGroupInfoBlob +//! [`EXTERNAL_COMMIT_POLICY`]: xmtp_mls_common::app_data::component_id::ComponentId::EXTERNAL_COMMIT_POLICY + +use openmls_traits::OpenMlsProvider as _; +use prost::Message as _; +use tls_codec::Serialize as _; + +use crate::context::XmtpSharedContext; +use crate::groups::external_commit_policy::load_external_commit_policy; +use crate::groups::{GroupError, MlsGroup}; +use xmtp_mls_common::invite::encrypted_group_info::wrap_group_info; +use xmtp_mls_common::invite::payload::{ + MIN_EXTERNAL_GROUP_ID_LEN, SYMMETRIC_KEY_LEN, build_payload, +}; + +/// Options for creating an external invite (QR-code or shareable-link join). +#[derive(Debug, Clone, Default)] +pub struct CreateExternalInviteOpts { + /// Application-defined opaque bytes describing where the encrypted + /// GroupInfo blob can be fetched (URL, service ID, etc.). Interpreted + /// by the downstream consumer; libxmtp does not parse this. + pub service_pointer: Vec, + /// Optional caller-supplied tighter expiry hint (nanoseconds since + /// UNIX epoch) for the encrypted blob. The effective expiry is the + /// tightest of: this hint, the policy's absolute `expires_at_ns`, + /// and `now_ns + policy.expire_in_ns`. `None` means "use whatever + /// the policy says". + pub blob_expires_at_ns: Option, +} + +/// Output of [`MlsGroup::create_external_invite`]. Both fields are +/// protobuf-serialized bytes; the application chooses the transport +/// encoding for the invite payload and uploads the encrypted blob to +/// its service indexed by the payload's `external_group_id`. +#[derive(Debug, Clone)] +pub struct CreateExternalInviteOutput { + /// Serialized [`ExternalInvitePayload`] proto (V1 envelope). + /// + /// [`ExternalInvitePayload`]: xmtp_proto::xmtp::mls::message_contents::ExternalInvitePayload + pub invite_payload: Vec, + /// Serialized [`EncryptedGroupInfoBlob`] proto (V1 envelope). + /// + /// [`EncryptedGroupInfoBlob`]: xmtp_proto::xmtp::mls::message_contents::EncryptedGroupInfoBlob + pub encrypted_group_info: Vec, +} + +impl MlsGroup +where + Context: XmtpSharedContext, +{ + /// Produce a QR-invite payload + an encrypted GroupInfo blob for the + /// current epoch of this group. + /// + /// The symmetric key and `external_group_id` are taken from + /// [`EXTERNAL_COMMIT_POLICY.v1`] (populated by + /// [`MlsGroup::set_allow_external_commit`] when enabled). Calling this + /// before an admin has enabled the policy fails with + /// [`GroupError::ExternalCommitNotAllowed`]. + /// + /// The exported `GroupInfo` always carries the ratchet tree (i.e. + /// `with_ratchet_tree = true`) so that the joining client can perform + /// an external commit without an additional out-of-band lookup. The + /// blob carries `epoch` and `group_state_hash` (epoch authenticator + /// bytes) so the service can total-order uploads and reject + /// same-epoch fork uploads. + /// + /// The blob's `expires_at_ns` is derived from the policy's + /// `expires_at_ns` (absolute) and `expire_in_ns` (relative to + /// upload time), with an optional per-call tightening via + /// [`CreateExternalInviteOpts::blob_expires_at_ns`]. + /// + /// [`EXTERNAL_COMMIT_POLICY.v1`]: xmtp_proto::xmtp::mls::message_contents::ExternalCommitPolicyV1 + #[tracing::instrument(level = "debug", skip_all)] + pub async fn create_external_invite( + &self, + opts: CreateExternalInviteOpts, + ) -> Result { + self.ensure_not_paused().await?; + + let signer = self.context.identity().installation_keys.clone(); + + self.load_mls_group_with_lock_async(async |openmls_group| { + // Read EXTERNAL_COMMIT_POLICY. Any member with the policy + // visible in their AppData dict can produce an invite from + // the in-group key + external_group_id. + let policy = load_external_commit_policy(&openmls_group)? + .ok_or(GroupError::ExternalCommitNotAllowed)?; + + if !policy.allow_external_commit { + return Err(GroupError::ExternalCommitNotAllowed); + } + if policy.symmetric_key.len() != SYMMETRIC_KEY_LEN { + return Err(GroupError::ExternalCommitPolicyMalformed(format!( + "symmetric_key has wrong length: got {}, expected {}", + policy.symmetric_key.len(), + SYMMETRIC_KEY_LEN, + ))); + } + if policy.external_group_id.len() < MIN_EXTERNAL_GROUP_ID_LEN { + return Err(GroupError::ExternalCommitPolicyMalformed(format!( + "external_group_id too short: got {}, min {}", + policy.external_group_id.len(), + MIN_EXTERNAL_GROUP_ID_LEN, + ))); + } + + let key_array: [u8; SYMMETRIC_KEY_LEN] = policy + .symmetric_key + .as_slice() + .try_into() + .expect("length checked immediately above"); + + let provider = self.context.mls_provider(); + let group_info_message = + openmls_group.export_group_info(provider.crypto(), &signer, true)?; + let group_info_bytes = group_info_message.tls_serialize_detached()?; + + let epoch = openmls_group.epoch().as_u64(); + let group_state_hash = openmls_group.epoch_authenticator().as_slice().to_vec(); + let blob_expires_at_ns = compute_blob_expiry( + &policy, + opts.blob_expires_at_ns, + xmtp_common::time::now_ns(), + ); + + let encrypted_blob = wrap_group_info( + &group_info_bytes, + &key_array, + epoch, + group_state_hash, + blob_expires_at_ns, + )?; + + let payload = build_payload( + opts.service_pointer, + policy.external_group_id.clone(), + key_array, + ); + + Ok::(CreateExternalInviteOutput { + invite_payload: payload.encode_to_vec(), + encrypted_group_info: encrypted_blob.encode_to_vec(), + }) + }) + .await + } +} + +/// Derive the blob's effective `expires_at_ns` from policy + per-call hint. +/// +/// Tightest active expiry wins. `0` anywhere means "this rule contributes +/// nothing"; absent contributors produce a `0` (no expiry) blob. +fn compute_blob_expiry( + policy: &xmtp_proto::xmtp::mls::message_contents::ExternalCommitPolicyV1, + opts_expires_at_ns: Option, + now_ns: i64, +) -> u64 { + let mut candidates: Vec = Vec::new(); + if policy.expires_at_ns != 0 { + candidates.push(policy.expires_at_ns); + } + if policy.expire_in_ns != 0 { + let now_u64 = u64::try_from(now_ns).unwrap_or(0); + candidates.push(now_u64.saturating_add(policy.expire_in_ns)); + } + if let Some(t) = opts_expires_at_ns + && t != 0 + { + candidates.push(t); + } + candidates.into_iter().min().unwrap_or(0) +} + +#[cfg(test)] +mod tests { + //! Unit-level coverage for the producer path. End-to-end coverage + //! that exercises the full + //! `set_allow_external_commit → create_external_invite → join` + //! pipeline (including blob round-trip + epoch + state hash + //! assertions) lives in the QR-invite integration test (T-1). + use super::*; + use crate::tester; + use xmtp_mls_common::invite::payload::{MIN_EXTERNAL_GROUP_ID_LEN, SYMMETRIC_KEY_LEN}; + use xmtp_proto::xmtp::mls::message_contents::ExternalCommitPolicyV1; + + #[xmtp_common::test(unwrap_try = true)] + async fn create_external_invite_fails_when_policy_not_set() { + tester!(alix); + let group = alix.create_group(None, None)?; + + let err = group + .create_external_invite(CreateExternalInviteOpts::default()) + .await + .unwrap_err(); + assert!( + matches!(err, GroupError::ExternalCommitNotAllowed), + "expected ExternalCommitNotAllowed, got {err:?}", + ); + } + + // NOTE: end-to-end tests that exercise + // set_allow_external_commit(true|false) → create_external_invite + // live in the QR-invite integration test (T-1, libxmtp-integration-test). + // The L-6 setter routes through the AppDataUpdate intent path; the full + // commit / decrypt / round-trip surface is best validated in a setting + // that also exercises the joiner. + + #[test] + fn compute_blob_expiry_picks_tightest_active_value() { + // Policy with absolute expiry, no relative window, no opts hint. + let policy = ExternalCommitPolicyV1 { + allow_external_commit: true, + expires_at_ns: 5_000, + expire_in_ns: 0, + symmetric_key: vec![0u8; SYMMETRIC_KEY_LEN], + external_group_id: vec![0u8; MIN_EXTERNAL_GROUP_ID_LEN], + }; + assert_eq!(compute_blob_expiry(&policy, None, 1_000), 5_000); + + // Policy with relative window picks up `now + expire_in_ns`. + let policy = ExternalCommitPolicyV1 { + expire_in_ns: 200, + ..Default::default() + }; + assert_eq!(compute_blob_expiry(&policy, None, 1_000), 1_200); + + // Opts hint tighter than policy wins. + let policy = ExternalCommitPolicyV1 { + expires_at_ns: 10_000, + ..Default::default() + }; + assert_eq!(compute_blob_expiry(&policy, Some(3_000), 0), 3_000); + + // Opts hint zero is ignored (treated as "unset"). + let policy = ExternalCommitPolicyV1 { + expires_at_ns: 7_000, + ..Default::default() + }; + assert_eq!(compute_blob_expiry(&policy, Some(0), 0), 7_000); + + // No active expiry anywhere → 0 (no expiry). + let policy = ExternalCommitPolicyV1::default(); + assert_eq!(compute_blob_expiry(&policy, None, 1_000), 0); + } +} diff --git a/crates/xmtp_mls/src/groups/mls_ext.rs b/crates/xmtp_mls/src/groups/mls_ext.rs index d3e36ffc49..96b40b4df6 100644 --- a/crates/xmtp_mls/src/groups/mls_ext.rs +++ b/crates/xmtp_mls/src/groups/mls_ext.rs @@ -2,10 +2,8 @@ mod commit_log_storer; mod decrypted_welcome; mod mls_ext_welcome_pointee_encryption_aead_type; mod reload; -mod welcome_wrapper; pub(crate) use commit_log_storer::*; pub(crate) use decrypted_welcome::*; pub use mls_ext_welcome_pointee_encryption_aead_type::*; pub use reload::*; -pub use welcome_wrapper::*; diff --git a/crates/xmtp_mls/src/groups/mls_ext/decrypted_welcome.rs b/crates/xmtp_mls/src/groups/mls_ext/decrypted_welcome.rs index 234785b7b6..9404b7d40a 100644 --- a/crates/xmtp_mls/src/groups/mls_ext/decrypted_welcome.rs +++ b/crates/xmtp_mls/src/groups/mls_ext/decrypted_welcome.rs @@ -9,20 +9,14 @@ use tls_codec::{Deserialize, Serialize}; use xmtp_db::XmtpMlsStorageProvider; use xmtp_db::XmtpOpenMlsProviderRef; -use crate::{ - client::ClientError, - groups::{ - GroupError, - mls_ext::{unwrap_welcome, unwrap_welcome_symmetric}, - }, - identity::parse_credential, -}; -use xmtp_configuration::MAX_PAST_EPOCHS; +use crate::{client::ClientError, groups::GroupError, identity::parse_credential}; +use xmtp_configuration::{MAX_PAST_EPOCHS, WELCOME_HPKE_LABEL}; use xmtp_db::{ NotFound, sql_key_store::{KEY_PACKAGE_REFERENCES, KEY_PACKAGE_WRAPPER_PRIVATE_KEY}, }; use xmtp_id::key_package::WrapperAlgorithm; +use xmtp_mls_common::mls_ext::payload_encryption::{unwrap_payload_hpke, unwrap_payload_symmetric}; use xmtp_proto::{ mls_v1::WelcomeMetadata, types::{ @@ -60,8 +54,13 @@ impl DecryptedWelcome { let hash_ref = find_key_package_hash_ref(provider, hpke_public_key)?; let private_key = find_private_key(provider, &hash_ref, &wrapper_ciphersuite)?; - let (welcome_bytes, welcome_metadata_bytes) = - unwrap_welcome(data, welcome_metadata, &private_key, wrapper_ciphersuite)?; + let (welcome_bytes, welcome_metadata_bytes) = unwrap_payload_hpke( + data, + welcome_metadata, + &private_key, + wrapper_ciphersuite, + WELCOME_HPKE_LABEL, + )?; let welcome = deserialize_welcome(&welcome_bytes)?; let welcome_metadata = if welcome_metadata_bytes.is_empty() { @@ -104,13 +103,13 @@ impl DecryptedWelcome { } }; - let decrypted_welcome_data = unwrap_welcome_symmetric( + let decrypted_welcome_data = unwrap_payload_symmetric( v1.data.as_slice(), aead_type, &decrypted_welcome_pointer.encryption_key, &decrypted_welcome_pointer.data_nonce, )?; - let decrypted_welcome_metadata = unwrap_welcome_symmetric( + let decrypted_welcome_metadata = unwrap_payload_symmetric( v1.welcome_metadata.as_slice(), aead_type, &decrypted_welcome_pointer.encryption_key, @@ -287,11 +286,12 @@ pub(crate) fn decrypt_welcome_pointer( let wrapper_algorithm = WrapperAlgorithm::try_from(welcome_pointer.wrapper_algorithm)?; let private_key = find_private_key(provider, &hash_ref, &wrapper_algorithm)?; - let welcome_bytes = unwrap_welcome( + let welcome_bytes = unwrap_payload_hpke( &welcome_pointer.welcome_pointer, &[], &private_key, wrapper_algorithm, + WELCOME_HPKE_LABEL, )?; Ok(DecryptedWelcomePointer::decode(welcome_bytes.0.as_slice())?) diff --git a/crates/xmtp_mls/src/groups/mls_ext/welcome_wrapper.rs b/crates/xmtp_mls/src/groups/mls_ext/welcome_wrapper.rs deleted file mode 100644 index 06e646356b..0000000000 --- a/crates/xmtp_mls/src/groups/mls_ext/welcome_wrapper.rs +++ /dev/null @@ -1,474 +0,0 @@ -use openmls::ciphersuite::hpke::Error as OpenmlsHpkeError; -use openmls::prelude::tls_codec::Error as TlsCodecError; -use openmls_traits::crypto::OpenMlsCrypto; -use openmls_traits::types::HpkeCiphertext; -use thiserror::Error; -use tls_codec::{Deserialize, Serialize}; -use xmtp_common::RetryableError; -use xmtp_configuration::WELCOME_HPKE_LABEL; -use xmtp_id::key_package::WrapperAlgorithm; - -static LIBCRUX_CRYPTO_PROVIDER: std::sync::LazyLock = - std::sync::LazyLock::new(|| { - openmls_libcrux_crypto::CryptoProvider::new().expect("Failed to create CryptoProvider") - }); - -#[derive(Debug, Error)] -pub enum WrapWelcomeError { - #[error("OpenMLS HPKE error: {0}")] - Hpke(#[from] OpenmlsHpkeError), - #[error("TLS Codec error: {0}")] - TlsError(#[from] TlsCodecError), - #[error(transparent)] - Crypto(#[from] openmls_traits::types::CryptoError), -} - -#[derive(Debug, Error)] -pub enum UnwrapWelcomeError { - #[error("OpenMLS HPKE error: {0}")] - Hpke(#[from] OpenmlsHpkeError), - #[error("TLS Codec error: {0}")] - TlsError(#[from] TlsCodecError), - #[error(transparent)] - Crypto(#[from] openmls_traits::types::CryptoError), -} - -impl RetryableError for WrapWelcomeError { - fn is_retryable(&self) -> bool { - false - } -} - -impl RetryableError for UnwrapWelcomeError { - fn is_retryable(&self) -> bool { - false - } -} - -/// Wrap a message in an outer layer of encryption using -/// the specified [WrapperAlgorithm]. -/// The algorithm and public key type MUST match -/// -/// For the XWingMLKEM768Draft6 algorithm, the openmls_welcome and welcome_metadata are wrapped using the same HPKE public key -/// and the first vec returned is the HpkeCiphertext with tls serialization. The second vec is just ciphertext. -pub fn wrap_welcome( - welcome: &[u8], - welcome_metadata: &[u8], - hpke_public_key: &[u8], - wrapper_algorithm: WrapperAlgorithm, -) -> Result<(Vec, Vec), WrapWelcomeError> { - // The following implementation is the same as calling openmls_libcrux_crypto::CryptoProvider::hpke_seal(...) - // but uses the context to encrypt multiple messages at once using the same context - // because openmls only supports one shot messages. - - let context = openmls::prelude::hpke::EncryptContext::from((WELCOME_HPKE_LABEL, [].as_slice())); - let info = context.tls_serialize_detached()?; - let aad = &[]; - - let map_hpke_error = |e| match e { - hpke_rs::HpkeError::InvalidConfig => openmls::prelude::CryptoError::SenderSetupError, - _ => openmls::prelude::CryptoError::HpkeEncryptionError, - }; - - let pk_r = hpke_rs::HpkePublicKey::new(hpke_public_key.to_vec()); - let mut config = wrapper_algorithm.to_hpke_config(); - - let (enc, mut ctxt) = config - .setup_sender(&pk_r, &info, None, None, None) - .map_err(map_hpke_error)?; - - let encrypted_welcome = ctxt - .seal(aad, welcome) - .map(|ct| HpkeCiphertext { - kem_output: enc.into(), - ciphertext: ct.into(), - }) - .map_err(map_hpke_error)?; - let encrypted_welcome_metadata = ctxt.seal(aad, welcome_metadata).map_err(map_hpke_error)?; - - Ok(( - encrypted_welcome.tls_serialize_detached()?, - encrypted_welcome_metadata, - )) -} - -/// Unwrap a message that was wrapped using the specified [WrapperAlgorithm]. -/// The algorithm and private key type MUST match. -pub fn unwrap_welcome( - wrapped_welcome: &[u8], - wrapped_welcome_metadata: &[u8], - private_key: &[u8], - wrapper_algorithm: WrapperAlgorithm, -) -> Result<(Vec, Vec), UnwrapWelcomeError> { - let ciphertext = HpkeCiphertext::tls_deserialize_exact(wrapped_welcome)?; - - // The following implementation is the same as calling openmls_libcrux_crypto::CryptoProvider::hpke_open(...) - // but uses the context to decrypt multiple messages at once using the same context - // because openmls only supports one shot messages. - - let context = openmls::prelude::hpke::EncryptContext::from((WELCOME_HPKE_LABEL, [].as_slice())); - let info = context.tls_serialize_detached()?; - let aad = &[]; - - let config = wrapper_algorithm.to_hpke_config(); - - let sk_r = hpke_rs::HpkePrivateKey::new(private_key.to_vec()); - - let map_hpke_error = |_| openmls::ciphersuite::hpke::Error::DecryptionFailed; - - let mut ctxt = config - .setup_receiver( - ciphertext.kem_output.as_ref(), - &sk_r, - &info, - None, - None, - None, - ) - .map_err(map_hpke_error)?; - - let welcome = ctxt - .open(aad, ciphertext.ciphertext.as_ref()) - .map_err(map_hpke_error)?; - let welcome_metadata = if wrapped_welcome_metadata.is_empty() { - vec![] - } else { - ctxt.open(aad, wrapped_welcome_metadata) - .map_err(map_hpke_error)? - }; - - Ok((welcome, welcome_metadata)) -} - -pub fn wrap_welcome_symmetric( - data: &[u8], - aead_type: openmls::prelude::AeadType, - symmetric_key: &[u8], - nonce: &[u8], -) -> Result, WrapWelcomeError> { - (*LIBCRUX_CRYPTO_PROVIDER) - .aead_encrypt(aead_type, symmetric_key, data, nonce, &[]) - .map_err(Into::into) -} - -pub fn unwrap_welcome_symmetric( - data: &[u8], - aead_type: openmls::prelude::AeadType, - symmetric_key: &[u8], - nonce: &[u8], -) -> Result, UnwrapWelcomeError> { - (*LIBCRUX_CRYPTO_PROVIDER) - .aead_decrypt(aead_type, symmetric_key, data, nonce, &[]) - .map_err(Into::into) -} - -#[cfg(test)] -mod tests { - use xmtp_cryptography::utils::generate_local_wallet; - use xmtp_db::{MlsProviderExt, XmtpMlsStorageProvider}; - - use crate::{ - builder::ClientBuilder, - groups::mls_ext::{find_key_package_hash_ref, find_private_key}, - identity::NewKeyPackageResult, - }; - - use super::*; - - fn find_key_package_private_key( - provider: &impl XmtpMlsStorageProvider, - hpke_public_key: &[u8], - wrapper_algorithm: WrapperAlgorithm, - ) -> Vec { - let hash_ref = find_key_package_hash_ref(provider, hpke_public_key).unwrap(); - find_private_key(provider, &hash_ref, &wrapper_algorithm).unwrap() - } - - #[xmtp_common::test] - async fn round_trip_curve25519() { - let client = ClientBuilder::new_test_client(&generate_local_wallet()).await; - let provider = client.context.mls_provider(); - - let NewKeyPackageResult { key_package, .. } = - client.identity().new_key_package(&provider, false).unwrap(); - - let hpke_public_key = key_package.hpke_init_key().as_slice(); - - let private_key = find_key_package_private_key( - provider.key_store(), - hpke_public_key, - WrapperAlgorithm::Curve25519, - ); - - let to_encrypt = xmtp_common::rand_vec::<1000>(); - let to_encrypt_metadata = xmtp_common::rand_vec::<32>(); - - // Encryption doesn't require any details about the sender, so we can test using one client - let wrapped = wrap_welcome( - to_encrypt.as_slice(), - to_encrypt_metadata.as_slice(), - hpke_public_key, - WrapperAlgorithm::Curve25519, - ) - .unwrap(); - - assert_ne!(&to_encrypt, &wrapped.0); - assert_ne!(&to_encrypt_metadata, &wrapped.1); - - let unwrapped = unwrap_welcome( - &wrapped.0, - &wrapped.1, - &private_key, - WrapperAlgorithm::Curve25519, - ) - .unwrap(); - - assert_eq!(unwrapped, (to_encrypt, to_encrypt_metadata)); - } - - #[xmtp_common::test] - async fn round_trip_xwing_mlkem512() { - let client = ClientBuilder::new_test_client(&generate_local_wallet()).await; - let provider = client.context.mls_provider(); - - let NewKeyPackageResult { - pq_pub_key: maybe_pq_pub_key, - .. - } = client.identity().new_key_package(&provider, true).unwrap(); - let pq_pub_key = maybe_pq_pub_key.unwrap(); - let private_key = find_key_package_private_key( - provider.key_store(), - &pq_pub_key, - WrapperAlgorithm::XWingMLKEM768Draft6, - ); - let to_encrypt = xmtp_common::rand_vec::<1000>(); - let to_encrypt_metadata = xmtp_common::rand_vec::<32>(); - - // Test error handling - wrap_welcome( - to_encrypt.as_slice(), - to_encrypt_metadata.as_slice(), - &pq_pub_key[..pq_pub_key.len().saturating_sub(1)], - WrapperAlgorithm::XWingMLKEM768Draft6, - ) - .unwrap_err(); - - let wrapped = wrap_welcome( - to_encrypt.as_slice(), - to_encrypt_metadata.as_slice(), - &pq_pub_key, - WrapperAlgorithm::XWingMLKEM768Draft6, - ) - .unwrap(); - - assert_ne!(&to_encrypt, &wrapped.0); - assert_ne!(&to_encrypt_metadata, &wrapped.1); - - let unwrapped = unwrap_welcome( - &wrapped.0, - &wrapped.1, - &private_key, - WrapperAlgorithm::XWingMLKEM768Draft6, - ) - .unwrap(); - - assert_eq!(unwrapped, (to_encrypt.clone(), to_encrypt_metadata)); - - let unwrapped = unwrap_welcome( - &wrapped.0, - &[], - &private_key, - WrapperAlgorithm::XWingMLKEM768Draft6, - ) - .unwrap(); - - assert_eq!(unwrapped, (to_encrypt, vec![])); - - unwrap_welcome( - &unwrapped.0, - &unwrapped.1, - &private_key[..private_key.len().saturating_sub(1)], - WrapperAlgorithm::XWingMLKEM768Draft6, - ) - .unwrap_err(); - } - - fn wrap_welcome_inner( - crypto_provider: &impl openmls_traits::crypto::OpenMlsCrypto, - unwrapped_welcome: &[u8], - hpke_public_key: &[u8], - ciphersuite: openmls::prelude::Ciphersuite, - ) -> Result, WrapWelcomeError> { - Ok(openmls::prelude::hpke::encrypt_with_label( - hpke_public_key, - WELCOME_HPKE_LABEL, - &[], - unwrapped_welcome, - ciphersuite, - crypto_provider, - )? - .tls_serialize_detached()?) - } - - fn unwrap_welcome_inner( - crypto_provider: &impl openmls_traits::crypto::OpenMlsCrypto, - ciphertext: &HpkeCiphertext, - private_key: &[u8], - wrapper_ciphersuite: openmls::prelude::Ciphersuite, - ) -> Result, UnwrapWelcomeError> { - Ok(openmls::prelude::hpke::decrypt_with_label( - private_key, - WELCOME_HPKE_LABEL, - &[], - ciphertext, - wrapper_ciphersuite, - crypto_provider, - )?) - } - // Verifies wrap_welcome / unwrap_welcome is byte-compatible with - // openmls's canonical hpke::encrypt_with_label / - // decrypt_with_label path. Currently ignored: we pin XWING to the - // obsolete 0x004D codepoint for wire compat with v1.9 / v1.10 - // (hpke-rs 0.4 only knew 0x004D — see to_hpke_config). The two - // paths thus produce different HPKE suite_id labels and can't - // round-trip against each other. Un-ignore + verify once the d14n - // cutover migrates everyone back to the canonical 0x647a (#3661). - #[ignore = "pinned to obsolete XWING codepoint for v1.9 / v1.10 wire compat (#3661)"] - #[xmtp_common::test] - async fn round_trip_xwing_mlkem512_current_to_previous_and_back() { - let client = ClientBuilder::new_test_client(&generate_local_wallet()).await; - let provider = client.context.mls_provider(); - - let NewKeyPackageResult { - pq_pub_key: maybe_pq_pub_key, - .. - } = client.identity().new_key_package(&provider, true).unwrap(); - let pq_pub_key = maybe_pq_pub_key.unwrap(); - let private_key = find_key_package_private_key( - provider.key_store(), - &pq_pub_key, - WrapperAlgorithm::XWingMLKEM768Draft6, - ); - - let to_encrypt = xmtp_common::rand_vec::<1000>(); - let to_encrypt_metadata = xmtp_common::rand_vec::<32>(); - - // Test the current code to previous code round trip - { - let wrapped = wrap_welcome( - &to_encrypt, - &to_encrypt_metadata, - &pq_pub_key, - WrapperAlgorithm::XWingMLKEM768Draft6, - ) - .unwrap(); - - assert_ne!(to_encrypt_metadata, wrapped.1); - - let unwrapped = unwrap_welcome_inner( - &openmls_libcrux_crypto::CryptoProvider::new().unwrap(), - &HpkeCiphertext::tls_deserialize_exact(&wrapped.0).unwrap(), - &private_key, - WrapperAlgorithm::XWingMLKEM768Draft6.to_mls_ciphersuite(), - ) - .unwrap(); - - assert_eq!(unwrapped, to_encrypt); - } - - // Test the previous code to current code round trip - { - let wrapped = wrap_welcome_inner( - &openmls_libcrux_crypto::CryptoProvider::new().unwrap(), - &to_encrypt, - &pq_pub_key, - WrapperAlgorithm::XWingMLKEM768Draft6.to_mls_ciphersuite(), - ) - .unwrap(); - - let unwrapped = unwrap_welcome( - &wrapped, - &[], - &private_key, - WrapperAlgorithm::XWingMLKEM768Draft6, - ) - .unwrap(); - - assert_eq!(unwrapped, (to_encrypt, vec![])); - } - } - - #[xmtp_common::test] - async fn round_trip_curve_25519_current_to_previous_and_back() { - let client = ClientBuilder::new_test_client(&generate_local_wallet()).await; - let provider = client.context.mls_provider(); - - let NewKeyPackageResult { key_package, .. } = - client.identity().new_key_package(&provider, false).unwrap(); - - let hpke_public_key = key_package.hpke_init_key().as_slice(); - - let private_key = find_key_package_private_key( - provider.key_store(), - hpke_public_key, - WrapperAlgorithm::Curve25519, - ); - let to_encrypt = xmtp_common::rand_vec::<1000>(); - let to_encrypt_metadata = xmtp_common::rand_vec::<32>(); - - // Test the current code to previous code round trip - { - let wrapped = wrap_welcome( - &to_encrypt, - &to_encrypt_metadata, - hpke_public_key, - WrapperAlgorithm::Curve25519, - ) - .unwrap(); - - assert_ne!(to_encrypt_metadata, wrapped.1); - - let unwrapped = unwrap_welcome_inner( - // Use old crypto provider to match previous code - &openmls_rust_crypto::RustCrypto::default(), - &HpkeCiphertext::tls_deserialize_exact(&wrapped.0).unwrap(), - &private_key, - WrapperAlgorithm::Curve25519.to_mls_ciphersuite(), - ) - .unwrap(); - - assert_eq!(unwrapped, to_encrypt); - } - - // Test the previous code to current code round trip - { - let wrapped = wrap_welcome_inner( - // Use old crypto provider to match previous code - &openmls_rust_crypto::RustCrypto::default(), - &to_encrypt, - hpke_public_key, - WrapperAlgorithm::Curve25519.to_mls_ciphersuite(), - ) - .unwrap(); - - let unwrapped = - unwrap_welcome(&wrapped, &[], &private_key, WrapperAlgorithm::Curve25519).unwrap(); - - assert_eq!(unwrapped, (to_encrypt, vec![])); - } - } - - #[xmtp_common::test] - async fn round_trip_symmetric_key() { - let symmetric_key = xmtp_common::rand_array::<32>(); - let nonce = xmtp_common::rand_array::<12>(); - let data = xmtp_common::rand_array::<1000>(); - let available_types = crate::groups::mls_ext::WelcomePointersExtension::available_types(); - for aead_type in available_types.supported_aead_types { - let wrapped = wrap_welcome_symmetric(&data, aead_type, &symmetric_key, &nonce).unwrap(); - let unwrapped = - unwrap_welcome_symmetric(&wrapped, aead_type, &symmetric_key, &nonce).unwrap(); - assert_eq!(data.as_slice(), unwrapped.as_slice()); - } - } -} diff --git a/crates/xmtp_mls/src/groups/mls_sync.rs b/crates/xmtp_mls/src/groups/mls_sync.rs index 1f8f7dcb1b..4d5cfd0104 100644 --- a/crates/xmtp_mls/src/groups/mls_sync.rs +++ b/crates/xmtp_mls/src/groups/mls_sync.rs @@ -21,7 +21,7 @@ use crate::{ groups::{ group_membership::{GroupMembership, MembershipDiffWithKeyPackages}, intents::{QueueIntent, ReaddInstallationsIntentData, UpdateMetadataIntentData}, - mls_ext::{CommitLogStorer, MlsGroupReload, WrapWelcomeError, wrap_welcome}, + mls_ext::{CommitLogStorer, MlsGroupReload}, mls_sync::{ GroupMessageProcessingError::OpenMlsProcessMessage, update_group_membership::apply_readd_installations_intent, @@ -83,6 +83,7 @@ use xmtp_configuration::{ GRPC_PAYLOAD_LIMIT, HMAC_SALT, MAX_GROUP_SIZE, MAX_GROUP_SYNC_RETRIES, MAX_INTENT_PUBLISH_ATTEMPTS, MAX_PAST_EPOCHS, SYNC_BACKOFF_TOTAL_WAIT_MAX_SECS, SYNC_BACKOFF_WAIT_MS, SYNC_JITTER_MS, SYNC_UPDATE_INSTALLATIONS_INTERVAL_NS, + WELCOME_HPKE_LABEL, }; use xmtp_content_types::{CodecError, ContentCodec, group_updated::GroupUpdatedCodec}; use xmtp_db::message_deletion::{QueryMessageDeletion, StoredMessageDeletion}; @@ -106,6 +107,9 @@ use xmtp_db::{ use xmtp_id::{InboxId, InboxIdRef}; use xmtp_mls_common::group_metadata::extract_group_metadata; use xmtp_mls_common::group_mutable_metadata::MetadataField; +use xmtp_mls_common::mls_ext::payload_encryption::{ + WrapPayloadError, wrap_payload_hpke, wrap_payload_symmetric, +}; use xmtp_proto::types::GroupId; use xmtp_proto::xmtp::mls::message_contents::content_types::DeleteMessage; use xmtp_proto::xmtp::mls::{ @@ -1092,25 +1096,129 @@ where let validated_commit = match &processed_message.content() { ProcessedMessageContent::StagedCommitMessage(staged_commit) => { - // OpenMLS already verified the framing signature against the - // sender's leaf during `process_message`, and `extract_message_sender` - // above asserted `Sender::Member` — so this match is exhaustive - // for the cases that reach here. - let committer_leaf_index = match processed_message.sender() { - openmls::prelude::Sender::Member(idx) => *idx, - _ => { + // Route by framing sender. `Sender::Member` is the standard + // member-authored-commit path (`from_staged_commit`). + // `Sender::NewMemberCommit` is the atomic external-commit + // / QR-invite join path (L-7 `from_external_commit`); the + // joiner is not yet a tree member, so the regular path's + // committer-leaf-index lookup does not apply. Both arms + // converge below at `merge_staged_commit_logged`. + let result = match processed_message.sender() { + openmls::prelude::Sender::Member(idx) => { + let committer_leaf_index = *idx; + ValidatedCommit::from_staged_commit( + &self.context, + staged_commit, + committer_leaf_index, + mls_group, + ) + .await + } + openmls::prelude::Sender::NewMemberCommit => { + // Source the policy set + metadata capability-aware, + // mirroring the proposal branch above. Migrated + // groups carry `GROUP_MEMBERSHIP` policy in the + // AppData COMPONENT_REGISTRY; unmigrated groups + // still have the legacy `GROUP_PERMISSIONS` + // extension. + let is_migrated = super::app_data::is_migrated_group(mls_group); + let extensions = mls_group.extensions(); + let _group_permissions = if is_migrated { + match super::app_data::policy::membership_policy_set_from_registry( + mls_group, + ) { + Ok(p) => p, + Err(e) => { + self.maybe_update_cursor(&self.context.db(), envelope)?; + return Err(CommitValidationError::from(e).into()); + } + } + } else { + match extract_group_permissions(mls_group) { + Ok(p) => p, + Err(e) => { + self.maybe_update_cursor(&self.context.db(), envelope)?; + return Err(CommitValidationError::from(e).into()); + } + } + }; + let immutable_metadata = if is_migrated { + match super::app_data::component_source::read_group_metadata_from_dict( + mls_group, + ) { + Ok(Some(seed)) => { + use xmtp_proto::xmtp::mls::message_contents::GroupMetadataV1 as GroupMetadataProto; + let proto = GroupMetadataProto { + conversation_type: seed.conversation_type, + creator_inbox_id: seed.creator_inbox_id, + creator_account_address: String::new(), + dm_members: seed.dm_members, + oneshot_message: seed.oneshot, + }; + match xmtp_mls_common::group_metadata::GroupMetadata::try_from( + proto, + ) { + Ok(m) => m, + Err(e) => { + self.maybe_update_cursor(&self.context.db(), envelope)?; + return Err(CommitValidationError::from(e).into()); + } + } + } + Ok(None) | Err(_) => { + self.maybe_update_cursor(&self.context.db(), envelope)?; + return Err(CommitValidationError::from( + xmtp_mls_common::group_metadata::GroupMetadataError::MissingExtension, + ) + .into()); + } + } + } else { + match extract_group_metadata(extensions) { + Ok(m) => m, + Err(e) => { + self.maybe_update_cursor(&self.context.db(), envelope)?; + return Err(CommitValidationError::from(e).into()); + } + } + }; + let mutable_metadata = match super::app_data::component_source::extract_group_mutable_metadata_capability_aware( + mls_group, + ) { + Ok(m) => m, + Err(e) => { + self.maybe_update_cursor(&self.context.db(), envelope)?; + return Err(CommitValidationError::from( + xmtp_mls_common::group_mutable_metadata::GroupMutableMetadataError::from(e), + ) + .into()); + } + }; + + let external_commit_allowed = + crate::groups::external_commit_policy::is_external_commit_allowed( + mls_group, + ); + ValidatedCommit::from_external_commit( + staged_commit, + processed_message.sender(), + &immutable_metadata, + &mutable_metadata, + external_commit_allowed, + ) + } + other => { + tracing::warn!( + inbox_id = self.context.inbox_id(), + group_id = %self.group_id, + ?other, + "rejecting commit from unsupported sender type" + ); return Err(GroupMessageProcessingError::CommitValidation( CommitValidationError::ActorNotMember, )); } }; - let result = ValidatedCommit::from_staged_commit( - &self.context, - staged_commit, - committer_leaf_index, - mls_group, - ) - .await; let validated_commit = match result { Err(e) if !e.is_retryable() => { @@ -1983,9 +2091,17 @@ where } self.load_mls_group_with_lock_async(async |mut mls_group| { - // ensure we are processing a private message + // Accept PrivateMessage (regular handshake/application messages) and + // PublicMessage (external commits from non-members — RFC 9420 §12.4.3.2). + // L-8's dispatch inside process_message_inner enforces the per-sender + // policy; anything else is rejected here at the framing level. + // + // The wildcard arm is structurally unreachable today + // (`ProtocolMessage` only has these two variants), but is kept as a + // defense-in-depth guard if openmls grows another variant. + #[allow(unreachable_patterns)] match &envelope.message { - ProtocolMessage::PrivateMessage(_) => (), + ProtocolMessage::PrivateMessage(_) | ProtocolMessage::PublicMessage(_) => (), other => { return Err(GroupMessageProcessingError::UnsupportedMessageType( discriminant(other), @@ -3917,13 +4033,13 @@ where } let aead_type = crate::groups::mls_ext::WelcomePointersExtension::preferred_type(); - let data = crate::groups::mls_ext::wrap_welcome_symmetric( + let data = wrap_payload_symmetric( &action.welcome_message, aead_type, symmetric_key.as_ref(), data_nonce.as_ref(), )?; - let welcome_metadata = crate::groups::mls_ext::wrap_welcome_symmetric( + let welcome_metadata = wrap_payload_symmetric( &welcome_metadata_bytes, aead_type, symmetric_key.as_ref(), @@ -3961,7 +4077,7 @@ where let total_installations = action.installations.len(); let welcomes_iter = action.installations.into_iter().map( - |installation| -> Result { + |installation| -> Result { // Unconditionally use the wrapper algorithm for the welcome pointer because it will always be post quantum compatible. let algorithm = installation.welcome_wrapper_algorithm; let wp_cap = installation.welcome_pointee_encryption_aead_types; @@ -3972,11 +4088,12 @@ where version: Some(WelcomeMessageInputVersion::WelcomePointer( WelcomePointerInput { installation_key: installation.installation_key, - welcome_pointer: wrap_welcome( + welcome_pointer: wrap_payload_hpke( welcome_pointer.as_ref(), &[], &installation.hpke_public_key, algorithm, + WELCOME_HPKE_LABEL, )? .0, hpke_public_key: installation.hpke_public_key, @@ -3987,11 +4104,12 @@ where } else { let installation_key = installation.installation_key; - let (data, welcome_metadata) = wrap_welcome( + let (data, welcome_metadata) = wrap_payload_hpke( &action.welcome_message, &welcome_metadata_bytes, &installation.hpke_public_key, algorithm, + WELCOME_HPKE_LABEL, )?; Ok(WelcomeMessageInput { version: Some(WelcomeMessageInputVersion::V1(WelcomeMessageInputV1 { @@ -4010,7 +4128,7 @@ where .into_iter() .map(Ok) .chain(welcomes_iter) - .collect::, WrapWelcomeError>>()?; + .collect::, WrapPayloadError>>()?; assert_eq!( welcomes.len(), @@ -4131,6 +4249,13 @@ where // Extracts the message sender, but does not do any validation to ensure that the // installation_id is actually part of the inbox. +// +// For `Sender::Member`, the sender's leaf is already in the tree and we read +// the inbox-id / installation-id from there. For `Sender::NewMemberCommit` +// (external commit / atomic join — see L-7 `from_external_commit`), the +// joiner is not yet in the tree; their leaf only appears on the staged +// commit's update-path. We fall back to that path-leaf as the source of +// truth for the joiner's identity and signature key. fn extract_message_sender( openmls_group: &mut OpenMlsGroup, decrypted_message: &ProcessedMessage, @@ -4145,6 +4270,21 @@ fn extract_message_sender( return Ok((sender_inbox_id, member.signature_key)); } + // Atomic external-commit path: the joiner's leaf lives on the staged + // commit's update-path, not in the pre-commit tree. OpenMLS has already + // verified the framing signature against that leaf during + // `process_message`, so trusting it here is sound. + if matches!(decrypted_message.sender(), Sender::NewMemberCommit) + && let ProcessedMessageContent::StagedCommitMessage(staged_commit) = + decrypted_message.content() + && let Some(joiner_leaf) = staged_commit.update_path_leaf_node() + { + let basic_credential = BasicCredential::try_from(joiner_leaf.credential().clone())?; + let sender_inbox_id = parse_credential(basic_credential.identity())?; + let sender_installation_id = joiner_leaf.signature_key().as_slice().to_vec(); + return Ok((sender_inbox_id, sender_installation_id)); + } + let basic_credential = BasicCredential::try_from(decrypted_message.credential().clone())?; Err(GroupMessageProcessingError::InvalidSender { message_time_ns: message_created_ns, diff --git a/crates/xmtp_mls/src/groups/mod.rs b/crates/xmtp_mls/src/groups/mod.rs index af17e7ef85..181dee6017 100644 --- a/crates/xmtp_mls/src/groups/mod.rs +++ b/crates/xmtp_mls/src/groups/mod.rs @@ -2,6 +2,8 @@ pub mod app_data; pub mod commit_log; pub mod commit_log_key; mod error; +pub mod external_commit_policy; +pub mod external_invite; pub mod group_membership; pub mod group_permissions; pub mod intents; @@ -2121,6 +2123,77 @@ where Ok(()) } + /// Set the full `EXTERNAL_COMMIT_POLICY` well-known component for + /// this group — master switch + time-window controls for MLS + /// External Commits per RFC 9420 §12.4.3.2 (the QR-invite flow). + /// + /// Writes via the generic `AppDataUpdate(EXTERNAL_COMMIT_POLICY)` + /// intent with `AppDataUpdateOp::Replace` semantics. Requires the + /// group to be migrated to AppData. The component's + /// `permissions.update_policy` (super-admin-only by default) gates + /// who can flip the bits. + pub async fn set_external_commit_policy( + &self, + policy: xmtp_proto::xmtp::mls::message_contents::ExternalCommitPolicyV1, + ) -> Result<(), GroupError> { + self.ensure_not_paused().await?; + + // Encode the policy proto into the wire-form bytes that go on + // both the local intent and the eventual AppDataUpdate proposal. + let policy_bytes = { + use prost::Message; + use xmtp_proto::xmtp::mls::message_contents::{ + ExternalCommitPolicyEntry, + external_commit_policy_entry::Version as ExternalCommitPolicyVersion, + }; + ExternalCommitPolicyEntry { + version: Some(ExternalCommitPolicyVersion::V1(policy)), + } + .encode_to_vec() + }; + + let intent_data: Vec = crate::groups::intents::AppDataUpdateIntentData::new( + xmtp_mls_common::app_data::component_id::ComponentId::EXTERNAL_COMMIT_POLICY.as_u16(), + policy_bytes, + ) + .into(); + let intent = QueueIntent::app_data_update() + .data(intent_data) + .queue(self)?; + + let _ = self.sync_until_intent_resolved(intent.id).await?; + Ok(()) + } + + /// Sugar wrapper over [`MlsGroup::set_external_commit_policy`] that + /// only flips the master switch and leaves the time-window controls + /// at their defaults (no automatic expiry / no staleness bound). + pub async fn set_allow_external_commit(&self, allowed: bool) -> Result<(), GroupError> { + let policy = if allowed { + // Enable: must populate symmetric_key and external_group_id atomically. + use xmtp_mls_common::invite::payload::{ + generate_external_group_id, generate_symmetric_key, + }; + xmtp_proto::xmtp::mls::message_contents::ExternalCommitPolicyV1 { + allow_external_commit: true, + expires_at_ns: 0, + expire_in_ns: 0, + symmetric_key: generate_symmetric_key().to_vec(), + external_group_id: generate_external_group_id().to_vec(), + } + } else { + // Revoke: clear symmetric_key and external_group_id atomically. + xmtp_proto::xmtp::mls::message_contents::ExternalCommitPolicyV1 { + allow_external_commit: false, + expires_at_ns: 0, + expire_in_ns: 0, + symmetric_key: Vec::new(), + external_group_id: Vec::new(), + } + }; + self.set_external_commit_policy(policy).await + } + fn min_protocol_version_from_extensions( mutable_metadata: &GroupMutableMetadata, ) -> Option { diff --git a/crates/xmtp_mls/src/groups/tests/test_welcome_pointers.rs b/crates/xmtp_mls/src/groups/tests/test_welcome_pointers.rs index 57afee193d..aa5c80f077 100644 --- a/crates/xmtp_mls/src/groups/tests/test_welcome_pointers.rs +++ b/crates/xmtp_mls/src/groups/tests/test_welcome_pointers.rs @@ -1,7 +1,5 @@ use crate::context::XmtpSharedContext; -use crate::groups::mls_ext::{ - WelcomePointersExtension, unwrap_welcome_symmetric, wrap_welcome, wrap_welcome_symmetric, -}; +use crate::groups::mls_ext::WelcomePointersExtension; use crate::groups::welcome_pointer::resolve_welcome_pointer; use crate::identity::ENABLE_WELCOME_POINTERS; use crate::tester; @@ -9,9 +7,13 @@ use crate::utils::test::TestMlsGroup; use futures::StreamExt; use prost::Message; use std::time::Duration; +use xmtp_configuration::WELCOME_HPKE_LABEL; use xmtp_db::group::QueryGroup; use xmtp_db::tasks::QueryTasks; use xmtp_id::key_package::WrapperAlgorithm; +use xmtp_mls_common::mls_ext::payload_encryption::{ + unwrap_payload_symmetric, wrap_payload_hpke, wrap_payload_symmetric, +}; use xmtp_proto::mls_v1::WelcomeMetadata; use xmtp_proto::types::{DecryptedWelcomePointer, WelcomeMessage, WelcomeMessageType}; use xmtp_proto::xmtp::mls::message_contents::welcome_pointer::WelcomeV1Pointer; @@ -276,8 +278,8 @@ fn test_welcome_pointer_encryption_round_trip() { // Test encryption let encrypted_welcome_data = - wrap_welcome_symmetric(&welcome_data, *aead_type, &symmetric_key, &data_nonce).unwrap(); - let encrypted_welcome_metadata = wrap_welcome_symmetric( + wrap_payload_symmetric(&welcome_data, *aead_type, &symmetric_key, &data_nonce).unwrap(); + let encrypted_welcome_metadata = wrap_payload_symmetric( &welcome_metadata_bytes, *aead_type, &symmetric_key, @@ -289,14 +291,14 @@ fn test_welcome_pointer_encryption_round_trip() { assert_ne!(encrypted_welcome_metadata, welcome_metadata_bytes); // Test decryption - let decrypted_welcome_data = unwrap_welcome_symmetric( + let decrypted_welcome_data = unwrap_payload_symmetric( &encrypted_welcome_data, *aead_type, &symmetric_key, &data_nonce, ) .unwrap(); - let decrypted_welcome_metadata = unwrap_welcome_symmetric( + let decrypted_welcome_metadata = unwrap_payload_symmetric( &encrypted_welcome_metadata, *aead_type, &symmetric_key, @@ -464,11 +466,12 @@ async fn test_welcome_pointer_task_retry_resolution() { ), }; - let welcome_pointer_encrypted_bytes = wrap_welcome( + let welcome_pointer_encrypted_bytes = wrap_payload_hpke( &welcome_pointer.encode_to_vec(), &[], bo_hpke_public_key, WrapperAlgorithm::XWingMLKEM768Draft6, + WELCOME_HPKE_LABEL, ) .unwrap() .0; @@ -592,14 +595,14 @@ async fn test_welcome_pointer_task_retry_resolution() { Ok::<_, crate::groups::GroupError>(action) }) .await?; - let data = wrap_welcome_symmetric( + let data = wrap_payload_symmetric( &send_welcome_action.welcome_message, WelcomePointersExtension::preferred_type(), &welcome_pointer_v1.encryption_key, &welcome_pointer_v1.data_nonce, ) .unwrap(); - let welcome_metadata = wrap_welcome_symmetric( + let welcome_metadata = wrap_payload_symmetric( WelcomeMetadata { message_cursor: 0 } .encode_to_vec() .as_slice(), diff --git a/crates/xmtp_mls/src/groups/validated_commit.rs b/crates/xmtp_mls/src/groups/validated_commit.rs index 4ce547dee4..f2c847b4be 100644 --- a/crates/xmtp_mls/src/groups/validated_commit.rs +++ b/crates/xmtp_mls/src/groups/validated_commit.rs @@ -15,7 +15,7 @@ use openmls::{ credentials::{BasicCredential, Credential as OpenMlsCredential, errors::BasicCredentialError}, extensions::{Extension, Extensions, UnknownExtension}, group::{GroupContext, MlsGroup as OpenMlsGroup, QueuedProposal, StagedCommit}, - messages::proposals::{Proposal, ProposalType}, + messages::proposals::{AppDataUpdateOperation, Proposal, ProposalOrRefType, ProposalType}, prelude::{LeafNodeIndex, Sender}, treesync::LeafNode, }; @@ -60,7 +60,9 @@ pub enum CommitValidationError { InvalidVersionFormat(String), #[error("Minimum supported protocol version {0} exceeds current version")] ProtocolVersionTooLow(String), - // TODO: We will need to relax this once we support external joins + // External joins do not flow through this variant — they are routed + // separately into [`ValidatedCommit::from_external_commit`], which + // builds its own actor/participant view from the joiner's path leaf. #[error("Actor not a member of the group")] ActorNotMember, #[error("Subject not a member of the group")] @@ -132,6 +134,92 @@ pub enum CommitValidationError { Bootstrap(#[from] super::app_data::bootstrap_validator::BootstrapValidationError), #[error(transparent)] Conversion(#[from] xmtp_proto::ConversionError), + + // ────────────────────────────────────────────────────────────────── + // External-commit validation failures (L-7). + // + // These are surfaced exclusively by + // [`ValidatedCommit::from_external_commit`] and its helpers. New + // variants are appended here so unrelated PRs that add other + // CommitValidationError variants don't conflict on the same line + // range. + // ────────────────────────────────────────────────────────────────── + /// The group's permission policy has `allow_external_commit = false` + /// — external joins are not accepted on this group. + #[error("external commits are not allowed on this group")] + ExternalCommitNotAllowed, + /// The commit was routed to the external-commit validator but its + /// framing sender is not `Sender::NewMemberCommit`. Either the + /// caller dispatched incorrectly or the commit is malformed. + #[error("external-commit validator invoked on non-NewMemberCommit sender")] + ExternalCommitNotNewMemberCommit, + /// An external commit must carry exactly one `ExternalInit` + /// proposal (RFC 9420 §12.4.3.2). The staged commit had none. + #[error("external commit is missing the required ExternalInit proposal")] + ExternalCommitMissingExternalInit, + /// An external commit must carry exactly one `ExternalInit` + /// proposal — this commit carried more than one. + #[error("external commit carried multiple ExternalInit proposals")] + ExternalCommitMultipleExternalInit, + /// RFC 9420 §12.4.3.2: external commits MUST NOT include any + /// proposals by reference. + #[error("external commit included a proposal by reference")] + ExternalCommitByReferenceProposalsForbidden, + /// External commits carry the joiner's leaf in the update path + /// — this commit had no update path, so we cannot identify the + /// joiner and refuse to accept the commit. + #[error("external commit is missing the joiner's update path leaf")] + ExternalCommitMissingPathLeaf, + /// An `Add` proposal in the external commit referenced a key + /// package whose credential inbox id differs from the joiner's + /// path-leaf inbox id. libxmtp v1 only allows external commits to + /// add installations belonging to the same inbox as the joiner — + /// this prevents a joiner from smuggling unrelated members in + /// under cover of an external commit. + #[error("Add proposal in external commit references a different inbox id")] + CrossInboxAddInExternalCommit, + /// External commits must register the joiner in the AppData + /// `GROUP_MEMBERSHIP` component via exactly one `AppDataUpdate` + /// proposal — this commit carried none. + #[error("external commit is missing the GROUP_MEMBERSHIP AppDataUpdate")] + ExternalCommitAppDataUpdateMissing, + /// External commits must register the joiner in the AppData + /// `GROUP_MEMBERSHIP` component via exactly one `AppDataUpdate` + /// proposal — this commit carried more than one. + #[error("external commit carried multiple AppDataUpdate proposals")] + ExternalCommitAppDataUpdateMultiple, + /// The single AppDataUpdate proposal in this external commit + /// targets a component other than `GROUP_MEMBERSHIP`. Only the + /// membership registration is permitted — broader AppData writes + /// are not allowed at join time. + #[error("external commit's AppDataUpdate must target GROUP_MEMBERSHIP")] + ExternalCommitAppDataUpdateWrongComponent, + /// The joiner's AppDataUpdate proposal mutates a `GROUP_MEMBERSHIP` + /// entry that is not their own inbox. Joiners may only insert their + /// own membership entry through an external commit. + #[error("external commit's AppDataUpdate is out of scope for the joiner")] + ExternalCommitAppDataUpdateOutOfScope, + /// The wire-form payload of the joiner's AppDataUpdate + /// (`TlsMapDelta`) failed to decode. Treat as a + /// terminal wire-format violation so the commit is rejected rather + /// than silently accepted. + #[error("external commit's AppDataUpdate payload is malformed: {0}")] + ExternalCommitAppDataUpdatePayloadMalformed(String), + /// The "resync" flavor of external commit (where the joiner removes + /// a stale prior leaf with a SelfRemove proposal) is not supported + /// in v1. + #[error("resync external commits are not supported in v1")] + ResyncExternalCommitNotSupported, + /// External commits must not carry a `GroupContextExtensions` + /// proposal — post-AppData migration, GCE updates are not a + /// legitimate join-time operation. + #[error("external commit must not carry GroupContextExtensions proposals")] + ExternalCommitGceForbidden, + /// The external commit carried a proposal type that is never legal + /// in an external commit (e.g. Update, Remove, ReInit, Custom, + /// AppEphemeral, _AppAck). + #[error("external commit carried unsupported proposal type: {0:?}")] + ExternalCommitUnsupportedProposalType(ProposalType), } impl RetryableError for CommitValidationError { @@ -829,6 +917,131 @@ impl ValidatedCommit { dm_members: immutable_metadata.dm_members, }) } + + /// Validate a `Sender::NewMemberCommit`-flavored MLS commit (external + /// commit) carrying an atomic "join the group" payload. + /// + /// External commits add a non-member to the group atomically with + /// all the state required to keep libxmtp's invariants intact: + /// + /// 1. Acceptance requires `policies.allow_external_commit == true`. + /// 2. The commit's framing sender is `Sender::NewMemberCommit`. + /// 3. Exactly one `ExternalInit` proposal is present. + /// 4. RFC 9420 §12.4.3.2: no proposals by reference. + /// 5. The joiner's leaf node is in the update path; its credential + /// carries the joiner's inbox id. + /// 6. Every `Add` proposal's KeyPackage credential carries the same + /// inbox id as the path leaf (libxmtp's anti-smuggling rule — + /// a joiner cannot drag unrelated inboxes in under cover of an + /// external commit). + /// 7. Exactly one `AppDataUpdate` proposal is present, targeting the + /// `GROUP_MEMBERSHIP` component, and every mutation in its + /// payload addresses only the joiner's own inbox id (cross-layer + /// invariant: tree-membership ↔ AppData-membership). + /// 8. No `SelfRemove` proposal — the "resync" external-commit flavor + /// is not supported in v1. + /// 9. No `GroupContextExtensions` proposal. + /// 10. Only legal proposal kinds in an external commit: + /// `ExternalInit`, `Add`, `AppDataUpdate`, `PreSharedKey`. + /// Anything else is rejected. + /// 11. PSK proposals are passed through unvalidated (allowed + /// by-value). + /// + /// Returns a `ValidatedCommit` whose `actor` is the joiner (built + /// from the path leaf), `added_inboxes`/`added_installations` + /// reflect the joiner's additions, and `removed_inboxes` is empty. + /// + /// The caller (L-8 — `mls_sync::validate_and_process_external_message`) + /// is responsible for sourcing `policies` from the group's + /// authoritative permission store (legacy `GroupMutablePermissions` + /// extension on unmigrated groups, or the equivalent post-migration + /// path). Threading the policy in as a parameter keeps this + /// validator pure and easy to unit-test. + pub fn from_external_commit( + staged_commit: &StagedCommit, + sender: &Sender, + immutable_metadata: &GroupMetadata, + mutable_metadata: &GroupMutableMetadata, + external_commit_allowed: bool, + ) -> Result { + // Rule 1: policy gate — short-circuit before any structural work. + // `external_commit_allowed` is sourced by the caller from the + // AppData-resident EXTERNAL_COMMIT_POLICY component via + // `crate::groups::external_commit_policy::is_external_commit_allowed`. + enforce_external_commit_policy(external_commit_allowed)?; + + // Rule 2: framing sender must be NewMemberCommit. Defensive + // double-check; the wider mls_sync dispatch should already have + // routed by sender, but layering the check here means the + // validator is safe to call directly from tests and tomorrow's + // refactors can't accidentally hand us a Sender::Member commit. + enforce_external_commit_sender(sender)?; + + // Rule 5 (early): identify the joiner via the path leaf so the + // remaining rules can assert their inbox-id binding against a + // single source of truth. + let joiner_leaf = staged_commit + .update_path_leaf_node() + .ok_or(CommitValidationError::ExternalCommitMissingPathLeaf)?; + let joiner_inbox_id = inbox_id_from_credential(joiner_leaf.credential())?; + let joiner_participant = + CommitParticipant::from_leaf_node(joiner_leaf, immutable_metadata, mutable_metadata)?; + + // Walk the proposal set once, categorizing as we go. Returns a + // summary the per-rule helpers consume; iterating once also + // means we never have to re-walk for a different lens. + let summary = collect_external_commit_proposals(staged_commit)?; + + // Rules 3, 8, 9, 10: structural shape — counts and forbidden + // proposal types. + enforce_external_commit_structure(&summary)?; + + // Rule 6: Add proposals must bind to the joiner's inbox id. + let added_installations = enforce_adds_bind_to_joiner(&summary, &joiner_inbox_id)?; + + // Rule 7: the AppDataUpdate must target GROUP_MEMBERSHIP and + // touch only the joiner's inbox entry. + enforce_app_data_update_scope( + summary + .app_data_update + .expect("structure check guarantees Some when no failure"), + &joiner_inbox_id, + )?; + + // PSK proposals (rule 11) need no further work — by-value PSKs + // are allowed and the rest of the libxmtp stack ignores them. + + // `added_inboxes` carries the joiner exactly once. We populate + // `proposer: None` because the proposer attribution machinery + // is built around `Sender::Member` leaf indices — the joiner + // is not a member yet, so the right shape is to elide the + // proposer rather than to attribute it to themselves with a + // pre-commit leaf index that doesn't yet exist in the tree. + let added_inboxes = vec![build_inbox( + &joiner_inbox_id, + immutable_metadata, + mutable_metadata, + )]; + + let installations_changed = !added_installations.is_empty(); + + Ok(Self { + actor: joiner_participant, + // External commits have no by-reference proposers from + // existing members — the joiner is the sole authoring + // party. We surface the same participant in `proposers` + // for downstream consumers that look there for "who + // wrote this commit". + proposers: Vec::new(), + added_inboxes, + removed_inboxes: Vec::new(), + readded_installations: HashSet::new(), + metadata_validation_info: MutableMetadataValidationInfo::default(), + installations_changed, + permissions_changed: false, + dm_members: immutable_metadata.dm_members.clone(), + }) + } } impl From for GroupMembershipChanges { @@ -1620,7 +1833,17 @@ pub(super) fn extract_commit_participant( mutable_metadata, )) } else { - // TODO: Handle external joins/commits + // External joins/commits don't flow through this helper. The + // joiner's leaf is not in the tree at the pre-commit snapshot + // captured by `member_at`, so callers on the external-commit + // path build their participant directly from the staged + // commit's `update_path_leaf_node` via + // [`CommitParticipant::from_leaf_node`]. Reaching this branch + // means someone routed a `Sender::NewMemberCommit` proposal + // through the member-only validator, which is a programmer + // error rather than a peer-attributable failure — surface it + // as `ActorNotMember` so the caller treats the commit as + // rejected. Err(CommitValidationError::ActorNotMember) } } @@ -2274,6 +2497,636 @@ impl FromWith for GroupUpdatedProto { } } +// ============================================================================= +// External-commit validator helpers (L-7). +// +// Pure functions extracted from `ValidatedCommit::from_external_commit` so the +// per-rule logic can be unit-tested without the considerable scaffolding +// required to construct a real `StagedCommit`. The orchestrator stays +// readable and the rules stay individually pinned. +// ============================================================================= + +/// Categorized view of the proposals carried by an external commit. +/// +/// Populated by [`collect_external_commit_proposals`]: a single pass over +/// `staged_commit.queued_proposals()` that fans out into typed buckets so +/// downstream rule-checks operate on Rust references rather than re-walking +/// the queue. +struct ExternalCommitProposalSummary<'a> { + /// Number of `ExternalInit` proposals seen. Must be exactly 1. + external_init_count: usize, + /// All Add proposals, by-value. + adds: Vec<&'a openmls::messages::proposals::AddProposal>, + /// All PSK proposals (counted but otherwise unused — PSKs are allowed). + psk_count: usize, + /// The single AppDataUpdate proposal, if exactly one was present. + app_data_update: Option<&'a openmls::messages::proposals::AppDataUpdateProposal>, + /// Number of AppDataUpdate proposals seen — pulled out separately + /// so the structure check can distinguish "missing" vs "too many". + app_data_update_count: usize, + /// True if a `SelfRemove` proposal was seen — drives the + /// resync-not-supported rejection. + saw_self_remove: bool, + /// True if a `GroupContextExtensions` proposal was seen. + saw_gce: bool, + /// The proposal type of the first encountered "other" proposal + /// (Update/Remove/ReInit/Custom/AppEphemeral/_AppAck), if any. + /// Captured for inclusion in the `ExternalCommitUnsupportedProposalType` + /// error payload. + first_unsupported: Option, + /// True if any proposal carried `ProposalOrRefType::Reference`. + saw_by_reference: bool, +} + +impl<'a> ExternalCommitProposalSummary<'a> { + fn new() -> Self { + Self { + external_init_count: 0, + adds: Vec::new(), + psk_count: 0, + app_data_update: None, + app_data_update_count: 0, + saw_self_remove: false, + saw_gce: false, + first_unsupported: None, + saw_by_reference: false, + } + } +} + +/// Reject if the group has not opted into accepting MLS External +/// Commits. This is the first gate — short-circuiting here means a +/// denied-policy group never pays for proposal-shape walks. +/// +/// `allowed` is the AppData-resident `EXTERNAL_COMMIT_POLICY.v1. +/// allow_external_commit` bit, sourced by the caller via +/// `crate::groups::external_commit_policy::is_external_commit_allowed`. +/// Threading it as a bool keeps this helper pure and unit-testable +/// without having to materialize an `OpenMlsGroup`. +fn enforce_external_commit_policy(allowed: bool) -> Result<(), CommitValidationError> { + if allowed { + Ok(()) + } else { + Err(CommitValidationError::ExternalCommitNotAllowed) + } +} + +/// Reject if the commit's framing sender is not `Sender::NewMemberCommit`. +/// Defensive: the wider message dispatch should route by sender before +/// reaching this validator. We also assert against `Sender::Member` here +/// so an attacker who somehow gets a member-authored commit dispatched +/// to the external path can't bypass the `Sender::Member` validator's +/// stricter membership/permission checks. +fn enforce_external_commit_sender(sender: &Sender) -> Result<(), CommitValidationError> { + match sender { + Sender::NewMemberCommit => Ok(()), + _ => Err(CommitValidationError::ExternalCommitNotNewMemberCommit), + } +} + +/// Single-pass categorization of every proposal in `staged_commit`. +/// +/// Returns an `ExternalCommitProposalSummary` whose buckets the +/// downstream rule-checks consume. Surfaces only one error of its own: +/// it never accepts a by-reference proposal (RFC 9420 §12.4.3.2) and +/// flags the first sighting so the caller can reject the commit +/// wholesale. +fn collect_external_commit_proposals( + staged_commit: &StagedCommit, +) -> Result, CommitValidationError> { + let mut summary = ExternalCommitProposalSummary::new(); + + for queued in staged_commit.queued_proposals() { + // Rule 4: no by-reference proposals. Captured here rather than + // in the structure check because the proposal_or_ref_type only + // exists on `QueuedProposal`, not on the post-categorization + // typed bucket — so it has to happen during the walk. + if matches!(queued.proposal_or_ref_type(), ProposalOrRefType::Reference) { + summary.saw_by_reference = true; + } + + match queued.proposal() { + Proposal::ExternalInit(_) => { + summary.external_init_count += 1; + } + Proposal::Add(add) => { + summary.adds.push(add.as_ref()); + } + Proposal::PreSharedKey(_) => { + summary.psk_count += 1; + } + Proposal::AppDataUpdate(app_data) => { + summary.app_data_update_count += 1; + if summary.app_data_update.is_none() { + summary.app_data_update = Some(app_data.as_ref()); + } + } + Proposal::SelfRemove => { + summary.saw_self_remove = true; + } + Proposal::GroupContextExtensions(_) => { + summary.saw_gce = true; + } + other => { + if summary.first_unsupported.is_none() { + summary.first_unsupported = Some(other.proposal_type()); + } + } + } + } + + Ok(summary) +} + +/// Apply rules 3, 8, 9, 10 against the categorized proposal summary. +/// +/// Order matters: we surface the most-specific failure first so error +/// messages and tests pin a single canonical reason per violation +/// shape. "Missing ExternalInit" is the strongest "this commit is not +/// an external commit at all" signal, so it comes ahead of "wrong +/// proposal type" failures. +fn enforce_external_commit_structure( + summary: &ExternalCommitProposalSummary<'_>, +) -> Result<(), CommitValidationError> { + // RFC 9420 §12.4.3.2: no by-reference proposals. + if summary.saw_by_reference { + return Err(CommitValidationError::ExternalCommitByReferenceProposalsForbidden); + } + + // ExternalInit count: exactly one. + match summary.external_init_count { + 0 => return Err(CommitValidationError::ExternalCommitMissingExternalInit), + 1 => {} + _ => return Err(CommitValidationError::ExternalCommitMultipleExternalInit), + } + + // Forbidden proposal kinds, in order of specificity. + if summary.saw_self_remove { + return Err(CommitValidationError::ResyncExternalCommitNotSupported); + } + if summary.saw_gce { + return Err(CommitValidationError::ExternalCommitGceForbidden); + } + if let Some(unsupported) = summary.first_unsupported { + return Err(CommitValidationError::ExternalCommitUnsupportedProposalType(unsupported)); + } + + // AppDataUpdate count: exactly one. + match summary.app_data_update_count { + 0 => return Err(CommitValidationError::ExternalCommitAppDataUpdateMissing), + 1 => {} + _ => return Err(CommitValidationError::ExternalCommitAppDataUpdateMultiple), + } + + Ok(()) +} + +/// Apply rule 6: every Add proposal's KeyPackage credential MUST carry +/// the same inbox id as the joiner's path leaf. +/// +/// Returns the set of installation ids added (signature keys from the +/// Add proposals' leaf nodes) for population into the resulting +/// `ValidatedCommit`. The path-leaf itself is the joiner's "primary" +/// installation; whether that signature key is also represented as an +/// Add proposal is up to the sender — we do not deduplicate here. +fn enforce_adds_bind_to_joiner( + summary: &ExternalCommitProposalSummary<'_>, + joiner_inbox_id: &str, +) -> Result>, CommitValidationError> { + let mut added_installations: HashSet> = HashSet::new(); + for add in &summary.adds { + let leaf = add.key_package().leaf_node(); + let inbox_id = inbox_id_from_credential(leaf.credential())?; + if inbox_id != joiner_inbox_id { + return Err(CommitValidationError::CrossInboxAddInExternalCommit); + } + added_installations.insert(leaf.signature_key().as_slice().to_vec()); + } + Ok(added_installations) +} + +/// Apply rule 7: the single AppDataUpdate proposal MUST target the +/// `GROUP_MEMBERSHIP` component AND every mutation in its +/// `TlsMapDelta` payload MUST address only the +/// joiner's inbox id. +/// +/// A `Remove` operation is rejected — joiners cannot remove existing +/// membership entries (only insert their own). An `Update` op carries +/// a delta whose mutations are iterated; the inbox-id of every +/// `Insert`/`Update`/`Delete` mutation must equal `joiner_inbox_id`. +/// +/// The joiner's `ActorAuthority` (non-admin, non-super-admin) is *not* +/// checked here against `validate_one_app_data_update`: by-default +/// deny-all policy on `GROUP_MEMBERSHIP.insert_policy` would reject a +/// non-admin write under the steady-state code path. The +/// `allow_external_commit` policy bit (enforced as rule 1) is the +/// authoritative gate for the joiner case; once that bit is true, +/// the joiner is authorized to insert their own entry — but ONLY +/// their own. That scope is what this function enforces. +fn enforce_app_data_update_scope( + proposal: &openmls::messages::proposals::AppDataUpdateProposal, + joiner_inbox_id: &str, +) -> Result<(), CommitValidationError> { + use tls_codec::Deserialize as TlsDeserialize; + use xmtp_mls_common::app_data::component_id::ComponentId; + use xmtp_mls_common::inbox_id::InboxId; + use xmtp_mls_common::tls_map::{TlsMapDelta, TlsMapMutation}; + + if ComponentId::from(proposal.component_id()) != ComponentId::GROUP_MEMBERSHIP { + return Err(CommitValidationError::ExternalCommitAppDataUpdateWrongComponent); + } + + let payload = match proposal.operation() { + AppDataUpdateOperation::Update(bytes) => bytes, + // A `Remove` op on GROUP_MEMBERSHIP wipes the entire component + // — not a legitimate join-time operation. The joiner can only + // *add* their entry. + AppDataUpdateOperation::Remove => { + return Err(CommitValidationError::ExternalCommitAppDataUpdateOutOfScope); + } + }; + + // Parse the delta. Treat any decoding failure as a wire-format + // violation rather than letting it surface as a silent "no + // mutations to check". + let delta = + TlsMapDelta::::tls_deserialize_exact(payload.as_slice()) + .map_err(|e| { + CommitValidationError::ExternalCommitAppDataUpdatePayloadMalformed(e.to_string()) + })?; + + // Parse the joiner's inbox-id string once and compare by raw + // bytes. We avoid re-encoding each mutation's key back to a hex + // string in the hot path. + let joiner_id = InboxId::from_hex(joiner_inbox_id).map_err(|e| { + CommitValidationError::ExternalCommitAppDataUpdatePayloadMalformed(format!( + "joiner inbox id is not valid hex: {e}" + )) + })?; + + // An empty delta is degenerate (no mutations) — the joiner did not + // actually register themselves. Reject so a future + // implementation-bug that produces an empty delta surfaces here + // rather than as a downstream invariant violation. + if delta.mutations.is_empty() { + return Err(CommitValidationError::ExternalCommitAppDataUpdateOutOfScope); + } + + for mutation in &delta.mutations { + let key = match mutation { + TlsMapMutation::Insert { key, .. } + | TlsMapMutation::Update { key, .. } + | TlsMapMutation::Delete { key } => key, + }; + if key != &joiner_id { + return Err(CommitValidationError::ExternalCommitAppDataUpdateOutOfScope); + } + } + + Ok(()) +} + +#[cfg(test)] +mod external_commit_validator_tests { + //! Pins the rule-by-rule behavior of + //! [`ValidatedCommit::from_external_commit`]. Each helper is exercised + //! directly so the tests don't have to construct a real + //! `StagedCommit` (which requires a full MLS group, identity, and + //! crypto provider). The orchestrator function itself is exercised + //! indirectly via integration tests in L-8/L-10/L-11. + use super::*; + use openmls::messages::proposals::AppDataUpdateProposal; + use tls_codec::{Serialize as TlsSerialize, VLBytes}; + use xmtp_mls_common::app_data::component_id::ComponentId as XmtpComponentId; + use xmtp_mls_common::inbox_id::{INBOX_ID_BYTE_LEN, InboxId}; + use xmtp_mls_common::tls_map::TlsMapDelta; + + // Note: the policy gate now lives in the AppData + // EXTERNAL_COMMIT_POLICY component; tests pass the bool directly to + // `enforce_external_commit_policy` rather than building a PolicySet. + + /// Build a hex inbox-id string with a stable seed byte so different + /// test inboxes are easy to compare visually. + fn make_inbox_id_hex(seed: u8) -> String { + hex::encode([seed; INBOX_ID_BYTE_LEN]) + } + + fn make_inbox_id(seed: u8) -> InboxId { + InboxId::from_bytes([seed; INBOX_ID_BYTE_LEN]) + } + + /// Encode a `TlsMapDelta` of `(InboxId, VLBytes)` mutations to the + /// wire payload an `AppDataUpdate::Update` proposal carries. + fn encode_membership_delta(delta: &TlsMapDelta) -> Vec { + delta.tls_serialize_detached().expect("delta serialize") + } + + /// Build an `AppDataUpdate(GROUP_MEMBERSHIP, Update())` + /// proposal — the canonical shape produced by the L-10/L-11 sender. + fn well_formed_membership_update_for(joiner: InboxId) -> AppDataUpdateProposal { + let entry: VLBytes = vec![0u8; 0].into(); + let delta = TlsMapDelta::::new().insert(joiner, entry); + let bytes = encode_membership_delta(&delta); + AppDataUpdateProposal::update(XmtpComponentId::GROUP_MEMBERSHIP.as_u16(), bytes) + } + + // ── enforce_external_commit_policy ─────────────────────────────── + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_when_allow_external_commit_is_false() { + let err = enforce_external_commit_policy(false) + .expect_err("disabled policy must reject external commits"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitNotAllowed + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn accepts_when_allow_external_commit_is_true() { + assert!(enforce_external_commit_policy(true).is_ok()); + } + + // ── enforce_external_commit_sender ─────────────────────────────── + + #[xmtp_common::test(unwrap_try = true)] + fn accepts_new_member_commit_sender() { + assert!(enforce_external_commit_sender(&Sender::NewMemberCommit).is_ok()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_member_sender() { + let err = enforce_external_commit_sender(&Sender::Member(LeafNodeIndex::new(0))) + .expect_err("Sender::Member must be rejected on the external path"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitNotNewMemberCommit + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_new_member_proposal_sender() { + let err = enforce_external_commit_sender(&Sender::NewMemberProposal) + .expect_err("Sender::NewMemberProposal must be rejected on the external path"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitNotNewMemberCommit + )); + } + + // ── enforce_external_commit_structure ──────────────────────────── + + fn summary_for_structure_test() -> ExternalCommitProposalSummary<'static> { + // We don't need real proposal references for the structure + // check — the counts and flags are what gate the verdict. The + // `adds` and `app_data_update` fields stay empty; the + // structure check only looks at counts. + ExternalCommitProposalSummary::new() + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_without_external_init_proposal() { + let mut summary = summary_for_structure_test(); + summary.external_init_count = 0; + summary.app_data_update_count = 1; + let err = enforce_external_commit_structure(&summary).expect_err("0 ExternalInit"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitMissingExternalInit + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_with_two_external_init_proposals() { + let mut summary = summary_for_structure_test(); + summary.external_init_count = 2; + summary.app_data_update_count = 1; + let err = enforce_external_commit_structure(&summary).expect_err("2 ExternalInit"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitMultipleExternalInit + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_with_by_reference_proposals() { + let mut summary = summary_for_structure_test(); + summary.external_init_count = 1; + summary.app_data_update_count = 1; + summary.saw_by_reference = true; + let err = enforce_external_commit_structure(&summary) + .expect_err("by-reference proposals must be rejected (RFC 9420 §12.4.3.2)"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitByReferenceProposalsForbidden + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_self_remove_proposal_resync_flavor() { + let mut summary = summary_for_structure_test(); + summary.external_init_count = 1; + summary.app_data_update_count = 1; + summary.saw_self_remove = true; + let err = enforce_external_commit_structure(&summary) + .expect_err("resync flavor must be rejected"); + assert!(matches!( + err, + CommitValidationError::ResyncExternalCommitNotSupported + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_group_context_extensions_proposal() { + let mut summary = summary_for_structure_test(); + summary.external_init_count = 1; + summary.app_data_update_count = 1; + summary.saw_gce = true; + let err = enforce_external_commit_structure(&summary) + .expect_err("GCE proposals not allowed in external commits"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitGceForbidden + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_update_proposal() { + let mut summary = summary_for_structure_test(); + summary.external_init_count = 1; + summary.app_data_update_count = 1; + summary.first_unsupported = Some(ProposalType::Update); + let err = enforce_external_commit_structure(&summary) + .expect_err("Update proposals not allowed in external commits"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitUnsupportedProposalType(ProposalType::Update) + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_remove_proposal() { + let mut summary = summary_for_structure_test(); + summary.external_init_count = 1; + summary.app_data_update_count = 1; + summary.first_unsupported = Some(ProposalType::Remove); + let err = enforce_external_commit_structure(&summary) + .expect_err("Remove proposals not allowed in external commits"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitUnsupportedProposalType(ProposalType::Remove) + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_when_no_app_data_update() { + let mut summary = summary_for_structure_test(); + summary.external_init_count = 1; + summary.app_data_update_count = 0; + let err = enforce_external_commit_structure(&summary) + .expect_err("missing AppDataUpdate must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitAppDataUpdateMissing + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_with_two_app_data_update_proposals() { + let mut summary = summary_for_structure_test(); + summary.external_init_count = 1; + summary.app_data_update_count = 2; + let err = enforce_external_commit_structure(&summary) + .expect_err("multiple AppDataUpdates must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitAppDataUpdateMultiple + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn accepts_canonical_external_commit_shape() { + // 1 ExternalInit + 1 AppDataUpdate + 0 unsupported + 0 ref → ok + let mut summary = summary_for_structure_test(); + summary.external_init_count = 1; + summary.app_data_update_count = 1; + assert!(enforce_external_commit_structure(&summary).is_ok()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn accepts_psk_proposal_alongside_atomic_shape() { + // PSK proposals are allowed; they don't trip first_unsupported + // because they are tracked separately. Structure check passes. + let mut summary = summary_for_structure_test(); + summary.external_init_count = 1; + summary.app_data_update_count = 1; + summary.psk_count = 2; + assert!(enforce_external_commit_structure(&summary).is_ok()); + } + + // ── enforce_app_data_update_scope ──────────────────────────────── + + #[xmtp_common::test(unwrap_try = true)] + fn accepts_app_data_update_scoped_to_joiner() { + let joiner_hex = make_inbox_id_hex(0x11); + let proposal = well_formed_membership_update_for(make_inbox_id(0x11)); + assert!(enforce_app_data_update_scope(&proposal, &joiner_hex).is_ok()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_app_data_update_for_other_inbox() { + let joiner_hex = make_inbox_id_hex(0x11); + // Insert someone else's entry — scope mismatch. + let proposal = well_formed_membership_update_for(make_inbox_id(0x22)); + let err = enforce_app_data_update_scope(&proposal, &joiner_hex) + .expect_err("delta keyed by a different inbox must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitAppDataUpdateOutOfScope + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_app_data_update_wrong_component() { + let joiner_hex = make_inbox_id_hex(0x11); + // Same delta payload but on COMPONENT_REGISTRY — wrong component. + let entry: VLBytes = vec![0u8; 0].into(); + let delta = TlsMapDelta::::new().insert(make_inbox_id(0x11), entry); + let bytes = encode_membership_delta(&delta); + let proposal = + AppDataUpdateProposal::update(XmtpComponentId::COMPONENT_REGISTRY.as_u16(), bytes); + let err = enforce_app_data_update_scope(&proposal, &joiner_hex) + .expect_err("non-GROUP_MEMBERSHIP target must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitAppDataUpdateWrongComponent + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_app_data_update_remove_op() { + let joiner_hex = make_inbox_id_hex(0x11); + let proposal = AppDataUpdateProposal::remove(XmtpComponentId::GROUP_MEMBERSHIP.as_u16()); + let err = enforce_app_data_update_scope(&proposal, &joiner_hex) + .expect_err("Remove op on GROUP_MEMBERSHIP must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitAppDataUpdateOutOfScope + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_app_data_update_empty_delta() { + let joiner_hex = make_inbox_id_hex(0x11); + let delta = TlsMapDelta::::new(); + let bytes = encode_membership_delta(&delta); + let proposal = + AppDataUpdateProposal::update(XmtpComponentId::GROUP_MEMBERSHIP.as_u16(), bytes); + let err = enforce_app_data_update_scope(&proposal, &joiner_hex) + .expect_err("empty delta must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitAppDataUpdateOutOfScope + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_app_data_update_malformed_payload() { + let joiner_hex = make_inbox_id_hex(0x11); + // Two truncation bytes — not a valid TlsMapDelta. + let proposal = AppDataUpdateProposal::update( + XmtpComponentId::GROUP_MEMBERSHIP.as_u16(), + vec![0xffu8, 0x00], + ); + let err = enforce_app_data_update_scope(&proposal, &joiner_hex) + .expect_err("malformed payload must be rejected"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitAppDataUpdatePayloadMalformed(_) + )); + } + + #[xmtp_common::test(unwrap_try = true)] + fn rejects_app_data_update_mixed_inbox_mutations() { + let joiner_hex = make_inbox_id_hex(0x11); + let entry: VLBytes = vec![0u8; 0].into(); + // Mutation 1: joiner's own entry (legal in isolation). + // Mutation 2: someone else's entry (must trip the scope check). + let delta = TlsMapDelta::::new() + .insert(make_inbox_id(0x11), entry.clone()) + .insert(make_inbox_id(0x99), entry); + let bytes = encode_membership_delta(&delta); + let proposal = + AppDataUpdateProposal::update(XmtpComponentId::GROUP_MEMBERSHIP.as_u16(), bytes); + let err = enforce_app_data_update_scope(&proposal, &joiner_hex) + .expect_err("any non-joiner mutation must trip the scope check"); + assert!(matches!( + err, + CommitValidationError::ExternalCommitAppDataUpdateOutOfScope + )); + } +} + #[cfg(test)] mod permission_on_receive_tests { //! Pins the receive-side permission check on `AppDataUpdate` diff --git a/crates/xmtp_mls/src/groups/welcome_sync.rs b/crates/xmtp_mls/src/groups/welcome_sync.rs index 094a3cd295..898948b50b 100644 --- a/crates/xmtp_mls/src/groups/welcome_sync.rs +++ b/crates/xmtp_mls/src/groups/welcome_sync.rs @@ -448,7 +448,6 @@ fn filter_groups_with_new_messages( #[cfg(test)] mod tests { use super::*; - use crate::groups::mls_ext::wrap_welcome; use crate::groups::test::NoopValidator; use crate::test::mock::*; use derive_builder::Builder; @@ -458,11 +457,13 @@ mod tests { use tls_codec::Serialize; use xmtp_common::Generate; use xmtp_configuration::Originators; + use xmtp_configuration::WELCOME_HPKE_LABEL; use xmtp_db::StorageError; use xmtp_db::refresh_state::EntityKind; use xmtp_db::sql_key_store::SqlKeyStore; use xmtp_db::{MemoryStorage, mock::MockDbQuery, sql_key_store::mock::MockSqlKeyStore}; use xmtp_id::key_package::WrapperAlgorithm; + use xmtp_mls_common::mls_ext::payload_encryption::wrap_payload_hpke; use xmtp_proto::mls_v1::WelcomeMetadata; use xmtp_proto::types::{ Cursor, GroupId, WelcomeMessage, WelcomeMessageType, WelcomeMessageV1, @@ -474,7 +475,7 @@ mod tests { welcome: MlsMessageOut, message_cursor: Option, ) -> WelcomeMessage { - let (data, welcome_metadata) = wrap_welcome( + let (data, welcome_metadata) = wrap_payload_hpke( &welcome.tls_serialize_detached().unwrap(), &WelcomeMetadata { message_cursor: message_cursor.unwrap_or(0), @@ -482,6 +483,7 @@ mod tests { .encode_to_vec(), &public_key, WrapperAlgorithm::Curve25519, + WELCOME_HPKE_LABEL, ) .unwrap(); diff --git a/crates/xmtp_mls/src/utils/bench/mod.rs b/crates/xmtp_mls/src/utils/bench/mod.rs index c9d7cb3484..7b6701a201 100644 --- a/crates/xmtp_mls/src/utils/bench/mod.rs +++ b/crates/xmtp_mls/src/utils/bench/mod.rs @@ -11,8 +11,9 @@ pub use clients::*; use thiserror::Error; /// Re-export of functions in private modules for benchmarks pub mod re_export { - pub use crate::groups::mls_ext::wrap_welcome; + pub use xmtp_configuration::WELCOME_HPKE_LABEL; pub use xmtp_id::key_package::WrapperAlgorithm; + pub use xmtp_mls_common::mls_ext::payload_encryption::wrap_payload_hpke; } pub mod groups; diff --git a/crates/xmtp_mls_common/Cargo.toml b/crates/xmtp_mls_common/Cargo.toml index a8ce003a65..1d9c6ee27a 100644 --- a/crates/xmtp_mls_common/Cargo.toml +++ b/crates/xmtp_mls_common/Cargo.toml @@ -13,7 +13,10 @@ workspace = true [dependencies] bon.workspace = true hex.workspace = true +hpke-rs.workspace = true openmls.workspace = true +openmls_libcrux_crypto.workspace = true +openmls_traits.workspace = true parking_lot.workspace = true prost.workspace = true serde.workspace = true @@ -28,6 +31,7 @@ xmtp_id.workspace = true xmtp_proto.workspace = true [dev-dependencies] +openmls_rust_crypto.workspace = true proptest.workspace = true xmtp_common = { workspace = true, features = ["test-utils"] } xmtp_configuration = { workspace = true, features = ["test-utils"] } diff --git a/crates/xmtp_mls_common/src/app_data/component_id.rs b/crates/xmtp_mls_common/src/app_data/component_id.rs index 7856131455..d5c7800117 100644 --- a/crates/xmtp_mls_common/src/app_data/component_id.rs +++ b/crates/xmtp_mls_common/src/app_data/component_id.rs @@ -82,6 +82,14 @@ impl ComponentId { pub const APP_DATA: Self = Self(0x8009); pub const MIN_SUPPORTED_PROTOCOL_VERSION: Self = Self(0x800A); pub const COMMIT_LOG_SIGNER: Self = Self(0x800B); + /// Group-wide external-commit policy component. Carries + /// `allow_external_commit` (defense-in-depth master switch for MLS + /// External Commits, RFC 9420 §12.4.3.2) plus `expires_at_ns` + /// (wall-clock auto-disable) and `expire_in_ns` (max staleness of + /// the referenced GroupInfo). Runtime-toggleable via + /// AppDataUpdate(EXTERNAL_COMMIT_POLICY); super-admin-only update + /// by default. + pub const EXTERNAL_COMMIT_POLICY: Self = Self(0x800C); // === Well-Known Immutable XMTP Component IDs (counting down from 0xBFFF) === diff --git a/crates/xmtp_mls_common/src/invite/encrypted_group_info.rs b/crates/xmtp_mls_common/src/invite/encrypted_group_info.rs new file mode 100644 index 0000000000..f009ccca4d --- /dev/null +++ b/crates/xmtp_mls_common/src/invite/encrypted_group_info.rs @@ -0,0 +1,323 @@ +//! Encryption/decryption helpers for GroupInfo blobs stored on an external +//! service as part of the QR-invite flow. The blob envelope is the proto +//! [`EncryptedGroupInfoBlob`]; the underlying AEAD is ChaCha20Poly1305 via +//! [`payload_encryption::wrap_payload_symmetric`]. +//! +//! `wrap_group_info` generates a fresh nonce for every call — callers MUST +//! NOT reuse a `(key, nonce)` pair across distinct ciphertexts (the AEAD +//! security argument collapses otherwise). The caller-supplied-nonce variant +//! [`wrap_group_info_with_nonce`] is provided for tests and explicit nonce +//! management scenarios only. +//! +//! The blob's plaintext metadata (epoch, group_state_hash, expires_at_ns) +//! is supplied by the caller because computing it requires the live +//! `MlsGroup` (epoch + tree hash) and an admin policy decision (expiry). +//! These fields are not derivable inside the pure-codec helpers. +//! +//! [`payload_encryption::wrap_payload_symmetric`]: crate::mls_ext::payload_encryption::wrap_payload_symmetric +//! [`EncryptedGroupInfoBlob`]: xmtp_proto::xmtp::mls::message_contents::EncryptedGroupInfoBlob + +use thiserror::Error; +use xmtp_proto::xmtp::mls::message_contents::{ + EncryptedGroupInfoBlob, EncryptedGroupInfoBlobV1, + encrypted_group_info_blob::Version as EncryptedGroupInfoBlobVersion, +}; + +use crate::invite::payload::NONCE_LEN; +use crate::mls_ext::payload_encryption::{ + UnwrapPayloadError, WrapPayloadError, unwrap_payload_symmetric, wrap_payload_symmetric, +}; + +/// Errors returned by [`wrap_group_info`], [`wrap_group_info_with_nonce`], and +/// [`unwrap_group_info`]. +#[derive(Debug, Error)] +pub enum EncryptedGroupInfoError { + /// The blob's `version` oneof carries a variant this build does not + /// recognize, or is unset entirely. + #[error("unsupported or missing EncryptedGroupInfoBlob version")] + UnsupportedVersion, + /// The blob's `nonce` field had a length other than [`NONCE_LEN`] bytes. + #[error("nonce must be exactly {NONCE_LEN} bytes (got {0})")] + InvalidNonceLength(usize), + /// The blob's `expires_at_ns` is non-zero and `<= now_ns`. + #[error("blob expired at {expires_at_ns} ns; current time {now_ns} ns")] + Expired { + /// Wall-clock expiry encoded in the blob. + expires_at_ns: u64, + /// Wall-clock time the caller used for the check. + now_ns: u64, + }, + /// The underlying AEAD wrap step failed. + #[error("wrap failed: {0}")] + Wrap(#[from] WrapPayloadError), + /// The underlying AEAD unwrap step failed (wrong key, tampered ciphertext, + /// etc.). + #[error("unwrap failed: {0}")] + Unwrap(#[from] UnwrapPayloadError), +} + +/// Wrap plaintext bytes (a TLS-serialized `MlsMessageOut(GroupInfo)`) into an +/// [`EncryptedGroupInfoBlob`] using the provided symmetric key and a freshly +/// generated nonce (ChaCha20Poly1305 nonce-uniqueness requirement). +/// +/// * `epoch` — current MLS epoch of the GroupInfo being wrapped. Service +/// uses for total-order on uploads. +/// * `group_state_hash` — tree hash (or equivalent group-state digest) of +/// the wrapped GroupInfo. Service uses for fork detection at equal epoch. +/// * `expires_at_ns` — wall-clock blob expiry. `0` means no expiry. +/// +/// [`EncryptedGroupInfoBlob`]: xmtp_proto::xmtp::mls::message_contents::EncryptedGroupInfoBlob +pub fn wrap_group_info( + plaintext: &[u8], + key: &[u8; 32], + epoch: u64, + group_state_hash: Vec, + expires_at_ns: u64, +) -> Result { + let nonce = crate::invite::payload::generate_nonce(); + wrap_group_info_with_nonce( + plaintext, + key, + &nonce, + epoch, + group_state_hash, + expires_at_ns, + ) +} + +/// Same as [`wrap_group_info`] but with a caller-supplied nonce. Use only when +/// explicit nonce management is required (e.g. tests). Reusing a `(key, +/// nonce)` pair across distinct ciphertexts breaks ChaCha20Poly1305's security +/// argument. +pub fn wrap_group_info_with_nonce( + plaintext: &[u8], + key: &[u8; 32], + nonce: &[u8; NONCE_LEN], + epoch: u64, + group_state_hash: Vec, + expires_at_ns: u64, +) -> Result { + let ciphertext = wrap_payload_symmetric( + plaintext, + openmls::prelude::AeadType::ChaCha20Poly1305, + key, + nonce, + )?; + + Ok(EncryptedGroupInfoBlob { + version: Some(EncryptedGroupInfoBlobVersion::V1( + EncryptedGroupInfoBlobV1 { + nonce: nonce.to_vec(), + ciphertext, + epoch, + group_state_hash, + expires_at_ns, + }, + )), + }) +} + +/// Unwrap an [`EncryptedGroupInfoBlob`] using the symmetric key. Verifies the +/// envelope version, nonce length, and (when `now_ns` is `Some`) wall-clock +/// expiry before decryption. +/// +/// Returns the plaintext + a borrowed reference to the unwrapped V1 envelope +/// so callers can inspect `epoch` / `group_state_hash` after decryption. +/// +/// * [`EncryptedGroupInfoError::UnsupportedVersion`] for an unset or +/// unrecognised version oneof. +/// * [`EncryptedGroupInfoError::InvalidNonceLength`] if `nonce.len() != NONCE_LEN`. +/// * [`EncryptedGroupInfoError::Expired`] when `now_ns` is supplied and the +/// blob's `expires_at_ns` is non-zero and `<= now_ns`. +/// * [`EncryptedGroupInfoError::Unwrap`] for any AEAD-level failure. +pub fn unwrap_group_info<'a>( + blob: &'a EncryptedGroupInfoBlob, + key: &[u8; 32], + now_ns: Option, +) -> Result<(Vec, &'a EncryptedGroupInfoBlobV1), EncryptedGroupInfoError> { + let v1 = match &blob.version { + Some(EncryptedGroupInfoBlobVersion::V1(v1)) => v1, + None => return Err(EncryptedGroupInfoError::UnsupportedVersion), + }; + if v1.nonce.len() != NONCE_LEN { + return Err(EncryptedGroupInfoError::InvalidNonceLength(v1.nonce.len())); + } + if let Some(now) = now_ns + && v1.expires_at_ns != 0 + && now >= v1.expires_at_ns + { + return Err(EncryptedGroupInfoError::Expired { + expires_at_ns: v1.expires_at_ns, + now_ns: now, + }); + } + + let plaintext = unwrap_payload_symmetric( + &v1.ciphertext, + openmls::prelude::AeadType::ChaCha20Poly1305, + key, + &v1.nonce, + )?; + Ok((plaintext, v1)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_wrap(key: &[u8; 32], plaintext: &[u8]) -> EncryptedGroupInfoBlob { + wrap_group_info(plaintext, key, 1, b"state-hash".to_vec(), 0).unwrap() + } + + #[xmtp_common::test(unwrap_try = true)] + fn round_trip_default_nonce() { + let key = [0x11u8; 32]; + let plaintext = b"the quick brown fox jumps over the lazy dog"; + + let blob = fixture_wrap(&key, plaintext); + let (recovered, v1) = unwrap_group_info(&blob, &key, None)?; + assert_eq!(recovered.as_slice(), plaintext.as_slice()); + assert_eq!(v1.epoch, 1); + assert_eq!(v1.group_state_hash, b"state-hash"); + assert_eq!(v1.expires_at_ns, 0); + assert_eq!(v1.nonce.len(), NONCE_LEN); + assert_ne!(v1.ciphertext.as_slice(), plaintext.as_slice()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn round_trip_explicit_nonce() { + let key = [0x22u8; 32]; + let nonce = [0x33u8; NONCE_LEN]; + let plaintext = b"explicit nonce path"; + + let blob = wrap_group_info_with_nonce(plaintext, &key, &nonce, 7, vec![], 0)?; + let (recovered, v1) = unwrap_group_info(&blob, &key, None)?; + assert_eq!(v1.nonce.as_slice(), nonce.as_slice()); + assert_eq!(v1.epoch, 7); + assert_eq!(recovered.as_slice(), plaintext.as_slice()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn missing_version_rejected() { + let blob = EncryptedGroupInfoBlob { version: None }; + let err = unwrap_group_info(&blob, &[0u8; 32], None).unwrap_err(); + assert!(matches!(err, EncryptedGroupInfoError::UnsupportedVersion)); + } + + #[xmtp_common::test(unwrap_try = true)] + fn nonce_too_short_rejected() { + let key = [0x55u8; 32]; + let plaintext = b"short-nonce payload"; + + let mut blob = fixture_wrap(&key, plaintext); + if let Some(EncryptedGroupInfoBlobVersion::V1(ref mut v1)) = blob.version { + v1.nonce.truncate(NONCE_LEN - 1); + } + let err = unwrap_group_info(&blob, &key, None).unwrap_err(); + match err { + EncryptedGroupInfoError::InvalidNonceLength(len) => { + assert_eq!(len, NONCE_LEN - 1); + } + other => panic!("expected InvalidNonceLength, got {other:?}"), + } + + let mut blob = fixture_wrap(&key, plaintext); + if let Some(EncryptedGroupInfoBlobVersion::V1(ref mut v1)) = blob.version { + v1.nonce.push(0); + } + let err = unwrap_group_info(&blob, &key, None).unwrap_err(); + match err { + EncryptedGroupInfoError::InvalidNonceLength(len) => { + assert_eq!(len, NONCE_LEN + 1); + } + other => panic!("expected InvalidNonceLength, got {other:?}"), + } + } + + #[xmtp_common::test(unwrap_try = true)] + fn wrong_key_fails_unwrap() { + let key_a = [0x66u8; 32]; + let key_b = [0x77u8; 32]; + let plaintext = b"key A wrote me"; + + let blob = fixture_wrap(&key_a, plaintext); + let err = unwrap_group_info(&blob, &key_b, None).unwrap_err(); + assert!( + matches!(err, EncryptedGroupInfoError::Unwrap(_)), + "expected Unwrap, got {err:?}" + ); + } + + #[xmtp_common::test(unwrap_try = true)] + fn tampered_ciphertext_fails_unwrap() { + let key = [0x88u8; 32]; + let plaintext = b"do not tamper with me, monkey"; + + let mut blob = fixture_wrap(&key, plaintext); + if let Some(EncryptedGroupInfoBlobVersion::V1(ref mut v1)) = blob.version { + assert!(!v1.ciphertext.is_empty()); + v1.ciphertext[0] ^= 0x01; + } + let err = unwrap_group_info(&blob, &key, None).unwrap_err(); + assert!( + matches!(err, EncryptedGroupInfoError::Unwrap(_)), + "expected Unwrap, got {err:?}" + ); + } + + #[xmtp_common::test(unwrap_try = true)] + fn fresh_nonces_differ() { + let key = [0x99u8; 32]; + let plaintext = b"same plaintext, different nonces please"; + + let blob1 = fixture_wrap(&key, plaintext); + let blob2 = fixture_wrap(&key, plaintext); + + let (_, v1_a) = unwrap_group_info(&blob1, &key, None)?; + let (_, v1_b) = unwrap_group_info(&blob2, &key, None)?; + assert_ne!(v1_a.nonce, v1_b.nonce, "fresh nonces must differ"); + assert_ne!( + v1_a.ciphertext, v1_b.ciphertext, + "ciphertexts must differ when nonces differ" + ); + } + + #[xmtp_common::test(unwrap_try = true)] + fn expired_blob_rejected_when_now_supplied() { + let key = [0xaau8; 32]; + let plaintext = b"expires at 100"; + let blob = wrap_group_info(plaintext, &key, 1, vec![], 100)?; + + // Not yet expired. + assert!(unwrap_group_info(&blob, &key, Some(99)).is_ok()); + + // At and after expiry. + for now in [100u64, 101, u64::MAX] { + let err = unwrap_group_info(&blob, &key, Some(now)).unwrap_err(); + match err { + EncryptedGroupInfoError::Expired { + expires_at_ns, + now_ns, + } => { + assert_eq!(expires_at_ns, 100); + assert_eq!(now_ns, now); + } + other => panic!("expected Expired, got {other:?}"), + } + } + + // None bypasses expiry enforcement entirely. + assert!(unwrap_group_info(&blob, &key, None).is_ok()); + } + + #[xmtp_common::test(unwrap_try = true)] + fn zero_expiry_means_no_expiry() { + let key = [0xbbu8; 32]; + let plaintext = b"never expires"; + let blob = wrap_group_info(plaintext, &key, 1, vec![], 0)?; + + // Even with a `now_ns` supplied, an explicit zero expiry never fails. + assert!(unwrap_group_info(&blob, &key, Some(0)).is_ok()); + assert!(unwrap_group_info(&blob, &key, Some(u64::MAX)).is_ok()); + } +} diff --git a/crates/xmtp_mls_common/src/invite/mod.rs b/crates/xmtp_mls_common/src/invite/mod.rs new file mode 100644 index 0000000000..f720a3bfca --- /dev/null +++ b/crates/xmtp_mls_common/src/invite/mod.rs @@ -0,0 +1,11 @@ +//! External-invite primitives for QR-code-based group joining via MLS external +//! commits. +//! +//! The [`payload`] module provides helpers for the +//! [`ExternalInvitePayload`] proto. The [`encrypted_group_info`] module +//! provides the encryption envelope. +//! +//! [`ExternalInvitePayload`]: xmtp_proto::xmtp::mls::message_contents::ExternalInvitePayload + +pub mod encrypted_group_info; +pub mod payload; diff --git a/crates/xmtp_mls_common/src/invite/payload.rs b/crates/xmtp_mls_common/src/invite/payload.rs new file mode 100644 index 0000000000..5639a94f5c --- /dev/null +++ b/crates/xmtp_mls_common/src/invite/payload.rs @@ -0,0 +1,233 @@ +//! Helpers for the [`ExternalInvitePayload`] proto. +//! +//! Centralises the small but easy-to-get-wrong pieces of building and +//! validating an external-invite payload: +//! +//! * fresh symmetric keys / nonces / external-group-ids from the workspace CSPRNG +//! * recognising / unwrapping the `oneof version { V1 v1 }` envelope +//! * a [`build_payload`] convenience constructor +//! +//! The actual encryption of the [`GroupInfo`] blob is performed by the +//! sibling `encrypted_group_info` module (which also owns the blob-side +//! expiry semantics, since `expires_at_ns` lives on the +//! [`EncryptedGroupInfoBlob`] envelope and not the payload). +//! +//! [`ExternalInvitePayload`]: xmtp_proto::xmtp::mls::message_contents::ExternalInvitePayload +//! [`EncryptedGroupInfoBlob`]: xmtp_proto::xmtp::mls::message_contents::EncryptedGroupInfoBlob +//! [`GroupInfo`]: openmls::messages::group_info::GroupInfo + +use thiserror::Error; +use xmtp_proto::xmtp::mls::message_contents::{ + ExternalInvitePayload, ExternalInvitePayloadV1, + external_invite_payload::Version as ExternalInvitePayloadVersion, +}; + +/// Length in bytes of the ChaCha20Poly1305 key used to wrap the encrypted +/// `GroupInfo` blob referenced by an [`ExternalInvitePayload`]. +pub const SYMMETRIC_KEY_LEN: usize = 32; + +/// Length in bytes of the ChaCha20Poly1305 nonce used alongside +/// [`SYMMETRIC_KEY_LEN`]-byte keys. +pub const NONCE_LEN: usize = 12; + +/// Minimum length of `external_group_id`. The proto schema enforces this as +/// MUST; tiny services that don't need much collision resistance may pick +/// the floor, but `RECOMMENDED_EXTERNAL_GROUP_ID_LEN` random bytes is the +/// libxmtp default when no application-specific scheme is in use. +pub const MIN_EXTERNAL_GROUP_ID_LEN: usize = 4; + +/// Recommended random length for `external_group_id` when callers don't +/// have an application-specific scheme. 16 bytes (128 bits) gives ample +/// collision resistance for any realistic single-service deployment. +pub const RECOMMENDED_EXTERNAL_GROUP_ID_LEN: usize = 16; + +/// Errors returned when validating an [`ExternalInvitePayload`]. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum InvitePayloadError { + /// The payload's `version` oneof carries a variant this build does not + /// recognize, or is unset entirely. + #[error("unsupported or missing external-invite payload version")] + UnsupportedVersion, + /// `external_group_id` was shorter than [`MIN_EXTERNAL_GROUP_ID_LEN`]. + #[error("external_group_id must be at least {min} bytes (got {len})", min = MIN_EXTERNAL_GROUP_ID_LEN)] + InvalidExternalGroupIdLength { + /// Observed length. + len: usize, + }, + /// `symmetric_key` was not exactly [`SYMMETRIC_KEY_LEN`] bytes. + #[error("symmetric_key must be exactly {SYMMETRIC_KEY_LEN} bytes (got {0})")] + InvalidSymmetricKeyLength(usize), +} + +/// Generate a fresh 32-byte symmetric key from the workspace CSPRNG. +/// +/// The key is intended for use with ChaCha20Poly1305 when wrapping the +/// encrypted GroupInfo blob referenced by the resulting +/// [`ExternalInvitePayload`]. +pub fn generate_symmetric_key() -> [u8; SYMMETRIC_KEY_LEN] { + xmtp_common::rand_array::() +} + +/// Generate a fresh 12-byte nonce from the workspace CSPRNG. +/// +/// Intended for use with ChaCha20Poly1305 alongside a key produced by +/// [`generate_symmetric_key`]. The nonce is *not* stored in the payload +/// itself — it lives next to the ciphertext in the encrypted GroupInfo blob. +pub fn generate_nonce() -> [u8; NONCE_LEN] { + xmtp_common::rand_array::() +} + +/// Generate a fresh random `external_group_id` of the recommended length +/// ([`RECOMMENDED_EXTERNAL_GROUP_ID_LEN`] bytes from the workspace CSPRNG). +/// +/// Callers with application-specific identifier schemes (UUIDs, snowflakes, +/// short slot keys, …) should construct their own bytes instead — this +/// helper exists as the safe default. +pub fn generate_external_group_id() -> [u8; RECOMMENDED_EXTERNAL_GROUP_ID_LEN] { + xmtp_common::rand_array::() +} + +/// Validate that `payload.version` carries a recognised variant and that +/// the V1 fields meet their length requirements. +/// +/// Currently the only recognised variant is V1. Future versions extend the +/// oneof; unknown variants are rejected (fail closed). +pub fn validate( + payload: &ExternalInvitePayload, +) -> Result<&ExternalInvitePayloadV1, InvitePayloadError> { + let v1 = match &payload.version { + Some(ExternalInvitePayloadVersion::V1(v1)) => v1, + None => return Err(InvitePayloadError::UnsupportedVersion), + }; + if v1.external_group_id.len() < MIN_EXTERNAL_GROUP_ID_LEN { + return Err(InvitePayloadError::InvalidExternalGroupIdLength { + len: v1.external_group_id.len(), + }); + } + if v1.symmetric_key.len() != SYMMETRIC_KEY_LEN { + return Err(InvitePayloadError::InvalidSymmetricKeyLength( + v1.symmetric_key.len(), + )); + } + Ok(v1) +} + +/// Build an [`ExternalInvitePayload`] wrapping a [`ExternalInvitePayloadV1`] +/// with the supplied fields. +/// +/// * `service_pointer` — application-defined opaque bytes describing where +/// the encrypted GroupInfo blob can be fetched. +/// * `external_group_id` — service-slot identifier carried on the wire and +/// verified by the joiner against the group's +/// `EXTERNAL_COMMIT_POLICY.external_group_id` after joining. MUST be at +/// least [`MIN_EXTERNAL_GROUP_ID_LEN`] bytes. +/// * `symmetric_key` — typically the output of [`generate_symmetric_key`]. +pub fn build_payload( + service_pointer: Vec, + external_group_id: Vec, + symmetric_key: [u8; SYMMETRIC_KEY_LEN], +) -> ExternalInvitePayload { + ExternalInvitePayload { + version: Some(ExternalInvitePayloadVersion::V1(ExternalInvitePayloadV1 { + service_pointer, + external_group_id, + symmetric_key: symmetric_key.to_vec(), + })), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn well_formed_payload() -> ExternalInvitePayload { + build_payload( + b"https://invites.example/abc".to_vec(), + generate_external_group_id().to_vec(), + [0x42u8; SYMMETRIC_KEY_LEN], + ) + } + + #[xmtp_common::test(unwrap_try = true)] + fn key_nonce_and_id_are_random() { + let k1 = generate_symmetric_key(); + let k2 = generate_symmetric_key(); + assert_eq!(k1.len(), SYMMETRIC_KEY_LEN); + assert_eq!(k2.len(), SYMMETRIC_KEY_LEN); + assert_ne!(k1, k2, "two CSPRNG-generated keys must differ"); + assert_ne!(k1, [0u8; SYMMETRIC_KEY_LEN], "key must not be all-zero"); + + let n1 = generate_nonce(); + let n2 = generate_nonce(); + assert_eq!(n1.len(), NONCE_LEN); + assert_eq!(n2.len(), NONCE_LEN); + assert_ne!(n1, n2, "two CSPRNG-generated nonces must differ"); + + let id1 = generate_external_group_id(); + let id2 = generate_external_group_id(); + assert_eq!(id1.len(), RECOMMENDED_EXTERNAL_GROUP_ID_LEN); + assert_ne!( + id1, id2, + "two CSPRNG-generated external_group_ids must differ" + ); + } + + #[xmtp_common::test(unwrap_try = true)] + fn validate_accepts_well_formed_v1() { + let payload = well_formed_payload(); + let v1 = validate(&payload)?; + assert_eq!(v1.symmetric_key.len(), SYMMETRIC_KEY_LEN); + assert!(v1.external_group_id.len() >= MIN_EXTERNAL_GROUP_ID_LEN); + } + + #[xmtp_common::test(unwrap_try = true)] + fn validate_rejects_missing_version() { + let payload = ExternalInvitePayload { version: None }; + assert_eq!( + validate(&payload), + Err(InvitePayloadError::UnsupportedVersion) + ); + } + + #[xmtp_common::test(unwrap_try = true)] + fn validate_rejects_short_external_group_id() { + let payload = build_payload( + b"svc".to_vec(), + vec![0u8; MIN_EXTERNAL_GROUP_ID_LEN - 1], + [0x42u8; SYMMETRIC_KEY_LEN], + ); + assert_eq!( + validate(&payload), + Err(InvitePayloadError::InvalidExternalGroupIdLength { + len: MIN_EXTERNAL_GROUP_ID_LEN - 1 + }) + ); + } + + #[xmtp_common::test(unwrap_try = true)] + fn validate_rejects_wrong_symmetric_key_length() { + let mut payload = well_formed_payload(); + if let Some(ExternalInvitePayloadVersion::V1(ref mut v1)) = payload.version { + v1.symmetric_key = vec![0u8; SYMMETRIC_KEY_LEN - 1]; + } + assert_eq!( + validate(&payload), + Err(InvitePayloadError::InvalidSymmetricKeyLength( + SYMMETRIC_KEY_LEN - 1 + )) + ); + } + + #[xmtp_common::test(unwrap_try = true)] + fn build_payload_round_trip() { + let service_pointer = b"https://invites.example/abc".to_vec(); + let external_group_id = generate_external_group_id().to_vec(); + let key = [0x42u8; SYMMETRIC_KEY_LEN]; + + let payload = build_payload(service_pointer.clone(), external_group_id.clone(), key); + let v1 = validate(&payload)?; + assert_eq!(v1.service_pointer, service_pointer); + assert_eq!(v1.external_group_id, external_group_id); + assert_eq!(v1.symmetric_key, key.to_vec()); + } +} diff --git a/crates/xmtp_mls_common/src/lib.rs b/crates/xmtp_mls_common/src/lib.rs index 17045438bc..18e0e75912 100644 --- a/crates/xmtp_mls_common/src/lib.rs +++ b/crates/xmtp_mls_common/src/lib.rs @@ -3,5 +3,7 @@ pub mod group; pub mod group_metadata; pub mod group_mutable_metadata; pub mod inbox_id; +pub mod invite; +pub mod mls_ext; pub mod tls_map; pub mod tls_set; diff --git a/crates/xmtp_mls_common/src/mls_ext/mod.rs b/crates/xmtp_mls_common/src/mls_ext/mod.rs new file mode 100644 index 0000000000..13574c0ac0 --- /dev/null +++ b/crates/xmtp_mls_common/src/mls_ext/mod.rs @@ -0,0 +1 @@ +pub mod payload_encryption; diff --git a/crates/xmtp_mls_common/src/mls_ext/payload_encryption.rs b/crates/xmtp_mls_common/src/mls_ext/payload_encryption.rs new file mode 100644 index 0000000000..e357509364 --- /dev/null +++ b/crates/xmtp_mls_common/src/mls_ext/payload_encryption.rs @@ -0,0 +1,406 @@ +use openmls::ciphersuite::hpke::Error as OpenmlsHpkeError; +use openmls::prelude::tls_codec::Error as TlsCodecError; +use openmls_traits::crypto::OpenMlsCrypto; +use openmls_traits::types::HpkeCiphertext; +use thiserror::Error; +use tls_codec::{Deserialize, Serialize}; +use xmtp_common::RetryableError; +use xmtp_id::key_package::WrapperAlgorithm; + +static LIBCRUX_CRYPTO_PROVIDER: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + openmls_libcrux_crypto::CryptoProvider::new().expect("Failed to create CryptoProvider") + }); + +#[derive(Debug, Error)] +pub enum WrapPayloadError { + #[error("OpenMLS HPKE error: {0}")] + Hpke(#[from] OpenmlsHpkeError), + #[error("TLS Codec error: {0}")] + TlsError(#[from] TlsCodecError), + #[error(transparent)] + Crypto(#[from] openmls_traits::types::CryptoError), +} + +#[derive(Debug, Error)] +pub enum UnwrapPayloadError { + #[error("OpenMLS HPKE error: {0}")] + Hpke(#[from] OpenmlsHpkeError), + #[error("TLS Codec error: {0}")] + TlsError(#[from] TlsCodecError), + #[error(transparent)] + Crypto(#[from] openmls_traits::types::CryptoError), +} + +impl RetryableError for WrapPayloadError { + fn is_retryable(&self) -> bool { + false + } +} + +impl RetryableError for UnwrapPayloadError { + fn is_retryable(&self) -> bool { + false + } +} + +/// Wrap a payload (plus optional secondary payload) in an outer layer of HPKE +/// encryption using the specified [WrapperAlgorithm]. The algorithm and public +/// key type MUST match. +/// +/// `label` is fed to the HPKE `EncryptContext` as the domain-separation label. +/// Use [`xmtp_configuration::WELCOME_HPKE_LABEL`] for welcome-flow compatibility. +/// +/// For the `XWingMLKEM768Draft6` algorithm, `payload` and `secondary_payload` +/// are wrapped using the same HPKE public key. The first returned vec is the +/// `HpkeCiphertext` with TLS serialization. The second vec is just ciphertext. +pub fn wrap_payload_hpke( + payload: &[u8], + secondary_payload: &[u8], + hpke_public_key: &[u8], + wrapper_algorithm: WrapperAlgorithm, + label: &str, +) -> Result<(Vec, Vec), WrapPayloadError> { + // The following implementation is the same as calling openmls_libcrux_crypto::CryptoProvider::hpke_seal(...) + // but uses the context to encrypt multiple messages at once using the same context + // because openmls only supports one shot messages. + + let context = openmls::prelude::hpke::EncryptContext::from((label, [].as_slice())); + let info = context.tls_serialize_detached()?; + let aad = &[]; + + let map_hpke_error = |e| match e { + hpke_rs::HpkeError::InvalidConfig => openmls::prelude::CryptoError::SenderSetupError, + _ => openmls::prelude::CryptoError::HpkeEncryptionError, + }; + + let pk_r = hpke_rs::HpkePublicKey::new(hpke_public_key.to_vec()); + let mut config = wrapper_algorithm.to_hpke_config(); + + let (enc, mut ctxt) = config + .setup_sender(&pk_r, &info, None, None, None) + .map_err(map_hpke_error)?; + + let encrypted_payload = ctxt + .seal(aad, payload) + .map(|ct| HpkeCiphertext { + kem_output: enc.into(), + ciphertext: ct.into(), + }) + .map_err(map_hpke_error)?; + let encrypted_secondary_payload = ctxt.seal(aad, secondary_payload).map_err(map_hpke_error)?; + + Ok(( + encrypted_payload.tls_serialize_detached()?, + encrypted_secondary_payload, + )) +} + +/// Unwrap a payload that was wrapped using the specified [WrapperAlgorithm]. +/// The algorithm and private key type MUST match. `label` MUST match the value +/// used at wrap time. +pub fn unwrap_payload_hpke( + wrapped_payload: &[u8], + wrapped_secondary_payload: &[u8], + private_key: &[u8], + wrapper_algorithm: WrapperAlgorithm, + label: &str, +) -> Result<(Vec, Vec), UnwrapPayloadError> { + let ciphertext = HpkeCiphertext::tls_deserialize_exact(wrapped_payload)?; + + // The following implementation is the same as calling openmls_libcrux_crypto::CryptoProvider::hpke_open(...) + // but uses the context to decrypt multiple messages at once using the same context + // because openmls only supports one shot messages. + + let context = openmls::prelude::hpke::EncryptContext::from((label, [].as_slice())); + let info = context.tls_serialize_detached()?; + let aad = &[]; + + let config = wrapper_algorithm.to_hpke_config(); + + let sk_r = hpke_rs::HpkePrivateKey::new(private_key.to_vec()); + + let map_hpke_error = |_| openmls::ciphersuite::hpke::Error::DecryptionFailed; + + let mut ctxt = config + .setup_receiver( + ciphertext.kem_output.as_ref(), + &sk_r, + &info, + None, + None, + None, + ) + .map_err(map_hpke_error)?; + + let payload = ctxt + .open(aad, ciphertext.ciphertext.as_ref()) + .map_err(map_hpke_error)?; + let secondary_payload = if wrapped_secondary_payload.is_empty() { + vec![] + } else { + ctxt.open(aad, wrapped_secondary_payload) + .map_err(map_hpke_error)? + }; + + Ok((payload, secondary_payload)) +} + +/// Wrap a payload with symmetric AEAD encryption (caller-supplied key + nonce). +/// +/// Domain separation is handled by construction: callers MUST scope the +/// symmetric key to a single use-case. +pub fn wrap_payload_symmetric( + data: &[u8], + aead_type: openmls::prelude::AeadType, + symmetric_key: &[u8], + nonce: &[u8], +) -> Result, WrapPayloadError> { + (*LIBCRUX_CRYPTO_PROVIDER) + .aead_encrypt(aead_type, symmetric_key, data, nonce, &[]) + .map_err(Into::into) +} + +/// Unwrap a payload that was wrapped with [`wrap_payload_symmetric`]. +pub fn unwrap_payload_symmetric( + data: &[u8], + aead_type: openmls::prelude::AeadType, + symmetric_key: &[u8], + nonce: &[u8], +) -> Result, UnwrapPayloadError> { + (*LIBCRUX_CRYPTO_PROVIDER) + .aead_decrypt(aead_type, symmetric_key, data, nonce, &[]) + .map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + use openmls_traits::{crypto::OpenMlsCrypto, random::OpenMlsRand}; + use xmtp_configuration::{CIPHERSUITE, POST_QUANTUM_CIPHERSUITE, WELCOME_HPKE_LABEL}; + + const TEST_LABEL: &str = "test xmtp payload"; + + fn fresh_curve25519_keypair() -> (Vec, Vec) { + let crypto = openmls_rust_crypto::RustCrypto::default(); + let ikm = crypto.random_vec(CIPHERSUITE.hash_length()).unwrap(); + let kp = crypto + .derive_hpke_keypair(CIPHERSUITE.hpke_config(), &ikm) + .unwrap(); + (kp.public, kp.private.to_vec()) + } + + fn fresh_xwing_keypair() -> (Vec, Vec) { + let crypto = openmls_libcrux_crypto::CryptoProvider::new().unwrap(); + let ikm = crypto + .random_vec(POST_QUANTUM_CIPHERSUITE.hash_length()) + .unwrap(); + let kp = crypto + .derive_hpke_keypair(POST_QUANTUM_CIPHERSUITE.hpke_config(), &ikm) + .unwrap(); + (kp.public, kp.private.to_vec()) + } + + #[xmtp_common::test] + fn round_trip_curve25519_hpke() { + let (pk, sk) = fresh_curve25519_keypair(); + + let payload = xmtp_common::rand_vec::<1000>(); + let secondary = xmtp_common::rand_vec::<32>(); + + let wrapped = wrap_payload_hpke( + &payload, + &secondary, + &pk, + WrapperAlgorithm::Curve25519, + TEST_LABEL, + ) + .unwrap(); + + assert_ne!(payload, wrapped.0); + assert_ne!(secondary, wrapped.1); + + let unwrapped = unwrap_payload_hpke( + &wrapped.0, + &wrapped.1, + &sk, + WrapperAlgorithm::Curve25519, + TEST_LABEL, + ) + .unwrap(); + + assert_eq!(unwrapped, (payload, secondary)); + } + + #[xmtp_common::test] + fn round_trip_xwing_hpke() { + let (pk, sk) = fresh_xwing_keypair(); + + let payload = xmtp_common::rand_vec::<1000>(); + let secondary = xmtp_common::rand_vec::<32>(); + + let wrapped = wrap_payload_hpke( + &payload, + &secondary, + &pk, + WrapperAlgorithm::XWingMLKEM768Draft6, + TEST_LABEL, + ) + .unwrap(); + + assert_ne!(payload, wrapped.0); + assert_ne!(secondary, wrapped.1); + + let unwrapped = unwrap_payload_hpke( + &wrapped.0, + &wrapped.1, + &sk, + WrapperAlgorithm::XWingMLKEM768Draft6, + TEST_LABEL, + ) + .unwrap(); + + assert_eq!(unwrapped, (payload.clone(), secondary)); + + // Empty secondary payload short-circuits to vec![]. + let unwrapped = unwrap_payload_hpke( + &wrapped.0, + &[], + &sk, + WrapperAlgorithm::XWingMLKEM768Draft6, + TEST_LABEL, + ) + .unwrap(); + + assert_eq!(unwrapped, (payload, vec![])); + } + + #[xmtp_common::test] + fn wrong_key_fails_curve25519() { + let (pk, _sk) = fresh_curve25519_keypair(); + let (_pk2, sk2) = fresh_curve25519_keypair(); + + let payload = xmtp_common::rand_vec::<128>(); + let secondary = xmtp_common::rand_vec::<32>(); + + let wrapped = wrap_payload_hpke( + &payload, + &secondary, + &pk, + WrapperAlgorithm::Curve25519, + TEST_LABEL, + ) + .unwrap(); + + unwrap_payload_hpke( + &wrapped.0, + &wrapped.1, + &sk2, + WrapperAlgorithm::Curve25519, + TEST_LABEL, + ) + .unwrap_err(); + } + + #[xmtp_common::test] + fn wrong_label_fails_curve25519() { + let (pk, sk) = fresh_curve25519_keypair(); + + let payload = xmtp_common::rand_vec::<128>(); + let secondary = xmtp_common::rand_vec::<32>(); + + let wrapped = wrap_payload_hpke( + &payload, + &secondary, + &pk, + WrapperAlgorithm::Curve25519, + TEST_LABEL, + ) + .unwrap(); + + unwrap_payload_hpke( + &wrapped.0, + &wrapped.1, + &sk, + WrapperAlgorithm::Curve25519, + "different label", + ) + .unwrap_err(); + } + + #[xmtp_common::test] + fn welcome_label_round_trip_matches_xmtp_configuration() { + // Sanity-check that the welcome label still round-trips correctly through + // the generalized API. This is the configuration the welcome flow uses. + let (pk, sk) = fresh_curve25519_keypair(); + + let payload = xmtp_common::rand_vec::<256>(); + let secondary = xmtp_common::rand_vec::<32>(); + + let wrapped = wrap_payload_hpke( + &payload, + &secondary, + &pk, + WrapperAlgorithm::Curve25519, + WELCOME_HPKE_LABEL, + ) + .unwrap(); + + let unwrapped = unwrap_payload_hpke( + &wrapped.0, + &wrapped.1, + &sk, + WrapperAlgorithm::Curve25519, + WELCOME_HPKE_LABEL, + ) + .unwrap(); + + assert_eq!(unwrapped, (payload, secondary)); + } + + #[xmtp_common::test] + fn round_trip_symmetric() { + let symmetric_key = xmtp_common::rand_array::<32>(); + let nonce = xmtp_common::rand_array::<12>(); + let data = xmtp_common::rand_array::<1000>(); + + let wrapped = wrap_payload_symmetric( + &data, + openmls::prelude::AeadType::ChaCha20Poly1305, + &symmetric_key, + &nonce, + ) + .unwrap(); + let unwrapped = unwrap_payload_symmetric( + &wrapped, + openmls::prelude::AeadType::ChaCha20Poly1305, + &symmetric_key, + &nonce, + ) + .unwrap(); + assert_eq!(data.as_slice(), unwrapped.as_slice()); + } + + #[xmtp_common::test] + fn symmetric_wrong_key_fails() { + let symmetric_key = xmtp_common::rand_array::<32>(); + let wrong_key = xmtp_common::rand_array::<32>(); + let nonce = xmtp_common::rand_array::<12>(); + let data = xmtp_common::rand_array::<1000>(); + + let wrapped = wrap_payload_symmetric( + &data, + openmls::prelude::AeadType::ChaCha20Poly1305, + &symmetric_key, + &nonce, + ) + .unwrap(); + unwrap_payload_symmetric( + &wrapped, + openmls::prelude::AeadType::ChaCha20Poly1305, + &wrong_key, + &nonce, + ) + .unwrap_err(); + } +}