Skip to content

docs: state what remote sync protects, and what it does not - #767

Merged
fujibee merged 10 commits into
integration/remotefrom
docs/security-properties-of-remote-sync
Aug 14, 2026
Merged

docs: state what remote sync protects, and what it does not#767
fujibee merged 10 commits into
integration/remotefrom
docs/security-properties-of-remote-sync

Conversation

@fujibee

@fujibee fujibee commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Declared reviewers: 1

A security document for docs/, written for an outside reviewer who intends to
check 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 read
nor 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 is
a 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 job
and nothing binds a key to a person (:93-94); metadata is not protected. A
reviewer 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 some
rows of the properties table are summaries with no citation, and that rows
resting on something other than this tree say so — inherited for a property of
the age format, assumption for 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-v1 accept an injected cipher: "none" envelope?
The client is in this repository — scripts/internal/sync-cipher.mjs, 819 lines
of age implementation — so it was run, not reasoned about. openEnvelope
dispatches on the envelope's own cipher and returns the plaintext projection.
The caller refuses it, at scripts/internal/remote-sync.mjs:1686, and only
there: the profile-match check three lines below runs if (cipher === "age-v1")
so a none envelope never enters it. That one if is the whole defence, and
two of its three disjuncts read serverPolicy — values a hostile server
declares — so only the third protects against adversary A.

How does a deployment end up on e2ee-required? Every caller of
remote-sync.sh configure in the tree, docs/ excluded, passes it together
with --cipher age-v1, as literals. There are two, and the pull route that
acquires age-v1 without calling configure (scripts/remote.sh:916) cannot
read anything until unlock, which is one of those two callers.

What the grep over server/ measures, and what it does not

It measures that server/ has no age vocabulary — no implementation, no key
interpretation, 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 where
the two disagree the English is correct — more than the one-line pointer the
other *.ja.md files 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, commands
and 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

head                              c0e917e2d312517cc29a8b913b87119808b3bb54
files this branch changes         docs/security.md, docs/security.ja.md — no code
citations, en and ja              identical sets, 32 each, all resolving
sections, en and ja               11 and 11
words                             3248 en, 1257 ja
drift (base integration/remote)   EMPTY, AND MEASURED

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, not main.

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 fujibee left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  1. docs/security.md:8-13 and docs/security.ja.md:11-14 still 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.

  2. docs/security.md:401-405 and Japanese :387-390 infer that nothing in server/ 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.

  3. 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 fujibee left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@fujibee
fujibee merged commit d0b762d into integration/remote Aug 14, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant