docs: state what remote sync protects, and what it does not - #767
Conversation
Written for an outside reviewer who intends to check it rather than believe it, so the document is built around being falsifiable: every claim carries a `file:line` at `452da72`, and the section that matters most ends with the grep that would contradict it. The conclusion is that for a deployment which selects `age-v1`, the message server can neither read nor forge a participant's messages. The second half is the load-bearing one and is easy to state wrongly: forging an age file needs the recipients' PUBLIC keys, and the specification places those outside the server (`docs/spec/ref/age-v1-profile.md:342-345`). That is a requirement on the protocol, not a property of how the server happens to be written today. Because a specification requirement is worth less if the code quietly disagrees, the implementation was measured too: searching `server/src` for anything cryptographic returns seven hits and all seven are the STRING `"age-v1"` being compared or stored as a profile name. `cipher` is validated against a character pattern and otherwise opaque. There is no age implementation, no key material and no decryption path in the server — a stronger statement than "it is not given the keys". The document prints that grep so a reader can re-run it. Three things are said before the properties table rather than buried, because a reviewer finds them anyway and finding them late costs the rest its credit: the default profile is `cipher: "none"` and E2EE is opt-in; peer authentication is the operator's job and nothing binds a key to a person; metadata is not protected. RFC 3552 shape, including the adversary model the claims are relative to. Three adversaries, and the properties table says which one each row is about — two rows are about a compromised participant, not the server, and would otherwise read as weaker claims against the server than they are. `envelopeDigest` gets its own section. It is an unkeyed SHA-256 the server computes to detect an id resent with different content (`server/src/storage.ts:189`). It is not a sender's signature and does not need to be. The reading "no key, therefore the contents are unprotected" is available to anyone looking at that function, and closing it in advance is cheaper than answering it later. One question is marked OUT OF SCOPE rather than unverified: whether a client configured for `age-v1` rejects an injected `cipher: "none"` envelope. No code here decides it — the client lives elsewhere. Out of scope means "not here", unverified would mean "here and not measured", and using the weaker word would misdescribe what a reader can learn from this repository. Verified before committing: all 18 citations resolve to the line counts claimed, and the 11 quoted code lines match their files exactly — checked by deriving the citation set from the document rather than from a list.
…t got wrong
The `age-v1` profile is marked "proposed (dogfood)" and was last touched on
2026-07-27 by the commit that filed it as reference material — it sits under
`ref/`, whose README says nobody is building toward anything in there. Citing it
without checking it against the code would have been the whole failure mode this
document exists to avoid, so the two load-bearing citations were verified:
:342-345 keys provisioned outside the server -> holds, and more strongly
than stated: `server/` has no age implementation at all
:58-63 X25519 stanzas only -> ENFORCED, in the client, at
sync-cipher.mjs:277-281
The audit also found the previous version of this document wrong on two points.
FIRST, it said the sync client "lives elsewhere". It does not. It is in this
repository: `scripts/internal/sync-cipher.mjs` is 819 lines of age
implementation, header parsing and stanza validation included.
SECOND, and following from that, the downgrade question was marked "out of
scope" on the grounds that no code here decides it. Code here decides it, and it
was measured rather than deferred:
openEnvelope dispatches on the envelope's OWN cipher field and consults no
configured profile. Called directly with a well-formed `cipher: "none"`
envelope built from this repository's test vectors, it returns the plaintext
projection.
The single caller refuses it — remote-sync.mjs:1686, and only when local
policy is `e2ee-required`. The profile-match check three lines below runs
`if (envelope.cipher === "age-v1")`, so a `none` envelope never enters it.
Line 1686 is the whole defence.
The properties table row is now "provided, conditionally" rather than "out of
scope", and names the condition. Under `plaintext-allowed` the injected envelope
is accepted, and the document says so.
One thing is left explicitly unmeasured: whether any default path sets
`minimum_security_mode` to `e2ee-required`. The row above depends on it, and
tracing it was not done.
Also adds the RFC 3552 adversary model the claims are relative to, and marks
which adversary each row of the properties table is about.
Verified before committing: 22 citations resolve to the line counts claimed, the
quoted code lines match their files, and the citation set was derived from the
document rather than from a list.
… open question Two corrections, both from review. FIRST — the open question is answered. The document said whether any default path sets `minimum_security_mode: "e2ee-required"` was not traced. Derived now: every call to `remote-sync.sh configure` in the repository passes `--cipher age-v1` and `--minimum-security e2ee-required` TOGETHER, and there are exactly two of them (`scripts/remote.sh:1280`, `:1758`). `grep -rn -- '--cipher'` returns four hits; the other two are a usage string and an argument check. So the supported path cannot select `age-v1` without also setting `e2ee-required`. The properties row is "provided on the supported path" rather than "conditional" — the condition it depends on is one the supported path always sets. Reaching the unsafe pairing means invoking `configure` by hand. SECOND, and the reason this needed saying — the refusal at `remote-sync.mjs:1686` is one `if` with three disjuncts, and it reads as three defences. Two of them consult `serverPolicy`: values the SERVER declares. A server that wants to inject plaintext declares a policy permitting plaintext, so against a hostile server they are worth nothing. They catch misconfiguration, not attack. The third disjunct is the whole defence, because `localPolicy` is the only term the server does not supply. The document now says that in those words, beside the quoted condition, so a reader cannot come away believing the protection is redundant. One caveat added rather than smoothed over: a machine can also acquire `cipher_profile: "age-v1"` from a value pulled off the server (`scripts/remote.sh:916`), and that write does not go through `configure`. Whether such a machine runs one of the two configure calls before it pulls messages was not traced, and the document says so. Citations re-verified: every path:line resolves to the claimed line count, and the two derivations this commit rests on (two configure calls, four `--cipher` hits) are reproducible with the commands as written.
The document said a machine can acquire `cipher_profile: "age-v1"` from a value pulled off the server without going through `configure`, and that whether such a machine reaches a configure before syncing was "not traced". Review raised that gap as the finding it is: an untraced route is exactly where a claim about "the supported path" stops holding. Traced now. `cmd_pull` copies the declared cipher into the binding (`remote.sh:916`) and calls no configure, so at that moment the machine has `age-v1` and no `minimum_security_mode` of its own. It cannot read messages in that state: reading requires `unlock`, and `cmd_unlock` (`:1076`) is one of the two callers of `configure` — passing `--minimum-security e2ee-required` and `--cipher age-v1` together at `:1284-1285`. So the pull route does not bypass the pairing. It arrives at it one command later. The other caller, `_remote_configure_keyed_team` (`:1729`, reached from `:1916`), passes the same two flags. That closes the set rather than widening it: two callers, both on the path a machine must take before it can read anything. The wording elsewhere already avoided over-claiming — "provided on the supported path", with the by-hand pairing named as the way out — and that stays. What changes is that the one route which could have contradicted it has been walked instead of flagged. Citations verified: all seven newly cited lines say what this says they say.
…ed path" The document said the supported path "always sets" `e2ee-required`. Two words doing more work than the measurement behind them: what was measured is `remote.sh` — its two `configure` callers, and the pull route that reaches one of them before it can read anything. "The supported path" reads as a property of the product. It is a property of one script. A deployment driving `remote-sync.sh` by some other means is not described by that row, and the previous wording did not say so. Now: "a machine that reaches `age-v1` through `remote.sh`", with the boundary stated as a boundary — this document measured `remote.sh`, and the row does not extend past it. The table row and the prose were changed together; narrowing one and leaving the other would have been worse than narrowing neither, because a half-updated claim reads as a checked one. Nothing about the finding changes. The pairing still holds on every route through that script, and the way out is still calling `remote-sync.sh configure` directly. Citations re-verified after the edit: every path:line still resolves to the claimed line count.
…tch it The paragraph asserting "that is the whole set" derived it from a grep inside `remote.sh` alone. A search in one file cannot rule out a caller in another, so the sentence was broader than what produced it — and narrowing the subject to "the `remote.sh` path", as the previous commit did, fixed the mismatch from the wrong end: it made the claim smaller instead of the evidence bigger. The search is tree-wide now, excluding docs and tests, and printed in the document so a reader runs the same thing: scripts/remote.sh:1280 a production caller scripts/remote.sh:1758 a production caller scripts/internal/remote-sync.mjs:29 a usage string Two callers, one usage line, nothing else. Both pass `--minimum-security e2ee-required` and `--cipher age-v1` as literals — checked, because a variable there would let something upstream set them apart. With that in hand the subject goes back to what is true: a machine reaching `age-v1` through any code in this repository cannot get there without `e2ee-required`. The table row, the derivation section and the properties note were changed together. The boundary that remains is the honest one and is stated: this measures callers IN THIS REPOSITORY. Someone invoking `remote-sync.sh configure` by hand can pair `--cipher age-v1` with `--minimum-security plaintext-allowed`. Nothing forbids it; nothing shipped here produces it. Reproduced the derivation independently before pasting it — the result was handed to me and I ran it myself rather than citing someone else's grep.
Two claims, one narrower search behind them.
the sentence "every call to `remote-sync.sh configure` in the repository",
"there is no third place"
the command grep -rn -- '--cipher' scripts/ tests/
A reader running that gets an answer about two directories. Worse, it includes
`tests/` while missing `server/test/`, so it reads as "the tests were checked
too" while leaving a test directory out — the most misleading shape available
for a document whose value is that a reader can re-run it.
The search is now the tree minus `docs/`, and it is printed:
scripts/remote.sh:1280 a caller
scripts/remote.sh:1758 a caller
scripts/internal/remote-sync.mjs:29 a usage string
`server/test/sync-client.integration.test.ts` does invoke `remote-sync.sh`
through a helper that forwards `...args`, so it could call `configure`. It does
not — zero occurrences in that file — and the document says that rather than
leaving the reader to notice the helper and wonder.
The conclusion is unchanged. What changes is that the evidence now reaches as
far as the claim.
Also: citations were pinned to `452da72` while the branch head had moved six
commits past it. Every one of those commits touches only `docs/security.md`, so
the code is identical at both revisions — the document now says so and gives a
recipe that works against either, instead of naming a revision a reader has to
check out.
Verified by running the document's own recipe verbatim, and re-resolving every
citation after the edits.
…irrors
Two things in one commit on purpose: splitting them would have made the
translation stale the moment it was born.
FIRST, the English. The section supporting "every call to `remote-sync.sh
configure` in the repository" and "there is no third place" carried TWO grep
blocks with different scopes — the widened one, and an older narrower one
excluding `docs|tests`. Two commands for one claim, one of which does not reach
as far as the sentence. The narrow block is gone; the derivation is stated once,
tree-wide minus `docs/`, and it names `server/test/` explicitly because that is
the directory a `scripts/ tests/` search silently omits while reading as though
the tests were covered.
SECOND, `docs/security.ja.md`. Existing `*.ja.md` files here carry a one-line
`*[English](…)*` pointer; a security document needs more than a pointer, so the
header says the English is canonical and that where the two disagree the English
is correct.
Translation rules followed, and they are not the usual ones:
claims are not softened — Japanese moves strength in the verb ending, and
"cannot" rendered as "しにくい" is a different
security claim. Checked: zero hedging words.
claims are not strengthened — the conditional "for a deployment that selects
`age-v1`" is part of the claim and is kept
code is verbatim — file:line, commands, flags and snippets are not
translated. A reader has to be able to run the
same command and get the same answer
structure is identical — 11 sections and 8 subsections in both, and the
order that puts what is NOT protected before
what is
the boundary section stays — a translation that drops "What has not been
examined" is a stronger document than the
original
Verified mechanically rather than by reading: section counts match, the citation
sets are byte-identical between the two files, all citations in the translation
resolve to the claimed line counts, and both files carry 34 code fences.
…all cite The document opened by saying every claim carries a `file:line`. That sentence was itself an uncited claim about the document, and it was false — two rows of the properties table rested on nothing visible: Integrity of message contents "age AEAD" Metadata confidentiality "visible to the server by design" Neither is wrong. Both were unlabelled, which in a document whose selling point is "you can check this" is the worst kind of gap: the reader has no way to tell a claim that was verified from one that was assumed. Four labels now, and they are not interchangeable — cited, assumption, out of scope, inherited. The two rows above are `inherited` (age's own AEAD, a property of the format, checked by reading age rather than this tree — the profile adds no second AEAD layer, `docs/spec/ref/age-v1-profile.md:13`) and `assumption` (the server routes by addressing and timing, so it reads them; nothing in the tree states it as a rule). The same pass closes the finding that came with it. The vocabulary grep over `server/` was carrying "no key material, no decryption path" — more than a search for age-specific words can support. The server stores and forwards opaque blobs, so the absence of those words constrains nothing about the bytes inside a generic buffer. It rules out a server that WORKS WITH keys, not one that holds them. That the keys are not there at all is the specification requirement at `:342-345`, and the two now support each other rather than one standing in for the other. Both changes are in the English and the Japanese at the same width — a translation that keeps the old universal claim would be a stronger document than the original, which is the failure mode the ja rules exist to prevent. Also corrected: the revision note said the only file this branch changes is "this document". It is two documents now. Verified: citation sets byte-identical between en and ja, every citation in both resolves to the claimed line count, and no row of either properties table is left without a citation or a label.
The opening paragraph has now been wrong twice, the same way.
v1 "every claim cites a file and a line, and anything without one is marked
as an assumption" -> false: two table rows had neither
v2 "every claim is labelled with what stands behind it"
-> also false, and mine. Seven constructs
carry a label; the prose does not
Replacing a false universal claim with a different false universal claim is not
a fix, and I did that in the previous commit. Caught by measuring the
replacement instead of assuming it — which is the check the first version
skipped too.
What it says now is weaker and true: the central claims carry a `file:line`,
some rows of the properties table are summaries with no citation, and rows
resting on something other than this tree say so in place of one. It also says
what is NOT claimed — that every sentence has been individually classified.
That fuller pass is worth doing and is filed as #787, with an owner
named rather than left to whoever reads it: a condition with no name attached is
where work stops.
In a security document a false statement about its own rigour is expensive out
of proportion to its size. It is the first thing a reviewer can check, and
checking it costs them nothing.
Both documents, same width. Citation sets remain identical between them.
fujibee
left a comment
There was a problem hiding this comment.
BLOCKING — reviewed exact head 7c9435b0b141babd76408177948e5e00fc88c659. Static documentation review only; cryptographic validity was not judged.
The repo-wide configure derivation is now sufficient, and the Japanese version preserves the English conditions, negative claims, commands, ordering, and unexamined boundary. No cloud-specific implementation assumption was found.
-
docs/security.md:8-13anddocs/security.ja.md:11-14still claim every assertion has a citation or assumption marker, but the properties tables contain uncited/unmarked claims such as age AEAD integrity and metadata visibility. Existing-citation resolution does not prove claim coverage. Derive and classify the claim set, or narrow the universal statement. -
docs/security.md:401-405and Japanese:387-390infer that nothing inserver/could hold or transit a recipient key from an age-vocabulary grep. The server stores/transports opaque blobs; absence of age-specific words does not constrain generic bytes. Narrow this to the measured absence of age implementation, key interpretation, and decryption vocabulary; keep external provisioning as a specification requirement. -
The artifacts do not describe this head. Both docs say only “this document” changed since
452da72, but this head adds two docs. The PR body still says the client is elsewhere and downgrade is out of scope, reports old 18/18 and 11/11 counts, and does not name this full head, while the current docs measure the in-repo client and downgrade path. Update the body last from one measurement of this exact head, and correct the branch-point statement.
CI is 21/21 successful and the PR is mergeable; those do not resolve these documentation contradictions.
fujibee
left a comment
There was a problem hiding this comment.
CLEARED — reviewed exact head c0e917e.
No blocking findings remain in this static documentation review. The three previously blocking boundaries are resolved: (1) citation coverage is limited to the central claims actually tied to file:line evidence, with inherited properties and assumptions labeled separately; (2) the repository vocabulary scan no longer claims that opaque payloads cannot contain key material, and the protocol requirement is stated separately from the implementation observation; and (3) the PR body now names this exact head and reports the current two-document artifact and measurements.
I also compared the English and Japanese documents for claim strength and scope. The Japanese version preserves the English conditions, exceptions, commands, file:line references, and the “What has not been examined” boundary. This is not a judgment on cryptographic validity; it verifies that the documents are internally scoped, traceable to the cited repository artifacts, and do not present unexamined areas as established facts.
All 21 reported checks for this head are successful.
Declared reviewers: 1
A security document for
docs/, written for an outside reviewer who intends tocheck it rather than believe it, plus a Japanese translation.
Measured once, at head
c0e917e2d312517cc29a8b913b87119808b3bb54.What it concludes
For a deployment that selects
age-v1, the message server can neither readnor forge a participant's messages.
The forging half is the load-bearing one and is easy to state wrongly: producing
a valid age file needs the recipients' public keys, and with X25519 "cannot
forge without the key" means without the public key. The specification places
those outside the server (
docs/spec/ref/age-v1-profile.md:342-345), so this isa requirement on the protocol rather than a property of today's server.
Three things said before the properties table
The default profile is
cipher: "none"and E2EE is opt-in(
docs/design/remote-sync.md:83-85); peer authentication is the operator's joband nothing binds a key to a person (
:93-94); metadata is not protected. Areviewer finds these anyway, and finding them late costs the rest its credit.
What it does not promise
The opening paragraph says the central claims carry a
file:line, that somerows of the properties table are summaries with no citation, and that rows
resting on something other than this tree say so —
inheritedfor a property ofthe
ageformat,assumptionfor something taken as given.It used to promise more. Two earlier versions claimed every claim was cited, or
every claim labelled; both were false, and both were uncited claims about the
document. The full pass — derive the claim set, label each one — is filed as
#787 with an owner, not left as a condition nobody holds.
Questions the review moved from "out of scope" to measured
Does a participant on
age-v1accept an injectedcipher: "none"envelope?The client is in this repository —
scripts/internal/sync-cipher.mjs, 819 linesof age implementation — so it was run, not reasoned about.
openEnvelopedispatches on the envelope's own
cipherand returns the plaintext projection.The caller refuses it, at
scripts/internal/remote-sync.mjs:1686, and onlythere: the profile-match check three lines below runs
if (cipher === "age-v1")so a
noneenvelope never enters it. That oneifis the whole defence, andtwo of its three disjuncts read
serverPolicy— values a hostile serverdeclares — so only the third protects against adversary A.
How does a deployment end up on
e2ee-required? Every caller ofremote-sync.sh configurein the tree,docs/excluded, passes it togetherwith
--cipher age-v1, as literals. There are two, and the pull route thatacquires
age-v1without callingconfigure(scripts/remote.sh:916) cannotread anything until
unlock, which is one of those two callers.What the grep over
server/measures, and what it does notIt measures that
server/has no age vocabulary — no implementation, no keyinterpretation, no decryption path. It does not establish that no key
material is present: the server stores opaque blobs, and the absence of
age-specific words constrains nothing about bytes inside a generic buffer. That
the keys are elsewhere is the specification requirement, cited separately. An
earlier version of this section ran the two together.
The Japanese translation
docs/security.ja.md. The header states the English is canonical and that wherethe two disagree the English is correct — more than the one-line pointer the
other
*.ja.mdfiles carry, because this document is meant to be relied on.Translation constraints, verified mechanically rather than by reading: claims are
not softened (Japanese moves strength in the verb ending — "cannot" rendered as
"しにくい" is a different security claim), not strengthened (the conditional "for
a deployment that selects
age-v1" is part of the claim),file:line, commandsand code are verbatim, section order is identical including "what is not
protected" before "what is", and "What has not been examined" is translated — a
version that drops it is a stronger document than the original.
Checks
The citation set is derived from the document rather than kept as a list, so
a citation added later cannot escape the check.
Land target
integration/remote, notmain.