From b88a3c6d3f20c5a315b2b9e1de8b135f46d8f525 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Wed, 30 Sep 2026 21:14:01 +0530 Subject: [PATCH] docs(spec): the custody chapter, with its receipt prefix reserved A forwarder holds a stranger's frame for five seconds; the device's own messages get seven days on disk. The custody chapter specifies how a device closes that gap for one class of traffic, and it is written before the code so the shape is agreed where the mechanism is invariant-laden. The chapter states seven invariants first: custody is replication, never transfer; a receipt settles nothing; the hold is strictly shorter than the outbox lifetime, because a custodian holds ciphertext it cannot re-seal; a custodian never blanks the route it is holding; Class A only, by the depositor's word, since a custodian cannot classify a sealed frame; a deposit comes from the depositor itself over the link that proved it, so the frame's sender must be the proven arrival peer and every forwarder strips the unsigned deposit key from any third-party frame it transmits; and custody is opt-in with its own erase. Then the deposit (the depositor's own frame with a reserved hop-local metadata key, accepted only where forwarding has already failed and the forwarding identifier is already released), the receipt (a signed control frame under the ordinary gate, sent once over the arrival link and gated on a capability entry so a peer that does not reserve the prefix never receives one), redelivery through a dedicated governor intake that skips the suppression check and the hop accounting and returns a frame that reaches no link to the store, a hold judged in wall time from the acceptance timestamp so a lowered hold applies to records already held and a custodian that was off for a week delivers nothing stale, the quotas with a stranger tier of zero, and the erase. The chapter states the condition on "cannot falsely settle": it holds with crypto recovery enabled, the default; with it disabled a stale copy is a terminal decrypt failure that is acknowledged, and for Class A the next version-offer exchange re-offers the change. Reservations, each in the registry that owns it: `__CUSTODY_RECEIPT__` in the control-message chapter and the engine's prefix macro, so the two-way registry test binds them and application text can never occupy the name; `__custody` in the wire-format chapter's reserved metadata keys; `data_versions` entry 7 in the capability chapter. No handler consumes the prefix yet and no peer advertises the entry, so nothing changes on the wire until the implementation lands. The threat model gains R19 (custody-borne re-key pressure, its condition, and what an integrator should read from the signal), R20 (a custodian retains third-party routing metadata for hours) and R21 (deposit spam, bounded by one proven address for depositor, tier, receipt and capability). The acknowledgement side-channel section says custody changes no acknowledgement decision. The replication state machine records the redelivery trigger, what each redelivered kind does at the recipient, and that the bottom rung of the catch-up ladder becomes reachable more often, which is one of the two reasons the hold is hours and not days. The conformance chapter lists custody among the chapters that are not yet surfaces: the receipt vector lands with the codec. --- CHANGELOG.md | 20 + .../offline-protocol/src/protocol/prefixes.rs | 8 + docs/README.md | 1 + docs/security/threat-model.md | 104 ++++ docs/spec/README.md | 1 + docs/spec/capability-negotiation.md | 21 +- docs/spec/conformance.md | 4 + docs/spec/control-messages.md | 20 + docs/spec/custody.md | 502 ++++++++++++++++++ docs/spec/wire-format.md | 1 + docs/state-machines/data-replication.md | 10 + 11 files changed, 691 insertions(+), 1 deletion(-) create mode 100644 docs/spec/custody.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 42f60e319..8c4244b1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,26 @@ archived by series under [docs/changelog/](docs/changelog/); see the a Rust guard pins the three tables to the definition, the engine and the reference server. Nothing ships in this entry but the contract; the reference server follows it. + +- **The custody chapter.** `docs/spec/custody.md` specifies how a device + holds a neighbour's replication frame for hours instead of the five + seconds a forwarder gives it today: an explicit deposit in which the + depositor asserts the class and this version carries only `delta`, `snap`, + `vv` and `blob_gone`; a signed receipt that settles nothing; a hold that is + validated strictly shorter than the outbox lifetime, because a custodian + holds ciphertext it cannot re-seal; acceptance only for what the mesh could + not forward, at the point where the forwarding identifier is already + released, so a custodian never blanks its own route; redelivery as an + ordinary forward; entry and byte quotas per depositor with a stranger tier + of zero; and an erase of its own, because no global wipe exists. The + control-message registry and the engine's prefix list reserve + `__CUSTODY_RECEIPT__`, the wire-format chapter reserves the metadata key + `__custody`, and `data_versions` gains entry 7 for the receipt. The threat + model gains R19 (custody-borne re-key pressure), R20 (a custodian retains + third-party routing metadata) and R21 (deposit spam). Nothing ships in this + entry but the contract and the reservations; the store, the quotas and the + receipt follow it, and custody stays off until they do. + - **The leaf node builds for ESP32 RISC-V parts and for Cortex-M0.** `offline-protocol-leaf` now compiles, and CI lints it, for `riscv32imac-unknown-none-elf` (ESP32-C6, ESP32-H2), diff --git a/crates/offline-protocol/src/protocol/prefixes.rs b/crates/offline-protocol/src/protocol/prefixes.rs index d0447e809..02722a742 100644 --- a/crates/offline-protocol/src/protocol/prefixes.rs +++ b/crates/offline-protocol/src/protocol/prefixes.rs @@ -134,6 +134,14 @@ define_internal_prefixes! { TYPING_INDICATOR = "__TYPING__", /// Prefix for read receipt messages. READ_RECEIPT = "__READ_RECEIPT__", + /// Prefix for the custody receipt a custodian answers a deposit with + /// (`docs/spec/custody.md`). Reserved ahead of the implementation on the + /// same terms as `DATA_V1`: the name is registered before any frame uses + /// it, so no user message sent in the meantime can occupy it. Not in + /// `DATA_PLANE_PREFIXES`, so it is signature-gated by construction; no + /// handler consumes it yet, and no peer is sent one until it advertises + /// the custody capability entry. + CUSTODY_RECEIPT = "__CUSTODY_RECEIPT__", } /// Data-plane prefixes that are **excluded** from the security gate. diff --git a/docs/README.md b/docs/README.md index a1c40a96f..df4f7e5c5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -64,6 +64,7 @@ implementation written against these documents should interoperate. | [Username discovery and invites](spec/username-discovery.md) | The self-certifying invite payload, the username directory, and the signing-domain registry | | [The gateway contract](spec/gateway-contract.md) | What a gateway is, the five verbs, the daemon wire protocol, and the backbone | | [The local API](spec/local-api.md) | One server, several local applications: JSON-RPC over a WebSocket, the method and event tables, routing, replay, errors | +| [Custody](spec/custody.md) | Holding a neighbour's replication frame for hours: the deposit, the receipt that settles nothing, the hold, redelivery, quotas, erase | | [Conformance](spec/conformance.md) | The two profiles, what every implementation owes, and the vectors that decide it | ## Security diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index a75cfd1a5..b8b92a6c7 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -200,6 +200,14 @@ Consequences for application teams are in [Delivery and ACKs](../state-machines/delivery-and-acks.md). The headline: **a missing acknowledgement is not proof of non-delivery.** +A frame delivered out of [custody](../spec/custody.md) hours after it was +sent is answered by exactly these rules and no others: the custodian's +receipt is not an acknowledgement, the recipient's acknowledgement travels +the ordinary ladder to the sender and never to the custodian, and a copy +sealed to an epoch the recipient has left is withheld like any other desync. +Custody widens nobody's view of the acknowledgement channel in this version, +because acknowledgements are not carried. + ## Known residual risks Stated plainly, because a threat model that lists only what it defeats is @@ -766,6 +774,102 @@ parent's access list. And a restored backup of the key directory rolls MLS state back: it cannot leak a message, but it reinstates spent ratchet secrets on disk and breaks the sessions until they are re-established. +### R19. Custody-borne re-key pressure + +A [custodian](../spec/custody.md) holds ciphertext it cannot re-seal, for +hours, and delivers it when a path appears. If the pair re-keyed in the +meantime, the copy classifies at the recipient as a session desync **when +crypto recovery is enabled, which is the default**: the acknowledgement is +withheld and the identifier unmarked, so nothing settles falsely, and the same +branch schedules a rate-limited re-key and emits the `SESSION_REKEY_TRIGGERED` +security warning. Benign late delivery therefore raises the rate of a signal +this document, and the configuration documentation, tell integrators to read +as injection. + +With crypto recovery disabled, the same copy is a terminal decrypt failure +instead: dropped and acknowledged, and the acknowledgement settles the +depositor's outbox entry for a frame the recipient never read. Custody is what +makes that path reachable, because a direct resend is re-sealed to the live +epoch and never arrives stale. For the one class carried, the recipient's +version vector still lacks the change and the next version-offer exchange +re-offers it, so the loss is a delay rather than a divergence; that is one of +the reasons only replication frames are carried, and a deployment that +disables crypto recovery anywhere should leave custody off. + +**Why it stands:** the recipient cannot tell a stale custody copy from an +injected wrong-epoch frame, for the reason R2 gives: every pre-authentication +verdict is structurally outside the reach of the credential check. The +re-key floor bounds the work, not the signal. + +**What bounds it:** this version carries only replication frames, and a +replication frame that dies with an epoch is re-derivable from state, so +the copy that fails is one the depositor's own re-sealing resend replaces. +The floor (`REKEY_INTERVAL_SECS`) holds one re-key per peer per window +whatever the cause. The hold is strictly shorter than the outbox lifetime, +so a copy never outlives the sender's own ladder. + +**What integrators should read:** in a deployment with custody enabled, a +re-key warning that follows a `peer_rediscovered` event for the same pair by +seconds is the expected shape of a late custody copy, and a sustained rate +with no rediscovery is still the injection signature. The implementation +does not distinguish the two on the warning itself; doing so would need the +custodian to be trusted about provenance, which it is not. + +### R20. A custodian retains routing metadata about third parties + +A sealed payload is opaque at every hop, but the outer frame is not: sender, +recipient, identifier, application id, priority, hop fields, timestamp and +size are readable by whoever holds it. A forwarder learns them for five +seconds in memory. A custodian keeps them for hours, on disk, for traffic +between two other parties, and an A1 or A2 adversary who volunteers as a +custodian collects them by design. + +**Why it stands:** routing needs the recipient, and a custodian needs the +identifier to deduplicate and the sender to key its quotas. Sealing the +held record protects the directory, not the custodian, which is the party +the exposure is to. + +**What bounds it:** custody is off by default; the stranger tier defaults to +zero, so a device that has met nobody holds nothing and a device holds only +for peers it has a session with; the hold is bounded and validated; every +record is erased on the data-layer wipe and at start when custody is +disabled. Quotas bound volume, not observation: an attacker who wants to +observe a pair needs only to be their neighbour with custody on, which is +the same position A1 already has for five seconds per frame. What custody +adds is retention, and the opt-in documentation MUST say so in those words. + +### R21. Deposit spam + +A deposit is a metadata key on the depositor's own frame, authenticated by +nothing beyond the transport identity of the peer that handed it over, and +the class it asserts cannot be checked by the custodian. An A2 adversary can +therefore offer frames for custody as fast as the mesh admits them, assert +`data` on any sealed body, and spend the custodian's durable writes and +storage. + +**Why it stands:** a custodian cannot see inside a sealed frame, so the +assertion cannot be verified, and a deposit cannot be signed by anything +the custodian could check that the transport identity does not already +prove. + +**What bounds it:** acceptance requires the frame's `sender` to be the +address the transport proved for the peer that handed it over, so a deposit +cannot be made on another device's behalf, and the quotas, the receipt and +the capability lookup all key on one address the transport proved. Every +forwarder strips the deposit key from a third-party frame before transmitting +it, so an attacker cannot recruit honest forwarders to deposit its frames at +custodians it never touched. The stranger tier is zero by default, so the +attacker must first complete a session, which costs a key package exchange +and gives the operator a name. Per-depositor entry and byte budgets cap what +one address can hold, the global budgets cap the store, the forwarding +governor's per-neighbour rate limit is applied before a frame ever reaches +the custody decision, and a duplicate identifier is never stored twice. A +false class assertion costs the depositor's own recipient exactly what a +direct resend would, because the recipient's deduplication and its terminal +data-layer outcomes apply unchanged, so it buys the attacker nothing a +direct send does not. Refusals are silent and counted, so the quotas are not +an oracle. + ## Network egress Until 0.26 the Rust crates opened no socket: every byte that left a device diff --git a/docs/spec/README.md b/docs/spec/README.md index 91eb1b928..b66e13af9 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -26,6 +26,7 @@ document says which reading is normative for the wire. | [Username discovery and invites](username-discovery.md) | The self-certifying invite payload, and the non-authoritative username directory | | [The gateway contract](gateway-contract.md) | What a gateway is, the five verbs it implements, the gateway-daemon wire protocol, and the backbone | | [The local API](local-api.md) | One server fronting one engine for several local applications: JSON-RPC over a WebSocket, the `hello` handshake, the method and event tables, routing, replay, and the errors | +| [Custody](custody.md) | Holding a neighbour's replication frame for hours instead of seconds: the deposit, the receipt that settles nothing, the hold, redelivery, quotas and erase | | [Conformance](conformance.md) | The two profiles, what every implementation owes, and how the vectors decide it | ## Conformance vectors diff --git a/docs/spec/capability-negotiation.md b/docs/spec/capability-negotiation.md index ba872f637..a53a1291b 100644 --- a/docs/spec/capability-negotiation.md +++ b/docs/spec/capability-negotiation.md @@ -10,7 +10,7 @@ Peers advertise what they can parse in the key package payload, the body of a | `wire_versions` | Hop-local | Which frame encodings we may emit to this peer | JSON only | | `env_versions` | End to end | Which `__MLS_ENC__` payload forms we may emit | Legacy JSON envelope only | | `rich_versions` | End to end | Whether we may seal a `__RICH_V1__` body, and the v2 media envelope | Plain text only, extras dropped | -| `data_versions` | End to end | Whether we may send `__DATA_V1__` document sync frames, and which document encoding they carry. Entry 1 is 1:1 replication; entry 2 additionally means the peer intercepts these frames inside a *group* ciphertext; entry 3 additionally means the peer speaks the blob-fetch frames and routes a data-purposed media transfer into its document layer; entry 4 additionally means the peer reads the removals a version offer carries; entry 5 additionally means it answers inside the interest an offer declares; entry 6 additionally means it carries attachment bytes inside a group | No replication with that peer | +| `data_versions` | End to end | Whether we may send `__DATA_V1__` document sync frames, and which document encoding they carry. Entry 1 is 1:1 replication; entry 2 additionally means the peer intercepts these frames inside a *group* ciphertext; entry 3 additionally means the peer speaks the blob-fetch frames and routes a data-purposed media transfer into its document layer; entry 4 additionally means the peer reads the removals a version offer carries; entry 5 additionally means it answers inside the interest an offer declares; entry 6 additionally means it carries attachment bytes inside a group; entry 7 additionally means it parses the custody receipt, so a custodian may answer its deposits | No replication with that peer | | `ctrl_versions` | End to end | Which control-frame signing payload we build for this peer. Entry 2 means the peer verifies `offline-ctrl-v2`, which binds the frame's timestamp | Build `offline-ctrl-v1`, which states no freshness | | `nostr_pubkey` | End to end | Which key metadata is sealed to on the Nostr path | Seal to the publicly computable key | @@ -114,6 +114,25 @@ answer that was never coming. A requester MUST refuse only on knowledge: a member this device has never exchanged key packages with is the ordinary case, and reading its absence as incapacity would refuse every such fetch. +Entry 7 gates one frame in one direction. A peer that advertises it parses +the `__CUSTODY_RECEIPT__` control frame ([Custody](custody.md)), so a +custodian that accepted one of its replication frames may answer with a +receipt. A custodian MUST NOT send a receipt to a peer without it: the peer +does not know the prefix as a control frame, so with encryption on it refuses +the receipt as inbound plaintext and records a security refusal, and on a +plaintext-only deployment it shows the receipt to its user as a message. The +gate exists to keep a helpful neighbour from ever causing either. It gates +nothing else. A deposit request is a metadata key an unaware receiver ignores, so a +depositor MAY write it toward any neighbour, and acceptance of a deposit is +decided by the custodian's quotas, never by this entry. It lives in the +replication family rather than in a list of its own because custody carries +replication frames only: a device that does not replicate has nothing to +deposit and nothing to hold, and a separate list would add a key package +field, which is a codec change with vectors, to state what the family +already scopes. It has no attested sibling, because the receipt is a 1:1 +control frame between neighbours and a group inviter has nothing to say +about one. + Entry 2 has a second source, because members of a group never exchange key packages with each other: a group inviter MAY attest it for a member on the Add commit and in the Welcome, exactly as it attests `rich_versions`. An diff --git a/docs/spec/conformance.md b/docs/spec/conformance.md index 0b14210d3..ded89ecb3 100644 --- a/docs/spec/conformance.md +++ b/docs/spec/conformance.md @@ -224,3 +224,7 @@ Some chapters specify behaviour that has no vector file: reads as a key problem rather than an encoding one. The rest of the chapter is a JSON message vocabulary whose mistakes surface as a rejected message, and it stays prose. +- [Custody](custody.md) adds one encoding, the receipt body, and has no + vector for it yet: the chapter precedes the codec, and a vector computed + from prose pins nothing. The vector lands with the implementation, in the + crate that holds the codec, and this list loses the entry then. diff --git a/docs/spec/control-messages.md b/docs/spec/control-messages.md index 7e4dee6a7..40cca800b 100644 --- a/docs/spec/control-messages.md +++ b/docs/spec/control-messages.md @@ -154,6 +154,26 @@ encryption requirement**, so discovery gossip and the application-supplied request and response bodies are sent in cleartext. See [residual risk R9](../security/threat-model.md#r9-service-discovery-and-service-bodies-are-signed-not-encrypted). +### Custody + +| Prefix | Direction | Body | +|--------|-----------|------| +| `__CUSTODY_RECEIPT__` | custodian to depositor | JSON `{"v":1,"id":,"hold_ms":}`. Signature-gated like every control frame; introduces no signing domain of its own | + +The receipt is the one frame [Custody](custody.md) adds. It is reserved +ahead of the implementation on the same terms `__DATA_V1__` was: the name is +registered before any frame uses it, so no application message sent in the +meantime can occupy it. A receipt settles nothing and is never routed as an +acknowledgement; a receiver that reserves the prefix but does not implement +custody MUST NOT advertise `data_versions` entry 7, and then never receives +one. + +The deposit itself is not a frame and has no prefix. It is the depositor's own +`__MLS_ENC__` frame carrying the reserved metadata key `__custody` +([reserved metadata keys](wire-format.md#reserved-metadata-keys)), which every +forwarder strips from a third-party frame before transmitting it, so a deposit +travels exactly one hop. + ## Document sync frames `__DATA_V1__` frames replicate documents. They are specified in their own diff --git a/docs/spec/custody.md b/docs/spec/custody.md new file mode 100644 index 000000000..d676dcd75 --- /dev/null +++ b/docs/spec/custody.md @@ -0,0 +1,502 @@ +# Custody + +## What this chapter is for + +A frame this device did not originate is held for five seconds. That is the +whole of what the mesh does for a stranger's message today: a forwarder queues +it, tries to put it on a link, and when no neighbour can take it within +`RELAY_QUEUE_MAX_OVERDUE` the frame is abandoned and its identifier released. +The device's own messages get the opposite treatment: sealed on disk, retried +for seven days sliding and twenty-eight absolute. Custody is the contract that +lets a device close that gap for one class of traffic, by holding a frame it +could not forward for hours rather than seconds and delivering it when a path +appears. + +This chapter specifies the deposit, the receipt, the hold, redelivery, the +quotas and the erase. It does not change what any existing frame means, and it +adds one control frame and one reserved metadata key to the wire. Nothing in it +is a delivery guarantee; the [invariants](#invariants) say why, and they come +first because every mechanism below is a consequence of one of them. + +Custody is off by default and needs no counterpart to be useful: a device that +never enables it, and a device that never meets a custodian, both behave +exactly as they do today. + +## Invariants + +Seven things hold regardless of implementation, and a change that breaks one is +a protocol break rather than a refactor. + +- **Custody is replication, never transfer.** A custodian is an additional + holder of a frame. The depositor's outbox entry, acknowledgement tracking, + retry ladder and park state are untouched by a deposit and untouched by a + receipt. The failure this prevents is the one the sender-holds-custody + invariant exists for + ([state machines](../state-machines/README.md#the-one-invariant-that-spans-all-six)): + a sender that released a message to a carrier which then walked away would + have lost it silently. Under this chapter that case costs latency and nothing + else. +- **A receipt settles nothing.** Only the recipient settles a message, and it + does so with an acknowledgement, never with anything a custodian sends. A + receipt MUST NOT settle an outbox entry, cancel an acknowledgement timer, + remove a retry entry or un-park a message. The receipt is a separate control + frame with its own prefix rather than an acknowledgement with a flag, because + a receipt that is called an acknowledgement is eventually routed like one, + and the attribution gate that refuses acknowledgements from anyone but the + recipient exists precisely because a false confirmation is strictly worse + than a refusal. +- **The hold is strictly shorter than the outbox lifetime.** A custodian holds + ciphertext it cannot re-seal. The depositor survives a session re-key because + it retains the plaintext and re-seals on resend + ([ADR 0007](../adr/0007-reseal-on-resend.md)); a custodian has neither the + plaintext nor the keys, so its copy has a validity horizon it cannot observe. + A hold that outlives the outbox would keep bytes whose sender has already + reported the message failed, and deliver a frame the recipient may no longer + be able to open, to settle an entry that no longer exists. The bound is + validated at configuration, not documented as advice, because the outbox + lifetime is itself a dial. It is validated on the custodian, against the + custodian's own outbox dial, and it holds network-wide only where depositor + and custodian share a configuration, which this chapter cannot enforce. + Where they do not, the consequence is bounded by the class carried: a Class + A copy delivered after the depositor's entry expired is absorbed by the + recipient, and its acknowledgement names nothing in the depositor's outbox + and settles nothing. A wasted write, never a false settlement. +- **A custodian never blanks the route it is holding.** Accepting a frame into + custody MUST NOT record its identifier in the forwarding suppression cache, + and MUST release it there if the forwarding path recorded it. The cache + retains an identifier for ten minutes against a five-second hold; a custodian + that both held a frame and suppressed its own forwarding of the depositor's + retransmissions of that frame would turn a helpful device into a black hole + on exactly the route now known to be slow. +- **Class A only, by the depositor's word.** A custodian cannot see inside a + sealed frame, so it cannot classify one. Custody is therefore an explicit + deposit in which the depositor asserts the class, and this version accepts + one class: the document replication frames `delta`, `snap`, `vv` and + `blob_gone`. Everything else is refused by name, and the + [class table](#the-classes) says why for each. +- **A deposit comes from the depositor itself, over the link that proved it.** + A custodian accepts a frame into custody only when the frame's `sender` is + the address the transport proved for the peer it arrived from, and every + device that forwards a third-party frame MUST strip the deposit request from + the copy it transmits, whether or not custody is enabled on it. Nothing else + keeps custody to one hop: the request is a metadata key, the control + signature covers the sender, identifier, recipient and content and never the + metadata, and an `__MLS_ENC__` frame carries no control signature at all, so + the key rides the wire unsigned and survives any forwarder that does not + remove it. Without both rules, one frame fanned out to three neighbours, each + of which fans out to three, is held for hours on every device in a zone, and + an honest two-hop network deposits a frame at a custodian that keys its + quotas on the forwarder while answering a sender it may hold no key package + for. With them, the depositor, the tier key, the capability lookup and the + receipt's recipient are one address, and it is one the transport proved. +- **Custody is opt-in and has its own erase.** A device holds other people's + traffic only because its operator said so, and stops holding it the moment + the operator asks. There is no global wipe in this protocol, so custody + cannot inherit one; the erase is its own verb and the data-layer wipe calls + it. + +## The classes + +Custody is defined per payload class, not per message. The classes are the +ones the design fixed, and only Class A is carried in this version. + +| Class | Frames | This version | Why | +|-------|--------|--------------|-----| +| **A. Replication state** | `delta`, `snap`, `vv`, `blob_gone`, sealed to a pairwise session | **Carried** | Idempotent, kind by kind. A duplicate `delta` or `snap` is short-circuited as already applied before the CRDT engine is touched, which is a safety verdict on a compacted replica rather than an optimisation. A redelivered `vv` is an offer, answered from the recipient's live state like any offer, so it costs one exchange that converges on nothing the recipient did not already hold. A redelivered `blob_gone` is a floor report the recipient has already applied. All four are unordered, terminal in every outcome, at most 32 KiB of blob per frame, and re-derivable from state if a carried copy dies with an epoch | +| **B. Replication requests and their answers** | `need_blob`, `need_snap`, `chunk` | **Refused** | Not idempotent in cost. Each acted-on copy of a request spends a whole media transfer or a snapshot export, and both endpoints bound those exchanges on the assumption that a request arrives once. `chunk` is the answer to a group blob request, up to 32 pieces of 32 KiB, and a carried copy repeats an answer the requester already had | +| **C. Direct messages** | Any other `__MLS_ENC__` body | Refused | Subject to epoch death for the whole hold, with no way for the custodian to notice. A carried copy that arrives after a re-key raises the recipient's re-key security signal with benign traffic (see [R19](../security/threat-model.md#r19-custody-borne-re-key-pressure)) | +| **D. Media chunks** | `FileChunk` frames | Refused | Media is never persisted, by policy; a custodian that persisted a chunk would break it, and the chunk-zero purpose mark has a refusal path a carrier must not be able to steer | +| **E. Control frames** | Signed frames under the control-plane gate | Refused | Deferred rather than refused on principle: the thirty-day freshness window already bounds any hold, but each kind has its own consequences on late arrival, and none has been examined for a delivery hours late | +| **F. Acknowledgements** | Frames carrying `ack_for` | Refused | The strongest candidate for the next version, because the attribution gate needs no change to accept a relayed acknowledgement, but it widens who can observe delivery timing and is not in this one | + +Group frames are Class A by content and are still refused: a frame sealed once +under a group key is carried by the group message path, never offered to the +mesh as the depositor's own direct frame, and a group epoch changes on every +membership commit. The depositor's engine never writes a deposit request on one. + +## Reserved names + +This chapter reserves the following. Each exists once, and the registry that +publishes it is named. + +| Name | Kind | Where it is registered | +|------|------|------------------------| +| `__CUSTODY_RECEIPT__` | Control-frame prefix, signature-gated | [Control messages](control-messages.md#custody) | +| `__custody` | Hop-local metadata key, engine-written, stripped by every forwarder | [Reserved metadata keys](wire-format.md#reserved-metadata-keys) | +| `data_versions` entry 7 | Capability | [Capability negotiation](capability-negotiation.md) | + +## The deposit + +A deposit is not a frame. It is the depositor's own frame, offered to its +neighbours exactly as it is offered today, with one reserved metadata key that +says what the frame carries. + +### What the depositor writes + +The depositor MUST write the key `__custody` with the value `data`, and MUST +write it only when all of the following hold: + +1. The frame is the depositor's own, sealed to a pairwise session (its outer + prefix is `__MLS_ENC__`), and the depositor is handing it to neighbours + because it cannot reach the recipient itself. +2. The sealed body is a Class A replication frame. The depositor knows this + from the plaintext it retains for re-sealing on resend; a frame whose + plaintext it no longer holds, for instance after a restart, is offered + without the key. +3. The frame is being offered over a mesh transport to a neighbour whose + address the transport has proved. The key is never written on a frame sent + to the relay, over the internet, or to a gateway, each of which has its own + store-and-forward. + +The depositor is always the frame's `sender`. A deposit is made only by the +device that originated the frame, and only directly: a device never writes the +key on a frame it is forwarding for somebody else, and a custodian never +deposits a held frame onward. + +The value is a class token. `data` names Class A and is the only token defined. +A custodian MUST refuse a deposit whose token it does not know, and MUST NOT +infer a class from anything but the token. + +### What a forwarder does + +A device that transmits a third-party frame MUST remove `__custody` from the +copy it transmits, at the point where it adjusts the frame's hop fields, whether +or not custody is enabled on it and whether or not it understands the key. The +key is not signed and is not inside the sealed body, so nothing else stops it +travelling on. The failure this rule prevents is stated under the invariants: +without it a two-hop network deposits a frame at a custodian that never saw +its sender, and a zone holds every frame everywhere. + +An application MUST NOT write `__custody`; it is engine-written, like the two +signature keys. Nothing authenticates the token beyond the transport identity +of the peer that handed the frame over, and nothing needs to: a false assertion +costs the depositor's own recipient exactly what a direct resend of the same +frame would have cost, and a depositor can already send that directly. + +### What a custodian does on arrival + +A frame carrying a deposit request is, first, an ordinary forward. The +custodian queues it, tries to put it on a link and hands it onward if it can, +under every gate and budget that governs forwarding today. Custody begins only +where forwarding ends. + +**A custodian holds only what it could not forward.** Acceptance happens at the +point where a forward has waited past `RELAY_QUEUE_MAX_OVERDUE` without reaching +a link and would be abandoned. At that point the forwarding path has already +released the frame's identifier from the suppression cache, which is what +makes the fourth invariant a consequence of existing code rather than a new +mechanism. A frame that transmitted is never held: holding every copy of every +fan-out would multiply storage by the fan-out at each hop, for frames another +device is already carrying. + +A queued forward that the governor marked as a held frame being redelivered +([Redelivery](#redelivery)) is returned to the store at this point, and is not +judged by the table below: it is not a deposit, its refusal would not be a +refusal, and it is not counted as one. The drop point MUST NOT touch the +suppression cache for a marked forward: the held intake never recorded the +identifier there, so releasing it is at best a no-op and at worst releases an +entry the depositor's own retransmission legitimately holds if the ordinary +intake admitted one in the meantime. + +At that point the custodian MUST accept the frame into custody when every one +of these holds, and MUST refuse otherwise: + +| Condition | Refusal reason when it fails | +|-----------|------------------------------| +| Custody is enabled | `disabled` | +| The frame carries `__custody` | `no_request` | +| The token is `data` | `unknown_class` | +| The outer prefix is `__MLS_ENC__` | `not_sealed` | +| The frame arrived from a peer whose address the transport proved | `unproven_peer` | +| The frame's `sender` is the address the transport proved for that peer | `not_depositor` | +| The frame is not addressed to this device | never fails: a frame for this device is delivered, not held | +| No frame with this identifier is already held | `duplicate` | +| The depositor's tier admits it: a peer with an established session under the session tier, any other proven peer under the stranger tier | `stranger_refused` | +| The depositor's entry and byte budgets have room, or the overflow policy makes room | `depositor_full` | +| The global entry and byte budgets have room, or the overflow policy makes room | `store_full` | +| The battery is above the soft relay floor | `battery` | + +The `unproven_peer` and `not_depositor` rows together make the depositor one +address: the peer that handed the frame over, the frame's `sender`, the key the +tiers are looked up under, the peer whose capabilities gate the receipt, and +the receipt's recipient. A frame whose `sender` is not the arrival peer reached +this device through a forwarder, which means the forwarder did not strip the +key; the custodian refuses it and MUST strip the key itself if it transmits the +frame onward. + +A refusal is silent. The depositor learns only that no receipt came, which it +cannot distinguish from a lost receipt or an absent custodian. A detailed +refusal would be an oracle for probing a device's quotas and its session list, +so refusals are counted by reason for the operator and never answered on the +wire. + +A duplicate deposit, one for an identifier already held, is not stored, not +answered and counted. The first receipt already suppressed re-deposit at a +depositor that received it; a depositor that did not will deposit again on its +next retry, and absorbing that here costs nothing. + +### What is held + +An accepted frame becomes one sealed record under the custody storage category, +holding: + +- the frame exactly as it arrived with the `__custody` key removed, hop fields + as the forwarding path adjusted them; +- the depositor's address, which is both the frame's `sender` and the peer the + frame arrived from, so redelivery never hands it back; +- the wall-clock time of acceptance, in milliseconds since the Unix epoch, + never a monotonic instant: a monotonic clock does not run while the device is + off, and a custodian powered off for a week would otherwise wake believing + its hold had just begun; +- the class token. + +The record is sealed for the same reason the pending-decrypt queue is sealed: +it is other people's ciphertext plus routing metadata about them, and it +outlives the process. Sealing authenticates presence, not absence, so a +custodian cannot detect that held records were deleted from under it; that is +the walked-away case, which costs latency and nothing else. + +## The receipt + +A custodian that accepted a frame answers with a receipt, and this is the only +frame custody adds to the wire. + +### Shape + +The receipt is a control frame with the reserved prefix `__CUSTODY_RECEIPT__` +followed by a JSON body: + +``` +__CUSTODY_RECEIPT__{"v":1,"id":"","hold_ms":} +``` + +| Field | Meaning | +|-------|---------| +| `v` | Body version, `1` | +| `id` | The identifier of the frame now held | +| `hold_ms` | How much longer the custodian will hold it, relative to the receipt's own timestamp. Relative rather than absolute so the depositor applies it to its own clock and skew cannot expire a valid receipt | + +The custodian's address is the frame's `sender`; the depositor's is its +`recipient`. Unknown fields MUST be ignored. A receiver MUST refuse a body it +cannot parse, or whose `v` it does not know, exactly as it refuses any +malformed control frame: silently, without an acknowledgement. + +### Authentication + +The receipt is signature-gated like every other control frame in the registry: +signed over the control-plane canonical payload (`offline-ctrl-v2`, or +`offline-ctrl-v1` where that is what the depositor negotiated), verified against +the key its sender's address derives from, and refused outside the freshness +window ([Control messages](control-messages.md#the-control-plane-signature-gate)). +It introduces no signing domain of its own. An unsigned receipt is refused by +the gate before this chapter is consulted. + +### Sending rules + +A custodian MUST send at most one receipt per accepted frame, at acceptance, +addressed to the depositor and transmitted once over the link the deposit +arrived on, to that peer. It is never placed in the custodian's outbox, never +offered to the mesh, and never sent through a relay or a gateway: it names a +neighbour that was on a link a moment ago, and a receipt that cannot be put on +that link is dropped and counted, because the depositor deposits again on its +next retry and the custodian absorbs the duplicate. The receipt MUST NOT +request an acknowledgement: it is advisory, and putting it on the retry ladder +toward a neighbour that may already have walked away spends a retry budget on +nothing. + +A custodian MUST send a receipt only to a depositor whose key package advertised +`data_versions` entry 7. A peer that does not reserve the prefix does not know +the frame as a control frame, so it treats the receipt as an application +message: with encryption on, it refuses it as inbound plaintext from a peer +that has proved it can encrypt, which its threat model classifies as a security +refusal and records as one; on a plaintext-only deployment, it hands the +receipt to its application as a text message. The gate exists so that a +custodian never provokes either. Acceptance of a deposit is not gated on the +entry: a depositor whose capabilities are unknown, which is the ordinary case +for a stranger, is judged by its tier and simply gets no receipt. + +### What the depositor does with it + +A depositor that receives a valid receipt for an identifier still in its outbox +MUST suppress further deposit requests for that identifier toward that +custodian until `hold_ms` elapses, and MAY surface the fact for observability. +It MUST NOT do anything else with it: the second invariant lists what a receipt +never touches. Suppression state is in memory; losing it at a restart costs one +duplicate deposit, which the custodian absorbs. + +A receipt whose `id` names nothing in the depositor's outbox is ignored and +counted. That is the ordinary shape of a receipt arriving after the recipient's +acknowledgement settled the message, and it is also the shape a forged receipt +for a never-sent identifier would take, which is why it is not an error. + +## Redelivery + +Held frames leave custody by delivery, by expiry or by erase. + +**On neighbour discovery.** When a neighbour is discovered on any mesh +transport, the custodian walks its held frames and queues each eligible one +toward that neighbour through a **dedicated intake** of the forwarding +governor, not the ordinary one. The ordinary intake would refuse or mangle a +held frame in three ways: it consults the suppression cache, which would refuse +a second re-origination toward another neighbour within the cache's ten-minute +window; it spends a hop, while a held frame is transmitted with the hop fields +it was stored with, because the custodian is the same forwarder it was when +the frame arrived; and it charges the per-peer rate of the frame's arrival +peer, hours after that peer sent anything. The held-frame intake: + +- MUST skip the suppression check and the hop accounting; +- MUST take the queue capacity, the send budget (the forward budget, never the + own-traffic reserve) and the per-neighbour rate toward the neighbour it + targets, and MUST obey `allow_relay` and the battery floors; +- MUST NOT target the depositor; +- MUST mark the queued forward by its held identifier, so that when it reaches + no link within the overdue window the drop point returns it to the store + rather than judging it as a deposit: not a refusal, not counted as one, no + new record and no new receipt; +- MUST record the single target neighbour on the marked forward, which is + transmitted to that neighbour only and never through target selection: a + fan-out at flush time would defeat at most once per neighbour. + +A frame whose recipient is the discovered neighbour is queued toward that +neighbour and released from the store on transmission: the custodian has no +way to know more than that the bytes left, and the recipient's +acknowledgement, if any, travels the ordinary ladder back to the depositor, +never to the custodian. Any other frame is re-originated toward the neighbour +at most once per distinct neighbour during its hold, with the set of +neighbours tried bounded the way tracked neighbours are bounded, and stays in +the store after transmission until its hold ends. + +**What the custodian transmits never carries `__custody`.** The key is removed +at acceptance, and the custodian is a forwarder like any other for the rule +that a forwarder strips it. + +**Expiry.** A record is expired when its acceptance time is older than the +`hold_ms` in force, judged in wall time at restore and at every sweep against +the configuration of that moment, the way a control frame's freshness is +judged against the window of the verifier that receives it. So a lowered +`hold_ms` applies to records already held, and a custodian that was powered off +for longer than its hold delivers nothing stale when it returns. An expired +record is dropped, counted under `expired`, and nothing is sent to anybody: the +depositor never lost anything, so there is nothing to report, and a +`message_failed` for a frame this device did not originate would be a lie to an +application that never sent it. + +**At the recipient.** A redelivered frame is an ordinary inbound forward. The +receiver's deduplication, its acknowledgement decision and its stale-epoch rule +apply unchanged, and nothing about custody changes any of them: + +- A copy that arrives after the original is deduplicated when it lands inside + the receiver's window (two thousand identifiers over twenty-four hours, + persisted). When it does not, a `delta` or `snap` is absorbed below the CRDT + engine as already applied, a `vv` is answered from live state as one more + offer, and a `blob_gone` is a floor report already applied. +- A copy sealed to an epoch the recipient has since left classifies as a + session desync **when the recipient has crypto recovery enabled, which is + the default**. The recipient then withholds the acknowledgement and unmarks + the identifier rather than confirming what it could not read, so the copy + cannot falsely settle the depositor's entry, and the depositor's own + re-sealing resend is the recovery. The same branch schedules a rate-limited + session re-key and emits the re-key security warning; what integrators + should read from that is stated under + [R19](../security/threat-model.md#r19-custody-borne-re-key-pressure). +- **With crypto recovery disabled, the same copy is a terminal decrypt + failure**: dropped and acknowledged, and that acknowledgement settles the + depositor's outbox entry for a frame the recipient never read. A direct + resend never reaches this path, because the depositor re-seals it to the + live epoch; a custody copy is exactly what does. For Class A the loss is + recoverable and the recovery is the exchange itself: the recipient's version + vector still lacks the change, so the next version offer between the two + re-offers it, and the depositor's document state, not its outbox, is the + source. This is a third reason Class A is the only class carried, and a + deployment that disables crypto recovery on any device SHOULD leave custody + off. + +## Quotas + +The scarce resource is durable writes, not bandwidth: every built-in storage +provider pays a device barrier per stored record and another per delete, +whatever the record's size, so a quota that metered bytes alone would let ten +thousand tiny deposits cost more than one large one. Entries and bytes are +metered together, per depositor and globally, in the shape the pending-decrypt +queue already has. + +| Dial | Reference default | Bound, and the failure the bound names | +|------|-------------------|----------------------------------------| +| `enabled` | `false` | The off switch. Refusing to hold other people's traffic is this dial's job, never a zeroed budget's | +| `hold_ms` | 6 hours | `0 < hold_ms < retry.outbox_max_lifetime_ms`, refused at configuration otherwise: a hold that outlives the outbox delivers frames whose sender has already reported them failed. Judged in wall time from each record's acceptance timestamp against the value in force at the sweep, so lowering it expires records already held | +| `max_entries_per_depositor`, `max_bytes_per_depositor` | 64 entries, 2 MiB | Session tier. Both must be positive when `enabled`, or the store is on and holds nothing, which is the silent dial the off switch exists to replace. The byte cap must admit at least one replication frame at its ceiling, or every deposit is refused as `depositor_full` | +| `max_entries`, `max_bytes` | 512 entries, 16 MiB | Global. Each must be at least its per-depositor sibling, or the per-depositor dial can never be reached | +| `stranger_max_entries`, `stranger_max_bytes` | 0, 0 | Stranger tier. Zero means deposits are accepted only from peers with an established session, which is the default and is stated rather than inferred; a deployment that wants strangers held raises both | +| `overflow_policy` | drop oldest | What happens when a budget is full: evict the oldest held frame to admit the new one, or refuse the new one. The per-depositor caps are what keep one depositor's flood from evicting everyone else's frames under drop-oldest | + +The defaults are shapes awaiting field data, exactly as the forwarding +governor's were before topology measurements tuned them. The bounds are +normative; the numbers are the reference implementation's. + +**Tiers are keyed on the proven arrival peer.** A depositor is under the +session tier when this device holds an established MLS session with the peer +that handed the frame over, and under the stranger tier otherwise. A session +costs a key package exchange, which is what makes the session tier resistant +to an attacker who can mint addresses for free. + +**Battery.** Below the soft relay floor a custodian accepts no new deposits. +Held frames are never dropped for battery: dropping them is the one local +optimisation that converts latency into loss, and redelivery simply pauses with +the forwarding gate until the floor is cleared. + +**Counters.** A custodian counts, at least: accepted, delivered, re-originated, +expired, duplicates, and each refusal reason in the acceptance table. Counters +are aggregate and never per depositor on any wire, for the oracle reason above. + +## Erase + +`erase_custody` deletes every held record and resets the counters. It MUST be +callable on its own, and the data-layer wipe MUST call it: a custody store that +survived a logout would hold other people's traffic past the point the user +asked for erasure. A device that starts with custody disabled MUST erase any +held records at restore rather than keep them, because nothing would ever +deliver them. + +At restore with custody enabled, a custodian walks its records under a budget, +drops every record whose acceptance time is older than the current `hold_ms`, +and keeps the rest exactly as stored. Restore never sends anything. + +## What a custodian learns + +A sealed payload is opaque at every hop, but the outer message is not: sender, +recipient, identifier, application id, priority, hop fields, timestamp and size +are readable by whoever holds the frame. Custody extends the retention of that +metadata, for traffic between two other parties, from five seconds in memory to +hours on disk. This is the design's primary privacy cost, it is not mitigable by +sealing because routing needs the recipient, and it is stated as +[R20](../security/threat-model.md#r20-a-custodian-retains-routing-metadata-about-third-parties) +so that an operator enabling custody to be a good citizen knows what they are +also volunteering to store. + +## Conformance + +The receipt body is the one encoding this chapter adds, and it has no frozen +vector yet: the chapter precedes the codec, and a vector computed from prose +alone pins nothing. The vector lands with the implementation, in the crate that +holds the codec, and [Conformance](conformance.md) lists this chapter among +those that are not yet surfaces until then. + +## What this chapter does not specify + +- **Custody transfer.** The sender never releases; the first invariant. +- **Any incentive scheme.** Credit needs a ledger and reputation needs gossip, + and a network whose defining condition is partition has neither. Quotas are + local state and are enough. +- **Ordering, sequencing or exactly-once.** The replication layer is + at-least-once and unordered by design, and custody supplies exactly that. +- **Classes B to F.** Named in the table so the refusal is by name, not + specified for carriage. +- **A custody role, promotion or election.** Every device can hold bytes and + several custodians are several copies, so custody is a flag and a set of + quotas, never a state machine. +- **Relay or gateway custody.** The relay has its own store-and-forward and its + own account model. A gateway is a device like any other here; if the gateway + contract gains a custody verb, that chapter defines it. +- **Custody-level retry against a data-layer refusal.** Every data-layer + outcome is terminal, and a held frame is delivered once per path, never + retried against the recipient's verdict. diff --git a/docs/spec/wire-format.md b/docs/spec/wire-format.md index 5af16610d..0a6c464ba 100644 --- a/docs/spec/wire-format.md +++ b/docs/spec/wire-format.md @@ -342,6 +342,7 @@ observed on the wire: | `ack_transport` | Transport the acknowledged message arrived on | | `transport_preference` | Requested transport for this message | | `original_content_type` | Pre-chunking content type of a file transfer | +| `__custody` | The class a depositor asserts for its own frame when it offers it into custody; engine-written, unsigned, and stripped by every device that forwards the frame, so it travels one hop. See [Custody](custody.md) | | `__ctrl_sig` | Base64 Ed25519 signature over the control-message canonical payload | | `__ctrl_pk` | Base64 Ed25519 public key of the signer, 32 raw bytes | diff --git a/docs/state-machines/data-replication.md b/docs/state-machines/data-replication.md index 18d086939..aa4a48bb5 100644 --- a/docs/state-machines/data-replication.md +++ b/docs/state-machines/data-replication.md @@ -160,6 +160,7 @@ Triggers, each of which names its cause in the logs: | Peer rediscovered on any transport | `peer_rediscovered` | That peer, and groups shared with them | | Start-up | `start` | 1:1 spaces only | | Group joined, or a member added | `group_joined`, `member_added` | That member | +| A neighbour appears while a custodian holds a frame for it | `custody_redelivery` ([Custody](../spec/custody.md)) | That frame, transmitted as stored. The custodian starts no exchange: it forwards, it does not offer. At the recipient a redelivered `vv` is answered from live state like any offer, a duplicate `delta` or `snap` is absorbed as already applied, and a `blob_gone` is a floor report | Offers to one peer are suppressed for 30 seconds after the last one. The window delays only the reconciliation sweep: a local change does not wait for @@ -198,6 +199,15 @@ The end of the ladder is reported rather than logged. `data_doc_unsyncable` means two replicas that will not converge while both keep accepting edits, and nothing else about that state looks like a problem. +[Custody](../spec/custody.md) makes the bottom rung reachable more often. A +held `delta` or `snap` arrives hours after it was sealed, which is more time +for the receiver to have compacted the history it is built on; a stale +`delta` still has `need_snap` above it, but a stale `snap` refused on trim +has no rung above it and the replicas stay apart until the next exchange +from live state. This is one of the two reasons the custody hold is hours +and not days, and it is why a custody-delivered frame is an ordinary +forward: the ladder that recovers from it is the one already here. + ## An attachment fetch The fetching side. Every path out of `Outstanding` reports, because a