Skip to content

docs(sync): the relay never carried the ledger, and half of what it holds is readable - #113

Merged
lessevv merged 1 commit into
mainfrom
docs/relay-what-it-actually-holds
Sep 13, 2026
Merged

lessevv merged 1 commit into
mainfrom
docs/relay-what-it-actually-holds

Conversation

@lessevv

@lessevv lessevv commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Why

The public site says the sync relay "only ever holds encrypted data it cannot read" and that changes wait in it when a device is asleep. Both sentences come from this repository, and both are false of the product. beatrax-app/website#5 corrects the site's own copy, but /faq renders this repo's 90-appendix/faq.md live at build time — so until this lands, /faq (English, from the spec) and /nl/faq (Dutch, corrected in the website PR) contradict each other on the same page.

Verified against beatrax@origin/main rather than taken on report:

Claim What the code does
"changes wait in a relay" MobileSyncTriggerService::syncOnce() reaches the relay leg after the LAN leg, and its own comment says it "carries no ops — only epoch wraps". PairingPeerOutbox serves only pairing frames out of relay_mailbox. No op-log delivery path touches a relay.
"the relay holds ciphertext with no key" True of half. GdkRotationService::deliverWrap() does sodium_crypto_box_seal() first, so the key is sealed to the recipient. PairingPeerOutbox::queueFor() does json_encode($frame) and hands the raw blob to RelayMailbox::deliverIfUnderQuota() — no encryption.
what a pairing frame exposes PairingFrame::buildResponderAccept() carries token_hash, the responder's device id, its Ed25519 and X25519 public keys as hex, and responder_name. buildConfirm() carries both device ids and a signature. Every mailbox row is addressed sender → recipient regardless, so both identifiers are visible either way.
"signed but not encrypted" True of PAIR_CONFIRM only. PAIR_RESPONDER_ACCEPT carries no sig_hex.
the device name DeviceNameDetector::detect() — a neutral platform label on desktop ("Mac", "PC", "Linux"), and on a phone the OS-reported name, which NativeDeviceName documents as "the user-set device name first ("Wessel's S24")". Deliberately never the hostname.

The product's own copy was already right: the relay_endpoint_help string in Modules/Sync/Resources/lang/en/devices.php says pairing and key exchange only, transactions LAN-only, and TheRelayHelpPromisesNoPullThePhoneCannotMakeTest pins it. The specification and the shipped string have been contradicting each other, and the shipped string was the honest one.

The term

ADR-0033 rules that "zero-knowledge relay" is retired, not redefined. Whatever a reader takes "zero-knowledge" to mean — a proof system, or the industry's looser "the operator cannot read it even if it wants to" — neither survives a plaintext pairing frame. A house definition cannot repair a word whose outside meaning is the overclaim, and the reader being misled is the one who never opens the glossary. The property that is true is narrower and already written down as E3-R7: no cryptography on a blob, and never looking inside one. That is a statement about what the relay does; "zero-knowledge" is a statement about what it could do.

The thing is a pairing relay. "zero-knowledge relay" and "zero-knowledge sync" join the glossary's Deliberately not used table, alongside "cloud sync" and "server", which were retired on exactly this ground.

ADR-0016 and ADR-0004 keep their bodies and get notes naming the claims that no longer hold — ADR-0031 already refused to edit ADR-0016 in place, and that refusal is right. ADR-0016 keeps its filename too: its title is a fact about what was decided on 2026-06-15, and renaming it would edit the history this repository keeps decisions for. That is deliberate and is recorded in ADR-0033 so it is not later mistaken for an oversight.

Sites corrected

The three that were reported, plus fourteen more found by sweeping the whole repository for relay, zero-knowledge and store-and-forward:

Page Was Now
90-appendix/faq.md ×2 (rendered live by the site) "changes wait in a relay that only ever holds encrypted data it cannot read"; "The relay holds ciphertext with no key" changes wait on the device that made them; a relay is for pairing and keys only, and what crosses it is named in both halves
00-overview/glossary.md Zero-knowledge relay — "Holds opaque ciphertext blobs" Pairing relay, plus a Deliberately not used row retiring the term
00-overview/roadmap.md ×2 "zero-knowledge relay fallback"; "dials out over LAN with relay fallback" "a pairing relay for devices that cannot reach each other"; "reaches a relay for pairing and key material"
00-overview/vision.md ×2 P2: "the fallback is a store-and-forward relay that only ever holds ciphertext it cannot decrypt"; non-goal: "end-to-end encrypted and zero-knowledge" P2 rewritten — no operation reaches an intermediary at all, and what a relay holds stated in full; non-goal reworded
00-overview/decisions/0016-*.md decision body: "holds opaque ciphertext blobs" body untouched; status line + a second supersession note
00-overview/decisions/0004-*.md "the optional relay holds ciphertext only and can decrypt nothing" body untouched; status line + a supersession note
10-functional/features/e-sync/e3-transport.md "### The relay knows nothing" / "The relay stores and forwards opaque blobs" "### A relay carries pairing, not the ledger" and "### What a relay can read", with a table of what is and is not opaque; two edge cases; E3-R24
10-functional/features/e-sync/e2-device-pairing.md "propagates over the zero-knowledge relay … it cannot read them" it can read them, and why that is survivable
10-functional/features/e-sync/e5-mobile-peer.md "falls back to the relay" reaches a relay for key material and pairing frames, not for operations
10-functional/features/g-ux/g1-privacy.md ×2 outbound table: "Ciphertext-only store-and-forward"; honesty item 2: "The relay sees metadata" both halves named; G1-R22 added
10-functional/features/g-ux/g5-plain-language.md "A relay sees metadata even though it cannot read content" it reads the pairing frames; no transaction crosses one
10-functional/journeys/j5-adding-a-device.md ×3 "carries the frames without being able to read them"; "through the relay when one is not"; failure row "The relay can read a blob" corrected, plus a second failure row for an operation delivered over a relay
10-functional/journeys/j2-daily-use.md "through a relay that cannot read anything if the desktop is asleep" the phone's changes wait on the phone
20-architecture/system-context.md actor: "ciphertext-only … moves opaque bytes" pairing frames and sealed key epochs only
20-architecture/data-flow.md sync-path diagram: "transport ── LAN-direct, or relay" "the LAN-direct session only", with a line saying what a relay does carry
40-quality/security.md ×2 "A hostile relay operator | The relay holds ciphertext" ; "recipient identifiers are observable" no operation crosses it, the epochs are sealed, the frames are readable; both identifiers and frame contents observable
90-appendix/data-retention.md "A relay, if configured, holds ciphertext it cannot read" carries pairing frames and sealed key epochs — never a transaction
30-repos/website.md · 60-brand/surface-mapping.md "what the relay can see" "that no transaction crosses a relay, and what a relay can read of what does"

