You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

121 lines
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
1. **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`) inside `max(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`, and `max` hides 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 `bitlen` built as `31 − Math.clz32(L)` fails the same way.
- **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.
- **Fix:** add rows above 2³² where Padme > `bloque256`, to §29.1 and `padding.json`:
| L | P, code 1 | P, code 2 | `PAYLOAD_AGE`, code 1 / 2 |
|---|---|---|---|
| 2³² + 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»: «`bitlen` MUST NOT calcularse con `Math.clz32` ni con operaciones de 32 bits».
- Add a `cbor.json` accept vector with `payload_length` = `48 0000000100000001`, which must decode to 4294967297. It catches readers that read only the low 4 bytes.
2. **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 the `time_and_key` capsule.
- **§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 `RawX25519Identity` goes through `id.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.
- **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`).
3. **The relabel tests for `time_and_key` stop at the optional `capsule_digest`. They never reach the control-version guard.**
- **Evidence:**
- §11 builds these cases with "+ .dkk".
- The v0.8.2 `.dkk` carries `capsule_digest` (`testdata/fixtures/time_and_key_portable.dkk.json`), and `Encrypt` always writes one (`capsule/encrypt.go:232`).
- `Open` checks it when the input is seekable (`capsule/open.go:140-146`). The result is `ERR_ACCESS_INVALID` at 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` (the `AGE-SECRET-KEY` form of `access_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:false` companion case with the `.dkk`: `ERR_ACCESS_INVALID`, step 9.
4. **"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_PAYLOAD` and can seal a control. That is anyone after the date in `time_only`, and any credential holder in `time_and_key`.
- In `time_and_key`, the holder keeps the 16 stanzas and recomputes the INNER MAC and STREAM with `FK_ACCESS`.
- `OUTER` only needs the public tlock key.
- **What they can build:** a format-1 capsule with the same `PAYLOAD_AGE` (`VERSION` 1, control v1, recomputed `header_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_PAYLOAD` selle otro control de formato 1 (§55.1): es una reescritura, no un downgrade. `VERSION` no está autenticado hasta los pasos 14 y 15; lo que impide el downgrade es la versión de `CONTROL_CBOR`, autenticada por los MAC de `age` frente a quien no conoce la file key, y `header_binding`.»
5. **The reason given for a fresh `I_PAYLOAD` is 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, in `time_only`) opens B's payload before B's date.
- **Derived from the content:** the `PAYLOAD_AGE` stanza 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.
6. **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.
7. **The shuffle MUST should bind the resulting header, not the call into age.**
- Both libraries keep recipient order: Go `age.go:125-137`, TS `index.js` `encrypt`. The reference appends `R_ACCESS` last (`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.»
8. **Gaps in §55.2 (D6):**
- **"Nunca visible" holds only under X25519 Diffie–Hellman.** A future quantum adversary (§7.7) who keeps the `.dkc` can recover the ephemeral scalars. With candidate public keys, it can test each stanza and identify real recipients.
- **Add to "Oculto":** whether a portable `.dkk` exists, and which stanza is its own.
- **`bloque256` gives a 256-byte bucket at any size,** so a known public file can be recognised by its size. Also, a P that `reforzado` does not produce reveals a non-default writer.
- **`VERSION` 1** reveals a writer from before v0.9.
- **"Número de credenciales" under "Oculto":** say it applies to `time_and_key`. In `time_only` it is 0, and `access_policy` shows that.
9. **L and P can be used for denial of service.**
- Anyone can seal a `time_only` control (§36.1) that declares L = L_MAX with a 456-byte `PAYLOAD_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.
10. **§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 `Open` that 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».
11. **The writer self-check does not catch missing padding.**
- «`I_PAYLOAD` abre 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).
12. **The float `log2` rationale 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.log2` gives 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).
13. **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.
14. **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.
15. **§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 `VERSION` 2 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_PAYLOAD` or 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) and `PAYLOAD_AGE` lengths 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).

Powered by TurnKey Linux.