Skip to content

feat(protocol): seal service request and response bodies end to end - #544

Merged
bahdotsh merged 7 commits into
mainfrom
feat/533-sealed-service-bodies
Oct 9, 2026
Merged

bahdotsh merged 7 commits into
mainfrom
feat/533-sealed-service-bodies

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

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:

  • our encrypt_service_messages switch is on (new, default on),
  • the recipient advertised svc_versions entry 1 in a signed key package, or has already sent us a sealed service frame,
  • the session with the recipient is confirmed.

The provider's service_request_received.sender is 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

  • Capability. KeyPackagePayload::svc_versions and SVC_SEALED_V1 = 1 in offline-protocol-sealed. It is honoured only from a signed key package, like nostr_pubkey, because it sits beside a confidentiality decision. It is persisted in PeerCapabilities and restored on initialize_mls. The leaf advertises it empty.
  • Send. 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 automatic not_found. Sealed frames go through a new send_sealed_internal_message, unsigned like every 1:1 envelope, and stage OutboxReseal. 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 shared dispatch_internal_message.
  • Receive. A decrypted __SVC_ plaintext from a session: slot is routed to handle_service_message with 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 automatic not_found sent 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.
  • Bindings. encrypt_service_messages is appended last to the flat UDL ProtocolConfig. 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_section is the first Rust guard over the encryption section across bridges.

Wire and compatibility

  • One additive optional field on __MLS_KEY_PKG__. The vectors are regenerated through tools/spec-vectors/generate.py.
  • No new prefix and no registry change. The sealed form is __MLS_ENC__, already data plane.
  • An older peer never advertises, so it never receives a sealed service frame. A full revert is safe: a persisted svc_versions is ignored by older code via serde default.
  • EncryptionConfig gets 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.md section 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

Check Result
cargo test --workspace (incl. doctests) 3294 passed, 0 failed
New engine tests (protocol/tests/service_seal.rs) 16, including a real two-device handshake
Mutations of the guarded behaviour 10 of 10 turned a test red
fmt, both CI clippy steps, cargo doc -D warnings clean
Bare metal (4 targets) and WASI for sealed and leaf clean
Python bindings 1085 passed, 1 skipped
iOS SwiftPM 462 passed; React-header typecheck of OfflineProtocolModule.swift passes, negative control fails as expected
Android ProtocolConfigParserTest 46 passed
React Native tsc and test:js pass
Regenerating bindings from a clean tree no drift; 54 generator guards pass

The new bridge guard was negative-controlled against each bridge site it reads.

Not covered

  • No test pins that a __SVC_ plaintext inside a group ciphertext stays unrouted. The session: check does it, but it is not pinned.
  • The native package jobs and the full Android suite run only in CI.

Review fixes (ab014c9)

  • Proof ratchet. A peer that sent us a sealed service frame is recorded durably beside the control-freshness ratchet, where no key package reaches. A replayed key package, which is admitted at any age when signed under offline-ctrl-v1, can no longer send that peer's bodies back to plaintext.
  • No plaintext on a fault. A seal failure on a confirmed session returns the error instead of sending signed plaintext.
  • encrypted on the service events. service_request_received and service_response_received say which form arrived. Rust code naming every field of either variant needs it or .., hence the !.
  • Docs. The freshness-window claim in R9, the capability chapter and the field doc is corrected.
  • Six new tests. Reverting each fix turns at least one test red. cargo test --workspace gives 3300 passed, 0 failed.

Follow-ups (not in this PR)

  • A strict mode under require_encryption that refuses the plaintext fallback toward a peer that advertised or proved the sealed form.
  • A test pinning that a __SVC_ plaintext inside a group ciphertext stays unrouted.

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.
@bahdotsh
bahdotsh merged commit 7d7bb25 into main Oct 9, 2026
46 of 47 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 9, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Encrypt service request and response bodies end to end

1 participant