Requirements

No existing requirement changes meaning. E3-R7 (no cryptography on a blob, never look inside one) and E3-R14 (document the metadata, claim no traffic-analysis resistance) were accurate as written and are untouched, as are G1-R15, G5-R5, REPO-R41, E2-R15 and E5-R19.

Two are added, for facts nothing previously required anyone to state:

  • E3-R24 — an op-log entry MUST NOT be delivered over a relay; a relay MUST carry only pairing frames and sealed key-epoch wraps, and what it holds MUST be documented in both halves.
  • G1-R22 — the product's own copy MUST state that no transaction crosses a relay, and describe what one holds in both halves rather than only the sealed one.

Neither is marked (Open): both describe shipped behaviour, so no manifest entry is needed and manifest_check.py is clean.

Gates

integrity.py clean · manifest_check.py clean (373 goals) · typos clean · markdownlint-cli2 0 issues in 160 files · lychee 0 errors · commit_lint.py and dco_check.py clean against origin/main. gen_summary.py picks up ADR-0033 for the mdbook build.

Not merged, and auto-merge not armed

This is public-facing copy about privacy. The owner decides when it goes live.

Paired with beatrax-app/website#5, which corrects the site's own strings and the Dutch FAQ. With both merged, /faq and /nl/faq say the same thing: transactions never cross a relay; a relay exists only to finish pairing and exchange keys; the key handover is sealed to the receiving device and the pairing messages are not.

@lessevv
lessevv force-pushed the docs/relay-what-it-actually-holds branch from ef596a5 to d1ddd23 Compare September 13, 2026 16:29
…olds is readable

Two sentences about the relay have been in this specification since the
founding commit and neither is true of the product.

The first is that op-log entries wait in the relay when a peer is asleep. They
do not. An operation travels only inside a mutually-authenticated session
between two paired devices; the mobile sync pass reaches a relay for key
material only, and says so in its own comment. Where neither device can reach
the other, the changes wait on the device that made them. The relay setting's
own help string has said exactly that since 2026-09-05, and a test pins it --
including that it must not promise a pull the phone cannot make. Since then the
specification and the shipped string have been contradicting each other
outright, and the shipped string was the honest one.

The second is that what a relay holds is opaque ciphertext. Half of it is. An
epoch wrap is sealed to the receiving device's X25519 key and cannot be opened.
A pairing frame is not sealed at all: it is JSON handed to the mailbox as
written, and between the two frame types and the addressing that carries them a
relay operator reads both device identifiers, the responder's Ed25519 and X25519
public keys, the pairing token's hash, and the name the responder's device goes
by -- on a phone often one a person chose. Only the confirming frame is signed;
the accepting one is not, so "signed but not encrypted" is true of one of the
two and not of both.

None of that is a defect in the design. A pairing frame is public-key material
exchanged before any shared secret exists, and the safety number both readers
compare is what makes a swapped key fail. It is a defect in the description: a
reader who believes a relay holds only ciphertext concludes that nominating one
reveals nothing.

ADR-0033 records the ruling and retires the term "zero-knowledge relay" rather
than redefining it -- a house definition cannot repair a word whose outside
meaning is the overclaim, and the property that is actually true is narrower,
checkable and already written down: no cryptography on a blob, and never looking
inside one. The thing is a pairing relay. ADR-0016 and ADR-0004 keep their
bodies and get notes naming the claims that no longer hold; ADR-0016 keeps its
filename too, because its title is a fact about what was decided in June.

Seventeen further pages carried the claim downstream of that term, including the
FAQ the public site renders live and the roadmap line it pulls into its
milestones. E3-R24 pins what may cross a relay and that both halves of what it
holds must be described; G1-R22 pins saying so in the product's own copy. No
existing requirement changes meaning: E3-R7 and E3-R14 were accurate and stand
as written.

Signed-off-by: Wessel Verheij <info@nightworks.io>
@lessevv
lessevv force-pushed the docs/relay-what-it-actually-holds branch from d1ddd23 to d50a0c9 Compare September 13, 2026 16:29
@sonarqubecloud

Copy link
Copy Markdown

@github-actions github-actions Bot added documentation Docs only architecture functional awaiting-maintainer Green PR with no review — a maintainer needs to act labels Sep 13, 2026
@lessevv
lessevv merged commit 85bf8bb into main Sep 13, 2026
13 checks passed
@github-actions github-actions Bot added awaiting-maintainer Green PR with no review — a maintainer needs to act and removed awaiting-maintainer Green PR with no review — a maintainer needs to act labels Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

architecture awaiting-maintainer Green PR with no review — a maintainer needs to act documentation Docs only functional

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant