This document states what the remote-sync protocol protects, what it does not, and what has not been examined. It follows the structure RFC 3552 (BCP 72) asks for, because that structure forces the second and third of those to be written down rather than implied.
It is written for a reviewer who intends to check it. The central claims — what
is protected, what is not, and why — carry a file:line in this repository.
Some rows of the properties table are summaries and carry no citation; where a
row rests on something other than this tree, it says so in place of one
(inherited for a property of the age format itself, assumption for
something taken as given).
That is a weaker promise than the one this paragraph used to make. The earlier version said every claim carries a citation and that anything without one is marked as an assumption. Both halves were false, and the sentence was itself an uncited claim about the document — the shape this whole document exists to refuse. A true weak promise is strictly better here: in a security document, a false statement about its own rigour discredits the parts that are rigorous.
What is NOT claimed: that every sentence below has been individually classified.
Deriving the full claim set and labelling each one is real work and is filed as
fujibee/agmsg#787.
If any citation does not say what this document says it says, treat every other claim here as unverified until re-checked — that is the correct response, and the reason the citations are here.
Measured on integration/remote. Citations resolve at this branch's head. The
only files this branch changes are docs/security.md and docs/security.ja.md
— no code moves on it — so every file:line below points at code that is
identical at the head and at the branch point 452da72. Verify against
whichever you have.
In scope: the remote-sync protocol and the reference message server in this
repository — server/, docs/spec/, docs/design/.
Out of scope: the hosted service. It is not covered by this document.
In scope: the sync client's handling of a received envelope. It lives here
— scripts/internal/sync-cipher.mjs and scripts/internal/remote-sync.mjs —
and the downgrade question it decides was measured rather than deferred.
RFC 3552 asks for this explicitly, and the claims below are meaningless without it. Three adversaries are considered, and they are not equally constrained.
A. The message server. Honest-but-curious, or fully malicious. It sees every
envelope, stores them, and can replay, reorder, drop, or fabricate anything it
is capable of producing. This is the adversary the age-v1 profile is aimed at,
and the one the "cannot read, cannot forge" claim is about. It is not assumed
to be trustworthy anywhere in this document.
B. A network attacker between a participant and the server. Assumed to be
handled by the transport, which is out of this document's scope. Nothing below
depends on the network being confidential — under age-v1 the envelope's
contents are already protected before transmission, and under cipher: "none"
they are not protected at any layer this document covers.
C. A participant whose identity has been compromised. Reads everything addressed to the epochs that identity belongs to, including messages sent before the compromise. See "On forward secrecy" — this is not mitigated, and rotation addresses only what comes after.
Not considered: an adversary with access to a participant's machine while it is running, side channels, and traffic analysis beyond the plain statement that metadata is visible.
For a deployment that selects the age-v1 cipher profile, the message server
can neither read nor forge a participant's messages.
Two separate arguments, and the second is the one that usually surprises people:
- Cannot read. Decryption requires a private identity. The server holds none, and the specification places identities outside it.
- Cannot forge. Producing a valid age file requires the recipients' public keys. With X25519 public-key encryption, "cannot forge without the key" means without the public key — an unusual property, and the one that decides this question. The specification places those outside the server too.
That placement is a specification requirement, not an artefact of how the current server happens to be written:
docs/spec/ref/age-v1-profile.md:342-345
"Recipient public keys, private identities, recipient-set manifests, and
epoch history are provisioned outside the message server over an
authenticated, freshness-proving channel. Copying only the current private
key is insufficient for history or rollback resistance."
And the default profile is not age-v1. See the next section before
reading any of the above as a property of a running deployment.
A specification requirement is worth less if the code quietly does something
else, so this was measured rather than assumed. Searching the server's
implementation for anything cryptographic — age-, AGE-SECRET, x25519,
recipient, decrypt, privateKey — returns seven hits, and every one of
them is the string "age-v1" being compared or stored as a profile name:
server/src/storage.ts:206 ["none", "age-v1"].includes(envelope.cipher)
server/src/storage.ts:891 ARRAY['none', 'age-v1']::TEXT[]
server/src/storage.ts:921 ARRAY['none', 'age-v1']::TEXT[]
server/src/provision.ts:102 ARRAY['none', 'age-v1']::TEXT[]
server/src/provision.ts:109 ARRAY['none', 'age-v1']::TEXT[]
server/src/protocol.ts:197 cipher_profile: z.enum(["none", "age-v1"])
server/src/storage.ts:269 (a comment)
The server treats cipher as an opaque label, validated only against a
character pattern:
server/src/protocol.ts:13 const cipherPattern = /^[a-z0-9][a-z0-9._-]{0,63}$/;
server/src/protocol.ts:68 cipher: z.string().regex(cipherPattern),
What that measures, exactly: server/ contains no age vocabulary — no
implementation, no key interpretation, no decryption path, nothing that names a
recipient stanza.
What it does not measure, and the difference matters: the server stores and forwards opaque blobs. The absence of age-specific words constrains nothing about the bytes inside a generic buffer, and this search cannot rule out a server that happens to hold key material it never names. It rules out a server that works with keys.
The claim that keys are not there at all is a specification requirement, not
a finding of this grep — docs/spec/ref/age-v1-profile.md:342-345, quoted
above. The two support each other and neither replaces the other: the spec says
the keys are provisioned elsewhere, and this search says the code has nothing
that would use them if they arrived.
Reproduce it with one command:
grep -rniE '\bage-|AGE-SECRET|x25519|recipient|decrypt|privateKey' server/src --include='*.ts'
A hit that is not a profile-name comparison would contradict this section, and should be treated as contradicting the two claims above it.
Three facts that a reviewer will otherwise discover on their own, at which point everything else here is worth less.
docs/design/remote-sync.md:83-85
"## No required keys
`cipher: "none"` is the base, as the envelope spec already says. E2EE
remains …"
Every "cannot read" and "cannot forge" statement in this document is a statement
about a deployment that has selected age-v1. A deployment that has not
selected it gets none of them.
Nothing in the protocol binds a key to a person. The handshake that would have made key distribution convenient was removed deliberately:
docs/design/remote-sync.md:93-94
"What this removes is the machinery for making key distribution
*convenient*: the `key request` / `key approve` handshake between two of
your machines."
The specification's requirement for an "authenticated, freshness-proving channel" (cited above) is a requirement on the operator. Whether a given deployment satisfies it cannot be determined from this repository.
Who is talking to whom, when, and how much is visible to the server. The envelope's addressing and timing are how the server routes and orders messages; they are not encrypted, and no part of the design claims they are.
Each row is provided, not provided, or out of scope, for a
deployment that has selected age-v1, against adversary A (the server)
unless the row says otherwise. "Not provided" means the property is absent and
this is known. "Out of scope" means this document did not examine it — it is not
a weaker way of saying "not provided".
Two rows are about adversary C rather than A, and are marked. No row in this table is a claim about adversary B.
| Property | Status | Basis |
|---|---|---|
| Confidentiality of message contents | Provided | age X25519 encryption; identities are outside the server (docs/spec/ref/age-v1-profile.md:342-345) |
| Integrity of message contents | Provided | inherited — age's own AEAD, a property of the age format rather than of this tree. The profile deliberately adds no second AEAD layer (docs/spec/ref/age-v1-profile.md:13), so the guarantee is age's and is checked by reading age, not this repository. The server's digest is not the mechanism — see below |
| Unforgeability of message contents | Provided | Requires recipients' public keys, which the spec places outside the server (docs/spec/ref/age-v1-profile.md:342-345, :58-63) |
| Peer authentication | Not provided | No key-to-person binding in the protocol (docs/design/remote-sync.md:93-94) |
| Metadata confidentiality | Not provided | assumption, and the easy kind: the server routes and orders by the envelope's addressing and timing, so it reads them. No citation is offered because nothing in the tree states it as a rule — it follows from the server doing its job |
| Forward secrecy (adversary C) | Not provided | Recipient sets are per-epoch and immutable (docs/spec/ref/age-v1-profile.md:88); an identity that is later compromised decrypts that epoch's history |
| Post-compromise recovery (adversary C) | Partial, by rotation | A new epoch is a new recipient set. The journal records the rotation and a fingerprint, never the key (docs/design/remote-sync.md:103-104) |
| Downgrade resistance (server-forced) | Provided by the spec's stanza rules | Scrypt, SSH, plugin and every other non-X25519 stanza are excluded (docs/spec/ref/age-v1-profile.md:58-63) |
Downgrade resistance (client accepting cipher: none) |
Provided by every caller in the tree | Both configure calls pass --cipher age-v1 and --minimum-security e2ee-required together (scripts/remote.sh:1280, :1758), and there is no third. The refusal is scripts/internal/remote-sync.mjs:1686. Removable only by invoking configure directly with plaintext-allowed |
age-v1 provides none, and the reason is structural rather than an oversight. A
key_id names an immutable recipient set:
docs/spec/ref/age-v1-profile.md:88
"A `key_id` identifies an immutable set of X25519 recipients and its private …"
An identity compromised at time T decrypts every message addressed to the epochs that identity belongs to, including those sent before T. Rotation limits the window going forward; it does not close what is already written.
This is the single most likely misreading of the code, so it is closed here explicitly. The digest is an unkeyed SHA-256 that the server computes for itself:
server/src/protocol.ts:221-238
export function envelopeDigest(envelope: Envelope): Buffer {
…
return createHash("sha256")
.update(…)
.digest();
}
server/src/storage.ts:307
const digest = envelopeDigest(message.envelope);
server/src/storage.ts:189
: record.digest.equals(envelopeDigest(message.envelope));
It carries no key, so it authenticates nothing and is trivially recomputable by
anyone holding the envelope. That is not a weakness, because it is not doing
that job. Its purpose is at storage.ts:189: detecting that the same message
id has been resent with different content, which the server rejects.
The reasoning that leads somewhere wrong is: the digest has no key → the
contents are unprotected. The step that fails is the second one. Under
age-v1, the contents are protected by age's own AEAD before the server ever
sees them; the digest is a bookkeeping device layered above that, not the thing
standing between an attacker and the plaintext. Under cipher: "none" the
contents are not protected — but that is section 1 above, not a property of the
digest.
Question: does a participant configured for age-v1 accept an injected
cipher: "none" envelope?
The sync client is in this repository — scripts/internal/sync-cipher.mjs (819
lines, a full age implementation) and scripts/internal/remote-sync.mjs. So
this is answerable here, and it was answered by running it rather than by
reading.
Layer 1 — the envelope opener does not decide it. openEnvelope dispatches
on the envelope's own cipher field and consults no configured profile:
scripts/internal/sync-cipher.mjs:718-725
export async function openEnvelope(input) {
…
const profile = cipherProfiles[envelope.cipher];
if (!profile) throw new CipherStateError("unsupported_cipher", …);
return profile.open(input);
}
Called directly with a well-formed cipher: "none" envelope built from the
repository's own test vectors, it returns the plaintext projection. Measured:
node -e '… sealEnvelope({…, cipher: "none", key_id: null, recipients: []}) …'
ACCEPTED — openEnvelope returned the plaintext projection, with no reference
to any configured profile
Layer 2 — the caller does decide it, and refuses. The single call site is guarded, three checks above it:
scripts/internal/remote-sync.mjs:1686
(localPolicy.minimum_security_mode === "e2ee-required" && message.envelope.cipher === "none")
-> status: "policy_violation", reason: "envelope violates effective policy"
So the answer is: a downgraded envelope is refused, by the pull path's policy
check, not by the cipher layer. The protection is real and it is one if
away from the plaintext — which is worth knowing precisely, because any future
caller of openEnvelope that does not replicate that check would accept
injected plaintext silently.
Note also scripts/internal/remote-sync.mjs:1699: the check that the configured
profile matches only runs if (message.envelope.cipher === "age-v1"). A none
envelope does not enter that branch at all. Line 1686 is the whole defence.
Line 1686 is a single if with three disjuncts, and a reader will take it as
three defences. It is one:
scripts/internal/remote-sync.mjs:1684-1686
!serverPolicy.accepted_envelope_versions.includes(message.envelope.v) ||
!serverPolicy.write_allowed_ciphers.includes(message.envelope.cipher) ||
(localPolicy.minimum_security_mode === "e2ee-required" && message.envelope.cipher === "none")
The first two read serverPolicy — values the server declares. Against
adversary A they are worth nothing: a server that wants to inject plaintext
declares a policy that permits plaintext. They are useful for catching
misconfiguration and version drift, not attack.
The third disjunct is the entire defence against a hostile server, because
localPolicy is the only term in that condition the server does not supply.
Derived rather than assumed: every call to remote-sync.sh configure in the
repository, and the flags each one passes.
scripts/remote.sh:1280 --minimum-security e2ee-required --cipher age-v1
scripts/remote.sh:1758 --minimum-security e2ee-required --cipher age-v1
Those are the only two, and the search is the whole tree minus docs/ — the
earlier version of this looked in scripts/ and tests/, which is narrower
than the sentence it was supporting and, worse, reads as "the tests were checked
too" while missing server/test/:
grep -rn 'remote-sync\.sh' . --exclude-dir=docs --exclude-dir=.git \
--exclude-dir=node_modules | grep configure
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 generic helper that forwards ...args, so it could in principle call
configure. It does not: configure appears zero times in that file.
So a machine that reaches age-v1 through any code in this repository
cannot get there without also setting e2ee-required — the two flags are passed together, in both places, and there
is no third place. That is stronger than "the default is safe": there is no
supported way to reach the unsafe combination.
It is still reachable by invoking remote-sync.sh configure directly with
--cipher age-v1 --minimum-security plaintext-allowed. Nothing forbids that
pairing; it is simply not what any code path here does.
A second machine reaches age-v1 by a different route, and it was traced rather
than left open. remote.sh's cmd_pull copies the team's declared cipher into
the binding (scripts/remote.sh:916) without calling configure — so at that
moment the machine has cipher_profile: "age-v1" and no
minimum_security_mode of its own.
It cannot sync in that state. Reading messages requires unlock, and
cmd_unlock is one of the two callers of configure:
scripts/remote.sh:1076 cmd_unlock() {
scripts/remote.sh:1280 bash "$SCRIPT_DIR/remote-sync.sh" configure \
scripts/remote.sh:1284 --minimum-security e2ee-required \
scripts/remote.sh:1285 --cipher age-v1
So the pull route does not bypass the pairing; it arrives at it one command
later. The other caller, _remote_configure_keyed_team
(scripts/remote.sh:1729, reached from :1916), passes the same two flags.
That is the whole set — the same two callers derived above. Both pass
--minimum-security e2ee-required and --cipher age-v1 as literals, not
variables something upstream could set apart, and both sit on the path a machine
must take before it can read anything.
The row reads provided by every caller in the tree — not "out of scope",
and not merely "conditional". There are two callers of remote-sync.sh configure in the whole repository, both pass the pairing as literals, and both
sit on the path a machine must take before it can read anything.
The boundary that remains is narrow and worth naming: this measures callers
in this repository. Someone invoking remote-sync.sh configure by hand, or
from their own script, can pair --cipher age-v1 with
--minimum-security plaintext-allowed. Nothing forbids that pairing; nothing
shipped here produces it.
The hosted service. Out of scope.
(The minimum_security_mode question this section used to leave open has been
measured; see "How a deployment ends up on e2ee-required" above.)
docs/spec/ref/age-v1-profile.md is marked "Status: proposed (dogfood
profile)" and was last touched on 2026-07-27, by the commit that filed it as
reference material — 1a56d8e docs: file superseded work as reference. It lives
under ref/, whose README says plainly: "Nobody is building toward anything in
a ref/ directory."
That is a real reason to distrust it, so the two citations this document leans on were checked against the code rather than taken on the document's authority:
:342-345(keys provisioned outside the message server). Consistent with the implementation, and more strongly than the sentence claims: the server has no age implementation at all (see "The implementation agrees with the specification"). Nothing inserver/could hold, transit, or use a recipient key.:58-63(X25519 stanzas only; scrypt, SSH and plugin excluded). Enforced, and not in the server — in the client:
scripts/internal/sync-cipher.mjs:277-281
const activeType = fields[1] === "scrypt" || fields[1] === "ssh-rsa" ||
fields[1] === "ssh-ed25519" || fields[1].startsWith("plugin-");
…
if (!stanzaIsGrease) malformed("age-v1 rejects active non-X25519 recipient stanzas");
So both cited requirements hold in code today. A reviewer should still read the profile document as proposed, and should treat any claim in it that this document does not cite as unchecked — this document verified the two it relies on, not the whole profile.
Provided as a cross-reference for reviewers who work from ASVS. It is not the spine of this document — the substance is above, and where the two disagree the sections above are the claim.
| ASVS V6 area | Where this document addresses it |
|---|---|
| V6.1 Data classification | "Read this before the properties table", items 1 and 3 |
| V6.2 Algorithms | Properties table; docs/spec/ref/age-v1-profile.md:58-63 (X25519 only) |
| V6.2 Integrity | "envelopeDigest is not a signature" |
| V6.4 Secret management | docs/spec/ref/age-v1-profile.md:342-345 (provisioning is outside the server) |
| V6.4 Key rotation | Properties table, post-compromise recovery; docs/design/remote-sync.md:103-104 |
Every citation is path:line. To verify one, against this branch's head or the
branch point — the code is the same at both:
sed -n '342,345p' docs/spec/ref/age-v1-profile.md
sed -n '221,238p' server/src/protocol.ts
Line numbers move. If a citation does not land where this says, check the same
file at 452da72 before concluding the claim is wrong — and if it is wrong,
that is worth reporting, because the rest of this document is only as good as
the weakest citation in it.