From bc9d9777cd632238c3c112cfe45da0d6557066f3 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Wed, 4 Nov 2026 21:58:16 +0530 Subject: [PATCH 1/2] feat(services): sealed, proven service discovery for a configured node Step 7c of the shared network plan (ADR 0037, "Service discovery"), the services half. - The sealed layer gains the discovery constants, in their one home (ADR 0022): the body key label `offline-app-svc-v1`, the 16-byte salt, the sentinel `__sealed` a configured node writes in the cleartext `service_id` (a service id may not begin with `__`, so no provider can register it), and `discovery_bound_body`, the bytes a discovery proof binds: `u16be(len(query_id)) || query_id || originator(21) || sealed`. Pinned by a new `discovery` block in the membership vectors, and the discovery query vector now binds that body. no_std clean. - `DiscoverySealer`: what the engine lends a configured node (the application key, the certificate, the identity key, a clock), so this crate links no cipher and knows nothing of MLS. - With a sealer, a query carries only `query_id`, `originator` and the hop budget in the clear; the service asked for, `issued_at` and the originator's identity key travel sealed, with a proof of kind 4. A provider answers only a proven query, only while `|now - issued_at|` is within the 60 s lifetime, once per `query_id`, addressed to the originator. An originator reports only a response whose sealed provider is its sender and whose proof verifies. Forwarding is unchanged either way, and the sealed fields ride through every forwarder, configured or not. - Without a sealer every frame is byte for byte what it was. The proof travels in a `member_proof` payload field, not frame metadata: every forwarder builds a new frame, so metadata would not survive a hop. --- Cargo.lock | 2 + .../offline-protocol-sealed/src/membership.rs | 148 +++++- .../tests/data/app-membership-v1.vectors.json | 24 +- crates/offline-protocol-services/Cargo.toml | 6 + crates/offline-protocol-services/src/lib.rs | 7 + .../offline-protocol-services/src/payloads.rs | 23 +- .../offline-protocol-services/src/sealing.rs | 301 ++++++++++++ .../src/sealing_tests.rs | 451 ++++++++++++++++++ .../offline-protocol-services/src/services.rs | 333 +++++++++++-- tools/spec-vectors/generate.py | 35 +- 10 files changed, 1273 insertions(+), 57 deletions(-) create mode 100644 crates/offline-protocol-services/src/sealing.rs create mode 100644 crates/offline-protocol-services/src/sealing_tests.rs diff --git a/Cargo.lock b/Cargo.lock index b614cc774..bf70efcfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2669,7 +2669,9 @@ dependencies = [ name = "offline-protocol-services" version = "0.31.0" dependencies = [ + "base64 0.22.1", "offline-protocol-core", + "offline-protocol-sealed", "serde", "serde_json", "thiserror 2.0.20", diff --git a/crates/offline-protocol-sealed/src/membership.rs b/crates/offline-protocol-sealed/src/membership.rs index db8bfbd41..d76cec4a1 100644 --- a/crates/offline-protocol-sealed/src/membership.rs +++ b/crates/offline-protocol-sealed/src/membership.rs @@ -172,6 +172,64 @@ pub const MEMBER_PROOF_META_KEY: &str = "__member_proof"; /// this exists to blank. pub const WIRE_APP_ID: &str = "offline"; +/// HKDF `info` for the key a configured node seals a service discovery body +/// under: `k = HKDF-SHA256(ikm = K_app, salt, info)`, with a fresh +/// [`DISCOVERY_SALT_LEN`]-byte salt per body. +/// +/// Apart from every other label here, so a discovery body key is never also +/// a proof key or an exchange key: the same salt under one label would make +/// the two one key, and both seal under a zero nonce. +pub const DISCOVERY_SEAL_INFO: &[u8] = b"offline-app-svc-v1"; + +/// Length of a sealed discovery body's salt, in bytes. The body travels as +/// `salt || ChaCha20-Poly1305(k, nonce = 0, aad = empty, plaintext)`. +pub const DISCOVERY_SALT_LEN: usize = 16; + +/// What a configured node writes in the cleartext `service_id` of a +/// discovery query and response, whose real fields travel sealed. +/// +/// It names no service: it begins with `__`, which a service id may not +/// (`offline_protocol_core::ServiceId` refuses it), so no provider can +/// register it and an unconfigured provider matches nothing against it. The +/// field cannot simply be left out: an absent `service_id` in a query means +/// "every service" to an unconfigured provider, which would then answer +/// every configured query with its whole service list. It does not end in +/// `__`, so it is not shaped like a reserved frame prefix and the prefix +/// registry never reads it as one. +pub const DISCOVERY_SEALED_SERVICE_ID: &str = "__sealed"; + +/// The bytes an end-to-end proof binds for a service discovery query +/// ([`ProofKind::DiscoveryQuery`]) or response +/// ([`ProofKind::DiscoveryResponse`]), passed as `frame_body` to +/// [`e2e_proof_signing_payload`]: +/// `u16be(len(query_id)) || query_id || originator(21) || sealed`. +/// +/// A discovery frame is re-serialised at every hop (its hop budget goes down +/// by one), so its content is not a stable body to hash; these are the +/// fields no hop changes. `query_id` is the one variable field before the +/// end and carries its length, and `sealed` (the body's salt and +/// ciphertext, which covers the service, the provider and the originator's +/// clock) is last, so the layout has one parse. The hop budget is not bound: +/// it is a routing field every forwarder rewrites. +/// +/// # Errors +/// +/// [`SealedError::Deserialization`] for a `query_id` longer than +/// `u16::MAX` bytes. +pub fn discovery_bound_body( + query_id: &str, + originator: &Address, + sealed: &[u8], +) -> Result> { + let len = u16::try_from(query_id.len()).map_err(|_| invalid("query id too long"))?; + let mut out = Vec::with_capacity(2 + query_id.len() + Address::PAYLOAD_LEN + sealed.len()); + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(query_id.as_bytes()); + out.extend_from_slice(&originator.payload()); + out.extend_from_slice(sealed); + Ok(out) +} + /// The version byte every `__MEMBER__` frame body begins with. pub const MEMBER_FRAME_VERSION: u8 = 1; @@ -516,11 +574,12 @@ pub enum ProofKind { /// proof: `MEMBER_E2E_DOMAIN || recipient(21) || kind(1) || /// SHA-256(frame_body)(32)`. /// -/// `frame_body` is the package, the Welcome or the discovery payload with the -/// proof field absent. A discovery query is gossip and has no single -/// recipient, so its recipient field is 21 zero bytes, which no address -/// payload is (the version byte is never zero); the query is bound through -/// its body, which carries its `query_id` and `originator`. +/// `frame_body` is the package or the Welcome (the frame's content after its +/// prefix), or, for a discovery query or response, [`discovery_bound_body`]. +/// A discovery query is gossip and has no single recipient, so its recipient +/// field is 21 zero bytes, which no address payload is (the version byte is +/// never zero); the query is bound through its body, which carries its +/// `query_id` and `originator`. /// /// # Errors /// @@ -699,7 +758,7 @@ mod tests { /// signature or key from one step stand in for another. #[test] fn the_labels_are_pinned_and_distinct() { - let labels: [&[u8]; 12] = [ + let labels: [&[u8]; 13] = [ APP_KEY_INFO, MEMBER_CERT_DOMAIN, MEMBER_HELLO_LABEL, @@ -712,6 +771,7 @@ mod tests { WIFI_DIRECT_NETWORK_INFO, WIFI_DIRECT_PASSPHRASE_INFO, WEB_NEARBY_INFO, + DISCOVERY_SEAL_INFO, ]; assert_eq!(BLE_APP_TAG_LABEL, b"offline-app-ble-tag-v1"); assert_eq!(WIFI_DIRECT_APP_INFO, b"offline-app-wifi-direct-record-v1"); @@ -724,6 +784,7 @@ mod tests { b"offline-app-wifi-direct-passphrase-v1" ); assert_eq!(WEB_NEARBY_INFO, b"offline-app-web-nearby-v1"); + assert_eq!(DISCOVERY_SEAL_INFO, b"offline-app-svc-v1"); assert_eq!(APP_KEY_INFO, b"offline-protocol-app-v1"); assert_eq!(MEMBER_CERT_DOMAIN, b"offline-app-member-v1"); assert_eq!(MEMBER_HELLO_LABEL, b"offline-app-member-hello-v1"); @@ -947,6 +1008,35 @@ mod tests { assert!(offline_protocol_core::AppId::new(WIRE_APP_ID).is_ok()); } + /// The sentinel is a wire constant, and it must be something no service + /// id can be: a provider that could register it would answer every + /// configured query in the clear. + #[test] + fn the_sealed_service_id_names_no_service() { + assert_eq!(DISCOVERY_SEALED_SERVICE_ID, "__sealed"); + assert!(offline_protocol_core::ServiceId::new(DISCOVERY_SEALED_SERVICE_ID).is_err()); + assert_eq!(DISCOVERY_SALT_LEN, 16); + } + + /// Each field of the bound body has one place: moving bytes between the + /// query id and the sealed body changes the length prefix, so two frames + /// that differ in either never bind the same bytes. + #[test] + fn the_discovery_bound_body_has_one_parse() { + let a = addr(1); + let body = discovery_bound_body("q1", &a, b"xyz").unwrap(); + assert_eq!(&body[..2], &[0, 2]); + assert_eq!(&body[2..4], b"q1"); + assert_eq!(&body[4..4 + Address::PAYLOAD_LEN], &a.payload()); + assert_eq!(&body[4 + Address::PAYLOAD_LEN..], b"xyz"); + assert_ne!( + discovery_bound_body("q1x", &a, b"yz").unwrap(), + discovery_bound_body("q1", &a, b"xyz").unwrap() + ); + assert_ne!(discovery_bound_body("q1", &addr(2), b"xyz").unwrap(), body); + assert!(discovery_bound_body(&"q".repeat(70_000), &a, b"").is_err()); + } + /// A proof for one kind of frame must not verify as another over the same /// bytes: a key package's proof replayed onto a Welcome. #[test] @@ -1038,6 +1128,7 @@ mod vectors { "proof", "provision", "identifiers", + "discovery", ] { assert!(v[block].is_object(), "{block} is present"); } @@ -1186,6 +1277,21 @@ mod vectors { field(query, &["signing_payload_hex"]) ); + let response = &v["proof"]["discovery_response"]; + assert_eq!( + response["kind"].as_u64(), + Some(ProofKind::DiscoveryResponse as u64) + ); + assert_eq!( + hex(&e2e_proof_signing_payload( + Some(&address(&v, "a")), + ProofKind::DiscoveryResponse, + &unhex(field(response, &["frame_body_hex"])) + ) + .expect("a payload")), + field(response, &["signing_payload_hex"]) + ); + let salt = unhex(field(&v, &["proof", "salt_hex"])); let k_app = unhex(field(&v, &["app_key", "k_app_hex"])); assert_eq!( @@ -1282,6 +1388,36 @@ mod vectors { } } + #[test] + fn the_discovery_body_matches() { + let v = vectors(); + let d = &v["discovery"]; + assert_eq!( + field(d, &["sealed_service_id"]), + DISCOVERY_SEALED_SERVICE_ID + ); + assert_eq!(field(d, &["key_info"]).as_bytes(), DISCOVERY_SEAL_INFO); + let salt = unhex(field(d, &["salt_hex"])); + assert_eq!(salt.len(), DISCOVERY_SALT_LEN); + let k_app = unhex(field(&v, &["app_key", "k_app_hex"])); + assert_eq!( + hex(&hkdf(&k_app, Some(&salt), DISCOVERY_SEAL_INFO)), + field(d, &["key_hex"]) + ); + let originator: Address = field(d, &["originator"]).parse().expect("an address"); + let bound = discovery_bound_body( + field(d, &["query_id"]), + &originator, + &unhex(field(d, &["sealed_hex"])), + ) + .expect("a bound body"); + assert_eq!(hex(&bound), field(d, &["bound_body_hex"])); + assert_eq!( + field(d, &["bound_body_hex"]), + field(&v, &["proof", "discovery_query", "frame_body_hex"]) + ); + } + #[test] fn every_rejected_body_is_refused() { for case in vectors()["rejects"].as_array().expect("rejects") { diff --git a/crates/offline-protocol-sealed/tests/data/app-membership-v1.vectors.json b/crates/offline-protocol-sealed/tests/data/app-membership-v1.vectors.json index 7e4273faf..d7c16c765 100644 --- a/crates/offline-protocol-sealed/tests/data/app-membership-v1.vectors.json +++ b/crates/offline-protocol-sealed/tests/data/app-membership-v1.vectors.json @@ -62,10 +62,17 @@ "signing_payload_hex": "6f66666c696e652d6170702d6d656d6265722d6532652d763101dac073e0123bdea59dd9b3bda9cf6037f63aca8201449715279bedf3a327fd4a13d3b69f7e97f540e858b2ebf3fbed93d3940f1946" }, "discovery_query": { - "note": "Gossip names no single recipient: 21 zero bytes, which no address payload is.", + "note": "Gossip names no single recipient: 21 zero bytes, which no address payload is. frame_body is the discovery bound body.", "kind": 4, - "frame_body_hex": "7b2271756572795f6964223a227131222c226f726967696e61746f72223a226f6666317179756c7779377335657a7a323063793232327a727730347277647333397561707176396a387230227d", - "signing_payload_hex": "6f66666c696e652d6170702d6d656d6265722d6532652d763100000000000000000000000000000000000000000004bb0fd8a3884e5cd6c5bb5bbec7fd79a58df594d6b9125c65a4efc8a3f5a86f3f" + "frame_body_hex": "000271310139f713d0a644253f04529421b9f51b9b08979d083c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3ccdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "signing_payload_hex": "6f66666c696e652d6170702d6d656d6265722d6532652d763100000000000000000000000000000000000000000004aeb6af84a79117ba87fb1b4fd794983fe5b2fd1e982864fc5d424161f21e3e48" + }, + "discovery_response": { + "note": "Bound to the originator (a), over a bound body of the same layout as the query's.", + "recipient": "off1qyulwy7s5ezz20cy222zrw04rwds39uapqv9j8r0", + "kind": 5, + "frame_body_hex": "000271310139f713d0a644253f04529421b9f51b9b08979d083c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3ccdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "signing_payload_hex": "6f66666c696e652d6170702d6d656d6265722d6532652d76310139f713d0a644253f04529421b9f51b9b08979d0805aeb6af84a79117ba87fb1b4fd794983fe5b2fd1e982864fc5d424161f21e3e48" }, "salt_hex": "5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a", "key_hex": "168ab14402b4425a73089cc0c1b0dbc90b689847926c210f1de4e4cbd6e268d2", @@ -90,6 +97,17 @@ "topic_hex": "80a19a5eafffc8b1d9150102afc1f0d5" } }, + "discovery": { + "note": "A configured node's discovery body: salt || ChaCha20-Poly1305(k, nonce = 0, aad = empty, plaintext). The bound body is u16be(len(query_id)) || query_id || originator payload || sealed.", + "sealed_service_id": "__sealed", + "key_info": "offline-app-svc-v1", + "salt_hex": "3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c", + "key_hex": "09cb6e8f886e189dea2c3823a68ef350825928585eeffbdbeb5fcdfcc693ddf7", + "query_id": "q1", + "originator": "off1qyulwy7s5ezz20cy222zrw04rwds39uapqv9j8r0", + "sealed_hex": "3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3ccdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "bound_body_hex": "000271310139f713d0a644253f04529421b9f51b9b08979d083c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3ccdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" + }, "rejects": [ { "name": "version_2", diff --git a/crates/offline-protocol-services/Cargo.toml b/crates/offline-protocol-services/Cargo.toml index 35ce39955..b0865c78f 100644 --- a/crates/offline-protocol-services/Cargo.toml +++ b/crates/offline-protocol-services/Cargo.toml @@ -20,6 +20,12 @@ workspace = true [dependencies] offline-protocol-core = { workspace = true } +# The discovery layouts a configured node seals and proves (ADR 0037): the +# sentinel service id, the bound body and the proof kinds. Their one home is +# the sealed layer (ADR 0022); the cryptography itself is the engine's, +# lent through `DiscoverySealer`, so this crate links no cipher. +offline-protocol-sealed = { workspace = true } +base64 = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/offline-protocol-services/src/lib.rs b/crates/offline-protocol-services/src/lib.rs index e73b30e2e..bcdc9848e 100644 --- a/crates/offline-protocol-services/src/lib.rs +++ b/crates/offline-protocol-services/src/lib.rs @@ -8,6 +8,9 @@ pub mod error; /// Event types emitted by mesh service operations. pub mod events; pub(crate) mod payloads; +/// Service discovery on a node whose application configured a publisher key +/// (ADR 0037): sealed bodies, membership proofs and fresh answers. +pub mod sealing; /// Core service registry and message handling logic. pub mod services; /// Subscriptions: a consumer's lease on a provider's topic, and the updates @@ -31,6 +34,10 @@ pub use payloads::{ MAX_SERVICE_METHOD_LEN, MAX_SERVICE_PAYLOAD_SIZE, SVC_DISCOVER_QUERY, SVC_DISCOVER_RESPONSE, SVC_MESSAGE_PREFIX, SVC_REQUEST, SVC_RESPONSE, VALID_SERVICE_STATUSES, }; +pub use sealing::{ + discovery_response_ends_at, discovery_response_is_proven, DiscoverySealer, ProofKind, + DISCOVERY_QUERY_LIFETIME_SECS, DISCOVERY_SEALED_SERVICE_ID, +}; pub use services::{ DiscoverResult, MeshServices, OutboundMessage, SendRequestResult, SendResponseResult, ServiceAction, diff --git a/crates/offline-protocol-services/src/payloads.rs b/crates/offline-protocol-services/src/payloads.rs index 9d8ac3592..ac64b1bd2 100644 --- a/crates/offline-protocol-services/src/payloads.rs +++ b/crates/offline-protocol-services/src/payloads.rs @@ -46,6 +46,19 @@ pub(crate) struct ServiceDiscoveryQueryPayload { pub service_id: Option, #[serde(default = "default_discovery_max_hops")] pub remaining_hops: u8, + /// A configured originator's sealed body (base64 of + /// `salt || ciphertext`), which carries the real `service_id`; the + /// cleartext one is then the sentinel. Absent from an unconfigured + /// node's query, so that query is byte for byte what it was. Carried + /// unchanged by every forwarder, configured or not. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sealed: Option, + /// The originator's end-to-end membership proof (base64), next to the + /// body it binds. In the payload rather than the frame metadata, because + /// every forwarder builds a new frame and the metadata would not survive + /// the first hop. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub member_proof: Option, } /// Wire-format payload for a service discovery response. @@ -57,9 +70,17 @@ pub(crate) struct ServiceDiscoveryResponsePayload { pub provider_peer_id: String, pub capabilities: HashMap, pub hop_count: u8, - /// The original query originator — used for multi-hop response routing. + /// The original query originator, used for multi-hop response routing. #[serde(default)] pub originator: String, + /// A configured provider's sealed body (base64): the provider, service, + /// version, capabilities and hop count. The cleartext fields are then + /// the sentinel and empty. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sealed: Option, + /// The provider's end-to-end membership proof (base64). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub member_proof: Option, } /// Wire-format payload for a service request. diff --git a/crates/offline-protocol-services/src/sealing.rs b/crates/offline-protocol-services/src/sealing.rs new file mode 100644 index 000000000..1b1371944 --- /dev/null +++ b/crates/offline-protocol-services/src/sealing.rs @@ -0,0 +1,301 @@ +//! Service discovery on a node whose application configured a publisher key +//! ([ADR 0037], "Service discovery"). +//! +//! # Invariants +//! +//! - **Fan-out never reads membership.** Which peers a query goes to, and +//! whether a response is relayed toward its originator, never depends on +//! whether a body opened, a proof verified or a service matched. The caller +//! hands the mesh neighbours in as the targets; this module only decides +//! what this node *answers* and what it *reports*. +//! - **Only the routing fields are in the clear.** A configured node's query +//! and response carry `query_id`, `originator` and (for a query) the hop +//! budget readable; every other field travels sealed under the +//! application key, and the cleartext `service_id` holds +//! [`DISCOVERY_SEALED_SERVICE_ID`], which names no service. +//! - **Both ends prove membership.** A provider answers only a query whose +//! proof verifies for its originator, and an originator reports only a +//! response whose proof verifies for the provider that sent it. +//! - **A provider answers once per query, while it is fresh.** Freshness is +//! the originator's own `issued_at`, sealed and proof-bound, never a hop's +//! frame timestamp, which every forwarder signs anew. +//! +//! The cryptography is not here: the engine lends it through +//! [`DiscoverySealer`], so this crate stays free of MLS and of every cipher. +//! +//! [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 base64::engine::general_purpose::STANDARD; +use base64::Engine; +use offline_protocol_core::Address; +use offline_protocol_sealed::membership::discovery_bound_body; +pub use offline_protocol_sealed::membership::{ProofKind, DISCOVERY_SEALED_SERVICE_ID}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::payloads::{ServiceDiscoveryQueryPayload, ServiceDiscoveryResponsePayload}; + +/// How long after its originator issued it a configured provider still +/// answers a discovery query, in seconds, either side of `issued_at`. +/// +/// No longer than the query dedup window, so "fresh" and "answered once" +/// cannot drift apart: the provider remembers a query it answered for as +/// long as the query could still be judged fresh (see +/// `MeshServices::answered_queries`), and a capture replayed after that is +/// stale. A provider whose clock is off from the originator's by more than +/// this answers nothing, which costs a result, never a leak. +pub const DISCOVERY_QUERY_LIFETIME_SECS: u64 = 60; + +const _: () = assert!(DISCOVERY_QUERY_LIFETIME_SECS <= crate::DISCOVERY_QUERY_DEDUP_TTL_SECS); + +/// What a configured node lends service discovery: the application key, its +/// certificate, its identity key and a clock. +/// +/// The engine implements it when `ProtocolConfig::app_publisher_key` is set +/// and passes it on every discovery call; with no publisher key it passes +/// none, and discovery runs exactly as before this trait existed. +pub trait DiscoverySealer { + /// Unix seconds now: the originator's `issued_at`, and the provider's + /// freshness check against it. + fn now_unix(&self) -> u64; + + /// This node's Ed25519 identity public key. It travels inside the sealed + /// body so a verifier several hops away holds the key the proof's + /// identity signature is checked under; the verifier checks that it + /// derives to the address it speaks for. + fn identity_key(&self) -> Option>; + + /// `salt || ChaCha20-Poly1305(k, nonce = 0, aad = empty, plaintext)` + /// with `k = HKDF-SHA256(K_app, salt, "offline-app-svc-v1")` and a fresh + /// salt. + fn seal(&self, plaintext: &[u8]) -> Option>; + + /// The inverse of [`Self::seal`]; `None` for a body sealed under another + /// application's key, or random bytes. + fn open(&self, sealed: &[u8]) -> Option>; + + /// An end-to-end membership proof of `kind` toward `recipient` (`None` + /// for a query) over `bound`. A node that holds no certificate returns + /// random bytes of a proof's length, so whether a proof is real is not + /// visible to anyone who cannot open it. + fn prove(&self, recipient: Option<&str>, kind: ProofKind, bound: &[u8]) -> Vec; + + /// Whether `proof` opens under the application key, carries a current + /// certificate for `signer` signed by the publisher, and `identity_key` + /// (which must derive to `signer`) signed `recipient`, `kind` and + /// `bound`. + fn verify( + &self, + proof: &[u8], + signer: &str, + identity_key: &[u8], + recipient: Option<&str>, + kind: ProofKind, + bound: &[u8], + ) -> bool; +} + +/// The sealed body of a configured node's query. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct SealedQueryBody { + /// Unix seconds when the originator issued the query. + pub issued_at: u64, + /// The originator's identity public key, base64. + pub identity_key: String, + /// The service asked for; absent asks for every service. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub service_id: Option, +} + +/// The sealed body of a configured provider's response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct SealedResponseBody { + /// The provider's address. Must be the response's sender. + pub provider: String, + /// The provider's identity public key, base64. + pub identity_key: String, + pub service_id: String, + pub version: String, + #[serde(default)] + pub capabilities: HashMap, + #[serde(default)] + pub hop_count: u8, +} + +/// The bytes a discovery proof binds, from the payload's routing fields and +/// its sealed body (base64 decoded). +fn bound_body(query_id: &str, originator: &str, sealed: &[u8]) -> Option> { + let originator: Address = originator.parse().ok()?; + discovery_bound_body(query_id, &originator, sealed).ok() +} + +fn b64(bytes: &[u8]) -> String { + STANDARD.encode(bytes) +} + +fn unb64(text: &str) -> Option> { + STANDARD.decode(text).ok() +} + +/// A configured originator's query payload: the routing fields in the clear, +/// the rest sealed and proven. +pub(crate) fn seal_query( + sealer: &dyn DiscoverySealer, + query_id: &str, + originator: &str, + service_id: Option<&str>, + remaining_hops: u8, +) -> Option { + let body = SealedQueryBody { + issued_at: sealer.now_unix(), + identity_key: b64(&sealer.identity_key()?), + service_id: service_id.map(str::to_string), + }; + let sealed = sealer.seal(&serde_json::to_vec(&body).ok()?)?; + let bound = bound_body(query_id, originator, &sealed)?; + let proof = sealer.prove(None, ProofKind::DiscoveryQuery, &bound); + Some(ServiceDiscoveryQueryPayload { + query_id: query_id.to_string(), + originator: originator.to_string(), + service_id: Some(DISCOVERY_SEALED_SERVICE_ID.to_string()), + remaining_hops, + sealed: Some(b64(&sealed)), + member_proof: Some(b64(&proof)), + }) +} + +/// A query this node may answer: its body opened, and its proof verifies for +/// its originator. `None` for anything else, which is forwarded and never +/// matched. +pub(crate) fn open_query( + sealer: &dyn DiscoverySealer, + payload: &ServiceDiscoveryQueryPayload, +) -> Option { + let sealed = unb64(payload.sealed.as_deref()?)?; + let proof = unb64(payload.member_proof.as_deref()?)?; + let body: SealedQueryBody = serde_json::from_slice(&sealer.open(&sealed)?).ok()?; + let identity_key = unb64(&body.identity_key)?; + let bound = bound_body(&payload.query_id, &payload.originator, &sealed)?; + sealer + .verify( + &proof, + &payload.originator, + &identity_key, + None, + ProofKind::DiscoveryQuery, + &bound, + ) + .then_some(body) +} + +/// Whether a query issued at `issued_at` is fresh at `now`: within the +/// lifetime on either side, so a provider whose clock runs a little behind +/// the originator's still answers. +pub(crate) fn is_fresh(issued_at: u64, now: u64) -> bool { + now.abs_diff(issued_at) <= DISCOVERY_QUERY_LIFETIME_SECS +} + +/// A configured provider's response payload toward `originator`. +#[allow(clippy::too_many_arguments)] +pub(crate) fn seal_response( + sealer: &dyn DiscoverySealer, + query_id: &str, + originator: &str, + provider: &str, + service_id: &str, + version: &str, + capabilities: &HashMap, + hop_count: u8, +) -> Option { + let body = SealedResponseBody { + provider: provider.to_string(), + identity_key: b64(&sealer.identity_key()?), + service_id: service_id.to_string(), + version: version.to_string(), + capabilities: capabilities.clone(), + hop_count, + }; + let sealed = sealer.seal(&serde_json::to_vec(&body).ok()?)?; + let bound = bound_body(query_id, originator, &sealed)?; + let proof = sealer.prove(Some(originator), ProofKind::DiscoveryResponse, &bound); + Some(ServiceDiscoveryResponsePayload { + query_id: query_id.to_string(), + service_id: DISCOVERY_SEALED_SERVICE_ID.to_string(), + version: String::new(), + provider_peer_id: String::new(), + capabilities: HashMap::new(), + hop_count: 0, + originator: originator.to_string(), + sealed: Some(b64(&sealed)), + member_proof: Some(b64(&proof)), + }) +} + +/// A response this node may report: it names this node as its originator, +/// its body opened, the sealed provider is the frame's sender, and its proof +/// verifies for that provider toward this node. +pub(crate) fn open_response( + sealer: &dyn DiscoverySealer, + payload: &ServiceDiscoveryResponsePayload, + sender: &str, + user_id: &str, +) -> Option { + if payload.originator != user_id { + return None; + } + let sealed = unb64(payload.sealed.as_deref()?)?; + let proof = unb64(payload.member_proof.as_deref()?)?; + let body: SealedResponseBody = serde_json::from_slice(&sealer.open(&sealed)?).ok()?; + if body.provider != sender { + return None; + } + let identity_key = unb64(&body.identity_key)?; + let bound = bound_body(&payload.query_id, &payload.originator, &sealed)?; + sealer + .verify( + &proof, + sender, + &identity_key, + Some(user_id), + ProofKind::DiscoveryResponse, + &bound, + ) + .then_some(body) +} + +fn response_payload(content: &str) -> Option { + let data = content.strip_prefix(crate::SVC_DISCOVER_RESPONSE)?; + if data.len() > crate::MAX_SERVICE_PAYLOAD_SIZE { + return None; + } + serde_json::from_str(data).ok() +} + +/// Whether `content` is a discovery response that ends at `user_id`: one +/// that names it as the originator, or names none. +/// +/// Such a response is an end-to-end frame, so a configured node answers it +/// (even with an acknowledgement) only when its sender is admitted or it +/// carries a proof ([`discovery_response_is_proven`]). Every other discovery +/// frame, a query or a response on its way to someone else, is carriage. +pub fn discovery_response_ends_at(content: &str, user_id: &str) -> bool { + response_payload(content) + .is_some_and(|payload| payload.originator.is_empty() || payload.originator == user_id) +} + +/// Whether `content` is a discovery response to `user_id` that `sender` +/// proved it may send: what the configured originator reports, and the +/// one discovery frame from a sender not otherwise admitted it answers. +pub fn discovery_response_is_proven( + content: &str, + sender: &str, + user_id: &str, + sealer: &dyn DiscoverySealer, +) -> bool { + response_payload(content) + .is_some_and(|payload| open_response(sealer, &payload, sender, user_id).is_some()) +} + +#[cfg(test)] +#[path = "sealing_tests.rs"] +mod tests; diff --git a/crates/offline-protocol-services/src/sealing_tests.rs b/crates/offline-protocol-services/src/sealing_tests.rs new file mode 100644 index 000000000..9a82cce59 --- /dev/null +++ b/crates/offline-protocol-services/src/sealing_tests.rs @@ -0,0 +1,451 @@ +//! The configured node's discovery rules over a stand-in for the engine's +//! cryptography: an "application key" is a tag byte, a proof names its +//! signer, and a node without a certificate proves nothing. The real cipher +//! and signatures are exercised by the engine's tests. + +use super::*; +use crate::events::ServiceEvent; +use crate::services::{MeshServices, OutboundMessage, ServiceAction}; +use crate::{SVC_DISCOVER_QUERY, SVC_DISCOVER_RESPONSE}; +use offline_protocol_core::{ServiceDescriptor, ServiceId}; +use std::cell::Cell; + +fn addr(byte: u8) -> String { + Address::from_hash_bytes([byte; Address::HASH_LEN]).to_string() +} + +struct Fake { + me: String, + app: u8, + certified: bool, + now: Cell, +} + +impl Fake { + fn member(me: &str, app: u8) -> Self { + Self { + me: me.to_string(), + app, + certified: true, + now: Cell::new(1_800_000_000), + } + } + + fn uncertified(me: &str, app: u8) -> Self { + Self { + certified: false, + ..Self::member(me, app) + } + } + + fn proof_of(signer: &str, recipient: Option<&str>, kind: ProofKind, bound: &[u8]) -> Vec { + format!("{signer}|{recipient:?}|{}|{bound:?}", kind as u8).into_bytes() + } +} + +impl DiscoverySealer for Fake { + fn now_unix(&self) -> u64 { + self.now.get() + } + fn identity_key(&self) -> Option> { + Some(format!("pk-{}", self.me).into_bytes()) + } + fn seal(&self, plaintext: &[u8]) -> Option> { + let mut out = vec![self.app]; + out.extend_from_slice(plaintext); + Some(out) + } + fn open(&self, sealed: &[u8]) -> Option> { + let (tag, rest) = sealed.split_first()?; + (*tag == self.app).then(|| rest.to_vec()) + } + fn prove(&self, recipient: Option<&str>, kind: ProofKind, bound: &[u8]) -> Vec { + if self.certified { + let mut proof = vec![self.app]; + proof.extend(Self::proof_of(&self.me, recipient, kind, bound)); + proof + } else { + vec![0xEE; 189] + } + } + fn verify( + &self, + proof: &[u8], + signer: &str, + identity_key: &[u8], + recipient: Option<&str>, + kind: ProofKind, + bound: &[u8], + ) -> bool { + let Some((tag, rest)) = proof.split_first() else { + return false; + }; + *tag == self.app + && identity_key == format!("pk-{signer}").as_bytes() + && rest == Self::proof_of(signer, recipient, kind, bound).as_slice() + } +} + +fn provider(service: &str) -> MeshServices { + let mut svc = MeshServices::new(); + svc.register_service(ServiceDescriptor { + service_id: ServiceId::new(service).expect("a service id"), + version: "2.0".to_string(), + capabilities: HashMap::from([("format".to_string(), "json".to_string())]), + }) + .expect("registered"); + svc +} + +fn consumed(action: ServiceAction) -> (Vec, Vec) { + match action { + ServiceAction::Consumed { + messages_to_send, + events_to_emit, + } => (messages_to_send, events_to_emit), + ServiceAction::NotHandled => panic!("a discovery frame is always consumed"), + } +} + +fn query_payload(content: &str) -> ServiceDiscoveryQueryPayload { + serde_json::from_str(content.strip_prefix(SVC_DISCOVER_QUERY).expect("a query")) + .expect("a query payload") +} + +/// `alice` asks as a configured node, toward one neighbour. +fn ask(alice: &Fake, service: Option<&str>) -> String { + let mut svc = MeshServices::new(); + let result = svc + .discover_services_sealed(&alice.me, &[addr(9)], service, alice) + .expect("a sealed query"); + result.messages[0].content.clone() +} + +/// Hands `content` to `node` from `sender`, configured with `sealer`. +fn deliver( + node: &mut MeshServices, + sealer: &Fake, + content: &str, + sender: &str, + peers: &[String], +) -> (Vec, Vec) { + consumed(node.handle_incoming_message_sealed( + content, + sender, + 0, + &sealer.me, + peers, + Some(sealer), + )) +} + +fn answers(messages: &[OutboundMessage]) -> Vec<&OutboundMessage> { + messages + .iter() + .filter(|m| m.content.starts_with(SVC_DISCOVER_RESPONSE)) + .collect() +} + +#[test] +fn a_configured_query_shows_only_its_routing_fields() { + let alice = Fake::member(&addr(1), 7); + let content = ask(&alice, Some("weather")); + let payload = query_payload(&content); + assert_eq!(payload.originator, alice.me); + assert_eq!( + payload.service_id.as_deref(), + Some(DISCOVERY_SEALED_SERVICE_ID) + ); + assert!(!content.contains("weather")); + assert!(payload.sealed.is_some() && payload.member_proof.is_some()); +} + +#[test] +fn a_member_answers_a_member_toward_the_originator_and_it_is_reported() { + let (alice, bob) = (Fake::member(&addr(1), 7), Fake::member(&addr(2), 7)); + let query = ask(&alice, Some("weather")); + + let mut bob_svc = provider("weather"); + let relay = addr(5); + let (sent, events) = deliver(&mut bob_svc, &bob, &query, &relay, &[addr(6)]); + assert!(events.is_empty()); + let replies = answers(&sent); + assert_eq!(replies.len(), 1); + assert_eq!( + replies[0].recipient, alice.me, + "a configured provider answers the originator, never the direct sender" + ); + assert!(!replies[0].content.contains("weather")); + assert!(sent + .iter() + .any(|m| m.recipient == addr(6) && m.content.starts_with(SVC_DISCOVER_QUERY))); + + let mut alice_svc = MeshServices::new(); + let (sent, events) = deliver(&mut alice_svc, &alice, &replies[0].content, &bob.me, &[]); + assert!(sent.is_empty()); + match events.as_slice() { + [ServiceEvent::ServiceDiscovered { + service_id, + provider_peer_id, + version, + capabilities, + .. + }] => { + assert_eq!(service_id, "weather"); + assert_eq!(provider_peer_id, &bob.me); + assert_eq!(version, "2.0"); + assert_eq!(capabilities.get("format").map(String::as_str), Some("json")); + } + other => panic!("expected one discovery, got {other:?}"), + } +} + +/// The forwarder belongs to another application: it cannot open the body, +/// and still carries the query on, unchanged but for the hop budget, and +/// answers nothing. +#[test] +fn a_node_of_another_application_forwards_unchanged_and_answers_nothing() { + let alice = Fake::member(&addr(1), 7); + let other = Fake::member(&addr(3), 8); + let query = ask(&alice, Some("weather")); + let original = query_payload(&query); + + let mut other_svc = provider("weather"); + let (sent, events) = deliver(&mut other_svc, &other, &query, &alice.me, &[addr(6)]); + assert!(events.is_empty()); + assert!(answers(&sent).is_empty()); + let forwarded: Vec<_> = sent + .iter() + .filter(|m| m.content.starts_with(SVC_DISCOVER_QUERY)) + .collect(); + assert_eq!(forwarded.len(), 1); + let carried = query_payload(&forwarded[0].content); + assert_eq!(carried.query_id, original.query_id); + assert_eq!(carried.service_id, original.service_id); + assert_eq!(carried.sealed, original.sealed); + assert_eq!(carried.member_proof, original.member_proof); + assert_eq!(carried.remaining_hops, original.remaining_hops - 1); +} + +/// A stranger holding the key but no certificate: its query is carried and +/// never matched. +#[test] +fn a_query_without_a_valid_proof_is_forwarded_not_answered() { + let stranger = Fake::uncertified(&addr(4), 7); + let bob = Fake::member(&addr(2), 7); + let query = ask(&stranger, None); + let mut bob_svc = provider("weather"); + let (sent, events) = deliver(&mut bob_svc, &bob, &query, &stranger.me, &[addr(6)]); + assert!(events.is_empty()); + assert!(answers(&sent).is_empty()); + assert_eq!(sent.len(), 1, "the query is still forwarded"); +} + +/// An unconfigured node's cleartext query: a configured provider carries it +/// and does not answer, since that would be its service list in the clear +/// to a stranger. +#[test] +fn a_configured_provider_does_not_answer_a_cleartext_query() { + let bob = Fake::member(&addr(2), 7); + let mut plain = MeshServices::new(); + let query = plain + .discover_services(&addr(1), &[addr(2)], None) + .expect("a query") + .messages[0] + .content + .clone(); + let mut bob_svc = provider("weather"); + let (sent, _) = deliver(&mut bob_svc, &bob, &query, &addr(1), &[addr(6)]); + assert!(answers(&sent).is_empty()); + assert_eq!(sent.len(), 1); +} + +#[test] +fn a_query_is_answered_once_per_query_id() { + let (alice, bob) = (Fake::member(&addr(1), 7), Fake::member(&addr(2), 7)); + let query = ask(&alice, None); + let mut bob_svc = provider("weather"); + let (first, _) = deliver(&mut bob_svc, &bob, &query, &alice.me, &[]); + assert_eq!(answers(&first).len(), 1); + + // The dedup window passed, or another path brought it: the answered set + // still holds it while it is fresh. + bob_svc.forget_seen_queries(); + bob.now.set(bob.now.get() + 30); + let (second, _) = deliver(&mut bob_svc, &bob, &query, &addr(5), &[]); + assert!(answers(&second).is_empty()); +} + +#[test] +fn a_replayed_query_past_its_lifetime_is_not_answered() { + let (alice, bob) = (Fake::member(&addr(1), 7), Fake::member(&addr(2), 7)); + let query = ask(&alice, None); + + let mut late = provider("weather"); + bob.now + .set(alice.now.get() + DISCOVERY_QUERY_LIFETIME_SECS + 1); + let (sent, _) = deliver(&mut late, &bob, &query, &addr(5), &[addr(6)]); + assert!(answers(&sent).is_empty()); + assert_eq!(sent.len(), 1, "a stale query is still carried"); + + // Within the lifetime on either side of the originator's clock it is + // answered: a provider a little behind is not refused. + let mut behind = provider("weather"); + bob.now.set(alice.now.get() - DISCOVERY_QUERY_LIFETIME_SECS); + let (sent, _) = deliver(&mut behind, &bob, &query, &addr(5), &[]); + assert_eq!(answers(&sent).len(), 1); +} + +/// The answered set refuses a replay for as long as the query could be +/// judged fresh, which is the bound the ADR states for party 2. +#[test] +fn the_answered_set_outlives_freshness() { + let (alice, bob) = (Fake::member(&addr(1), 7), Fake::member(&addr(2), 7)); + let query = ask(&alice, None); + let mut bob_svc = provider("weather"); + let start = alice.now.get() - DISCOVERY_QUERY_LIFETIME_SECS; + bob.now.set(start); + let (first, _) = deliver(&mut bob_svc, &bob, &query, &addr(5), &[]); + assert_eq!(answers(&first).len(), 1); + for later in [ + 1, + DISCOVERY_QUERY_LIFETIME_SECS, + 2 * DISCOVERY_QUERY_LIFETIME_SECS, + ] { + bob_svc.forget_seen_queries(); + bob.now.set(start + later); + let (again, _) = deliver(&mut bob_svc, &bob, &query, &addr(5), &[]); + assert!(answers(&again).is_empty(), "answered twice {later} s on"); + } +} + +#[test] +fn a_response_whose_proof_names_another_provider_is_not_reported() { + let (alice, bob) = (Fake::member(&addr(1), 7), Fake::member(&addr(2), 7)); + let mallory = addr(3); + let query = ask(&alice, Some("weather")); + let qid = query_payload(&query).query_id; + + // Bob's genuine response, handed on by mallory as if it were hers. + let mut bob_svc = provider("weather"); + let (sent, _) = deliver(&mut bob_svc, &bob, &query, &addr(5), &[]); + let genuine = answers(&sent)[0].content.clone(); + let mut alice_svc = MeshServices::new(); + let (_, events) = deliver(&mut alice_svc, &alice, &genuine, &mallory, &[]); + assert!( + events.is_empty(), + "a response sent by someone other than its provider" + ); + assert!(!discovery_response_is_proven( + &genuine, &mallory, &alice.me, &alice + )); + + // A body naming mallory as the provider, under bob's proof. + let forged = seal_response( + &bob, + &qid, + &alice.me, + &mallory, + "weather", + "9.9", + &HashMap::new(), + 0, + ) + .expect("a response"); + let forged = format!( + "{SVC_DISCOVER_RESPONSE}{}", + serde_json::to_string(&forged).expect("json") + ); + let (_, events) = deliver(&mut alice_svc, &alice, &forged, &mallory, &[]); + assert!(events.is_empty(), "a proof made by bob over mallory's body"); + + // And the genuine one, from bob, is reported. + assert!(discovery_response_is_proven( + &genuine, &bob.me, &alice.me, &alice + )); + let (_, events) = deliver(&mut alice_svc, &alice, &genuine, &bob.me, &[]); + assert_eq!(events.len(), 1); +} + +/// A configured originator never asks in the clear, so a cleartext response +/// addressed to it is a stranger's and is not reported. One on its way to +/// someone else is carried toward its originator, proof or not. +#[test] +fn cleartext_responses_are_carried_but_never_reported() { + let alice = Fake::member(&addr(1), 7); + let mut svc = MeshServices::new(); + let plain = |originator: &str| { + format!( + "{SVC_DISCOVER_RESPONSE}{}", + serde_json::to_string(&ServiceDiscoveryResponsePayload { + query_id: "q".to_string(), + service_id: "weather".to_string(), + version: "1".to_string(), + provider_peer_id: addr(2), + capabilities: HashMap::new(), + hop_count: 0, + originator: originator.to_string(), + sealed: None, + member_proof: None, + }) + .expect("json") + ) + }; + let (sent, events) = deliver(&mut svc, &alice, &plain(&alice.me), &addr(2), &[]); + assert!(sent.is_empty() && events.is_empty()); + assert!(discovery_response_ends_at(&plain(&alice.me), &alice.me)); + assert!(discovery_response_ends_at(&plain(""), &alice.me)); + assert!(!discovery_response_is_proven( + &plain(&alice.me), + &addr(2), + &alice.me, + &alice + )); + + let elsewhere = plain(&addr(4)); + assert!(!discovery_response_ends_at(&elsewhere, &alice.me)); + let (sent, events) = deliver(&mut svc, &alice, &elsewhere, &addr(2), &[]); + assert!(events.is_empty()); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].recipient, addr(4)); +} + +/// No service id can be the sentinel, so `register_service` can never hold +/// it: the type refuses it before a descriptor exists, from code and from +/// the wire alike. +#[test] +fn register_service_refuses_the_sentinel() { + assert!(ServiceId::new(DISCOVERY_SEALED_SERVICE_ID).is_err()); + let descriptor = format!( + r#"{{"service_id":"{DISCOVERY_SEALED_SERVICE_ID}","version":"1","capabilities":{{}}}}"# + ); + assert!(serde_json::from_str::(&descriptor).is_err()); +} + +/// An unconfigured node's query is what it was: no sealed field, no proof, +/// the requested service in the clear. +#[test] +fn an_unconfigured_query_is_unchanged_on_the_wire() { + let mut svc = MeshServices::new(); + let content = svc + .discover_services("user1", &["bob".to_string()], Some("weather")) + .expect("a query") + .messages[0] + .content + .clone(); + let json: serde_json::Value = + serde_json::from_str(content.strip_prefix(SVC_DISCOVER_QUERY).expect("a query")) + .expect("json"); + let mut keys: Vec<_> = json + .as_object() + .expect("an object") + .keys() + .cloned() + .collect(); + keys.sort(); + assert_eq!( + keys, + ["originator", "query_id", "remaining_hops", "service_id"] + ); + assert_eq!(json["service_id"], "weather"); +} diff --git a/crates/offline-protocol-services/src/services.rs b/crates/offline-protocol-services/src/services.rs index bf1908e1d..3975fa5b9 100644 --- a/crates/offline-protocol-services/src/services.rs +++ b/crates/offline-protocol-services/src/services.rs @@ -8,6 +8,10 @@ use crate::payloads::{ MAX_SERVICE_PAYLOAD_SIZE, SVC_DISCOVER_QUERY, SVC_DISCOVER_RESPONSE, SVC_REQUEST, SVC_RESPONSE, VALID_SERVICE_STATUSES, }; +use crate::sealing::{ + is_fresh, open_query, open_response, seal_query, seal_response, DiscoverySealer, + DISCOVERY_QUERY_LIFETIME_SECS, +}; use offline_protocol_core::time::{Duration, Instant}; use offline_protocol_core::{MessagePriority, ServiceDescriptor}; use std::collections::{HashMap, VecDeque}; @@ -77,11 +81,29 @@ pub enum ServiceAction { /// receives a response it did not originate forwards it as a unicast toward /// the originator (see `try_handle_discover_response`) and emits no event of /// its own; only the originator emits `ServiceDiscovered`. +/// +/// ## On a configured node +/// +/// With a [`DiscoverySealer`] (the application configured a publisher key, +/// ADR 0037), the calls ending in `_sealed` run the rules of +/// [`crate::sealing`]: the body is sealed and proven, a provider answers +/// only a proven, fresh query, once, and addresses its answer to the +/// `originator` (the proof binds it, so it cannot be spoofed, and answering +/// the direct sender would hand a replayer a frame addressed to itself), and +/// only a proven response is reported. Forwarding is the same either way: +/// every query within its hop budget, every response toward its originator, +/// whatever this node made of it. pub struct MeshServices { local_services: HashMap, seen_discovery_queries: HashMap, /// Insertion-order index for O(1) oldest-entry eviction. seen_discovery_order: VecDeque, + /// Queries this configured provider answered, each with the Unix second + /// past which it is stale anyway. Kept apart from the dedup map, which + /// forgets on its own clock and at its cap: a replay the dedup map no + /// longer remembers must still find its answer here while the query is + /// fresh, or the provider would answer one capture twice. + answered_queries: HashMap, } impl MeshServices { @@ -91,6 +113,7 @@ impl MeshServices { local_services: HashMap::new(), seen_discovery_queries: HashMap::new(), seen_discovery_order: VecDeque::new(), + answered_queries: HashMap::new(), } } @@ -129,6 +152,8 @@ impl MeshServices { originator: user_id.to_string(), service_id: service_id.map(|s| s.to_string()), remaining_hops: DISCOVERY_QUERY_DEFAULT_MAX_HOPS, + sealed: None, + member_proof: None, }; let serialized = serde_json::to_string(&payload) .map_err(|e| ServiceError::Serialization(e.to_string()))?; @@ -157,6 +182,57 @@ impl MeshServices { Ok(DiscoverResult { query_id, messages }) } + /// [`Self::discover_services`] on a configured node: the query names + /// the sentinel in the clear, carries `service_id` and the issue time + /// sealed under the application key, and proves this node's membership. + /// + /// `neighbours` are the targets, and the caller takes them from the mesh + /// carriers only: gossip targets chosen from peers the application + /// admitted would make discovery one network per application. + /// + /// # Errors + /// + /// [`ServiceError::Other`] when the sealer has no identity key or cannot + /// seal, and [`ServiceError::Serialization`] as for the unconfigured call. + pub fn discover_services_sealed( + &mut self, + user_id: &str, + neighbours: &[String], + service_id: Option<&str>, + sealer: &dyn DiscoverySealer, + ) -> Result { + let query_id = uuid::Uuid::new_v4().to_string(); + let payload = seal_query( + sealer, + &query_id, + user_id, + service_id, + DISCOVERY_QUERY_DEFAULT_MAX_HOPS, + ) + .ok_or_else(|| ServiceError::Other("could not seal the discovery query".to_string()))?; + let serialized = serde_json::to_string(&payload) + .map_err(|e| ServiceError::Serialization(e.to_string()))?; + let content = format!("{}{}", SVC_DISCOVER_QUERY, serialized); + + self.record_seen_query(query_id.clone()); + let messages: Vec = select_fanout_peers( + neighbours, + DISCOVERY_INITIAL_BROADCAST_MAX, + &query_id, + user_id, + ) + .into_iter() + .map(|peer| OutboundMessage { + recipient: peer, + content: content.clone(), + priority: MessagePriority::Medium, + }) + .collect(); + + info!(query_id = %query_id, peer_count = messages.len(), "Broadcast sealed service discovery query"); + Ok(DiscoverResult { query_id, messages }) + } + /// Generates a service request message to a specific provider peer. pub fn send_service_request( &self, @@ -254,13 +330,29 @@ impl MeshServices { hop_count: u8, user_id: &str, known_peers: &[String], + ) -> ServiceAction { + self.handle_incoming_message_sealed(content, sender, hop_count, user_id, known_peers, None) + } + + /// [`Self::handle_incoming_message`], with the configured node's + /// discovery rules when `sealer` is given (see + /// [the configured node](MeshServices#on-a-configured-node)). With + /// `None` it is exactly [`Self::handle_incoming_message`]. + pub fn handle_incoming_message_sealed( + &mut self, + content: &str, + sender: &str, + hop_count: u8, + user_id: &str, + known_peers: &[String], + sealer: Option<&dyn DiscoverySealer>, ) -> ServiceAction { if let Some(action) = - self.try_handle_discover_query(content, sender, hop_count, user_id, known_peers) + self.try_handle_discover_query(content, sender, hop_count, user_id, known_peers, sealer) { return action; } - if let Some(action) = self.try_handle_discover_response(content, sender, user_id) { + if let Some(action) = self.try_handle_discover_response(content, sender, user_id, sealer) { return action; } if let Some(action) = self.try_handle_request(content, sender) { @@ -291,6 +383,14 @@ impl MeshServices { &self.seen_discovery_queries } + /// Forgets every query id the dedup map holds, as its window passing + /// would, so a test can replay a query the dedup map no longer stops. + #[cfg(test)] + pub(crate) fn forget_seen_queries(&mut self) { + self.seen_discovery_queries.clear(); + self.seen_discovery_order.clear(); + } + /// Inserts a query ID into the dedup map, evicting the oldest entry if at capacity. fn record_seen_query(&mut self, query_id: String) { if self.seen_discovery_queries.len() >= DISCOVERY_QUERY_MAX_DEDUP_ENTRIES { @@ -313,6 +413,7 @@ impl MeshServices { hop_count: u8, user_id: &str, known_peers: &[String], + sealer: Option<&dyn DiscoverySealer>, ) -> Option { let data = content.strip_prefix(SVC_DISCOVER_QUERY)?; @@ -345,45 +446,16 @@ impl MeshServices { } self.record_seen_query(payload.query_id.clone()); - let mut messages = Vec::new(); - - // Check local services for matches - let matches: Vec<_> = self - .local_services - .values() - .filter(|svc| { - payload - .service_id - .as_ref() - .is_none_or(|q| q == svc.service_id.as_str()) - }) - .cloned() - .collect(); - - // Send discovery responses back to the *sender* (not the originator) to - // prevent a spoofed originator field from leaking our service list to an - // arbitrary peer. The sender relays the response back along the path. - for svc in &matches { - let response = ServiceDiscoveryResponsePayload { - query_id: payload.query_id.clone(), - service_id: svc.service_id.as_str().to_string(), - version: svc.version.clone(), - provider_peer_id: user_id.to_string(), - capabilities: svc.capabilities.clone(), - hop_count, - originator: payload.originator.clone(), - }; - if let Ok(serialized) = serde_json::to_string(&response) { - let resp_content = format!("{}{}", SVC_DISCOVER_RESPONSE, serialized); - messages.push(OutboundMessage { - recipient: sender.to_string(), - content: resp_content, - priority: MessagePriority::Medium, - }); - } - } + let mut messages = match sealer { + Some(sealer) => self.answer_sealed_query(sealer, &payload, hop_count, user_id), + None => self.answer_query(&payload, sender, hop_count, user_id), + }; + let answered = messages.len(); - // Forward query to other known peers (gossip) if hops remain + // Forward query to other known peers (gossip) if hops remain. On a + // configured node too, whatever it made of the query: forwarding is + // carriage, and a node that forwarded only what it could open would + // stop every other application's discovery at itself. let forwarded_count = if payload.remaining_hops > 0 { let mut fwd_payload = payload.clone(); fwd_payload.remaining_hops -= 1; @@ -423,7 +495,7 @@ impl MeshServices { debug!( query_id = %payload.query_id, - matches = matches.len(), + answered, forwarded_to = forwarded_count, "Processed service discovery query" ); @@ -434,11 +506,145 @@ impl MeshServices { }) } + /// An unconfigured node's answers to a query: one response per matching + /// service, to the query's direct sender. + fn answer_query( + &self, + payload: &ServiceDiscoveryQueryPayload, + sender: &str, + hop_count: u8, + user_id: &str, + ) -> Vec { + let matches: Vec<_> = self + .local_services + .values() + .filter(|svc| { + payload + .service_id + .as_ref() + .is_none_or(|q| q == svc.service_id.as_str()) + }) + .cloned() + .collect(); + + // Send discovery responses back to the *sender* (not the originator) to + // prevent a spoofed originator field from leaking our service list to an + // arbitrary peer. The sender relays the response back along the path. + let mut messages = Vec::new(); + for svc in &matches { + let response = ServiceDiscoveryResponsePayload { + query_id: payload.query_id.clone(), + service_id: svc.service_id.as_str().to_string(), + version: svc.version.clone(), + provider_peer_id: user_id.to_string(), + capabilities: svc.capabilities.clone(), + hop_count, + originator: payload.originator.clone(), + sealed: None, + member_proof: None, + }; + if let Ok(serialized) = serde_json::to_string(&response) { + let resp_content = format!("{}{}", SVC_DISCOVER_RESPONSE, serialized); + messages.push(OutboundMessage { + recipient: sender.to_string(), + content: resp_content, + priority: MessagePriority::Medium, + }); + } + } + messages + } + + /// A configured provider's answers to a query: none unless the body + /// opened, the proof verifies for the originator, the query is fresh and + /// was not answered before; then one sealed, proven response per + /// matching service, addressed to the originator. + /// + /// A cleartext query (an unconfigured node's) is never answered here: + /// its originator is not of this application, and an answer would be + /// this node's service list in the clear to a stranger. + fn answer_sealed_query( + &mut self, + sealer: &dyn DiscoverySealer, + payload: &ServiceDiscoveryQueryPayload, + hop_count: u8, + user_id: &str, + ) -> Vec { + if payload.originator == user_id { + return Vec::new(); + } + let Some(body) = open_query(sealer, payload) else { + debug!(query_id = %payload.query_id, "Discovery query not proven to this application; carried only"); + return Vec::new(); + }; + let now = sealer.now_unix(); + if !is_fresh(body.issued_at, now) { + debug!(query_id = %payload.query_id, issued_at = body.issued_at, now, "Stale discovery query; not answered"); + return Vec::new(); + } + if !self.claim_answer(&payload.query_id, body.issued_at, now) { + debug!(query_id = %payload.query_id, "Discovery query already answered"); + return Vec::new(); + } + + let mut messages = Vec::new(); + for svc in self.local_services.values().filter(|svc| { + body.service_id + .as_ref() + .is_none_or(|q| q == svc.service_id.as_str()) + }) { + let Some(response) = seal_response( + sealer, + &payload.query_id, + &payload.originator, + user_id, + svc.service_id.as_str(), + &svc.version, + &svc.capabilities, + hop_count, + ) else { + continue; + }; + if let Ok(serialized) = serde_json::to_string(&response) { + messages.push(OutboundMessage { + recipient: payload.originator.clone(), + content: format!("{}{}", SVC_DISCOVER_RESPONSE, serialized), + priority: MessagePriority::Medium, + }); + } + } + messages + } + + /// Records that `query_id` is answered now, or refuses when it already + /// was. The entry stays until the query is stale (`issued_at` plus the + /// lifetime), so it outlives every moment the query could be judged + /// fresh. At the cap, with nothing stale to drop, it refuses: a dropped + /// fresh entry would let a replay be answered twice, and a refusal + /// costs a result, never a leak. + fn claim_answer(&mut self, query_id: &str, issued_at: u64, now: u64) -> bool { + if self.answered_queries.contains_key(query_id) { + return false; + } + if self.answered_queries.len() >= DISCOVERY_QUERY_MAX_DEDUP_ENTRIES { + self.answered_queries.retain(|_, until| *until >= now); + if self.answered_queries.len() >= DISCOVERY_QUERY_MAX_DEDUP_ENTRIES { + return false; + } + } + self.answered_queries.insert( + query_id.to_string(), + issued_at.saturating_add(DISCOVERY_QUERY_LIFETIME_SECS), + ); + true + } + fn try_handle_discover_response( &self, content: &str, sender: &str, user_id: &str, + sealer: Option<&dyn DiscoverySealer>, ) -> Option { let data = content.strip_prefix(SVC_DISCOVER_RESPONSE)?; @@ -487,16 +693,37 @@ impl MeshServices { }); } - Some(ServiceAction::Consumed { - messages_to_send: vec![], - events_to_emit: vec![ServiceEvent::ServiceDiscovered { + let event = match sealer { + None => Some(ServiceEvent::ServiceDiscovered { query_id: payload.query_id, service_id: payload.service_id, version: payload.version, provider_peer_id: payload.provider_peer_id, capabilities: payload.capabilities, hop_count: payload.hop_count, - }], + }), + // Reported only when the provider proved membership. A cleartext + // response is a stranger's: a configured node never asks in the + // clear, so nothing of its own is answered that way. + Some(sealer) => match open_response(sealer, &payload, sender, user_id) { + Some(body) => Some(ServiceEvent::ServiceDiscovered { + query_id: payload.query_id, + service_id: body.service_id, + version: body.version, + provider_peer_id: body.provider, + capabilities: body.capabilities, + hop_count: body.hop_count, + }), + None => { + debug!(query_id = %payload.query_id, sender = %sender, "Discovery response not proven; not reported"); + None + } + }, + }; + + Some(ServiceAction::Consumed { + messages_to_send: vec![], + events_to_emit: event.into_iter().collect(), }) } @@ -818,6 +1045,8 @@ mod tests { originator: "alice".to_string(), service_id: Some("weather".to_string()), remaining_hops: 5, + sealed: None, + member_proof: None, }; let content = format!( "{}{}", @@ -853,6 +1082,8 @@ mod tests { originator: "alice".to_string(), service_id: None, remaining_hops: 5, + sealed: None, + member_proof: None, }; let content = format!( "{}{}", @@ -881,6 +1112,8 @@ mod tests { capabilities: HashMap::new(), hop_count: 1, originator: "user1".to_string(), + sealed: None, + member_proof: None, }; let content = format!( "{}{}", @@ -1050,6 +1283,8 @@ mod tests { originator: "alice".to_string(), service_id: None, remaining_hops: 5, + sealed: None, + member_proof: None, }; let content = format!( "{}{}", @@ -1107,6 +1342,8 @@ mod tests { originator: "alice".to_string(), service_id: None, remaining_hops: 0, + sealed: None, + member_proof: None, }; let content = format!( "{}{}", @@ -1263,6 +1500,8 @@ mod tests { originator: "alice".to_string(), service_id: Some("weather".to_string()), remaining_hops: 5, + sealed: None, + member_proof: None, }; let content = format!( "{}{}", @@ -1356,6 +1595,8 @@ mod tests { capabilities: HashMap::new(), hop_count: 2, originator: "original_requester".to_string(), + sealed: None, + member_proof: None, }; let content = format!( "{}{}", @@ -1395,6 +1636,8 @@ mod tests { capabilities: HashMap::new(), hop_count: 1, originator: "me".to_string(), + sealed: None, + member_proof: None, }; let content = format!( "{}{}", diff --git a/tools/spec-vectors/generate.py b/tools/spec-vectors/generate.py index 577e5fccb..5de0f6a89 100644 --- a/tools/spec-vectors/generate.py +++ b/tools/spec-vectors/generate.py @@ -1812,10 +1812,20 @@ def payload(addr: str) -> bytes: body = b'{"example":"key package payload"}' e2e_kp = b"offline-app-member-e2e-v1" + b + bytes([1]) + hashlib.sha256(body).digest() - query = b'{"query_id":"q1","originator":"' + addr_a.encode("ascii") + b'"}' + # Service discovery: the body travels sealed under a key derived from + # K_app and a fresh salt, and the proof binds the fields no hop rewrites. + svc_query_id = "q1" + svc_salt = bytes([0x3C]) * 16 + svc_sealed = svc_salt + bytes([0xCD]) * 40 + query = ( + len(svc_query_id).to_bytes(2, "big") + svc_query_id.encode("utf-8") + a + svc_sealed + ) e2e_query = ( b"offline-app-member-e2e-v1" + bytes(21) + bytes([4]) + hashlib.sha256(query).digest() ) + e2e_response = ( + b"offline-app-member-e2e-v1" + a + bytes([5]) + hashlib.sha256(query).digest() + ) proof_salt = bytes([0x5A]) * 16 # The provisioning body a phone seals to a paired leaf: kind 3, which no @@ -1909,11 +1919,19 @@ def provision_reject(raw: bytes) -> str: }, "discovery_query": { "note": "Gossip names no single recipient: 21 zero bytes, which " - "no address payload is.", + "no address payload is. frame_body is the discovery bound body.", "kind": 4, "frame_body_hex": query.hex(), "signing_payload_hex": e2e_query.hex(), }, + "discovery_response": { + "note": "Bound to the originator (a), over a bound body of the " + "same layout as the query's.", + "recipient": addr_a, + "kind": 5, + "frame_body_hex": query.hex(), + "signing_payload_hex": e2e_response.hex(), + }, "salt_hex": proof_salt.hex(), "key_hex": hkdf(k_app, proof_salt, b"offline-app-member-e2e-key-v1").hex(), "bytes_hex": (proof_salt + sealed).hex(), @@ -1940,6 +1958,19 @@ def provision_reject(raw: bytes) -> str: "topic_hex": nearby_topic.hex(), }, }, + "discovery": { + "note": "A configured node's discovery body: salt || " + "ChaCha20-Poly1305(k, nonce = 0, aad = empty, plaintext). The bound " + "body is u16be(len(query_id)) || query_id || originator payload || sealed.", + "sealed_service_id": "__sealed", + "key_info": "offline-app-svc-v1", + "salt_hex": svc_salt.hex(), + "key_hex": hkdf(k_app, svc_salt, b"offline-app-svc-v1").hex(), + "query_id": svc_query_id, + "originator": addr_a, + "sealed_hex": svc_sealed.hex(), + "bound_body_hex": query.hex(), + }, "rejects": [ {"name": "version_2", "body": base64.b64encode(bytes([2, 1]) + bytes(32)).decode()}, {"name": "step_3", "body": base64.b64encode(bytes([1, 3]) + bytes(32)).decode()}, From 3b41810197cbfebd2b4d4d9d61c62cc02fcfcd22 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Wed, 4 Nov 2026 21:58:16 +0530 Subject: [PATCH 2/2] feat(protocol): a configured node discovers services under its application's rules Step 7c of the shared network plan (ADR 0037, "Service discovery"), the engine half. - `MembershipKeys`: this node's membership borrowed apart from the rest of the engine, the one place the end-to-end proof is made and checked for session-opening frames and discovery alike. It implements `DiscoverySealer`: the body under `K_app` with a fresh salt, the proof of kinds 4 and 5, and an identity key carried in a sealed body that must derive to the address it speaks for. A node with the key and no certificate sends random bytes in the proof's place. - With a publisher key set, discovery fans out to the mesh neighbours only, never to `known_peers`, which membership makes an admitted set. - The inbound gate no longer passes every `__SVC_DISC` frame: a query, and a response on its way to someone else, pass whoever sent them; a response that ends here passes only from an admitted sender or with its provider's proof. The acknowledgement point follows the same rule, so a member acknowledges gossip from anyone (no ack pattern marks its fellow members on the link) and a proven answer is not resent to its expiry. - The control-message chapter states the configured node's discovery wire: the sentinel, the sealed body, the bound body, where the proof travels and why, and the freshness and once rules. Tests over a line of nodes on the real send, relay and receive paths: two members three hops apart find each other's service through a member of another application and an unconfigured node, which carry both frames, never see the service id, and neither answer nor report; a stranger's query is forwarded and never answered; a forged answer to a member gets no event and no acknowledgement; an unconfigured originator reports as before. Disabling the response gate or the query proof check fails one each. --- .../src/protocol/config_accessors.rs | 35 +- .../src/protocol/membership.rs | 316 ++++++++++++--- .../src/protocol/message_dispatch.rs | 34 +- crates/offline-protocol/src/protocol/send.rs | 10 +- .../src/protocol/tests/membership.rs | 13 + .../protocol/tests/membership_discovery.rs | 374 ++++++++++++++++++ docs/spec/control-messages.md | 106 ++++- 7 files changed, 801 insertions(+), 87 deletions(-) create mode 100644 crates/offline-protocol/src/protocol/tests/membership_discovery.rs diff --git a/crates/offline-protocol/src/protocol/config_accessors.rs b/crates/offline-protocol/src/protocol/config_accessors.rs index 364f1ea9c..d5e990aa3 100644 --- a/crates/offline-protocol/src/protocol/config_accessors.rs +++ b/crates/offline-protocol/src/protocol/config_accessors.rs @@ -1,5 +1,6 @@ //! Configuration accessors, diagnostics, and service registration. +use super::membership::MembershipKeys; use super::mesh_relay::{MeshRelayConfig, MeshRelayStats}; use super::{ lock_shared_state, OfflineProtocol, PendingQueueMetrics, ProtocolState, KNOWN_PEER_TTL_SECS, @@ -440,15 +441,36 @@ impl OfflineProtocol { /// originator, and each device in between forwards the response on to it /// (`offline_protocol_services`), so discovery reaches as far as the query /// was gossiped. + /// + /// With `ProtocolConfig::app_publisher_key` set the query follows the + /// configured node's rules (ADR 0037, "Service discovery"): it goes to + /// the mesh neighbours only, never to `known_peers` (which membership + /// makes an admitted set), its `service_id` travels sealed under the + /// application key with a membership proof, and only a provider of the + /// same application that proves its membership is reported. pub fn discover_services(&mut self, service_id: Option<&str>) -> Result { // Service discovery messages are internal control messages (not user // content), so they are exempt from require_encryption. - let peers: Vec = self.known_peers.keys().cloned().collect(); - let result = self - .mesh_services - .discover_services(&self.local_id, &peers, service_id) - .map_err(Error::Service)?; + let result = if self.membership_configured() { + let peers: Vec = self + .transport_manager + .mesh_neighbors() + .into_iter() + .map(|neighbour| neighbour.peer_id) + .collect(); + let keys = + MembershipKeys::new(&self.membership, self.mls_manager.as_ref(), &self.local_id); + self.mesh_services + .discover_services_sealed(&self.local_id, &peers, service_id, &keys) + .map_err(Error::Service)? + } else { + let peers: Vec = self.known_peers.keys().cloned().collect(); + self.mesh_services + .discover_services(&self.local_id, &peers, service_id) + .map_err(Error::Service)? + }; + let total = result.messages.len(); let mut send_failures = 0usize; for msg in result.messages { if self @@ -461,8 +483,7 @@ impl OfflineProtocol { if send_failures > 0 { warn!( failures = send_failures, - total = peers.len(), - "Some discovery broadcasts failed to send" + total, "Some discovery broadcasts failed to send" ); } Ok(result.query_id) diff --git a/crates/offline-protocol/src/protocol/membership.rs b/crates/offline-protocol/src/protocol/membership.rs index 6f86ddde7..811ca3336 100644 --- a/crates/offline-protocol/src/protocol/membership.rs +++ b/crates/offline-protocol/src/protocol/membership.rs @@ -40,6 +40,7 @@ //! [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, HashSet}; +use std::sync::{Arc, RwLock}; use chacha20poly1305::aead::{Aead, KeyInit}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; @@ -48,12 +49,16 @@ use hkdf::Hkdf; use hmac::{Hmac, Mac}; use offline_protocol_core::time::{Duration, Instant}; use offline_protocol_core::{Address, ContentType, Message, MessageId, MessagePriority, TTL}; +use offline_protocol_mls::MlsManager; use offline_protocol_sealed::membership::{ app_key_info, e2e_proof_signing_payload, exchange_key_info, exchange_salt, hello_tag_input, transcript_signing_payload, MemberCert, MemberCredential, MemberFrame, MemberHello, - MemberProof, MemberProvision, ProofKind, APP_KEY_LEN, MEMBER_CREDENTIAL_LEN, - MEMBER_E2E_KEY_INFO, MEMBER_NONCE_LEN, MEMBER_PROOF_META_KEY, MEMBER_SEALED_LEN, - MEMBER_TAG_LEN, + MemberProof, MemberProvision, ProofKind, APP_KEY_LEN, DISCOVERY_SALT_LEN, DISCOVERY_SEAL_INFO, + MEMBER_CREDENTIAL_LEN, MEMBER_E2E_KEY_INFO, MEMBER_NONCE_LEN, MEMBER_PROOF_LEN, + MEMBER_PROOF_META_KEY, MEMBER_PROOF_SALT_LEN, MEMBER_SEALED_LEN, MEMBER_TAG_LEN, +}; +use offline_protocol_services::{ + discovery_response_ends_at, discovery_response_is_proven, DiscoverySealer, }; use offline_protocol_transport::TransportType; use rand_core::{OsRng, RngCore}; @@ -728,7 +733,10 @@ impl OfflineProtocol { /// the group data plane (it opens only under a group this node holds, so /// a stranger cannot produce one). A key package, a 1:1 Welcome or a /// group Welcome from a sender not otherwise admitted passes only with a - /// valid end-to-end membership proof. + /// valid end-to-end membership proof. Discovery gossip follows + /// [`Self::is_discovery_carriage`]: a query passes whoever sent it, so a + /// stranger's query is still forwarded, and a response ending here + /// passes only with its provider's proof. /// /// [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(crate) fn inbound_admitted( @@ -747,7 +755,7 @@ impl OfflineProtocol { || content.starts_with(internal_prefixes::CUSTODY_RECEIPT) || Self::is_unsignable_relay_answer(message, arrival) || Self::is_group_data_plane(content) - || Self::is_service_discovery(content) + || self.is_discovery_carriage(content, sender) || self.admitted(sender) { return true; @@ -763,11 +771,31 @@ impl OfflineProtocol { content.starts_with("__GRP_") && !content.starts_with(internal_prefixes::GROUP_MLS_WELCOME) } - /// Discovery gossip is carriage until its own membership rules land - /// (step 7c); it never answers a stranger with anything but a provider - /// answer, which those rules gate. - fn is_service_discovery(content: &str) -> bool { - content.starts_with("__SVC_DISC") + /// Whether a discovery frame is processed and acknowledged whatever its + /// sender: every query, and every response on its way to someone else, + /// since passing those on is carriage and a stranger's query must still + /// be forwarded. A response that ends at this node is end to end: it + /// passes only when it carries a proof from its provider (or its sender + /// is admitted), so a stranger's forged answer is not even + /// acknowledged. + /// + /// Passing here is not answering. The services layer still answers a + /// query only when its own proof verifies and reports a response only + /// when its proof does. + pub(crate) fn is_discovery_carriage(&self, content: &str, sender: &str) -> bool { + if content.starts_with(offline_protocol_services::SVC_DISCOVER_QUERY) { + return true; + } + if !content.starts_with(offline_protocol_services::SVC_DISCOVER_RESPONSE) { + return false; + } + !discovery_response_ends_at(content, &self.local_id) + || discovery_response_is_proven( + content, + sender, + &self.local_id, + &self.membership_keys(), + ) } /// The proof kind a frame of this content would carry, with the body @@ -839,23 +867,9 @@ impl OfflineProtocol { kind: ProofKind, body: &[u8], ) -> Option> { - let app_key = self.membership.app_key?; - let cert = self.membership.cert.clone()?; let recipient: Address = recipient.parse().ok()?; - let payload = e2e_proof_signing_payload(Some(&recipient), kind, body).ok()?; - let signature = self.sign_with_identity(&payload)?; - let credential = MemberCredential { cert, signature }; - let salt: [u8; 16] = random_array(); - let key = hkdf32(&app_key, Some(&salt), MEMBER_E2E_KEY_INFO); - let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); - let sealed = cipher - .encrypt( - Nonce::from_slice(&[0u8; 12]), - credential.to_bytes().as_slice(), - ) - .ok()?; - let sealed = <[u8; MEMBER_SEALED_LEN]>::try_from(sealed.as_slice()).ok()?; - Some(MemberProof { salt, sealed }.to_bytes().to_vec()) + self.membership_keys() + .member_proof(Some(&recipient), kind, body) } /// Verifies a proof from `signer` toward this node: it opens under @@ -870,45 +884,24 @@ impl OfflineProtocol { kind: ProofKind, body: &[u8], ) -> bool { - let (Some(app_key), Some(publisher_key), Some(identity_key)) = ( - self.membership.app_key, - self.membership.publisher_key, - identity_key, - ) else { - return false; - }; - let (Ok(proof), Ok(local), Ok(signer_addr)) = ( - MemberProof::from_bytes(proof), - self.local_id.parse::
(), - signer.parse::
(), - ) else { - return false; - }; - let key = hkdf32(&app_key, Some(&proof.salt), MEMBER_E2E_KEY_INFO); - let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); - let Ok(plaintext) = cipher.decrypt(Nonce::from_slice(&[0u8; 12]), proof.sealed.as_slice()) + let (Some(identity_key), Ok(local)) = (identity_key, self.local_id.parse::
()) else { return false; }; - let Ok(credential) = MemberCredential::from_bytes(&plaintext) else { - return false; - }; - let cert = &credential.cert; - if cert.address != signer_addr - || !cert_verifies(&publisher_key, cert) - || !cert.is_current(unix_now()) - { - return false; - } - let Ok(payload) = e2e_proof_signing_payload(Some(&local), kind, body) else { - return false; - }; - offline_protocol_mls::MlsManager::verify_signature( + self.membership_keys().check_member_proof( + proof, + signer, identity_key, - &payload, - &credential.signature, + Some(&local), + kind, + body, ) - .unwrap_or(false) + } + + /// What this node's membership lends to code that holds only a part of + /// the engine (service discovery). + fn membership_keys(&self) -> MembershipKeys<'_> { + MembershipKeys::new(&self.membership, self.mls_manager.as_ref(), &self.local_id) } /// Whether `peer` has proven, in a membership exchange, that it belongs to @@ -1288,9 +1281,7 @@ impl OfflineProtocol { /// 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() + sign_with_identity(self.mls_manager.as_ref(), payload) } /// Puts one membership frame on the neighbour's link: signed, TTL one, @@ -1332,3 +1323,202 @@ fn cert_verifies(publisher_key: &[u8; 32], cert: &MemberCert) -> bool { ) .unwrap_or(false) } + +/// An Ed25519 signature by this node's identity key, always 64 bytes. +fn sign_with_identity(mls: Option<&Arc>>, payload: &[u8]) -> Option<[u8; 64]> { + let manager = mls?.read().ok()?; + let signature = manager.sign_data(payload).ok()?; + <[u8; 64]>::try_from(signature.as_slice()).ok() +} + +/// ChaCha20-Poly1305 under `key` with the zero nonce and no associated data: +/// every key it is given is derived from a fresh salt and used once. +fn seal_once(key: &[u8; 32], plaintext: &[u8]) -> Option> { + ChaCha20Poly1305::new(Key::from_slice(key)) + .encrypt(Nonce::from_slice(&[0u8; 12]), plaintext) + .ok() +} + +fn open_once(key: &[u8; 32], ciphertext: &[u8]) -> Option> { + ChaCha20Poly1305::new(Key::from_slice(key)) + .decrypt(Nonce::from_slice(&[0u8; 12]), ciphertext) + .ok() +} + +/// This node's membership, borrowed apart from the rest of the engine: the +/// application key, the certificate, the identity signer and the address. +/// +/// Built from three fields rather than from `&OfflineProtocol`, so the +/// service layer can be handed it while the engine holds that layer +/// mutably. It is the one place the end-to-end proof is made and checked, +/// for the frames that open a session and for service discovery alike. +pub(crate) struct MembershipKeys<'a> { + membership: &'a Membership, + mls: Option<&'a Arc>>, + local_id: &'a str, +} + +impl<'a> MembershipKeys<'a> { + pub(crate) fn new( + membership: &'a Membership, + mls: Option<&'a Arc>>, + local_id: &'a str, + ) -> Self { + Self { + membership, + mls, + local_id, + } + } + + /// A proof of `kind` over `body` toward `recipient` (`None` for a + /// discovery query), when this node holds a certificate. + fn member_proof( + &self, + recipient: Option<&Address>, + kind: ProofKind, + body: &[u8], + ) -> Option> { + let app_key = self.membership.app_key?; + let cert = self.membership.cert.clone()?; + let payload = e2e_proof_signing_payload(recipient, kind, body).ok()?; + let signature = sign_with_identity(self.mls, &payload)?; + let credential = MemberCredential { cert, signature }; + let salt: [u8; MEMBER_PROOF_SALT_LEN] = random_array(); + let key = hkdf32(&app_key, Some(&salt), MEMBER_E2E_KEY_INFO); + let sealed = seal_once(&key, credential.to_bytes().as_slice())?; + let sealed = <[u8; MEMBER_SEALED_LEN]>::try_from(sealed.as_slice()).ok()?; + Some(MemberProof { salt, sealed }.to_bytes().to_vec()) + } + + /// Whether `proof` opens under `K_app`, its certificate names `signer`, + /// verifies under the publisher key and is current, and `identity_key` + /// signed `recipient`, `kind` and `body`. The caller owes the binding of + /// `identity_key` to `signer`. + fn check_member_proof( + &self, + proof: &[u8], + signer: &str, + identity_key: &[u8], + recipient: Option<&Address>, + kind: ProofKind, + body: &[u8], + ) -> bool { + let (Some(app_key), Some(publisher_key)) = + (self.membership.app_key, self.membership.publisher_key) + else { + return false; + }; + let (Ok(proof), Ok(signer_addr)) = + (MemberProof::from_bytes(proof), signer.parse::
()) + else { + return false; + }; + let key = hkdf32(&app_key, Some(&proof.salt), MEMBER_E2E_KEY_INFO); + let Some(plaintext) = open_once(&key, proof.sealed.as_slice()) else { + return false; + }; + let Ok(credential) = MemberCredential::from_bytes(&plaintext) else { + return false; + }; + let cert = &credential.cert; + if cert.address != signer_addr + || !cert_verifies(&publisher_key, cert) + || !cert.is_current(unix_now()) + { + return false; + } + let Ok(payload) = e2e_proof_signing_payload(recipient, kind, body) else { + return false; + }; + offline_protocol_mls::MlsManager::verify_signature( + identity_key, + &payload, + &credential.signature, + ) + .unwrap_or(false) + } +} + +/// Service discovery on a configured node ([ADR 0037], "Service +/// discovery"): the body sealed under `K_app` with a fresh salt, and the +/// end-to-end proof of kinds 4 and 5. +/// +/// [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 +impl DiscoverySealer for MembershipKeys<'_> { + fn now_unix(&self) -> u64 { + unix_now() + } + + fn identity_key(&self) -> Option> { + self.mls?.read().ok()?.get_identity_public_key().ok() + } + + fn seal(&self, plaintext: &[u8]) -> Option> { + let app_key = self.membership.app_key?; + let salt: [u8; DISCOVERY_SALT_LEN] = random_array(); + let key = hkdf32(&app_key, Some(&salt), DISCOVERY_SEAL_INFO); + let mut out = salt.to_vec(); + out.extend(seal_once(&key, plaintext)?); + Some(out) + } + + fn open(&self, sealed: &[u8]) -> Option> { + let app_key = self.membership.app_key?; + if sealed.len() < DISCOVERY_SALT_LEN { + return None; + } + let (salt, ciphertext) = sealed.split_at(DISCOVERY_SALT_LEN); + let key = hkdf32(&app_key, Some(salt), DISCOVERY_SEAL_INFO); + open_once(&key, ciphertext) + } + + /// With no certificate, random bytes of a proof's length: whether this + /// node holds one must not show to a carrier that cannot open a proof. + fn prove(&self, recipient: Option<&str>, kind: ProofKind, bound: &[u8]) -> Vec { + let recipient = match recipient.map(str::parse::
) { + Some(Ok(address)) => Some(address), + Some(Err(_)) => return random_array::().to_vec(), + None => None, + }; + self.member_proof(recipient.as_ref(), kind, bound) + .unwrap_or_else(|| random_array::().to_vec()) + } + + /// The identity key comes from inside a sealed body, not from a frame's + /// signature, so its binding to `signer` is checked here: it must derive + /// to `signer`'s address. + fn verify( + &self, + proof: &[u8], + signer: &str, + identity_key: &[u8], + recipient: Option<&str>, + kind: ProofKind, + bound: &[u8], + ) -> bool { + let derives = match ( + offline_protocol_sealed::derive_address(identity_key), + signer.parse::
(), + ) { + (Ok(derived), Ok(signer)) => derived == signer, + _ => false, + }; + if !derives { + return false; + } + let recipient = match recipient.map(str::parse::
) { + Some(Ok(address)) => Some(address), + Some(Err(_)) => return false, + None => None, + }; + // A proof toward a recipient names this node: a proof made for + // another member must not verify here. + if let Some(recipient) = &recipient { + if self.local_id.parse::
().ok().as_ref() != Some(recipient) { + return false; + } + } + self.check_member_proof(proof, signer, identity_key, recipient.as_ref(), kind, bound) + } +} diff --git a/crates/offline-protocol/src/protocol/message_dispatch.rs b/crates/offline-protocol/src/protocol/message_dispatch.rs index 2a5c6eaff..cd8575915 100644 --- a/crates/offline-protocol/src/protocol/message_dispatch.rs +++ b/crates/offline-protocol/src/protocol/message_dispatch.rs @@ -1,5 +1,6 @@ //! Message dispatch handlers for internal protocol messages. +use super::membership::MembershipKeys; use super::session::{Corroboration, SESSION_CORROBORATED}; use super::{ base64_decode, internal_prefixes, lock_shared_state, ConnectionAcceptedPayload, @@ -19,7 +20,8 @@ use offline_protocol_core::{Message, MessagePriority}; use offline_protocol_mls::{EncryptedMessage, WelcomeMessage}; use offline_protocol_sealed::{SessionCorroboration, SVC_SEALED_V1, SVC_SUB_V1}; use offline_protocol_services::{ - is_subscription_frame, ServiceAction, SVC_MESSAGE_PREFIX, SVC_REQUEST, SVC_RESPONSE, + is_subscription_frame, DiscoverySealer, ServiceAction, SVC_MESSAGE_PREFIX, SVC_REQUEST, + SVC_RESPONSE, }; use offline_protocol_transport::TransportType; use tracing::{debug, error, info, warn}; @@ -2598,18 +2600,34 @@ impl OfflineProtocol { if !sealed && self.drop_plaintext_subscription_frame(sender, content) { return; } - let peers: Vec = self - .known_peers - .keys() - .filter(|p| p.as_str() != sender) - .cloned() - .collect(); - match self.mesh_services.handle_incoming_message( + // A configured node gossips to its mesh neighbours only, as it + // relays: `known_peers` is an admitted set once a publisher key is + // set, and gossip targets chosen by membership would make discovery + // one network per application (ADR 0037, "Service discovery"). + let configured = self.membership_configured(); + let peers: Vec = if configured { + self.transport_manager + .mesh_neighbors() + .into_iter() + .map(|neighbour| neighbour.peer_id) + .filter(|p| p.as_str() != sender) + .collect() + } else { + self.known_peers + .keys() + .filter(|p| p.as_str() != sender) + .cloned() + .collect() + }; + let keys = MembershipKeys::new(&self.membership, self.mls_manager.as_ref(), &self.local_id); + let sealer: Option<&dyn DiscoverySealer> = if configured { Some(&keys) } else { None }; + match self.mesh_services.handle_incoming_message_sealed( content, sender, message.hop_count.value(), &self.local_id, &peers, + sealer, ) { ServiceAction::NotHandled => { warn!(sender = %sender, "Received unknown service message prefix, consuming"); diff --git a/crates/offline-protocol/src/protocol/send.rs b/crates/offline-protocol/src/protocol/send.rs index 527dde3ab..b15407673 100644 --- a/crates/offline-protocol/src/protocol/send.rs +++ b/crates/offline-protocol/src/protocol/send.rs @@ -5721,8 +5721,14 @@ impl OfflineProtocol { status: Option<&str>, ) -> Result<()> { // No acknowledgement of any kind to a sender this application has - // not admitted (ADR 0037). - if !self.admitted(message.sender.as_str()) { + // not admitted (ADR 0037), except for discovery carriage: a hop of + // gossip, or a provider's proven answer, is acknowledged whoever + // sent it, so a member's acknowledgements do not single out its + // fellow members on the link, and a proven answer is not resent to + // its expiry. + if !self.admitted(message.sender.as_str()) + && !self.is_discovery_carriage(&message.content, message.sender.as_str()) + { return Ok(()); } if status.is_none() && self.try_coalesce_ack(message, inbound_transport) { diff --git a/crates/offline-protocol/src/protocol/tests/membership.rs b/crates/offline-protocol/src/protocol/tests/membership.rs index ec4c7a5ce..7a94fd579 100644 --- a/crates/offline-protocol/src/protocol/tests/membership.rs +++ b/crates/offline-protocol/src/protocol/tests/membership.rs @@ -58,11 +58,21 @@ impl Node { /// `auto_key_exchange` off unless a test is about the key-package push /// on discovery: with it on, every link also carries a handshake. fn launch_with(label: &str, publisher_key: Option<[u8; 32]>, auto_key_exchange: bool) -> Self { + Self::launch_configured(label, publisher_key, auto_key_exchange, |_| {}) + } + + fn launch_configured( + label: &str, + publisher_key: Option<[u8; 32]>, + auto_key_exchange: bool, + tweak: impl FnOnce(&mut ProtocolConfig), + ) -> Self { let mut config: ProtocolConfig = create_test_config_for_user(label); config.app_id = APP.to_string(); config.encryption.enabled = true; config.encryption.auto_key_exchange = auto_key_exchange; config.app_publisher_key = publisher_key; + tweak(&mut config); let mut protocol = OfflineProtocol::new(config).expect("protocol"); let state = Arc::new(InMemoryStorage::new()); protocol @@ -1611,3 +1621,6 @@ fn frames_for_this_node_have_one_entry_and_it_is_gated() { "the gate runs before the first side effect" ); } + +#[path = "membership_discovery.rs"] +mod discovery; diff --git a/crates/offline-protocol/src/protocol/tests/membership_discovery.rs b/crates/offline-protocol/src/protocol/tests/membership_discovery.rs new file mode 100644 index 000000000..68a478d75 --- /dev/null +++ b/crates/offline-protocol/src/protocol/tests/membership_discovery.rs @@ -0,0 +1,374 @@ +//! Service discovery on configured nodes (ADR 0037, "Service discovery", +//! step 7c), over the real send, relay and receive paths of a line of +//! nodes with a mock BLE radio each. +//! +//! What is pinned: two members several hops apart find each other's service +//! through nodes of other applications, which carry the query and the answer +//! and learn nothing from them; a query or an answer a member cannot prove is +//! carried and never matched or reported; and a forged answer to a member is +//! not even acknowledged. + +use super::*; +use crate::constants::ACK_FOR_KEY; +use offline_protocol_core::{MessagePriority, ServiceDescriptor, ServiceId}; +use offline_protocol_services::{ + DISCOVERY_SEALED_SERVICE_ID, SVC_DISCOVER_QUERY, SVC_DISCOVER_RESPONSE, +}; +use std::collections::HashMap; + +const SERVICE: &str = "weather"; + +/// A node whose forwarding holds nothing back, so a line settles by +/// stepping. `key` configures the publisher key; `certified` also gives the +/// node its own certificate under it. +fn relay_node(label: &str, key: Option<&SigningKey>, certified: bool) -> Node { + let mut node = Node::launch_configured( + label, + key.map(|k| k.verifying_key().to_bytes()), + false, + |config| { + config.mesh_relay.jitter_min = std::time::Duration::from_millis(0); + config.mesh_relay.jitter_max = std::time::Duration::from_millis(0); + }, + ); + if let (Some(key), true) = (key, certified) { + let cert = certificate(key, &node.address); + node.protocol + .set_app_membership(&cert.to_bytes()) + .expect("the node's own certificate"); + } + node +} + +fn provide(node: &mut Node) { + node.protocol + .register_service(ServiceDescriptor { + service_id: ServiceId::new(SERVICE).expect("a service id"), + version: "1.0".to_string(), + capabilities: HashMap::new(), + }) + .expect("registered"); +} + +/// Nodes in a line, each in range of the next only. +struct Line { + nodes: Vec, + events: Vec>>>, + /// Every frame that crossed a link, as `(from, to, frame)`. + frames: Vec<(String, String, Message)>, +} + +impl Line { + fn new(mut nodes: Vec) -> Self { + let events = nodes.iter_mut().map(record_events).collect(); + for i in 0..nodes.len().saturating_sub(1) { + let (left, right) = nodes.split_at_mut(i + 1); + left[i].link(&right[0]); + right[0].link(&left[i]); + } + let mut line = Self { + nodes, + events, + frames: Vec::new(), + }; + line.run(); + line.frames.clear(); + line + } + + fn index_of(&self, address: &str) -> usize { + self.nodes + .iter() + .position(|n| n.address == address) + .unwrap_or_else(|| panic!("{address} is not on the line")) + } + + /// One round: every node processes what it holds, then what each put + /// on its radio crosses to the neighbour it was handed to. + fn step(&mut self) -> usize { + for node in &mut self.nodes { + while node.protocol.receive_message().is_some() {} + node.protocol.process().expect("process"); + } + let mut moved = 0; + for i in 0..self.nodes.len() { + let radio = &self.nodes[i].transport; + let mut handed: Vec<(String, Message)> = radio + .sent_messages() + .into_iter() + .map(|m| (m.recipient.as_str().to_string(), m)) + .collect(); + handed.extend(radio.peer_sends()); + radio.clear_sent_messages(); + radio.clear_peer_sends(); + let from = self.nodes[i].address.clone(); + for (to, message) in handed { + let j = self.index_of(&to); + assert_eq!( + i.abs_diff(j), + 1, + "{from} handed a frame to {to}, out of range" + ); + self.frames + .push((from.clone(), to.clone(), message.clone())); + self.nodes[j] + .transport + .queue_message_from(message, from.clone()); + moved += 1; + } + } + moved + } + + fn run(&mut self) { + for _ in 0..60 { + if self.step() == 0 { + return; + } + } + panic!("the line never went quiet"); + } + + /// The `ServiceDiscovered` events node `i` emitted, as (provider, service). + fn discovered(&self, i: usize) -> Vec<(String, String)> { + self.events[i] + .lock() + .unwrap() + .iter() + .filter_map(|event| match event { + Event::ServiceDiscovered { + provider_peer_id, + service_id, + .. + } => Some((provider_peer_id.clone(), service_id.clone())), + _ => None, + }) + .collect() + } + + fn discovery_frames(&self) -> Vec<&(String, String, Message)> { + self.frames + .iter() + .filter(|(_, _, m)| m.content.starts_with("__SVC_DISC")) + .collect() + } + + /// Discovery responses written by `node` (its own answers, not frames it + /// relayed for someone else). + fn answers_by(&self, i: usize) -> usize { + let address = &self.nodes[i].address; + let ids: std::collections::HashSet = self + .frames + .iter() + .filter(|(_, _, m)| { + m.content.starts_with(SVC_DISCOVER_RESPONSE) && m.sender.as_str() == address + }) + .map(|(_, _, m)| m.id.as_str()) + .collect(); + ids.len() + } +} + +/// alice and bob are members three hops apart. Between them stand a member +/// of another application and an unconfigured node, both offering the same +/// service id. The query and the answer cross both; neither matches, +/// answers or reports, and neither ever sees the service id or the provider. +#[test] +fn two_members_three_hops_apart_discover_each_others_service() { + let key = publisher(1); + let alice = relay_node("alice", Some(&key), true); + let mut xavier = relay_node("xavier", Some(&publisher(2)), true); + let mut yara = relay_node("yara", None, false); + let mut bob = relay_node("bob", Some(&key), true); + provide(&mut xavier); + provide(&mut yara); + provide(&mut bob); + let bob_address = bob.address.clone(); + let mut line = Line::new(vec![alice, xavier, yara, bob]); + + line.nodes[0] + .protocol + .discover_services(Some(SERVICE)) + .expect("a query"); + line.run(); + + assert_eq!( + line.discovered(0), + vec![(bob_address.clone(), SERVICE.to_string())] + ); + for i in 1..4 { + assert!( + line.discovered(i).is_empty(), + "node {i} reported a discovery" + ); + } + assert_eq!( + line.answers_by(1), + 0, + "another application's member answered" + ); + assert_eq!( + line.answers_by(2), + 0, + "an unconfigured node answered the sentinel" + ); + assert_eq!(line.answers_by(3), 1); + + let crossed = line.discovery_frames(); + assert!( + crossed + .iter() + .any(|(from, to, m)| m.content.starts_with(SVC_DISCOVER_QUERY) + && from == &line.nodes[2].address + && to == &bob_address), + "the query crossed both strangers to reach bob" + ); + for (from, to, frame) in crossed { + assert!( + !frame.content.contains(SERVICE), + "{from} -> {to} carried the service id in the clear" + ); + assert!( + frame.content.contains(DISCOVERY_SEALED_SERVICE_ID), + "{from} -> {to} carried no sentinel" + ); + if frame.content.starts_with(SVC_DISCOVER_RESPONSE) { + assert_eq!(frame.sender.as_str(), bob_address); + assert_eq!(frame.recipient.as_str(), line.nodes[0].address); + } + } + + // Acknowledgements are carriage here too. The other application's + // member acknowledges alice's query although it admits nobody on the + // line, and alice acknowledges bob's proven answer although bob is + // admitted by nothing else, so neither is resent to its expiry. + let acked = |by: &str, to: &str, prefix: &str| { + let ids: Vec = line + .frames + .iter() + .filter(|(_, _, m)| m.content.starts_with(prefix) && m.recipient.as_str() == by) + .map(|(_, _, m)| m.id.as_str()) + .collect(); + line.frames.iter().any(|(_, _, m)| { + m.sender.as_str() == by + && m.recipient.as_str() == to + && m.metadata + .get(ACK_FOR_KEY) + .is_some_and(|id| ids.contains(id)) + }) + }; + let (alice_address, xavier_address) = + (line.nodes[0].address.clone(), line.nodes[1].address.clone()); + assert!(acked(&xavier_address, &alice_address, SVC_DISCOVER_QUERY)); + assert!(acked(&alice_address, &bob_address, SVC_DISCOVER_RESPONSE)); +} + +/// A node holding the application's key but no certificate asks: the member +/// next to it carries the query on and does not answer, and nothing is +/// reported anywhere. +#[test] +fn a_strangers_query_is_carried_but_never_answered() { + let key = publisher(1); + let stranger = relay_node("sam", Some(&key), false); + let mut member = relay_node("mia", Some(&key), true); + let mut far = relay_node("nora", Some(&key), true); + provide(&mut member); + provide(&mut far); + let mut line = Line::new(vec![stranger, member, far]); + + line.nodes[0] + .protocol + .discover_services(Some(SERVICE)) + .expect("a query"); + line.run(); + + let (mia, nora) = (line.nodes[1].address.clone(), line.nodes[2].address.clone()); + assert!( + line.discovery_frames().iter().any(|(from, to, m)| m + .content + .starts_with(SVC_DISCOVER_QUERY) + && from == &mia + && to == &nora), + "the member forwarded the stranger's query" + ); + assert_eq!(line.answers_by(1), 0); + assert_eq!(line.answers_by(2), 0); + for i in 0..3 { + assert!(line.discovered(i).is_empty()); + } +} + +/// A forged discovery answer addressed to a member, from a sender it has not +/// admitted: no event, and not even an acknowledgement goes back, since an +/// answer ending at this node is end to end. +#[test] +fn a_forged_answer_to_a_member_is_not_acknowledged() { + let key = publisher(1); + let alice = relay_node("alice", Some(&key), true); + let eve = relay_node("eve", None, false); + let mut line = Line::new(vec![alice, eve]); + let (alice_address, eve_address) = + (line.nodes[0].address.clone(), line.nodes[1].address.clone()); + + let forged = format!( + "{SVC_DISCOVER_RESPONSE}{}", + serde_json::json!({ + "query_id": "q", + "service_id": SERVICE, + "version": "1", + "provider_peer_id": eve_address, + "capabilities": {}, + "hop_count": 0, + "originator": alice_address, + }) + ); + line.nodes[1] + .protocol + .send_internal_message(&alice_address, forged, MessagePriority::Medium) + .expect("sent"); + line.run(); + + assert!(line.discovered(0).is_empty()); + assert!( + !line + .frames + .iter() + .any(|(from, _, m)| from == &alice_address && m.metadata.contains_key(ACK_FOR_KEY)), + "the member acknowledged a stranger's forged answer" + ); +} + +/// The same forged answer to an unconfigured node is reported and +/// acknowledged, as before this release: invariant 5. +#[test] +fn an_unconfigured_originator_reports_an_answer_as_before() { + let alice = relay_node("alice", None, false); + let eve = relay_node("eve", None, false); + let mut line = Line::new(vec![alice, eve]); + let (alice_address, eve_address) = + (line.nodes[0].address.clone(), line.nodes[1].address.clone()); + + let answer = format!( + "{SVC_DISCOVER_RESPONSE}{}", + serde_json::json!({ + "query_id": "q", + "service_id": SERVICE, + "version": "1", + "provider_peer_id": eve_address, + "capabilities": {}, + "hop_count": 0, + "originator": alice_address, + }) + ); + line.nodes[1] + .protocol + .send_internal_message(&alice_address, answer, MessagePriority::Medium) + .expect("sent"); + line.run(); + + assert_eq!(line.discovered(0), vec![(eve_address, SERVICE.to_string())]); + assert!(line + .frames + .iter() + .any(|(from, _, m)| from == &alice_address && m.metadata.contains_key(ACK_FOR_KEY))); +} diff --git a/docs/spec/control-messages.md b/docs/spec/control-messages.md index 3ed325fa1..5f2691521 100644 --- a/docs/spec/control-messages.md +++ b/docs/spec/control-messages.md @@ -208,7 +208,11 @@ eight spellings above name the frames currently in it. None of the eight is a prefix of another, so a `starts_with` test names exactly one frame. They are signature-gated like any control frame, and they are **exempt from the -encryption requirement**. Discovery gossip is always sent in cleartext. +encryption requirement**. Discovery gossip is always a signed plaintext frame, +never an MLS envelope. On a node whose application configured a publisher key, +every field but the routing ones travels sealed under the application key +inside that frame ([Application membership](#application-membership)); on any +other node the whole payload is cleartext. `__SVC_REQ__` and `__SVC_RESP__` MAY instead travel inside `__MLS_ENC__` toward a peer that advertised `svc_versions` entry 1 @@ -421,7 +425,11 @@ identity key. A pass makes the sender visible until `not_after`. opens a session across the mesh, under the key `__member_proof`, base64 (standard, padded). It rides in the metadata rather than inside the payload so that `frame_body` is the payload exactly as it travels, the frame's content -after its prefix, and no verifier re-serialises anything to hash it. The +after its prefix, and no verifier re-serialises anything to hash it. A +discovery query and response are the exception: every forwarder builds a new +frame, so frame metadata does not survive a hop, and the proof travels in the +payload's `member_proof` field instead, over a body built from the fields no +hop rewrites ("Service discovery on a configured node", below). The metadata is outside the control signature, which costs nothing: the proof signs the recipient, the kind and the body itself, so stripping it fails closed and moving it onto another frame fails to verify. Every key package, @@ -447,7 +455,8 @@ The key label and the signing domain differ, so no byte string is both an HKDF | 4 | Service discovery query | 21 zero bytes: gossip names no single recipient, and the query is bound through its body, which carries `query_id` and `originator`. No address payload is all zero, since the version byte is never zero | | 5 | Service discovery response | The query originator's address payload | -`frame_body` is the frame's content after its prefix, byte for byte. The +For kinds 1 to 3, `frame_body` is the frame's content after its prefix, byte +for byte. For kinds 4 and 5 it is the discovery bound body below. The salt is fresh per proof because `K_app` is shared by every install of the application, and a fixed key with a fixed nonce would repeat across installs. @@ -457,7 +466,86 @@ address against the party the frame speaks for**: the frame's sender for kinds 1, 2, 3 and 5, and the body's `originator` for kind 4, since a discovery query reaches a provider through gossip and its direct sender is any relay. Without that check a member's proof, recorded once, would carry any other node's frame -of the same kind and body hash. +of the same kind and body hash. For kinds 1 to 3 the identity key is the one +that signed the frame, which the control-plane gate already bound to the +sender. For kinds 4 and 5 it travels inside the sealed body, and the verifier +checks that it derives to the address the proof speaks for. + +**Service discovery on a configured node.** Discovery is gossip and so is +carriage, and a node whose application configured a publisher key runs it +under four rules +([ADR 0037](../adr/0037-an-applications-users-are-visible-only-to-that-application.md#service-discovery)). +A node with no publisher key runs it exactly as before: its frames carry none +of the fields below, and it reads them only to carry them on. + +1. *Fan-out never reads membership.* A configured node sends a query it + originates, and forwards a query it receives, to its mesh neighbours (the + peer-to-peer carriers' links) and to nothing else, never to its known + peers. It forwards every query within its hop budget, and relays every + response toward its originator, whether or not the sender is admitted, + the body opened, the proof verified or a service matched. It acknowledges + a query, and a response on its way to someone else, whoever sent it: that + acknowledgement is carriage, and one given only to admitted senders would + mark a node's fellow members on the link. +2. *The body is sealed under the application key.* The cleartext payload + keeps `query_id`, `originator` and (in a query) `remaining_hops`. Its + `service_id` holds the sentinel `__sealed`, which names no service: a + service id may not begin with `__`, so no provider can register it, and an + unconfigured provider matches nothing against it. The field is not left + out, because an absent `service_id` asks an unconfigured provider for + every service. In a response `version` and `provider_peer_id` are empty, + `capabilities` is empty and `hop_count` is 0. The real fields travel in + `sealed`, base64 (standard, padded) of + + ``` + salt = random(16) + k = HKDF(ikm = K_app, salt, info = "offline-app-svc-v1") + sealed = salt || ChaCha20-Poly1305(k, nonce = 0, aad = empty, plaintext) + ``` + + where the plaintext is UTF-8 JSON: for a query + `{"issued_at": , "identity_key": , "service_id"?: }`, and for a response `{"provider": , "identity_key": , "service_id", + "version", "capabilities", "hop_count"}`. A node that cannot open the body + forwards the frame unchanged but for the hop budget and answers nothing. +3. *Both ends prove membership.* Each frame carries `member_proof`, base64 of + a proof of kind 4 (query) or 5 (response, bound to the originator), whose + `frame_body` is the **discovery bound body**: + + ``` + u16be(len(query_id)) || query_id || originator(21) || salt || ciphertext + ``` + + with `originator` its address payload and the last two fields the bytes + `sealed` decodes to. These are the fields no hop rewrites; the hop budget + is not bound. A provider answers only a query whose proof verifies + against `originator`. An originator reports `ServiceDiscovered` only for a + response that names it as `originator`, whose sealed `provider` is the + frame's sender, and whose proof verifies against that provider. A + configured node with no certificate sends 189 random bytes in + `member_proof`, so whether it holds one does not show to a carrier. +4. *A provider answers the originator, once, while the query is fresh.* The + response is an ordinary signed unicast addressed to `originator`, not to + the query's direct sender. The provider answers only while + `|now - issued_at|` is at most the discovery query lifetime, 60 seconds, + which is no longer than the query dedup window, and answers a `query_id` + at most once: it remembers each answered `query_id` until + `issued_at + 60`, past which the query is stale anyway, and at its cap it + refuses rather than forget one that is still fresh. Freshness is never + judged by the frame's control timestamp, which each forwarder signs anew. + +A configured provider never answers a cleartext query (one with no `sealed` +field): its originator is not of this application, and the answer would be +the provider's service list in the clear to a stranger. A configured +originator never reports a cleartext response, since it never asks in the +clear. + +The constants and the bound body are owned by `offline-protocol-sealed` +(`membership::DISCOVERY_SEAL_INFO`, `DISCOVERY_SEALED_SERVICE_ID`, +`discovery_bound_body`) and pinned by the `discovery` block of the vectors +above. **Leaf provisioning.** A leaf cannot fetch a certificate, so its paired phone hands it one inside their 1:1 session, as the plaintext of an ordinary sealed @@ -529,10 +617,14 @@ admitted is consumed as a blocked sender's is: not processed, not answered `no_session` reply), no event. The sender observes what it observes for an address nobody holds, and its retries run to expiry. A key package, 1:1 Welcome or group Welcome passes when it carries a valid end-to-end proof -(below). Passed whatever the sender: a `__MEMBER__` frame, a relay server's +(above). Passed whatever the sender: a `__MEMBER__` frame, a relay server's answer, a custody receipt, the group data plane (it opens only under a group -this node holds), and service discovery gossip, which has its own rules. A -frame addressed to someone else is relayed as before. +this node holds), and service discovery gossip, which has its own rules +(above): a query, and a response on its way to someone else, pass and are +acknowledged whoever sent them, while a response that names this node as its +originator, or names none, passes and is acknowledged only when its sender is +admitted or it carries a proof that verifies. A frame addressed to someone +else is relayed as before. Carriage is not gated: relay targets, the outbox flush to that peer and custody redelivery run for every neighbour. Neither is an effect the