Skip to content
Merged
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
8 changes: 8 additions & 0 deletions crates/offline-protocol/src/protocol/prefixes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions docs/security/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/spec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion docs/spec/capability-negotiation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/spec/conformance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
20 changes: 20 additions & 0 deletions docs/spec/control-messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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":<held frame id>,"hold_ms":<u64>}`. 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
Expand Down
Loading
Loading