Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

148 changes: 142 additions & 6 deletions crates/offline-protocol-sealed/src/membership.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>> {
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;

Expand Down Expand Up @@ -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
///
Expand Down Expand Up @@ -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,
Expand All @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -1038,6 +1128,7 @@ mod vectors {
"proof",
"provision",
"identifiers",
"discovery",
] {
assert!(v[block].is_object(), "{block} is present");
}
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions crates/offline-protocol-services/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
7 changes: 7 additions & 0 deletions crates/offline-protocol-services/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
23 changes: 22 additions & 1 deletion crates/offline-protocol-services/src/payloads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ pub(crate) struct ServiceDiscoveryQueryPayload {
pub service_id: Option<String>,
#[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<String>,
/// 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<String>,
}

/// Wire-format payload for a service discovery response.
Expand All @@ -57,9 +70,17 @@ pub(crate) struct ServiceDiscoveryResponsePayload {
pub provider_peer_id: String,
pub capabilities: HashMap<String, String>,
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<String>,
/// The provider's end-to-end membership proof (base64).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub member_proof: Option<String>,
}

/// Wire-format payload for a service request.
Expand Down
Loading
Loading