12 KiB
Adversarial review of the v0.9 design (security and privacy)
Paths: Go repo G:\bussines\datekeys\datekeys-go (spec lines refer to spec\DateKeys_Protocol_Specification_v0.9.md, which is still the same as v0.8.2). TS library: G:\bussines\datekeys\App\node_modules\age-encryption 0.3.1. I edited no files.
Corrections, most serious first
-
The padding vectors miss the 32-bit bug that §29.1 warns about. Readers could disagree, and capsules over 4 GiB could be lost.
-
Evidence: I simulated a JS Padme built with 32-bit operators (
((L+mask)&~mask)>>>0) insidemax(bloque256, ·).- It gives the right P for every row of the proposed §29.1 table and
padding.json: 0 … 2³²−1 and L_MAX. - The reason: at 2³²−1 and at L_MAX, Padme equals
bloque256, andmaxhides the wrong Padme. At 6·10⁸ and 10⁹ the 32-bit operators are still exact. - The first wrong value is at L = 2³²+1: it gives 4294967552 where the right value is 4362076160.
- A
bitlenbuilt as31 − Math.clz32(L)fails the same way.
- It gives the right P for every row of the proposed §29.1 table and
-
Consequence: a TS writer with this bug declares code 2 (the product default) but pads only to
bloque256.- Go rejects that capsule at step 17 with
ERR_INTEGRITY, after the date, when it can no longer be repaired. - The TS reader, with the same bug, opens it.
- Go rejects that capsule at step 17 with
-
Fix: add rows above 2³² where Padme >
bloque256, to §29.1 andpadding.json:L P, code 1 P, code 2 PAYLOAD_AGE, code 1 / 22³² + 1 4 294 967 552 4 362 076 160 4 296 016 328 / 4 363 141 304 5·10⁹ 5 000 000 000 5 033 164 800 5 001 220 888 / 5 034 393 784 10¹² 1 000 000 000 000 1 005 022 347 264 1 000 244 140 824 / 1 005 267 714 232 2⁵² + 1 4 503 599 627 370 752 4 573 968 371 548 160 4 504 699 138 998 728 / 4 575 085 063 045 304 -
Also:
- Add to «Aritmética»: «
bitlenMUST NOT calcularse conMath.clz32ni con operaciones de 32 bits». - Add a
cbor.jsonaccept vector withpayload_length=48 0000000100000001, which must decode to 4294967297. It catches readers that read only the low 4 bytes.
- Add to «Aritmética»: «
-
-
A dummy's private key is a full credential. The spec treats it as privacy hygiene.
- Why: a dummy stanza wraps the real
FK_ACCESS. Anyone who holds, predicts or recovers a dummy's private key opens thetime_and_keycapsule. - §39 should say so: «Un señuelo envuelve la misma
FK_ACCESS: su clave privada abre la cápsula como una credencial hasta que se borra». - The approved "discard at once" cannot be done through the age APIs:
- TS
generateX25519Identity()returns an immutable"AGE-SECRET-KEY-1…"string (recipients.js:34-38). - Go
RawX25519Identitygoes throughid.String()(agewrap/agewrap.go:431-436). - Proposed informative note: generate the scalar in a buffer that can be wiped, compute X25519(k, 9), wipe the buffer, and give only the recipient to age.
- TS
- Also a MUST: «MUST NOT almacenar, registrar ni entregar la permutación ni qué huecos son señuelos». That map reveals the number of credentials. The sidecars' "stanza index each credential opens" is fine only in test data.
- Optional, needs your approval (D1 fixes the mechanism):
- A dummy could be a uniformly random canonical u that is not of low order. Then no private key ever exists.
- It stays indistinguishable, because R never appears in the stanza (
age x25519.go:80-87).
- Why: a dummy stanza wraps the real
-
The relabel tests for
time_and_keystop at the optionalcapsule_digest. They never reach the control-version guard.- Evidence:
- §11 builds these cases with "+ .dkk".
- The v0.8.2
.dkkcarriescapsule_digest(testdata/fixtures/time_and_key_portable.dkk.json), andEncryptalways writes one (capsule/encrypt.go:232). Openchecks it when the input is seekable (capsule/open.go:140-146). The result isERR_ACCESS_INVALIDat step 9, with no network: see the mutation "capsule_digest of the .dkk does not match" (mutations.json~L1086,spec:false).- §69.1 «Alcance» (L2108) says the official vectors assume every optional check runs. So the expected codes "step 12" and "step 14" contradict the spec.
- The step-14 guard, which is the real anti-downgrade defense, would stay untested for
time_and_key.
- Fix:
- Pass the credential as
identities(theAGE-SECRET-KEYform ofaccess_material), as "access_policy=time_and_key with time_only structure" already does. - In §64, write «con la identity, sin
capsule_digest». - Add a
spec:falsecompanion case with the.dkk:ERR_ACCESS_INVALID, step 9.
- Pass the credential as
- Evidence:
-
"En ningún caso entrega el contenido con su relleno" (§70) and "No path outputs padding as content" (§1.3) claim too much.
- Who can do it: anyone who knows
I_PAYLOADand can seal a control. That is anyone after the date intime_only, and any credential holder intime_and_key.- In
time_and_key, the holder keeps the 16 stanzas and recomputes the INNER MAC and STREAM withFK_ACCESS. OUTERonly needs the public tlock key.
- In
- What they can build: a format-1 capsule with the same
PAYLOAD_AGE(VERSION1, control v1, recomputedheader_binding). Every reader opens it as content followed by zeros. - This is the rewriter case of §55.1, but the text must be scoped. Proposed wording:
«…salvo que quien ya conoce
I_PAYLOADselle otro control de formato 1 (§55.1): es una reescritura, no un downgrade.VERSIONno está autenticado hasta los pasos 14 y 15; lo que impide el downgrade es la versión deCONTROL_CBOR, autenticada por los MAC deagefrente a quien no conoce la file key, yheader_binding.»
- Who can do it: anyone who knows
-
The reason given for a fresh
I_PAYLOADis too weak.- The proposed §29 text and §76 case 6 give only the binding of §30.1. The real threat is confidentiality.
- Shared
I_PAYLOAD: opening capsule A (by anyone, at A's date, intime_only) opens B's payload before B's date. - Derived from the content: the
PAYLOAD_AGEstanza is visible before the date. Anyone can confirm a guessed content and decrypt it early. - Derived from a master secret: that one secret opens every payload, whatever the date.
- Fix: write this in §29, §62.1 rule 5 and §76 case 6. The same argument applies to dummies.
-
Loss of auditability is not documented.
- In format 1, a holder who expected n recipients could count the stanzas. In format 2 nobody can tell how many parties can open the capsule.
- Nobody can check the MUST NOT on storing dummy keys.
- A compromised SDK (§7.5) or creator device (§7.8) can keep a dummy key as a hidden credential, and nobody would notice.
- Fix: add this to §55.2 (for example «Lo que el formato 2 impide comprobar») and cite it from §39.
-
The shuffle MUST should bind the resulting header, not the call into age.
- Both libraries keep recipient order: Go
age.go:125-137, TSindex.jsencrypt. The reference appendsR_ACCESSlast (capsule/encrypt.go:289-295). - A library that sorts or groups stanzas would silently break the rule.
- Fix: «El orden de los 16 stanzas en la cabecera MUST ser una permutación uniformemente aleatoria, sin sesgo (p. ej. Fisher–Yates con muestreo por rechazo), independiente de qué huecos son credenciales y del orden de entrada.»
- Both libraries keep recipient order: Go
-
Gaps in §55.2 (D6):
- "Nunca visible" holds only under X25519 Diffie–Hellman. A future quantum adversary (§7.7) who keeps the
.dkccan recover the ephemeral scalars. With candidate public keys, it can test each stanza and identify real recipients. - Add to "Oculto": whether a portable
.dkkexists, and which stanza is its own. bloque256gives a 256-byte bucket at any size, so a known public file can be recognised by its size. Also, a P thatreforzadodoes not produce reveals a non-default writer.VERSION1 reveals a writer from before v0.9.- "Número de credenciales" under "Oculto": say it applies to
time_and_key. Intime_onlyit is 0, andaccess_policyshows that.
- "Nunca visible" holds only under X25519 Diffie–Hellman. A future quantum adversary (§7.7) who keeps the
-
L and P can be used for denial of service.
- Anyone can seal a
time_onlycontrol (§36.1) that declares L = L_MAX with a 456-bytePAYLOAD_AGE. - Add to §57:
- «L y P MUST NOT usarse para reservar memoria ni disco antes de recibir el plaintext».
- Optionally: MAY check
|PAYLOAD_AGE|= 184 + P + 16·c(P) at step 17, after the stanza rules. It has the same code.
- Anyone can seal a
-
§56 needs an RFC keyword for format 2.
- In format 2 the padding failure is found only after all L content bytes have gone out. A non-transactional reader, such as a caller of
Openthat ignores the error (capsule/open.go:85-88, 251-258), is left holding complete-looking content from an invalid object. - The proposed «no pueden presentarse» has no keyword. Use: «En formato 2 un lector MUST NOT entregar ni presentar como válidos los L primeros bytes hasta que el paso 17 termine sin error».
- In format 2 the padding failure is found only after all L content bytes have gone out. A non-transactional reader, such as a caller of
-
The writer self-check does not catch missing padding.
- «
I_PAYLOADabre la cabecera» passes even when padding is missing. That bug leaks L exactly, and after the date the capsule fails at step 17. - Add as a SHOULD: count the plaintext bytes handed to age (= P) and check that
|PAYLOAD_AGE|= 184 + P + 16·c(P).
- «
-
The float
log2rationale needs a better example.log2(2⁵³−1)is outside [0, L_MAX]. Inside the range, the first wrong result is at L = 2⁴⁹−1 (Math.log2gives 49; the right value is 48).- I checked k = 9..52 with offsets ±40 around 2^k: the wrong E never changes P, because both roundings reach 2^k.
- Keep the MUST (the E, S and lastBits vectors), but use L = 2⁵²−1 (in range) as the example. Say that the real risk to P is 32-bit operators (item 1).
-
Twist points are missing from the recipient rules. A canonical u on the twist is nobody's public key, so its stanza cannot be opened. This is the same reason as for non-canonical keys. Add it as a SHOULD (Legendre check), or say that the §37 list is not exhaustive.
-
The writer-clock MUST only catches user error. A writer whose clock is late can seal to a round that is already published. Add: the SDK SHOULD show the effective round time, and MAY compare the round with the latest published one when it is online.
-
§39 «misma distribución que la de una identity real» is misleading. Indistinguishability does not depend on how R is distributed, because R is not in the stanza. Replace it with the actual assumption: age X25519 anonymity (
x25519.go:28-29) under Diffie–Hellman.
Checked, no correction needed
- Version check before the network: v0.8.2 rejects
VERSION2 at step 2 with no network (capsule/framing.go:110;mutations.json"version changed",network:false). Relabelling with the original control fails at step 14 in both reader versions. - Padding determinism: zero bytes, P = rule(L) and plaintext length = P give one valid plaintext per control and payload. Without
I_PAYLOADor a file key nobody can change it. - No new covert channel of any size: the only one is the code choice, and P shows it only sometimes. It is negligible next to
capsule_id. - Padme arithmetic: every value in §3.4 reproduces.
reforzado(L_MAX+1)= 2⁵³ exactly. The L ≤ 256 branch avoids log2(0).- For L up to 300 000, P is monotone, P ≥ L, P is a multiple of 256, and rule(P) = P.
- Comparing the 8-byte L with L_MAX is safe even as hi·2³² + lo in doubles.
- Credential count: the fixtures'
SEALED_CONTROL_LEN(446 / 646 / 842) andPAYLOAD_AGElengths match the formulas (98 bytes per stanza). In format 2 it is constant (2128 / 458), and the minimal control is 103 bytes. - Timing: the reader tries every identity against every stanza (
agewrap/agewrap.go:385-403), so timing does not reveal the stanza index. - Low-order keys: both age libraries refuse to encrypt to a low-order recipient (Go
x25519.go:75-78; noble and WebCrypto in TS).