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.

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.