diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ff75b6a..71835674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,27 +19,17 @@ archived by series under [docs/changelog/](docs/changelog/); see the The control frame of the membership exchange ([ADR 0037](docs/adr/0037-an-applications-users-are-visible-only-to-that-application.md)) is reserved and consumed on receipt, never delivered as text or answered, - ahead of the exchange itself. A build that does not reserve it delivers the - frame to its application as text, so this release is the floor for sharing a - radio neighbourhood cleanly. `offline-protocol-sealed` gains `membership`: - the certificate, the two exchange frames and the end-to-end proof, each at - one fixed size, with their signing payloads and key derivation inputs, - pinned by `app-membership-v1.vectors.json` + ahead of the exchange itself. A build that does not reserve it rejects the + frame with a security warning, or shows it as text when `require_encryption` + is off, so this release is the floor for sharing a radio neighbourhood + cleanly. `offline-protocol-sealed` gains `membership`: the certificate, the + two exchange frames and the end-to-end proof, each at one fixed size, with + their signing payloads and key derivation inputs, pinned by + `app-membership-v1.vectors.json` ([control messages](docs/spec/control-messages.md#application-membership)). - Nothing is gated on membership yet. - -- **Every node runs the application membership exchange.** On discovering a - neighbour over BLE or Wi-Fi Direct, a node sends a `__MEMBER__` hello and - answers the neighbour's, always at the same two sizes, whether or not it is - configured - ([ADR 0037](docs/adr/0037-an-applications-users-are-visible-only-to-that-application.md)). - `ProtocolConfig.app_publisher_key` and `OfflineProtocol::set_app_membership` - (Rust only for now) make a node a member: two members of one application - learn that they are, and every other pair learns nothing. Nothing is gated - on it yet, and the bindings do not expose it yet. An older build that does - not reserve `__MEMBER__` shows each hello to its application as text. - The end-to-end proof's sealing key now has its own label, - `offline-app-member-e2e-key-v1`, apart from the signing domain. + The end-to-end proof's sealing key has its own label, + `offline-app-member-e2e-key-v1`, apart from the signing domain. No node + sends the frame: the exchange itself is held back, and ADR 0037 is parked. - **A browser node's start can be aborted.** `OfflineNode.start({ signal })` ends the worker of a start that has not finished, and with it the diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index d702ba43..f21392bb 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -8853,18 +8853,6 @@ mod tests { use std::sync::Arc; use std::thread; - /// The next frame in the Wi-Fi Direct slot that is not a membership - /// frame. Every engine says hello to a neighbour it discovers - /// (ADR 0037), so a test that asks what discovery or a send queued looks - /// past those. - fn next_wifi_direct_frame_past_membership( - engine: &OfflineProtocol, - ) -> Option { - let marker = offline_protocol_sealed::prefixes::MEMBER.as_bytes(); - std::iter::from_fn(|| engine.wifi_direct_get_next_message()) - .find(|m| !m.data.windows(marker.len()).any(|w| w == marker)) - } - /// Every `DataStore` method the UDL declares must be reachable from /// React Native, in all four places a method has to appear. /// @@ -11575,7 +11563,8 @@ mod tests { ); a.wifi_direct_status_changed(true).unwrap(); - let outbound = next_wifi_direct_frame_past_membership(&a) + let outbound = a + .wifi_direct_get_next_message() .expect("the key package goes out once the layer is up"); assert_eq!(outbound.recipient_id, b_address); @@ -11712,7 +11701,8 @@ mod tests { None, ) .unwrap(); - let outbound = next_wifi_direct_frame_past_membership(&a) + let outbound = a + .wifi_direct_get_next_message() .expect("the registered transport queued the message"); assert_eq!( outbound.recipient_id, b_address, diff --git a/crates/offline-protocol/Cargo.toml b/crates/offline-protocol/Cargo.toml index b79602c2..a8e6ebb0 100644 --- a/crates/offline-protocol/Cargo.toml +++ b/crates/offline-protocol/Cargo.toml @@ -43,7 +43,7 @@ test-utils = [] # keystore. Default on, like `data`, so the FFI entry that exposes them cannot # become a checksum mismatch; a mobile build that never calls them opts out # with `default-features = false` and drops the HKDF, HMAC and lock code. -file-store = ["dep:rustix"] +file-store = ["dep:hkdf", "dep:hmac", "dep:rustix"] [dependencies] offline-protocol-core = { workspace = true } @@ -96,12 +96,12 @@ sha2 = "0.10" chacha20poly1305 = { workspace = true } rand_core = { workspace = true } zeroize = { workspace = true } -# The application membership exchange (ADR 0037) derives its keys with HKDF -# and tags its hello with HMAC, on every build. The built-in sealed file store -# uses both too, for its sealing and naming keys. Both are already in the -# graph through the transport crate. -hkdf = { workspace = true } -hmac = { workspace = true } +# The built-in sealed file store for MLS material derives its sealing and +# naming keys from the operator's store key (HKDF) and names its files with a +# keyed digest (HMAC), so a stolen directory does not confirm which peers and +# groups it holds. Both are already in the graph through the transport crate. +hkdf = { workspace = true, optional = true } +hmac = { workspace = true, optional = true } # The built-in file stores hold an exclusive `flock` on their directory for # their lifetime, so a second store over it is refused rather than diverging diff --git a/crates/offline-protocol/src/config.rs b/crates/offline-protocol/src/config.rs index 41581086..d7c33794 100644 --- a/crates/offline-protocol/src/config.rs +++ b/crates/offline-protocol/src/config.rs @@ -945,24 +945,6 @@ pub struct ProtocolConfig { /// Service subscriptions; see [`ServiceSubscriptionConfig`]. pub service_subscriptions: ServiceSubscriptionConfig, - - /// This application's publisher public key, an Ed25519 key of 32 bytes, - /// or `None` (the default). - /// - /// With a key, this node runs the membership exchange of [ADR 0037] as a - /// member of the application the key and [`Self::app_id`] name: it - /// derives the application key from both, and a neighbour that presents - /// a certificate signed by this key, for its own address, becomes - /// visible. Without one the node still runs the exchange, answering every - /// neighbour with random bytes of the same sizes, so a configured node and - /// an unconfigured one look alike on the air. - /// - /// The engine never holds the publisher's private key. A member's own - /// certificate comes from - /// [`crate::OfflineProtocol::set_app_membership`]. - /// - /// [ADR 0037]: https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/docs/adr/0037-an-applications-users-are-visible-only-to-that-application.md - pub app_publisher_key: Option<[u8; 32]>, } impl ProtocolConfig { @@ -988,7 +970,6 @@ impl ProtocolConfig { data: DataConfig::default(), custody: CustodyConfig::default(), service_subscriptions: ServiceSubscriptionConfig::default(), - app_publisher_key: None, } } @@ -1473,13 +1454,6 @@ impl ProtocolConfigBuilder { self } - /// Sets this application's publisher public key - /// ([`ProtocolConfig::app_publisher_key`]). - pub fn app_publisher_key(mut self, key: [u8; 32]) -> Self { - self.config.app_publisher_key = Some(key); - self - } - /// Enables or disables BLE transport. pub fn ble_enabled(mut self, enabled: bool) -> Self { self.config.transport.ble_enabled = enabled; diff --git a/crates/offline-protocol/src/protocol/membership.rs b/crates/offline-protocol/src/protocol/membership.rs deleted file mode 100644 index e7edd0e0..00000000 --- a/crates/offline-protocol/src/protocol/membership.rs +++ /dev/null @@ -1,674 +0,0 @@ -//! The application membership exchange ([ADR 0037]): how two neighbours of -//! the same application learn that they are, and every other pair learns -//! nothing. -//! -//! # Invariants -//! -//! - **Nothing observable depends on membership.** Every node, configured or -//! not, sends one hello to each new neighbour and answers every fresh hello -//! with one response, both at their fixed sizes, and the answer costs one -//! identity signature and one seal whether it carries a credential or -//! random bytes. A node that stopped on a mismatch would be an oracle for -//! "does this peer run that application". -//! - **The frames never leave the link.** A hello and a response are sent -//! only over a mesh carrier, straight to the neighbour, with a TTL of one -//! and no acknowledgement requested. One that arrives over any other -//! carrier, after a hop, or unsigned is consumed and ignored: a relay -//! mailbox would hold a hello for days, and an acknowledgement is an answer -//! whose presence would depend on the receiver. -//! - **No sealing key is used twice.** The response key is derived from both -//! nonces, and a node answers each pair of nonces at most once, so a -//! replayed hello never makes it seal a second plaintext under a key it -//! already used. -//! - **Visible means verified.** A peer is visible only after its credential -//! opened under the key derived for its own address, its certificate -//! verified under this application's publisher key for that address and is -//! current, and its identity signature covers this node's fresh nonce. -//! -//! Nothing reads visibility yet (step 5 of the shared network plan): it is a -//! log line and [`OfflineProtocol::is_member_visible`]. The gate over -//! discovery events and replies is a later step. -//! -//! [ADR 0037]: https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/docs/adr/0037-an-applications-users-are-visible-only-to-that-application.md - -use std::collections::HashMap; - -use chacha20poly1305::aead::{Aead, KeyInit}; -use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; -use chrono::Utc; -use hkdf::Hkdf; -use hmac::{Hmac, Mac}; -use offline_protocol_core::time::{Duration, Instant}; -use offline_protocol_core::{Address, Message, MessagePriority, TTL}; -use offline_protocol_sealed::membership::{ - app_key_info, exchange_key_info, exchange_salt, hello_tag_input, transcript_signing_payload, - MemberCert, MemberCredential, MemberFrame, MemberHello, APP_KEY_LEN, MEMBER_CREDENTIAL_LEN, - MEMBER_NONCE_LEN, MEMBER_SEALED_LEN, MEMBER_TAG_LEN, -}; -use offline_protocol_transport::TransportType; -use rand_core::{OsRng, RngCore}; -use sha2::Sha256; -use tracing::{debug, error, info, warn}; - -use super::types::CTRL_PK_META_KEY; -use super::{base64_decode, internal_prefixes, OfflineProtocol}; -use crate::{Error, Result}; - -/// How long an exchange with one neighbour stands before a later discovery -/// of that neighbour starts a new one. -/// -/// Discovery fires on every reachability edge, and the exchange is two frames -/// each way plus a signature, so a neighbour that flaps its link would -/// otherwise cost that on every flap. A peer that restarts in the meantime -/// sends a fresh hello, which is answered whatever this says. -pub(crate) const MEMBER_EXCHANGE_INTERVAL: Duration = Duration::from_secs(5 * 60); - -/// Neighbours with an exchange in memory. The least recently started goes -/// first: a neighbourhood that mints addresses must not grow this without -/// limit, and the oldest exchange is the one least likely to be mid-flight. -pub(crate) const MAX_MEMBER_EXCHANGES: usize = 512; - -/// Peers held visible. The one whose certificate ends soonest goes first. -pub(crate) const MAX_VISIBLE_MEMBERS: usize = 1024; - -/// Nonces of one peer answered under one nonce of ours. Past this the -/// exchange starts over with a fresh nonce of ours, so the set stays bounded -/// and a replay of an older hello still finds a key never used. -const MAX_ANSWERED_PER_EXCHANGE: usize = 8; - -/// This node's side of the membership exchange. -#[derive(Default)] -pub(crate) struct Membership { - /// `K_app`, when a publisher key is configured. - app_key: Option<[u8; APP_KEY_LEN]>, - /// The publisher key certificates are verified under. - publisher_key: Option<[u8; 32]>, - /// This node's own certificate, once the application supplied one. - cert: Option, - /// One exchange per neighbour address. - exchanges: HashMap, - /// Verified members, by address, with their certificate's `not_after`. - visible: HashMap, -} - -struct Exchange { - started: Instant, - ours: [u8; MEMBER_NONCE_LEN], - theirs: Option<[u8; MEMBER_NONCE_LEN]>, - /// Every nonce of theirs answered under `ours`. - answered: Vec<[u8; MEMBER_NONCE_LEN]>, - /// A response that arrived before the hello it answers, held for it - /// with the identity key its frame was signed by. - pending_response: Option<([u8; MEMBER_SEALED_LEN], Option>)>, - /// Whether a response for (`ours`, `theirs`) has verified. - verified: bool, -} - -impl Exchange { - fn fresh() -> Self { - Self { - started: Instant::now(), - ours: random_array(), - theirs: None, - answered: Vec::new(), - pending_response: None, - verified: false, - } - } -} - -impl Membership { - /// The state for a configuration: `K_app` from the publisher key and the - /// application id, or nothing to derive when there is no key. - pub(crate) fn new(publisher_key: Option<[u8; 32]>, app_id: &str) -> Self { - let app_key = publisher_key.map(|pk| hkdf32(&pk, None, &app_key_info(app_id))); - Self { - app_key, - publisher_key, - ..Self::default() - } - } -} - -fn random_array() -> [u8; N] { - let mut out = [0u8; N]; - OsRng.fill_bytes(&mut out); - out -} - -fn hkdf32(ikm: &[u8], salt: Option<&[u8]>, info: &[u8]) -> [u8; 32] { - let mut out = [0u8; 32]; - // 32 bytes is far below HKDF-SHA256's 8160-byte ceiling, so expand - // cannot fail; the fallback keeps the function total without a panic. - // It is logged, because a random `K_app` would turn a member into a - // stranger to every peer with nothing else to say why. - if Hkdf::::new(salt, ikm) - .expand(info, &mut out) - .is_err() - { - error!("HKDF-SHA256 refused a 32-byte expand; membership keys are random"); - out = random_array(); - } - out -} - -/// HMAC-SHA256 keyed by `K_app`. HMAC takes a key of any length, so the -/// `None` arm is unreachable; it keeps the function free of a panic. -fn hello_mac(app_key: &[u8; APP_KEY_LEN]) -> Option> { - as Mac>::new_from_slice(app_key).ok() -} - -fn unix_now() -> u64 { - u64::try_from(Utc::now().timestamp()).unwrap_or(0) -} - -/// Whether a frame arrived the only way a `__MEMBER__` frame may: over a -/// mesh carrier, from the neighbour that made it. -fn arrived_on_the_link(message: &Message, arrival: Option) -> bool { - matches!( - arrival, - Some(TransportType::BLE | TransportType::WiFiDirect) - ) && message.hop_count.value() == 0 -} - -impl OfflineProtocol { - /// Supplies this node's membership certificate for the application named - /// by [`crate::ProtocolConfig::app_publisher_key`] and - /// [`crate::ProtocolConfig::app_id`]. - /// - /// The certificate is the 93-byte layout of the control-message chapter, - /// issued by the application's backend or by `sign_app_membership` in - /// embedded mode. It is checked before it is kept: it must name this - /// node's address, verify under the configured publisher key, and be - /// current, because a node that presents a bad certificate is a stranger - /// to every member and would never learn why. Held in memory: the - /// application supplies it on every launch. - /// - /// # Errors - /// - /// [`Error::InvalidConfiguration`] with no publisher key configured, and - /// [`Error::InvalidArgument`] for a certificate that is malformed, names - /// another address, does not verify, or has expired. Before - /// `initialize_mls` there is no address to check it against, which is - /// [`Error::InvalidArgument`] too. - pub fn set_app_membership(&mut self, cert: &[u8]) -> Result<()> { - let Some(publisher_key) = self.membership.publisher_key else { - return Err(Error::InvalidConfiguration( - "set_app_membership needs ProtocolConfig.app_publisher_key".to_string(), - )); - }; - let local: Address = self - .local_address() - .ok_or_else(|| { - Error::InvalidArgument( - "set_app_membership before initialize_mls: no address to bind".to_string(), - ) - })? - .parse() - .map_err(|e| { - Error::InvalidArgument(format!("The local address does not parse: {e}")) - })?; - let cert = MemberCert::from_bytes(cert).map_err(|e| { - Error::InvalidArgument(format!("Malformed membership certificate: {e}")) - })?; - if cert.address != local { - return Err(Error::InvalidArgument( - "The membership certificate names another address".to_string(), - )); - } - if !cert_verifies(&publisher_key, &cert) { - return Err(Error::InvalidArgument( - "The membership certificate does not verify under the publisher key".to_string(), - )); - } - if !cert.is_current(unix_now()) { - return Err(Error::InvalidArgument( - "The membership certificate has expired".to_string(), - )); - } - info!( - not_after = cert.not_after, - "Application membership certificate set" - ); - self.membership.cert = Some(cert); - self.restart_member_exchanges(); - Ok(()) - } - - /// Starts a fresh exchange with every current mesh neighbour. - /// - /// A certificate that arrives after this node met its neighbours (the - /// backend issues it once the application is running) would otherwise - /// reach none of them: every standing exchange already answered with - /// random bytes, and holds off a new one for - /// [`MEMBER_EXCHANGE_INTERVAL`]. Each neighbour sees a fresh nonce, which - /// reads as a restart, and answers it. - fn restart_member_exchanges(&mut self) { - self.membership.exchanges.clear(); - let neighbours: Vec = self - .transport_manager - .mesh_neighbors() - .into_iter() - .map(|n| n.peer_id) - .collect(); - for peer in neighbours { - self.start_member_exchange(&peer); - } - } - - /// Installs a certificate without the checks of - /// [`Self::set_app_membership`], so a test can stand in for a node that - /// presents a certificate that is not its own. - #[cfg(test)] - pub(crate) fn set_app_membership_unchecked(&mut self, cert: MemberCert) { - self.membership.cert = Some(cert); - } - - /// Ages every standing exchange past [`MEMBER_EXCHANGE_INTERVAL`], so a - /// test can ask what the next discovery does once the interval is over. - #[cfg(test)] - pub(crate) fn expire_member_exchanges(&mut self) { - for exchange in self.membership.exchanges.values_mut() { - if let Some(earlier) = Instant::now().checked_sub(MEMBER_EXCHANGE_INTERVAL) { - exchange.started = earlier; - } - } - } - - /// Whether `peer` has proven, in a membership exchange, that it belongs to - /// this node's application, and its certificate is still current. - /// - /// Always `false` with no publisher key configured. Nothing in the engine - /// acts on it yet. - #[cfg_attr( - not(test), - expect( - dead_code, - reason = "the visibility gate of a later step reads it; until then only tests do" - ) - )] - pub(crate) fn is_member_visible(&self, peer: &str) -> bool { - self.membership - .visible - .get(peer) - .is_some_and(|not_after| unix_now() <= *not_after) - } - - /// Starts the exchange with a neighbour discovery just reported, unless - /// one is standing. - /// - /// Called for every node, configured or not: an unconfigured node's hello - /// is random bytes of the same size, so sending it reveals nothing. - /// - /// Never skipped because the neighbour is already visible. Whether a - /// hello goes out would then depend on membership: a node that labels - /// its own link with a recorded address (R16) would learn, from one - /// connection, whether this node holds that address as a member, and a - /// passive listener would see member pairs fall silent on a rediscovery. - /// The visible set decides [`Self::is_member_visible`] and nothing that - /// goes on the air. - pub(super) fn start_member_exchange(&mut self, peer: &str) { - if self.mls_manager.is_none() || peer.parse::
().is_err() { - return; - } - if let Some(standing) = self.membership.exchanges.get(peer) { - if standing.started.elapsed() < MEMBER_EXCHANGE_INTERVAL { - return; - } - } - let exchange = Exchange::fresh(); - let ours = exchange.ours; - // Recorded only once the hello is on a link: a discovery that came - // from the relay's presence, with no mesh link to the peer, must not - // hold off the exchange the next mesh discovery would start. - if self.send_member_hello(peer, &ours) { - self.insert_member_exchange(peer, exchange); - } - } - - fn insert_member_exchange(&mut self, peer: &str, exchange: Exchange) { - let exchanges = &mut self.membership.exchanges; - if !exchanges.contains_key(peer) && exchanges.len() >= MAX_MEMBER_EXCHANGES { - if let Some(oldest) = exchanges - .iter() - .min_by_key(|(_, e)| e.started) - .map(|(id, _)| id.clone()) - { - exchanges.remove(&oldest); - } - } - exchanges.insert(peer.to_string(), exchange); - } - - /// A `__MEMBER__` frame, past the control gate. - /// - /// Consumed whatever it says. Nothing here sends anything that depends - /// on whether this node is a member: a hello is answered by every node, - /// and a response is answered by none. - pub(super) fn handle_member_frame( - &mut self, - message: &Message, - body: &str, - signed: bool, - arrival: Option, - ) { - let sender = message.sender.as_str(); - if !signed - || !arrived_on_the_link(message, arrival) - || message.recipient.as_str() != self.local_id - { - debug!( - sender = %sender, - signed, - arrival = ?arrival, - hop_count = message.hop_count.value(), - "Ignored a membership frame that did not come straight over a mesh link" - ); - return; - } - if sender == self.local_id || sender.parse::
().is_err() { - return; - } - match MemberFrame::parse(body) { - Ok(MemberFrame::Hello(hello)) => self.on_member_hello(sender, &hello), - Ok(MemberFrame::Response(sealed)) => self.on_member_response(message, &sealed), - Err(e) => debug!(sender = %sender, error = %e, "Refused a malformed membership frame"), - } - } - - fn on_member_hello(&mut self, peer: &str, hello: &MemberHello) { - // A neighbour that discovered this node first, or a standing exchange - // gone stale: this side's hello goes out before the answer, since the - // answer's key needs the peer to hold this side's nonce. - let standing = self - .membership - .exchanges - .get(peer) - .is_some_and(|e| e.started.elapsed() < MEMBER_EXCHANGE_INTERVAL); - if !standing { - let exchange = Exchange::fresh(); - let ours = exchange.ours; - if !self.send_member_hello(peer, &ours) { - return; - } - self.insert_member_exchange(peer, exchange); - } - - let Some(exchange) = self.membership.exchanges.get(peer) else { - return; - }; - if exchange.answered.contains(&hello.nonce) { - // The same hello again: a duplicate on the link or a replay. - // Answering it twice would seal a second time under one key. - return; - } - if exchange.answered.len() >= MAX_ANSWERED_PER_EXCHANGE { - let fresh = Exchange::fresh(); - let ours = fresh.ours; - if !self.send_member_hello(peer, &ours) { - return; - } - self.insert_member_exchange(peer, fresh); - } else if !exchange.answered.is_empty() { - // The peer has a new nonce after an answer: it restarted and no - // longer holds this side's nonce, so it gets the hello again. The - // same nonce, never a new one: a new one would read to the peer - // as this side restarting, and two nodes answering that with - // fresh nonces would never settle. - let ours = exchange.ours; - if !self.send_member_hello(peer, &ours) { - return; - } - } - - let Some(exchange) = self.membership.exchanges.get_mut(peer) else { - return; - }; - exchange.theirs = Some(hello.nonce); - exchange.verified = false; - exchange.answered.push(hello.nonce); - let ours = exchange.ours; - let pending = exchange.pending_response.take(); - - self.send_member_response(peer, &ours, hello); - if let Some((sealed, identity_key)) = pending { - self.open_member_response(peer, &sealed, identity_key); - } - } - - fn on_member_response(&mut self, message: &Message, sealed: &[u8; MEMBER_SEALED_LEN]) { - let peer = message.sender.as_str(); - let identity_key = message - .metadata - .get(CTRL_PK_META_KEY) - .and_then(|pk| base64_decode(pk).ok()); - let Some(exchange) = self.membership.exchanges.get_mut(peer) else { - return; - }; - if exchange.theirs.is_none() { - // Ahead of the hello it answers. One is held: the link delivers - // in order almost always, and a second early response would be a - // peer that answered twice before saying hello at all. - exchange.pending_response = Some((*sealed, identity_key)); - return; - } - self.open_member_response(peer, sealed, identity_key); - } - - /// Verifies a peer's response and, on a pass, makes it visible. - /// - /// `identity_key` is the key that signed the response's frame, which the - /// control gate already bound to the sender's address. `None` only for a - /// frame that carried no key, which the gate admits as unsigned and the - /// handler has already refused, so it fails closed here. - fn open_member_response( - &mut self, - peer: &str, - sealed: &[u8; MEMBER_SEALED_LEN], - identity_key: Option>, - ) { - let (Some(app_key), Some(publisher_key)) = - (self.membership.app_key, self.membership.publisher_key) - else { - return; - }; - let Some(exchange) = self.membership.exchanges.get(peer) else { - return; - }; - let (Some(theirs), false) = (exchange.theirs, exchange.verified) else { - return; - }; - let ours = exchange.ours; - let (Ok(local), Ok(sender)) = (self.local_id.parse::
(), peer.parse::
()) - else { - return; - }; - let Ok(salt) = exchange_salt(&local, &ours, &sender, &theirs) else { - return; - }; - let key = hkdf32(&app_key, Some(&salt), &exchange_key_info(&sender)); - let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); - let Ok(plaintext) = cipher.decrypt(Nonce::from_slice(&[0u8; 12]), sealed.as_slice()) else { - // A stranger's random bytes, or a response to a nonce of ours - // this side has since replaced. Neither is reported. - debug!(peer = %peer, "Membership response did not open: not a member"); - return; - }; - let Ok(credential) = MemberCredential::from_bytes(&plaintext) else { - return; - }; - let cert = &credential.cert; - if cert.address != sender || !cert_verifies(&publisher_key, cert) { - warn!(peer = %peer, "Membership response opened but its certificate is not this peer's"); - return; - } - if !cert.is_current(unix_now()) { - debug!(peer = %peer, not_after = cert.not_after, "Membership certificate has expired"); - return; - } - let Some(identity_key) = identity_key else { - return; - }; - let transcript = transcript_signing_payload(&sender, &theirs, &local, &ours); - let signed = offline_protocol_mls::MlsManager::verify_signature( - &identity_key, - &transcript, - &credential.signature, - ) - .unwrap_or(false); - if !signed { - warn!(peer = %peer, "Membership response's transcript signature does not verify"); - return; - } - - if let Some(exchange) = self.membership.exchanges.get_mut(peer) { - exchange.verified = true; - } - let visible = &mut self.membership.visible; - if !visible.contains_key(peer) && visible.len() >= MAX_VISIBLE_MEMBERS { - if let Some(soonest) = visible - .iter() - .min_by_key(|(_, not_after)| **not_after) - .map(|(id, _)| id.clone()) - { - visible.remove(&soonest); - } - } - visible.insert(peer.to_string(), cert.not_after); - info!(peer = %peer, not_after = cert.not_after, "Neighbour is a member of this application"); - } - - fn send_member_hello(&mut self, peer: &str, ours: &[u8; MEMBER_NONCE_LEN]) -> bool { - let tag = match ( - self.membership.app_key, - self.local_id.parse::
(), - peer.parse::
(), - ) { - (Some(app_key), Ok(local), Ok(peer_addr)) => match hello_mac(&app_key) { - Some(mut mac) => { - mac.update(&hello_tag_input(ours, &local, &peer_addr)); - let full = mac.finalize().into_bytes(); - let mut tag = [0u8; MEMBER_TAG_LEN]; - tag.copy_from_slice(&full[..MEMBER_TAG_LEN]); - tag - } - None => random_array(), - }, - // No application key: random bytes in the tag's place. - _ => random_array(), - }; - let frame = MemberFrame::Hello(MemberHello { nonce: *ours, tag }); - self.send_member_frame(peer, &frame) - } - - /// Answers a hello: a sealed credential when the hello's tag verifies and - /// this node holds a certificate, otherwise random bytes made by the same - /// work. - fn send_member_response( - &mut self, - peer: &str, - ours: &[u8; MEMBER_NONCE_LEN], - hello: &MemberHello, - ) { - let (Ok(local), Ok(peer_addr)) = - (self.local_id.parse::
(), peer.parse::
()) - else { - return; - }; - let member = match (self.membership.app_key, &self.membership.cert) { - (Some(app_key), Some(cert)) => hello_mac(&app_key).and_then(|mut mac| { - mac.update(&hello_tag_input(&hello.nonce, &peer_addr, &local)); - mac.verify_truncated_left(&hello.tag) - .is_ok() - .then(|| (app_key, cert.clone())) - }), - _ => None, - }; - let Ok(salt) = exchange_salt(&local, ours, &peer_addr, &hello.nonce) else { - return; - }; - - // The same signature, derivation and seal on both paths, so neither - // the size nor the cost of the answer says which one ran. On a - // mismatch the key is derived from random input keying material, - // which makes the sealed bytes random to everyone. - let transcript = transcript_signing_payload(&local, ours, &peer_addr, &hello.nonce); - let signature = match self.sign_with_identity(&transcript) { - Some(signature) => signature, - None => return, - }; - let (key, cert_bytes) = match &member { - Some((app_key, cert)) => ( - hkdf32(app_key, Some(&salt), &exchange_key_info(&local)), - Some(cert.to_bytes()), - ), - None => ( - hkdf32( - &random_array::<32>(), - Some(&salt), - &exchange_key_info(&local), - ), - None, - ), - }; - let mut plaintext = [0u8; MEMBER_CREDENTIAL_LEN]; - if let Some(cert_bytes) = cert_bytes { - plaintext[..cert_bytes.len()].copy_from_slice(&cert_bytes); - } - plaintext[MEMBER_CREDENTIAL_LEN - signature.len()..].copy_from_slice(&signature); - let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); - let Ok(sealed) = cipher.encrypt(Nonce::from_slice(&[0u8; 12]), plaintext.as_slice()) else { - return; - }; - let Ok(sealed) = <[u8; MEMBER_SEALED_LEN]>::try_from(sealed.as_slice()) else { - return; - }; - self.send_member_frame(peer, &MemberFrame::Response(sealed)); - } - - /// An Ed25519 signature by this node's identity key, always 64 bytes. - fn sign_with_identity(&self, payload: &[u8]) -> Option<[u8; 64]> { - let manager = self.mls_manager.as_ref()?.read().ok()?; - let signature = manager.sign_data(payload).ok()?; - <[u8; 64]>::try_from(signature.as_slice()).ok() - } - - /// Puts one membership frame on the neighbour's link: signed, TTL one, - /// no acknowledgement, mesh carriers only. Returns whether it went out. - fn send_member_frame(&mut self, peer: &str, frame: &MemberFrame) -> bool { - let content = format!("{}{}", internal_prefixes::MEMBER, frame.to_body()); - let mut message = match self.create_message(peer, content, Some(MessagePriority::Low), None) - { - Ok(message) => message, - Err(e) => { - debug!(peer = %peer, error = %e, "Could not build a membership frame"); - return false; - } - }; - message.requires_ack = false; - message.ttl = TTL::from_value(1); - if let Err(e) = self.sign_control_message(&mut message) { - debug!(peer = %peer, error = %e, "Could not sign a membership frame"); - return false; - } - self.deduplicator.mark_seen_local(message.id.clone()); - self.mesh_relay.mark_handled(&message.id.as_str()); - match self.transport_manager.send_to_neighbor(peer, &message) { - Ok(_) => true, - Err(e) => { - debug!(peer = %peer, error = %e, "No mesh link for a membership frame"); - false - } - } - } -} - -/// Whether the publisher signed `cert`. -fn cert_verifies(publisher_key: &[u8; 32], cert: &MemberCert) -> bool { - offline_protocol_mls::MlsManager::verify_signature( - publisher_key, - &MemberCert::signing_payload(&cert.address, cert.not_after), - &cert.signature, - ) - .unwrap_or(false) -} diff --git a/crates/offline-protocol/src/protocol/mod.rs b/crates/offline-protocol/src/protocol/mod.rs index 9cff07b3..7e7694b1 100644 --- a/crates/offline-protocol/src/protocol/mod.rs +++ b/crates/offline-protocol/src/protocol/mod.rs @@ -10,7 +10,6 @@ pub(crate) mod data; mod data_sync; mod decryption_queue; mod ephemeral; -mod membership; pub(crate) mod mesh_relay; mod message_dispatch; mod nostr_discovery; @@ -998,11 +997,6 @@ pub struct OfflineProtocol { /// lease, and a restart ends it on both sides. pub(crate) service_subscriptions: service_subscriptions::ServiceSubscriptions, - /// This node's side of the application membership exchange (ADR 0037): - /// the application key, its own certificate, one exchange per neighbour - /// and the peers verified as members. In memory only. - pub(crate) membership: membership::Membership, - /// Bundled state for mesh group messaging (member cache, dedup, pending commits). pub(crate) group_mesh: crate::group_mesh::GroupMeshState, @@ -1251,7 +1245,6 @@ impl OfflineProtocol { let telemetry_fallback_secret = *uuid::Uuid::new_v4().as_bytes(); let service_subscriptions = service_subscriptions::ServiceSubscriptions::new(&config.service_subscriptions); - let membership = membership::Membership::new(config.app_publisher_key, &config.app_id); Ok(Self { transport_manager, @@ -1373,7 +1366,6 @@ impl OfflineProtocol { restored_media_descriptors: HashMap::new(), mesh_services: MeshServices::new(), service_subscriptions, - membership, group_mesh: crate::group_mesh::GroupMeshState::default(), blocked_users: HashSet::new(), last_reconciliation_at: None, @@ -2536,11 +2528,6 @@ impl OfflineProtocol { self.known_peers.insert(peer_id.to_string(), Instant::now()); } - // The membership exchange (ADR 0037). Every node runs it, so a - // configured node and an unconfigured one look alike; it only puts - // frames on a mesh link, and decides nothing below yet. - self.start_member_exchange(peer_id); - // Flush any pending outbox messages destined for this peer self.flush_outbox_for_peer_via(peer_id, unpark_via); @@ -3699,13 +3686,12 @@ impl OfflineProtocol { } // A step of the application membership exchange (ADR 0037). Past the - // control gate like every signed frame. Always consumed, never - // delivered as text, and never acknowledged: a hello is answered by - // every node at one size whatever the result, and a response by - // none, so nothing observable depends on whether this node is a - // member. - if let Some(body) = content.strip_prefix(internal_prefixes::MEMBER) { - self.handle_member_frame(message, body, signed, arrival_transport); + // control gate like every signed frame. Reserved before the exchange + // exists, so it is consumed and nothing else: never delivered as + // text, and never answered, since an answer would depend on whether + // this node is a member and the exchange's privacy is that nothing + // observable does. + if content.starts_with(internal_prefixes::MEMBER) { return Some(InternalMessageResult::Consumed); } diff --git a/crates/offline-protocol/src/protocol/tests/custody.rs b/crates/offline-protocol/src/protocol/tests/custody.rs index 51b5b11a..850e5384 100644 --- a/crates/offline-protocol/src/protocol/tests/custody.rs +++ b/crates/offline-protocol/src/protocol/tests/custody.rs @@ -259,10 +259,6 @@ fn topology(custody: CustodyConfig) -> (Node, Node, Node) { bob.link(&alice); // Bob knows alice parses receipts, as her key package would have said. bob.protocol.peer_data_custody.insert(alice.address.clone()); - // Linking makes bob say hello for the membership exchange (ADR 0037), - // which every node does on discovery. Not what these tests watch: they - // ask what bob sends because of a deposit. - bob.take_peer_sends(); (alice, bob, carol) } diff --git a/crates/offline-protocol/src/protocol/tests/membership.rs b/crates/offline-protocol/src/protocol/tests/membership.rs deleted file mode 100644 index 934e19fc..00000000 --- a/crates/offline-protocol/src/protocol/tests/membership.rs +++ /dev/null @@ -1,765 +0,0 @@ -//! The application membership exchange over the real send and receive paths -//! (ADR 0037, step 5 of the shared network plan). -//! -//! Every frame goes through signing, the mock BLE link, the control gate and -//! the prefix dispatch. What is pinned is what each node concludes and what -//! a neighbour can observe on the link: that every node sends the same -//! frames at the same sizes whatever it is, that only two members of one -//! application see each other, and that nothing leaves the link. - -use std::sync::Arc; - -use chrono::Utc; -use ed25519_dalek::{Signer, SigningKey}; -use offline_protocol_core::{Address, HopCount, Message}; -use offline_protocol_sealed::membership::{ - MemberCert, MemberFrame, MEMBER_HELLO_LEN, MEMBER_SEALED_LEN, -}; -use offline_protocol_transport::{MockTransport, Transport, TransportType}; - -use crate::mls::InMemoryStorage; -use crate::protocol::prefixes::internal_prefixes; -use crate::protocol::tests::{create_test_config_for_user, id}; -use crate::protocol::{OfflineProtocol, TestProtocolStateStorage}; -use crate::ProtocolConfig; - -const APP: &str = "com.example.chat"; - -/// A publisher key pair, seeded so each test's keys are reproducible. -fn publisher(seed: u8) -> SigningKey { - SigningKey::from_bytes(&[seed; 32]) -} - -/// A certificate `publisher` signs over `address`, current for a day. -fn certificate(publisher: &SigningKey, address: &str) -> MemberCert { - let address: Address = address.parse().expect("an address"); - let not_after = u64::try_from(Utc::now().timestamp()).unwrap() + 86_400; - let signature = publisher - .sign(&MemberCert::signing_payload(&address, not_after)) - .to_bytes(); - MemberCert { - address, - not_after, - signature, - } -} - -struct Node { - protocol: OfflineProtocol, - transport: MockTransport, - address: String, -} - -impl Node { - fn launch(label: &str, publisher_key: Option<[u8; 32]>) -> Self { - let mut config: ProtocolConfig = create_test_config_for_user(label); - config.app_id = APP.to_string(); - config.encryption.enabled = true; - // The key-package push on discovery is not what is under test; with - // it on, every link would also carry a session handshake. - config.encryption.auto_key_exchange = false; - config.app_publisher_key = publisher_key; - let mut protocol = OfflineProtocol::new(config).expect("protocol"); - let state = Arc::new(InMemoryStorage::new()); - protocol - .initialize_mls( - crate::test_identity::seeded_storage(label), - Arc::new(TestProtocolStateStorage { storage: state }), - ) - .expect("initialize_mls"); - let mock = MockTransport::new(TransportType::BLE); - mock.start().expect("transport start"); - mock.set_reject_unknown_recipients(true); - let transport = mock.clone(); - protocol - .transport_manager_mut() - .add_transport(TransportType::BLE, Box::new(mock)); - protocol.start().expect("start"); - Self { - protocol, - transport, - address: id(label), - } - } - - /// A member: configured with `publisher`'s key and holding a certificate - /// it signed. - fn member(label: &str, publisher: &SigningKey) -> Self { - let mut node = Self::launch(label, Some(publisher.verifying_key().to_bytes())); - let cert = certificate(publisher, &node.address); - node.protocol - .set_app_membership(&cert.to_bytes()) - .expect("the node's own certificate"); - node - } - - /// Puts `peer` in range, as discovery would. - fn link(&mut self, peer: &Node) { - self.transport.add_connected_peer(peer.address.clone(), -55); - self.protocol.on_neighbor_discovered(&peer.address); - } - - /// The membership frames handed to a neighbour since the last take. - fn take_member_frames(&mut self) -> Vec<(String, Message)> { - let sends = self.transport.peer_sends(); - self.transport.clear_peer_sends(); - sends - .into_iter() - .filter(|(_, m)| m.content.starts_with(internal_prefixes::MEMBER)) - .collect() - } - - fn receive_from(&mut self, message: Message, from: &str) { - self.transport.queue_message_from(message, from.to_string()); - while self.protocol.receive_message().is_some() {} - } - - fn sees(&self, peer: &Node) -> bool { - self.protocol.is_member_visible(&peer.address) - } -} - -/// The body of a membership frame, parsed. -fn frame(message: &Message) -> MemberFrame { - let body = message - .content - .strip_prefix(internal_prefixes::MEMBER) - .expect("a membership frame"); - MemberFrame::parse(body).expect("a well-formed membership frame") -} - -/// Every membership frame each node sent, delivered to its neighbour, until -/// neither has anything more to say. Returns what crossed the link, in order. -fn run(a: &mut Node, b: &mut Node) -> Vec<(String, Message)> { - let mut crossed = Vec::new(); - for _ in 0..8 { - let from_a = a.take_member_frames(); - let from_b = b.take_member_frames(); - if from_a.is_empty() && from_b.is_empty() { - return crossed; - } - for (to, message) in from_a { - assert_eq!( - to, b.address, - "a membership frame goes to the neighbour only" - ); - crossed.push((a.address.clone(), message.clone())); - b.receive_from(message, &a.address.clone()); - } - for (to, message) in from_b { - assert_eq!( - to, a.address, - "a membership frame goes to the neighbour only" - ); - crossed.push((b.address.clone(), message.clone())); - a.receive_from(message, &b.address.clone()); - } - } - panic!("the exchange did not settle"); -} - -fn meet(a: &mut Node, b: &mut Node) -> Vec<(String, Message)> { - a.link(b); - b.link(a); - run(a, b) -} - -/// What a neighbour sees on the link: per sender, the sequence of -/// (step, payload size). This is everything the exchange lets a stranger -/// observe, and it must not vary with membership. -fn shape(crossed: &[(String, Message)], sender: &str) -> Vec<(u8, usize)> { - crossed - .iter() - .filter(|(from, _)| from == sender) - .map(|(_, m)| match frame(m) { - MemberFrame::Hello(_) => (1, MEMBER_HELLO_LEN), - MemberFrame::Response(sealed) => (2, sealed.len()), - }) - .collect() -} - -#[test] -fn two_members_of_one_application_see_each_other() { - let key = publisher(1); - let mut alice = Node::member("alice", &key); - let mut bob = Node::member("bob", &key); - - meet(&mut alice, &mut bob); - - assert!(alice.sees(&bob)); - assert!(bob.sees(&alice)); -} - -#[test] -fn members_of_two_applications_see_nothing_and_look_alike() { - let mut alice = Node::member("alice", &publisher(1)); - let mut bob = Node::member("bob", &publisher(2)); - let crossed = meet(&mut alice, &mut bob); - - assert!(!alice.sees(&bob)); - assert!(!bob.sees(&alice)); - - // The same pair under one application: the link must look identical. - let key = publisher(1); - let mut carol = Node::member("carol", &key); - let mut dave = Node::member("dave", &key); - let crossed_members = meet(&mut carol, &mut dave); - assert!(carol.sees(&dave)); - - let expected = vec![(1, MEMBER_HELLO_LEN), (2, MEMBER_SEALED_LEN)]; - assert_eq!(shape(&crossed, &alice.address), expected); - assert_eq!(shape(&crossed, &bob.address), expected); - assert_eq!(shape(&crossed_members, &carol.address), expected); - assert_eq!(shape(&crossed_members, &dave.address), expected); -} - -#[test] -fn an_unconfigured_node_runs_the_exchange_at_the_same_sizes() { - let mut alice = Node::member("alice", &publisher(1)); - let mut bob = Node::launch("bob", None); - let crossed = meet(&mut alice, &mut bob); - - assert!(!alice.sees(&bob)); - assert!(!bob.sees(&alice)); - let expected = vec![(1, MEMBER_HELLO_LEN), (2, MEMBER_SEALED_LEN)]; - assert_eq!(shape(&crossed, &bob.address), expected); - assert_eq!(shape(&crossed, &alice.address), expected); -} - -/// Party 3 of the attacker table: it holds the application's binary, so the -/// application key, but no certificate. It recognises a member, which the -/// ADR accepts, and is never recognised as one. -#[test] -fn a_node_with_the_key_and_no_certificate_is_never_visible() { - let key = publisher(1); - let mut alice = Node::member("alice", &key); - let mut mallory = Node::launch("mallory", Some(key.verifying_key().to_bytes())); - meet(&mut alice, &mut mallory); - - assert!(!alice.sees(&mallory)); - assert!( - mallory.sees(&alice), - "recognising a member is party 3's residual" - ); -} - -/// A member's certificate presented by another node: it opens, since the -/// presenter holds the application key, and still fails the address check. -#[test] -fn a_certificate_for_another_address_does_not_make_its_presenter_visible() { - let key = publisher(1); - let mut alice = Node::member("alice", &key); - let mut mallory = Node::launch("mallory", Some(key.verifying_key().to_bytes())); - mallory - .protocol - .set_app_membership_unchecked(certificate(&key, &id("carol"))); - meet(&mut alice, &mut mallory); - - assert!(!alice.sees(&mallory)); -} - -#[test] -fn every_membership_frame_stays_on_the_link() { - let key = publisher(1); - let mut alice = Node::member("alice", &key); - let mut bob = Node::member("bob", &key); - let crossed = meet(&mut alice, &mut bob); - - assert_eq!(crossed.len(), 4, "one hello and one response each way"); - for (_, message) in &crossed { - assert!( - !message.requires_ack, - "an acknowledgement would be an answer" - ); - assert_eq!(message.ttl.value(), 1, "no forwarder may pass it on"); - assert_eq!(message.hop_count.value(), 0); - } -} - -/// A hello that reaches this node any way but straight over a mesh link is -/// consumed and never answered: a relay mailbox would hold it for days, and -/// a forwarded one is not from a neighbour. -#[test] -fn a_hello_that_did_not_come_over_the_link_is_not_answered() { - let key = publisher(1); - let mut alice = Node::member("alice", &key); - let mut bob = Node::member("bob", &key); - alice.link(&bob); - let (_, hello) = alice.take_member_frames().remove(0); - bob.transport.add_connected_peer(alice.address.clone(), -55); - - let mut forwarded = hello.clone(); - forwarded.hop_count = HopCount::from_value(1); - bob.receive_from(forwarded, &alice.address); - assert!( - bob.take_member_frames().is_empty(), - "a forwarded hello is not answered" - ); - - assert!(bob - .protocol - .process_internal_message_via(&hello, Some(TransportType::Internet)) - .is_some()); - assert!( - bob.take_member_frames().is_empty(), - "a hello over the internet is not answered" - ); - - assert!(bob - .protocol - .process_internal_message_via(&hello, None) - .is_some()); - assert!( - bob.take_member_frames().is_empty(), - "a hello of unknown arrival is not answered" - ); -} - -/// A recorded hello replayed: answering it again would seal a second -/// plaintext under a key already used. -#[test] -fn a_replayed_hello_is_answered_once() { - let key = publisher(1); - let mut alice = Node::member("alice", &key); - let mut bob = Node::member("bob", &key); - alice.link(&bob); - bob.transport.add_connected_peer(alice.address.clone(), -55); - let (_, hello) = alice.take_member_frames().remove(0); - - bob.receive_from(hello.clone(), &alice.address); - let first = bob.take_member_frames(); - assert_eq!(first.len(), 2, "bob's own hello, then the answer"); - - // The same body in a fresh, validly signed frame: what the deduplicator - // does not catch, since the id is new. - let mut replay = hello; - replay.id = offline_protocol_core::MessageId::new(); - replay.metadata.clear(); - { - let manager = alice.protocol.mls_manager.as_ref().unwrap().read().unwrap(); - OfflineProtocol::sign_control_message_with(&mut replay, &manager, false).unwrap(); - } - bob.receive_from(replay, &alice.address); - assert!(bob.take_member_frames().is_empty()); -} - -/// The link delivers the peer's response ahead of its hello: it is held, and -/// the exchange still completes once the hello arrives. -#[test] -fn a_response_ahead_of_its_hello_still_completes() { - let key = publisher(1); - let mut alice = Node::member("alice", &key); - let mut bob = Node::member("bob", &key); - alice.link(&bob); - bob.transport.add_connected_peer(alice.address.clone(), -55); - - // Bob hears alice's hello and answers with his own hello and response. - let (_, alice_hello) = alice.take_member_frames().remove(0); - bob.receive_from(alice_hello, &alice.address); - let mut from_bob = bob.take_member_frames(); - assert_eq!(from_bob.len(), 2); - let (_, bob_response) = from_bob.pop().unwrap(); - let (_, bob_hello) = from_bob.pop().unwrap(); - assert!(matches!(frame(&bob_hello), MemberFrame::Hello(_))); - - alice.receive_from(bob_response, &bob.address); - assert!(!alice.sees(&bob), "nothing to open it with yet"); - alice.receive_from(bob_hello, &bob.address); - assert!(alice.sees(&bob)); -} - -#[test] -fn a_peer_that_restarts_is_recognised_again() { - let key = publisher(1); - let mut alice = Node::member("alice", &key); - let mut bob = Node::member("bob", &key); - meet(&mut alice, &mut bob); - assert!(alice.sees(&bob)); - - // Bob restarts: same identity, no memory of the exchange. - let mut bob = Node::member("bob", &key); - bob.link(&alice); - alice.transport.add_connected_peer(bob.address.clone(), -55); - run(&mut alice, &mut bob); - assert!(bob.sees(&alice)); -} - -#[test] -fn a_rediscovery_within_the_interval_sends_nothing() { - let key = publisher(1); - let mut alice = Node::member("alice", &key); - let mut bob = Node::member("bob", &key); - meet(&mut alice, &mut bob); - - alice.protocol.on_neighbor_discovered(&bob.address); - assert!(alice.take_member_frames().is_empty()); -} - -/// Past the interval a rediscovery runs the exchange again, for a member -/// exactly as for a stranger. Skipping it for a peer already visible would -/// make the hello an answer to "is that address a member here", readable by -/// anyone who presents a recorded address on a link of its own. -#[test] -fn a_member_rediscovered_after_the_interval_is_greeted_like_anyone() { - let key = publisher(1); - let mut alice = Node::member("alice", &key); - let mut bob = Node::member("bob", &key); - let mut carol = Node::member("carol", &publisher(2)); - meet(&mut alice, &mut bob); - meet(&mut alice, &mut carol); - assert!(alice.sees(&bob)); - assert!(!alice.sees(&carol)); - - alice.protocol.expire_member_exchanges(); - alice.protocol.on_neighbor_discovered(&bob.address); - alice.protocol.on_neighbor_discovered(&carol.address); - let sent = alice.take_member_frames(); - let hello_to = |peer: &Node| { - sent.iter() - .filter(|(to, m)| *to == peer.address && matches!(frame(m), MemberFrame::Hello(_))) - .count() - }; - assert_eq!(hello_to(&bob), 1, "a visible member is greeted again"); - assert_eq!(hello_to(&carol), 1, "as a stranger is"); - - // And the member stays recognised through the new exchange. - for (to, message) in sent { - if to == bob.address { - bob.receive_from(message, &alice.address); - } - } - run(&mut alice, &mut bob); - assert!(alice.sees(&bob)); - assert!(bob.sees(&alice)); -} - -/// A certificate the publisher signed for the presenter's own address, but -/// expired: it opens and verifies, and still does not make its presenter -/// visible. -/// -/// Two checks hold this: the exchange refuses an expired certificate, and -/// `is_member_visible` compares the cached `not_after` with the clock. Either -/// alone keeps the test green, and removing both fails it; the second is what -/// ages out a certificate that was current when it verified. -#[test] -fn an_expired_certificate_in_the_exchange_is_not_visible() { - let key = publisher(1); - let mut alice = Node::member("alice", &key); - let mut mallory = Node::launch("mallory", Some(key.verifying_key().to_bytes())); - let address: Address = mallory.address.parse().unwrap(); - mallory.protocol.set_app_membership_unchecked(MemberCert { - address: address.clone(), - not_after: 1, - signature: key - .sign(&MemberCert::signing_payload(&address, 1)) - .to_bytes(), - }); - meet(&mut alice, &mut mallory); - - assert!(!alice.sees(&mallory)); - assert!( - mallory.sees(&alice), - "the positive control: the pair did run" - ); -} - -/// The application's backend issues the certificate after the node has met -/// its neighbours. Supplying it starts a fresh exchange with each of them, so -/// they learn now rather than on some later discovery. -#[test] -fn a_certificate_supplied_after_meeting_is_presented_at_once() { - let key = publisher(1); - let mut alice = Node::member("alice", &key); - let mut bob = Node::launch("bob", Some(key.verifying_key().to_bytes())); - meet(&mut alice, &mut bob); - assert!(!alice.sees(&bob), "no certificate yet"); - - let cert = certificate(&key, &bob.address); - bob.protocol - .set_app_membership(&cert.to_bytes()) - .expect("bob's own certificate"); - run(&mut alice, &mut bob); - - assert!(alice.sees(&bob)); - assert!(bob.sees(&alice)); -} - -#[test] -fn set_app_membership_refuses_what_would_never_verify() { - let key = publisher(1); - let mut alice = Node::launch("alice", Some(key.verifying_key().to_bytes())); - - let other = certificate(&key, &id("bob")); - assert!(alice - .protocol - .set_app_membership(&other.to_bytes()) - .is_err()); - - let wrong_publisher = certificate(&publisher(2), &alice.address); - assert!(alice - .protocol - .set_app_membership(&wrong_publisher.to_bytes()) - .is_err()); - - let address: Address = alice.address.parse().unwrap(); - let expired = MemberCert { - address: address.clone(), - not_after: 1, - signature: key - .sign(&MemberCert::signing_payload(&address, 1)) - .to_bytes(), - }; - assert!(alice - .protocol - .set_app_membership(&expired.to_bytes()) - .is_err()); - - assert!(alice.protocol.set_app_membership(&[0u8; 10]).is_err()); - - let own = certificate(&key, &alice.address); - assert!(alice.protocol.set_app_membership(&own.to_bytes()).is_ok()); - - let mut unconfigured = Node::launch("carol", None); - let carol_cert = certificate(&key, &unconfigured.address); - assert!(matches!( - unconfigured - .protocol - .set_app_membership(&carol_cert.to_bytes()), - Err(crate::Error::InvalidConfiguration(_)) - )); -} - -// --- Frames built by hand ---------------------------------------------------- -// -// The tests below build the peer's frames from the chapter's construction with -// the primitives called directly, rather than by running a second engine. That -// does two things: it lets a test send what an honest engine never would, and -// it pins the engine's HKDF inputs and its AEAD (zero nonce, no associated -// data) against an independent reading of the spec, which the vector file -// cannot do because it does not pin the AEAD output. - -mod by_hand { - use super::*; - use chacha20poly1305::aead::{Aead, KeyInit}; - use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; - use hmac::Mac; - use offline_protocol_sealed::membership::{ - app_key_info, exchange_key_info, exchange_salt, hello_tag_input, - transcript_signing_payload, MemberHello, MEMBER_NONCE_LEN, - }; - use sha2::Sha256; - - fn hkdf(ikm: &[u8], salt: Option<&[u8]>, info: &[u8]) -> [u8; 32] { - let mut out = [0u8; 32]; - hkdf::Hkdf::::new(salt, ikm) - .expand(info, &mut out) - .unwrap(); - out - } - - fn k_app(publisher: &SigningKey) -> [u8; 32] { - hkdf( - &publisher.verifying_key().to_bytes(), - None, - &app_key_info(APP), - ) - } - - fn addr(node: &Node) -> Address { - node.address.parse().unwrap() - } - - fn tag(k_app: &[u8; 32], nonce: &[u8; 16], from: &Node, to: &Node) -> [u8; 16] { - let mut mac = as Mac>::new_from_slice(k_app).unwrap(); - mac.update(&hello_tag_input(nonce, &addr(from), &addr(to))); - mac.finalize().into_bytes()[..16].try_into().unwrap() - } - - /// A membership frame from `from` to `to`, signed by `from`'s identity as - /// its own engine would sign it. - fn signed_frame(from: &mut Node, to: &Node, frame: &MemberFrame) -> Message { - let content = format!("{}{}", internal_prefixes::MEMBER, frame.to_body()); - let mut message = from - .protocol - .create_message(to.address.clone(), content, None, None) - .unwrap(); - message.requires_ack = false; - message.ttl = offline_protocol_core::TTL::from_value(1); - let manager = from.protocol.mls_manager.as_ref().unwrap().read().unwrap(); - OfflineProtocol::sign_control_message_with(&mut message, &manager, false).unwrap(); - message - } - - fn identity_sign(node: &Node, payload: &[u8]) -> Vec { - let manager = node.protocol.mls_manager.as_ref().unwrap().read().unwrap(); - manager.sign_data(payload).unwrap() - } - - fn hello_nonce(message: &Message) -> [u8; MEMBER_NONCE_LEN] { - match frame(message) { - MemberFrame::Hello(hello) => hello.nonce, - MemberFrame::Response(_) => panic!("expected a hello"), - } - } - - /// Carol, a member, has said hello to alice; alice's side is built by - /// hand. `peer_nonce_signed` is the nonce of carol's that alice's - /// transcript signature names. Returns whether carol sees alice. - fn carol_sees_a_hand_built_alice(peer_nonce_signed: Option<[u8; 16]>) -> bool { - let key = publisher(1); - let k_app = k_app(&key); - let mut carol = Node::member("carol", &key); - let mut alice = Node::member("alice", &key); - carol.link(&alice); - let (_, carol_hello) = carol.take_member_frames().remove(0); - let nonce_c = hello_nonce(&carol_hello); - - let nonce_a = [9u8; 16]; - let hello = MemberFrame::Hello(MemberHello { - nonce: nonce_a, - tag: tag(&k_app, &nonce_a, &alice, &carol), - }); - - let salt = exchange_salt(&addr(&alice), &nonce_a, &addr(&carol), &nonce_c).unwrap(); - let sealing = hkdf(&k_app, Some(&salt), &exchange_key_info(&addr(&alice))); - let transcript = transcript_signing_payload( - &addr(&alice), - &nonce_a, - &addr(&carol), - &peer_nonce_signed.unwrap_or(nonce_c), - ); - let mut plaintext = certificate(&key, &alice.address).to_bytes().to_vec(); - plaintext.extend_from_slice(&identity_sign(&alice, &transcript)); - let sealed = ChaCha20Poly1305::new(Key::from_slice(&sealing)) - .encrypt(Nonce::from_slice(&[0u8; 12]), plaintext.as_slice()) - .unwrap(); - let response = MemberFrame::Response(sealed.try_into().unwrap()); - - let hello = signed_frame(&mut alice, &carol, &hello); - let response = signed_frame(&mut alice, &carol, &response); - carol.receive_from(hello, &alice.address); - carol.receive_from(response, &alice.address); - carol.sees(&alice) - } - - /// The positive control: a response built from the chapter alone, with - /// no associated data, is one the engine accepts. - #[test] - fn a_response_built_from_the_chapter_is_accepted() { - assert!(carol_sees_a_hand_built_alice(None)); - } - - /// The transcript signature is what ties a certificate to this exchange: - /// a credential whose signature names another nonce of the peer's, as one - /// recorded from an earlier exchange would, is refused. - #[test] - fn a_credential_signed_for_another_exchange_is_refused() { - assert!(!carol_sees_a_hand_built_alice(Some([0u8; 16]))); - } - - /// Carol's answer to a hello from alice: whether it opens under the key - /// a holder of `K_app` derives for it. `tag_ok` is whether alice's hello - /// carried the right tag. - fn carol_answer_opens(tag_ok: bool) -> bool { - let key = publisher(1); - let k_app = k_app(&key); - let mut carol = Node::member("carol", &key); - let mut alice = Node::member("alice", &key); - carol - .transport - .add_connected_peer(alice.address.clone(), -55); - - let nonce_a = [7u8; 16]; - let hello = MemberFrame::Hello(MemberHello { - nonce: nonce_a, - tag: if tag_ok { - tag(&k_app, &nonce_a, &alice, &carol) - } else { - [0u8; 16] - }, - }); - let hello = signed_frame(&mut alice, &carol, &hello); - carol.receive_from(hello, &alice.address); - let mut sent = carol.take_member_frames(); - assert_eq!(sent.len(), 2, "carol's own hello, then the answer"); - let (_, answer) = sent.pop().unwrap(); - let (_, carol_hello) = sent.pop().unwrap(); - let nonce_c = hello_nonce(&carol_hello); - let MemberFrame::Response(sealed) = frame(&answer) else { - panic!("expected a response"); - }; - let salt = exchange_salt(&addr(&carol), &nonce_c, &addr(&alice), &nonce_a).unwrap(); - let opening = hkdf(&k_app, Some(&salt), &exchange_key_info(&addr(&carol))); - ChaCha20Poly1305::new(Key::from_slice(&opening)) - .decrypt(Nonce::from_slice(&[0u8; 12]), sealed.as_slice()) - .is_ok() - } - - /// A peer that keeps sending fresh nonces is answered under one nonce of - /// this node's for eight of them, and the ninth starts this node over - /// with a fresh nonce, so the answered set stays bounded and no key - /// repeats. - #[test] - fn the_ninth_fresh_hello_restarts_with_a_fresh_nonce() { - let key = publisher(1); - let k_app = k_app(&key); - let mut carol = Node::member("carol", &key); - let mut alice = Node::member("alice", &key); - carol - .transport - .add_connected_peer(alice.address.clone(), -55); - - let mut carol_nonces = Vec::new(); - for i in 0..9u8 { - let nonce = [i + 1; 16]; - let hello = MemberFrame::Hello(MemberHello { - nonce, - tag: tag(&k_app, &nonce, &alice, &carol), - }); - let hello = signed_frame(&mut alice, &carol, &hello); - carol.receive_from(hello, &alice.address); - let sent = carol.take_member_frames(); - assert_eq!(sent.len(), 2, "hello {i}: carol's hello, then the answer"); - assert!(matches!(frame(&sent[1].1), MemberFrame::Response(_))); - carol_nonces.push(hello_nonce(&sent[0].1)); - } - assert!( - carol_nonces[..8].iter().all(|n| *n == carol_nonces[0]), - "the same nonce while the peer restarts, so the two settle" - ); - assert_ne!(carol_nonces[8], carol_nonces[0], "a fresh one past eight"); - } - - /// A member seals its credential only for a hello whose tag proves the - /// application key on this link. Anything else gets bytes no key opens. - #[test] - fn a_member_reveals_its_credential_only_to_a_hello_whose_tag_verifies() { - assert!(carol_answer_opens(true), "the positive control"); - assert!(!carol_answer_opens(false)); - } -} - -/// An unsigned hello carries no identity key, so its response could never be -/// checked and its sender is only a claim: it is not answered. -/// -/// The control gate refuses it before the handler runs, so this pins the -/// observable outcome. The handler's own refusal of an unsigned frame is a -/// second line behind the gate, and removing it alone changes nothing here. -#[test] -fn an_unsigned_hello_is_not_answered() { - let key = publisher(1); - let mut alice = Node::member("alice", &key); - let mut bob = Node::member("bob", &key); - alice.link(&bob); - bob.transport.add_connected_peer(alice.address.clone(), -55); - let (_, mut hello) = alice.take_member_frames().remove(0); - let signed = hello.clone(); - hello.metadata.clear(); - - bob.receive_from(hello, &alice.address); - assert!(bob.take_member_frames().is_empty()); - - // The positive control: the same hello, signed, is answered. - bob.receive_from(signed, &alice.address); - assert_eq!(bob.take_member_frames().len(), 2); -} diff --git a/crates/offline-protocol/src/protocol/tests/mod.rs b/crates/offline-protocol/src/protocol/tests/mod.rs index f605f20a..a07c1302 100644 --- a/crates/offline-protocol/src/protocol/tests/mod.rs +++ b/crates/offline-protocol/src/protocol/tests/mod.rs @@ -9,7 +9,6 @@ mod ephemeral; #[cfg(feature = "file-store")] mod file_stores; mod leaf_pairing; -mod membership; mod mesh_session; mod nostr_cold_contact; mod per_send_app_id; @@ -23586,6 +23585,45 @@ fn a_membership_frame_is_consumed_and_never_answered() { ); } +/// No node sends `__MEMBER__` on its own, and above all not on discovery. +/// +/// A build that does not reserve the prefix rejects the frame with a security +/// warning to its application, or shows it to the user as a text message when +/// it accepts plaintext, and such builds are in the field. ADR 0037 is parked; +/// whoever resumes it sends the hello only to a neighbour that advertised +/// support (`docs/spec/control-messages.md`, "Application membership"), and +/// this test is the reminder. +#[test] +fn discovering_a_neighbour_sends_no_membership_frame() { + let mut protocol = OfflineProtocol::new(create_test_config()).unwrap(); + let mock_transport = MockTransport::new(TransportType::BLE); + mock_transport.start().unwrap(); + let radio = mock_transport.clone(); + protocol + .transport_manager_mut() + .add_transport(TransportType::BLE, Box::new(mock_transport)); + protocol.start().unwrap(); + + let neighbour = id("alice"); + radio.add_connected_peer(neighbour.clone(), -55); + protocol.on_neighbor_discovered(&neighbour); + protocol.on_neighbor_lost(&neighbour); + protocol.on_neighbor_discovered(&neighbour); + + let member_frames: Vec = radio + .peer_sends() + .into_iter() + .map(|(_, message)| message.content) + .chain(radio.sent_messages().into_iter().map(|m| m.content)) + .filter(|content| content.starts_with(internal_prefixes::MEMBER)) + .collect(); + assert!( + member_frames.is_empty(), + "discovery put a membership frame on the air, which an older build \ + rejects or shows as text: {member_frames:?}" + ); +} + #[test] fn test_send_message_allows_normal_content() { let mut protocol = OfflineProtocol::new(create_test_config()).unwrap(); diff --git a/docs/adr/0037-an-applications-users-are-visible-only-to-that-application.md b/docs/adr/0037-an-applications-users-are-visible-only-to-that-application.md index 1591c1a0..7c6bbd13 100644 --- a/docs/adr/0037-an-applications-users-are-visible-only-to-that-application.md +++ b/docs/adr/0037-an-applications-users-are-visible-only-to-that-application.md @@ -1,6 +1,39 @@ # 0037. An application's users are visible only to that application -**Status:** Proposed +**Status:** Proposed. Parked on 2026-11-04: build it when a customer needs it. + +## Status note: why this is parked + +The invariant stands: carriage is never gated, only visibility. What is +parked is this mechanism. Only the reserved `__MEMBER__` prefix and the frame +layouts in `offline-protocol-sealed` are on `main`; no engine runs the +exchange. A review found the cost too high for a property no customer has +asked for yet: + +- **The recognition key ships in every copy of the application.** `K_app` is + derived from the publisher public key and the application id, and both are + in the binary. Anyone who installs the application can recognise its users, + so the protection is weaker than the decision below claims. +- **It fails closed while offline, and silently.** A certificate that expires + offline, or a clock that drifts past the discovery window, splits a group + with nothing shown to the user. Turning it on also splits an application's + users until every install has updated. +- **Older builds misread the frame.** A build that does not reserve + `__MEMBER__` rejects every hello with a security warning, or shows it as a + text message when `require_encryption` is off, and applications in the + field ship such builds. +- **Every new neighbour costs a signature, a seal and two frames.** On cheap + phones in a crowd that is real CPU and battery, and a configured + application's discovery stops working over the internet. + +If it is picked up again, these change first: + +1. The backend issues the recognition key with the certificate, instead of + every install deriving it from public values. +2. A node sends `__MEMBER__` only to a neighbour that advertised support in + its key package ([control messages](../spec/control-messages.md#application-membership)). +3. It rolls out over two releases: send proofs first, enforce them later. +4. Embedded-key mode is for managed devices only. ## Context diff --git a/docs/spec/control-messages.md b/docs/spec/control-messages.md index 16638895..37cbfbb0 100644 --- a/docs/spec/control-messages.md +++ b/docs/spec/control-messages.md @@ -315,6 +315,14 @@ to its application as text; the release that reserves it is the floor for sharing a radio neighbourhood cleanly. A receiver that reserves the prefix and does not run the exchange MUST consume the frame and MUST NOT answer it. +No engine sends this frame today: the decision is parked (see the ADR's +status). An implementation that does send it MUST first know that the +neighbour reserves the prefix, from a capability the neighbour advertised in +its key package, and MUST NOT send it to any other neighbour. A build that +does not reserve `__MEMBER__` rejects the frame as plaintext and raises a +security warning to its application under the default encryption policy, and +shows it to its user as a text message when `require_encryption` is off. + The layouts are owned by `offline-protocol-sealed` (`membership`), and the vectors in `crates/offline-protocol-sealed/tests/data/app-membership-v1.vectors.json` pin them. Every field has a fixed size and an address is always its 21-byte