Repository navigation
feat(protocol): seal service request and response bodies end to end - #544
Merged
Merged
Conversation
Adds svc_versions to the key package payload and SVC_SEALED_V1 = 1. Entry 1 means the peer routes a __SVC_REQ__ or __SVC_RESP__ found inside a decrypted 1:1 MLS plaintext into its service layer. Vectors regenerated from the generator; the negotiation chapter names the field, states the stated exception to rule 3, and the leaf advertises it empty.
A service request or response is sealed inside the recipient's 1:1 MLS session when our switch is on, the peer advertised svc_versions entry 1 in a signed key package (or proved it by sending a sealed service frame), and the session is confirmed. Otherwise it leaves as today's signed plaintext, and the encrypt attempt starts the session the way a direct message does, so the next request seals. A decrypted __SVC_ frame from a session slot is routed into the service layer with the MLS-authenticated sender, after the session confirm, so the automatic not_found answer to a sealed request is sealed too. A sealed frame stages re-seal provenance, so a resend after a re-key is sealed again rather than replayed dead. New EncryptionConfig switch encrypt_service_messages, default on.
Appended last to the flat UDL ProtocolConfig (default true), carried by the FFI config, and read by the iOS and Android parsers nested-then-flat in both spellings. The TypeScript wrapper resolves it into both shapes. Swift, Kotlin and Python regenerated together; the local API table regenerated. every_bridge_reads_the_encryption_config_section is the first guard over the encryption section across bridges.
R9 now says what is sealed and what still is not: discovery always, and a request or response toward an older peer or before the session confirms, including the first request to a never-met provider. The service chapter, the control-message and envelope specs, the HTTP front's invariant 1 and every config reference name the new switch. UPGRADING gives the measured cost: a sealed frame is about 1.34x the plaintext one with the compact envelope, so bodies above about 52 KB stop crossing Bluetooth LE at the MTU floor and above about 47 KiB stop fitting a Nostr relay.
Review of #544 found the plaintext fallback reaching further than the design says it does, and nobody able to see when it did. It turns out the proof that a peer routes sealed service frames lived in the same in-memory set the key package rewrites. So one replayed key package from before the peer upgraded removed it, and that peer's bodies went back to signed plaintext. The docs claimed the control frame freshness window bounds that replay. It doesn't: a key package signed under offline-ctrl-v1 is admitted at any age, on purpose, so it can always re-teach capabilities. A ratchet that any replay can clear is not a ratchet. Keep the proof where the control-freshness ratchet already lives, in the peer's encryption-capability record, which no key package touches. Restore it on start, seal on either the advertised or the proved set, and leave it standing through block and unblock. Every writer of that record now builds it from one place, because each write replaces the whole thing and a writer that spells a field out itself will one day spell it false. Second, a seal failure on a *confirmed* session used to send the body as signed plaintext and return Ok. The fallback exists for a peer we cannot seal to yet, not for an MLS or storage fault on one we can. Return the error, as send_message does. A failure while the session is still being set up keeps the documented fallback. Third, the service events now carry `encrypted`, so an application can tell which form arrived. That is the build break: Rust code naming every field of either variant needs the field or a `..`. Events cross the bindings as JSON, so nothing is regenerated. Fixes part of #533.
The advertised half of the sealed-service capability copied the bound its neighbours use: at 1000 peers, clear the whole set and start over. That is fine for the compact envelope or rich extras, where forgetting a peer costs a feature. It is *not* fine here, where forgetting a peer sends its service bodies in signed plaintext. So a neighbour mints a thousand identities, one keygen each, sends a thousand signed key packages, and every established peer that has not yet sent us a sealed frame gets its next request or response in the clear. No error, no event. Flood both ends before the first exchange and neither side ever earns the ratchet. This is not great. Make the set a strict subset of the capped encryption-capable set, refused at that cap instead of evicted, the same rule the proof ratchet and the reset watermark already follow. The gate marks a signed sender capable before its key package reaches dispatch, so a genuine peer still gets in, and a flood only costs its own later identities the capability. The "keep it for this run" fallback in the sealed-frame handler existed only to paper over the ratchet refusing a peer; with the subset rule it can never fire, so it goes. While at it, correct R9: a proved peer is not past all three plaintext cases. A request sent while a desync re-key rebuilds the session still takes the not-yet-confirmed fallback. And note the both-create owner that answers its first sealed request in the clear, which is inside that same documented window.
The receive side handed *any* decrypted plaintext starting with `__SVC_` to the service layer, while the send side only ever seals requests and responses. Discovery is never sealed, because every hop has to read it to match and forward. So a peer that sealed a discovery query anyway got it routed, and we then forwarded it onward as plaintext gossip. Not a disclosure the peer couldn't have caused by sending it in the clear, but the send rule and the receive rule disagreeing is how the next frame family gets routed before anyone designed it. Match the two frames that are sealed and drop any other service frame found in a 1:1 session. Dropped, not passed through: it must never surface as a chat message either. The spec now says so.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #533.
What changes
Service request and response bodies (
__SVC_REQ__,__SVC_RESP__) used to cross every hop as signed plaintext. They are now sealed inside the recipient's 1:1 MLS session when all of these hold:encrypt_service_messagesswitch is on (new, default on),svc_versionsentry 1 in a signed key package, or has already sent us a sealed service frame,The provider's
service_request_received.senderis then the MLS-authenticated requester.Otherwise the body leaves as today's signed plaintext. That covers an older peer, either side's switch off, and any request before the session confirms, including the first request to a never-met provider. The encrypt attempt on that first request starts the session the way a direct message does, so the next request seals. Discovery (
__SVC_DISC_Q__/__SVC_DISC_R__) is unchanged and stays plaintext.How
KeyPackagePayload::svc_versionsandSVC_SEALED_V1 = 1inoffline-protocol-sealed. It is honoured only from a signed key package, likenostr_pubkey, because it sits beside a confidentiality decision. It is persisted inPeerCapabilitiesand restored oninitialize_mls. The leaf advertises it empty.send_service_frame(protocol/service_seal.rs) is the single choke point for both public entry points and for the replies the service layer builds itself, such as the automaticnot_found. Sealed frames go through a newsend_sealed_internal_message, unsigned like every 1:1 envelope, and stageOutboxReseal. A resend after a desync re-key is therefore sealed again rather than replayed as dead ciphertext.send_internal_message's tail is now a shareddispatch_internal_message.__SVC_plaintext from asession:slot is routed tohandle_service_messagewith the MLS-verified sender. It is never re-fed through the control gate, which would refuse it unsigned. The session is confirmed before the frame is handled: otherwise the automaticnot_foundsent from inside the handler would find the session unconfirmed on the owner's first decrypt and leave in plaintext. Routing is unconditional (parsing is never gated). With the switch on, a sealed inbound frame also marks its sender capable, in memory. A replayed older key package cannot fake that, and it closes the provider's plaintext-reply window before the requester's fresh key package lands.encrypt_service_messagesis appended last to the flat UDLProtocolConfig. Swift, Kotlin and Python are regenerated together, along with the Python local API table. The iOS and Android readers take it nested-then-flat in both spellings, and the TypeScript wrapper carries it in both shapes.every_bridge_reads_the_encryption_config_sectionis the first Rust guard over the encryption section across bridges.Wire and compatibility
__MLS_KEY_PKG__. The vectors are regenerated throughtools/spec-vectors/generate.py.__MLS_ENC__, already data plane.svc_versionsis ignored by older code via serde default.EncryptionConfiggets a named serde default, so a config serialized before the field existed still parses, with the switch on.Measured cost (not estimated): a sealed frame is about 1.34x the plaintext one with the compact envelope, so a 64 KiB body becomes a frame of about 86 KiB. Bodies above about 52 KB stop crossing Bluetooth LE at the 185-byte MTU floor, and bodies above about 47 KiB stop fitting a Nostr relay.
docs/UPGRADING.mdsection 27 says so.Docs
The threat model R9 is retitled and rewritten, and both links to its anchor are updated. Also changed: the capability-negotiation chapter (the stated exception to rule 3), the control-messages and encryption-envelopes specs, HTTP front invariant 1,
service-discovery.md, every config reference, the CHANGELOG and UPGRADING.Verification
cargo test --workspace(incl. doctests)protocol/tests/service_seal.rs)cargo doc -D warningsOfflineProtocolModule.swiftpasses, negative control fails as expectedProtocolConfigParserTesttscandtest:jsThe new bridge guard was negative-controlled against each bridge site it reads.
Not covered
__SVC_plaintext inside a group ciphertext stays unrouted. Thesession:check does it, but it is not pinned.Review fixes (ab014c9)
offline-ctrl-v1, can no longer send that peer's bodies back to plaintext.encryptedon the service events.service_request_receivedandservice_response_receivedsay which form arrived. Rust code naming every field of either variant needs it or.., hence the!.cargo test --workspacegives 3300 passed, 0 failed.Follow-ups (not in this PR)
require_encryptionthat refuses the plaintext fallback toward a peer that advertised or proved the sealed form.__SVC_plaintext inside a group ciphertext stays unrouted.