Phase 2, step 7: tlock encryption, checked against Go both ways

- ibe.ts gains encryptOnG2RFC9380, EncryptCCAonG2 of kyber with the
  suite of tlock for Quicknet. Qid is H(id) on G1 with the RFC 9380 DST,
  sigma comes from crypto.getRandomValues, U = r·G2, V = sigma XOR
  H2(e(Qid, key)^r) and W = msg XOR H4(sigma). The key passes the
  canonical gate, and sigma and the masks are wiped. encryptOnG2WithSigma
  takes a given sigma, for the vectors only; index.ts exports neither.
- tlock.ts adds timeRecipient, the age-encryption Recipient of
  OUTER_TIME_AGE, as Go's agewrap.TimeRecipient. It writes the stanza
  "tlock <round> <chain hash>" with the checks and texts of
  NewTimeRecipient: the scheme and the pinned key, then the round range.
  age-encryption has no labels, so the writer of phase 3 adds it alone.

Vectors, in src/lib/dkc/testing/tlock-vectors.json from
scripts/tlock-go-vectors.go:
- Fixed-sigma encryptions of 1, 16 and 32 bytes for rounds 1000 and
  1001. Go restates EncryptCCAonG2, since kyber draws sigma itself, and
  checks the restatement with ibe.DecryptCCAonG2 and tlock.TimeUnlock.
  encryptOnG2WithSigma reproduces them byte for byte.
- The samples of scripts/tlock-ts-samples.mjs, which Node runs on the
  TypeScript sources: IBE bodies and age files that this library made
  for rounds 1000 and 1001. Go opened every one: the bodies with
  tlock.TimeUnlock and the age files with age.Decrypt and
  agewrap.NewTimeIdentity, the identity of step 11. It got the same
  file keys and plaintexts, and the samples are frozen with those
  verdicts.

tlock.test.ts replays both blocks, the random round trip, the
rejections with their texts, and an age file sealed with timeRecipient
and opened with the step-11 identity of open.ts. Coverage of ibe.ts and
tlock.ts is 100 %, now a threshold for tlock.ts too. Step 6 of the plan
is recorded as done: the canonicality amendment is in spec-v0.8.2.

npm run verify is green: 2,567 tests. The site does not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
dev 1 week ago
parent 243a42c004
commit 66970cf82b

@ -20,9 +20,9 @@ Implementa la especificación DateKeys 0.8.2 (tag `spec-v0.8.2` de `datekeys-go`
- la apertura, pasos 9 a 18 de §63 (`open.ts`), con el texto en claro en memoria. Las identidades estrictas de `agewrap` se apoyan en `x25519.ts`, que abre cada stanza X25519 por separado, y en `bech32.ts`. Los 65 casos del corpus de mutaciones pasan por `open` con el código y el paso de Go, y los cinco fixtures oficiales se abren a su texto en claro; - la apertura, pasos 9 a 18 de §63 (`open.ts`), con el texto en claro en memoria. Las identidades estrictas de `agewrap` se apoyan en `x25519.ts`, que abre cada stanza X25519 por separado, y en `bech32.ts`. Los 65 casos del corpus de mutaciones pasan por `open` con el código y el paso de Go, y los cinco fixtures oficiales se abren a su texto en claro;
- `@noble/ciphers` 2.4.0 como dependencia directa, aprobada el 28-09-2026: la copia que ya trae `age-encryption`; - `@noble/ciphers` 2.4.0 como dependencia directa, aprobada el 28-09-2026: la copia que ya trae `age-encryption`;
- la apertura en streaming. La entrada puede ser un `Blob`, del que se lee solo el prefijo de los pasos 1 a 8 (`prefix.ts`, antes en la página) y se descifra `PAYLOAD_AGE` en streaming. La salida puede ser un `WritableStream`, que se cierra solo tras el paso 18 y se aborta ante cualquier fallo. En el navegador, con un fichero OPFS, un fallo de STREAM deja intacto su contenido anterior. - la apertura en streaming. La entrada puede ser un `Blob`, del que se lee solo el prefijo de los pasos 1 a 8 (`prefix.ts`, antes en la página) y se descifra `PAYLOAD_AGE` en streaming. La salida puede ser un `WritableStream`, que se cierra solo tras el paso 18 y se aborta ante cualquier fallo. En el navegador, con un fichero OPFS, un fallo de STREAM deja intacto su contenido anterior.
- el cifrado del stanza tlock (`encryptOnG2RFC9380`) y el `Recipient` de `OUTER_TIME_AGE` (`tlock.ts`). Con sigma fijo, el cifrado reproduce byte a byte los vectores de Go. Además Go abre lo que cifra esta librería: el cuerpo IBE con `tlock.TimeUnlock` y el fichero `age` con `agewrap.NewTimeIdentity`.
- `VERSION` y `SPEC_VERSION`, también en el pie de la página. - `VERSION` y `SPEC_VERSION`, también en el pie de la página.
### Pendiente para 0.1.0 ### Pendiente para 0.1.0
- El cifrado a nivel de stanza y de fichero `age`, contrastado con Go (paso 7).
- La acción "abrir" en `/inspect`, cargada bajo demanda, con el fichero temporal de OPFS, la cuota libre y la descarga (paso 8). - La acción "abrir" en `/inspect`, cargada bajo demanda, con el fichero temporal de OPFS, la cuota libre y la descarga (paso 8).

@ -48,9 +48,10 @@ Lo que hay hoy (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegad
| `extension.ts` | Arrays de extensiones, leídos con su objeto (capa 3 de §69.1). Registros con ubicación opcional (`registeredIn`): una extensión conocida fuera de los objetos y arrays de su registro cuenta allí como desconocida (§54, §72), como `extension.Placement` en Go. Reglas del array: de 1 a 64, en orden estrictamente ascendente de los bytes UTF-8 de `extension_id` (nunca por unidades UTF-16), `extension_version` hasta 2³² − 1, `data` ausente o `bstr` no vacío, ningún id en los dos arrays; registros, críticas y no críticas (capa 4) | `extension` | | `extension.ts` | Arrays de extensiones, leídos con su objeto (capa 3 de §69.1). Registros con ubicación opcional (`registeredIn`): una extensión conocida fuera de los objetos y arrays de su registro cuenta allí como desconocida (§54, §72), como `extension.Placement` en Go. Reglas del array: de 1 a 64, en orden estrictamente ascendente de los bytes UTF-8 de `extension_id` (nunca por unidades UTF-16), `extension_version` hasta 2³² − 1, `data` ausente o `bstr` no vacío, ningún id en los dos arrays; registros, críticas y no críticas (capa 4) | `extension` |
| `profile.ts` | Provider Profile: CBOR exacto, `profile_hash`, reglas 1 a 4 de §12.1 en su orden (el límite de `period` de §74 en la capa del esquema; alfabetos, clave pública del grupo del scheme y fórmula de `chain_hash`), registro pinneado; Quicknet fijado por su CBOR y su hash | `profile` | | `profile.ts` | Provider Profile: CBOR exacto, `profile_hash`, reglas 1 a 4 de §12.1 en su orden (el límite de `period` de §74 en la capa del esquema; alfabetos, clave pública del grupo del scheme y fórmula de `chain_hash`), registro pinneado; Quicknet fijado por su CBOR y su hash | `profile` |
| `bls12381.ts` | Pertenencia de claves públicas BLS12-381 comprimidas (G1 y G2) al subgrupo, como `FromCompressed` de kilic | `kyber-bls12381` | | `bls12381.ts` | Pertenencia de claves públicas BLS12-381 comprimidas (G1 y G2) al subgrupo, como `FromCompressed` de kilic | `kyber-bls12381` |
| `ibe.ts` | IBE-CCA de tlock sobre G2 para Quicknet (§63 paso 11): `decryptOnG2`, con la puerta de codificación canónica de `bls12381.ts` sobre la firma y U; H2 sobre GT serializado en el orden de kilic (nunca `Fp12.toBytes` de noble), H3 y H4; `roundIdentity`; el cuerpo `U ‖ V ‖ W` de 128 bytes del stanza. Errores `IbeError` con motivo (`length`, `encoding`, `identity`, `proof`) y texto fijos, sin ningún valor del cálculo; borra sigma y los hashes derivados. Sobre `@noble/curves` 2.4.0; lleva el aviso MIT de `tlock-js`, cuya estructura sigue. Lo usa la apertura (`open.ts`) | `encrypt/ibe` de drand/kyber (`DecryptCCAonG2`), `tlock.BytesToCiphertext` y `TimeUnlock` | | `ibe.ts` | IBE-CCA de tlock sobre G2 para Quicknet (§63 paso 11): `decryptOnG2` y `encryptOnG2RFC9380` (Qid = H(id) en G1 con el DST de RFC 9380, sigma aleatorio, U = r·G2), con la puerta de codificación canónica de `bls12381.ts` sobre la firma y U; H2 sobre GT serializado en el orden de kilic (nunca `Fp12.toBytes` de noble), H3 y H4; `roundIdentity`; el cuerpo `U ‖ V ‖ W` de 128 bytes del stanza. Errores `IbeError` con motivo (`length`, `encoding`, `identity`, `proof`) y texto fijos, sin ningún valor del cálculo; borra sigma y los hashes derivados. Sobre `@noble/curves` 2.4.0; lleva el aviso MIT de `tlock-js`, cuya estructura sigue. Lo usa la apertura (`open.ts`) | `encrypt/ibe` de drand/kyber (`DecryptCCAonG2`), `tlock.BytesToCiphertext` y `TimeUnlock` |
| `release.ts` | Verificación local del release (§17, §51, §63 paso 10), en el orden y con los textos de `provider.Verify`:<br>1. el rango de la ronda (`ERR_DATEKEY_INVALID`);<br>2. la ronda del release antes que la firma (`ERR_ROUND_MISMATCH`);<br>3. la longitud de la firma;<br>4. la clave pinneada (`ERR_UNKNOWN_PROFILE`);<br>5. la firma: codificación canónica de un punto de G1 que no sea el infinito, y firma BLS válida de la ronda sobre `@noble/curves` 2.4.0, con el DST de RFC 9380 para G1 (`ERR_RELEASE_INVALID`).<br>Nada de noble se copia a los errores. Solo verifica el scheme de Quicknet: un perfil de otro scheme falla con `ERR_UNKNOWN_PROFILE` tras las comprobaciones de ronda, donde la referencia sí lo verificaría (decisión 3 del plan de la fase 2). También define `ReleaseSource`, con su contrato de fuentes de red y de la corrección 6, y `suppliedRelease`, el release que entrega quien llama | `provider` (`Verify`, `ReleaseSource`) | | `release.ts` | Verificación local del release (§17, §51, §63 paso 10), en el orden y con los textos de `provider.Verify`:<br>1. el rango de la ronda (`ERR_DATEKEY_INVALID`);<br>2. la ronda del release antes que la firma (`ERR_ROUND_MISMATCH`);<br>3. la longitud de la firma;<br>4. la clave pinneada (`ERR_UNKNOWN_PROFILE`);<br>5. la firma: codificación canónica de un punto de G1 que no sea el infinito, y firma BLS válida de la ronda sobre `@noble/curves` 2.4.0, con el DST de RFC 9380 para G1 (`ERR_RELEASE_INVALID`).<br>Nada de noble se copia a los errores. Solo verifica el scheme de Quicknet: un perfil de otro scheme falla con `ERR_UNKNOWN_PROFILE` tras las comprobaciones de ronda, donde la referencia sí lo verificaría (decisión 3 del plan de la fase 2). También define `ReleaseSource`, con su contrato de fuentes de red y de la corrección 6, y `suppliedRelease`, el release que entrega quien llama | `provider` (`Verify`, `ReleaseSource`) |
| `open.ts` | Los pasos 9 a 18 de §63 sobre los pasos 1 a 8 de `inspectWith`, con los checks, códigos y textos de `capsule.Open`:<br>- las credenciales y el release (paso 9), que cualquier fallo de la fuente convierte en `ERR_RELEASE_UNAVAILABLE` (corrección 6);<br>- la verificación del release (10);<br>- `OUTER_TIME_AGE` (11), la estructura frente a `access_policy` (12) e `INNER_ACCESS_AGE` (13);<br>- `CONTROL_CBOR` (14), `header_binding` (15), `I_PAYLOAD` (16), `PAYLOAD_AGE` (17) y el commit (18).<br>Abre los tres ficheros `age` con el `Decrypter` de `age-encryption` y con identidades propias que aplican las reglas de `agewrap`: la de tiempo, sobre `ibe.ts`; las de acceso y payload, sobre `x25519.ts`, stanza a stanza. Los fallos de `age` que no informa una identidad son `ERR_INTEGRITY` con el motivo fijo de su fase, cabecera o STREAM, sin copiar el texto de `age-encryption`.<br>La entrada puede ser un `Uint8Array` o un `Blob`, como un `File`. De un `Blob` solo se lee el prefijo de los pasos 1 a 8 (`prefix.ts`), el `capsule_digest` de la `.dkk` se calcula sobre su stream (`digest.ts`) y `PAYLOAD_AGE` se descifra en streaming.<br>El texto en claro va a memoria o a `output`, un `WritableStream`. Se escribe a medida que `age` autentica cada chunk, se cierra solo tras el paso 18 y se aborta ante cualquier fallo, en cualquier paso (§56). Un fallo del stream de salida es `ERR_INTEGRITY` con su texto, como en Go. El `WritableStream` de un fichero OPFS guarda lo escrito en un fichero de intercambio hasta el cierre: comprobado en el navegador, un fallo de STREAM deja intacto el contenido anterior | `capsule.Open`, `agewrap` (`TimeIdentity`, `AccessIdentity`, `PayloadIdentity`) | | `open.ts` | Los pasos 9 a 18 de §63 sobre los pasos 1 a 8 de `inspectWith`, con los checks, códigos y textos de `capsule.Open`:<br>- las credenciales y el release (paso 9), que cualquier fallo de la fuente convierte en `ERR_RELEASE_UNAVAILABLE` (corrección 6);<br>- la verificación del release (10);<br>- `OUTER_TIME_AGE` (11), la estructura frente a `access_policy` (12) e `INNER_ACCESS_AGE` (13);<br>- `CONTROL_CBOR` (14), `header_binding` (15), `I_PAYLOAD` (16), `PAYLOAD_AGE` (17) y el commit (18).<br>Abre los tres ficheros `age` con el `Decrypter` de `age-encryption` y con identidades propias que aplican las reglas de `agewrap`: la de tiempo, sobre `ibe.ts`; las de acceso y payload, sobre `x25519.ts`, stanza a stanza. Los fallos de `age` que no informa una identidad son `ERR_INTEGRITY` con el motivo fijo de su fase, cabecera o STREAM, sin copiar el texto de `age-encryption`.<br>La entrada puede ser un `Uint8Array` o un `Blob`, como un `File`. De un `Blob` solo se lee el prefijo de los pasos 1 a 8 (`prefix.ts`), el `capsule_digest` de la `.dkk` se calcula sobre su stream (`digest.ts`) y `PAYLOAD_AGE` se descifra en streaming.<br>El texto en claro va a memoria o a `output`, un `WritableStream`. Se escribe a medida que `age` autentica cada chunk, se cierra solo tras el paso 18 y se aborta ante cualquier fallo, en cualquier paso (§56). Un fallo del stream de salida es `ERR_INTEGRITY` con su texto, como en Go. El `WritableStream` de un fichero OPFS guarda lo escrito en un fichero de intercambio hasta el cierre: comprobado en el navegador, un fallo de STREAM deja intacto el contenido anterior | `capsule.Open`, `agewrap` (`TimeIdentity`, `AccessIdentity`, `PayloadIdentity`) |
| `tlock.ts` | `timeRecipient`, el `Recipient` de `age-encryption` para `OUTER_TIME_AGE` (§32, §35), como `agewrap.TimeRecipient`: cifra la file key con `ibe.ts` para una ronda de un perfil pinneado y escribe el stanza `tlock <ronda> <chain hash>` de tlock. Comprueba el perfil y luego el rango de la ronda, con los textos de `NewTimeRecipient`. `age-encryption` no tiene etiquetas, así que quien escriba `OUTER_TIME_AGE` (fase 3) lo añade como único recipient | `agewrap.TimeRecipient` |
| `digest.ts` | SHA-256 incremental de un stream, con `@noble/hashes`, para el `capsule_digest` de un `.dkc` que no está en memoria (Web Crypto solo calcula el hash de buffers enteros) | | | `digest.ts` | SHA-256 incremental de un stream, con `@noble/hashes`, para el `capsule_digest` de un `.dkc` que no está en memoria (Web Crypto solo calcula el hash de buffers enteros) | |
| `x25519.ts` | El stanza X25519 de `age`, abierto de uno en uno como `X25519Identity.Unwrap` de `age`: argumentos, share, acuerdo de claves, longitud del cuerpo y autenticación, en ese orden, con las primitivas que usa `age-encryption` (X25519 de `@noble/curves`, HKDF-SHA-256 de `@noble/hashes` y ChaCha20-Poly1305 de `@noble/ciphers`). También lee identidades `AGE-SECRET-KEY-1…` | `filippo.io/age` (`X25519Identity`), `agewrap` | | `x25519.ts` | El stanza X25519 de `age`, abierto de uno en uno como `X25519Identity.Unwrap` de `age`: argumentos, share, acuerdo de claves, longitud del cuerpo y autenticación, en ese orden, con las primitivas que usa `age-encryption` (X25519 de `@noble/curves`, HKDF-SHA-256 de `@noble/hashes` y ChaCha20-Poly1305 de `@noble/ciphers`). También lee identidades `AGE-SECRET-KEY-1…` | `filippo.io/age` (`X25519Identity`), `agewrap` |
| `bech32.ts` | Bech32 (BIP 173) tal como `internal/bech32` de `age`, que la referencia copia como `codec/bech32`; conserva su aviso MIT | `codec/bech32` | | `bech32.ts` | Bech32 (BIP 173) tal como `internal/bech32` de `age`, que la referencia copia como `codec/bech32`; conserva su aviso MIT | `codec/bech32` |
@ -59,7 +60,7 @@ Lo que hay hoy (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegad
| `framing.ts` | Prelude DKC1 (16 bytes) y DKK1 (12 bytes) en el orden de §23 y §40, longitudes de 1 byte hasta los límites de §57, y troceo de secciones | `capsule/framing.go` | | `framing.ts` | Prelude DKC1 (16 bytes) y DKK1 (12 bytes) en el orden de §23 y §40, longitudes de 1 byte hasta los límites de §57, y troceo de secciones | `capsule/framing.go` |
| `age.ts` | Parser estricto de la cabecera `age` v1 (§28.1) sobre los ficheros binarios, con los textos de error de `age`; reglas de stanzas; `MAX_AGE_HEADER_LEN` (2 MiB), el límite que usa la página para leer solo el prefijo de un `.dkc` grande | `agewrap`, `filippo.io/age/internal/format` | | `age.ts` | Parser estricto de la cabecera `age` v1 (§28.1) sobre los ficheros binarios, con los textos de error de `age`; reglas de stanzas; `MAX_AGE_HEADER_LEN` (2 MiB), el límite que usa la página para leer solo el prefijo de un `.dkc` grande | `agewrap`, `filippo.io/age/internal/format` |
| `inspect.ts` | Pasos 1 a 8 de §63 y la vista JSON de `datekeys inspect -json` (`inspectView`, `inspectJSON`) | `capsule/inspect.go`, `internal/inspectview` | | `inspect.ts` | Pasos 1 a 8 de §63 y la vista JSON de `datekeys inspect -json` (`inspectView`, `inspectJSON`) | `capsule/inspect.go`, `internal/inspectview` |
| `index.ts` | Reexporta todo salvo la fase 2 (`ibe.ts`, `release.ts`, `open.ts`, `x25519.ts`, `bech32.ts` y `digest.ts`). La página importa `index.ts`, y reexportarlos metería noble en `/inspect` (de 58,7 a 84,9 KB con gzip) aunque no los use, porque noble ejecuta código al cargarse. El paso 8 cargará la apertura bajo demanda | | | `index.ts` | Reexporta todo salvo la fase 2 (`ibe.ts`, `release.ts`, `open.ts`, `tlock.ts`, `x25519.ts`, `bech32.ts` y `digest.ts`). La página importa `index.ts`, y reexportarlos metería noble en `/inspect` (de 58,7 a 84,9 KB con gzip) aunque no los use, porque noble ejecuta código al cargarse. El paso 8 cargará la apertura bajo demanda | |
| `testing/` | Solo para tests: lectura de `testdata/` y de sus formatos (`vectors.ts`: ediciones, vectores), constructores de CBOR en hex, cirugía de cápsulas | | | `testing/` | Solo para tests: lectura de `testdata/` y de sus formatos (`vectors.ts`: ediciones, vectores), constructores de CBOR en hex, cirugía de cápsulas | |
Los tests (`*.test.ts`) están junto a cada fichero. Los tests (`*.test.ts`) están junto a cada fichero.
@ -153,7 +154,7 @@ npm run build:check # solo la comprobación del sitio ya construido
npm run verify # check, typecheck, coverage y build (con su comprobación) npm run verify # check, typecheck, coverage y build (con su comprobación)
``` ```
Umbrales de cobertura (`vitest.config.ts`): `cbor.ts`, `ibe.ts`, `release.ts`, `x25519.ts`, `bech32.ts` y `digest.ts` al 100 % en líneas, ramas, funciones y sentencias; el conjunto de `src/lib/dkc` al 95/90/95/95, y el de `src/lib/inspector` también. Umbrales de cobertura (`vitest.config.ts`): `cbor.ts`, `ibe.ts`, `release.ts`, `tlock.ts`, `x25519.ts`, `bech32.ts` y `digest.ts` al 100 % en líneas, ramas, funciones y sentencias; el conjunto de `src/lib/dkc` al 95/90/95/95, y el de `src/lib/inspector` también.
`vitest.config.ts` es la configuración de los tests; `vite.config.ts`, la del sitio con el plugin de SvelteKit. Vitest prefiere la primera, así que los tests de `src/lib` corren sin SvelteKit, y `src/lib/inspector` importa la librería por rutas relativas, sin el alias `$lib`. `tsconfig.json` extiende el que genera `svelte-kit sync` (por eso `typecheck` y `check` lo ejecutan antes, y `npm install` también, con `prepare`). `vitest.config.ts` es la configuración de los tests; `vite.config.ts`, la del sitio con el plugin de SvelteKit. Vitest prefiere la primera, así que los tests de `src/lib` corren sin SvelteKit, y `src/lib/inspector` importa la librería por rutas relativas, sin el alias `$lib`. `tsconfig.json` extiende el que genera `svelte-kit sync` (por eso `typecheck` y `check` lo ejecutan antes, y `npm install` también, con `prepare`).
@ -176,7 +177,13 @@ Umbrales de cobertura (`vitest.config.ts`): `cbor.ts`, `ibe.ts`, `release.ts`, `
- mensajes de 0, 1, 16 y 32 bytes cifrados por `EncryptCCAonG2` de kyber; - mensajes de 0, 1, 16 y 32 bytes cifrados por `EncryptCCAonG2` de kyber;
- el veredicto de `DecryptCCAonG2` sobre copias editadas del stanza de `time_only`: U con c0 + p, en el infinito o negado, V o W alterados, la firma de otra ronda, negada o en el infinito, y longitudes erróneas. - el veredicto de `DecryptCCAonG2` sobre copias editadas del stanza de `time_only`: U con c0 + p, en el infinito o negado, V o W alterados, la firma de otra ronda, negada o en el infinito, y longitudes erróneas.
H2, H3 y H4 no son públicas en kyber: el script las reescribe con sus etiquetas y las comprueba en cada fixture contra la file key de tlock y contra U = r·G2. Los cifrados de kyber usan un sigma aleatorio, así que el fichero se genera una vez y se congela. Para regenerarlo, desde un módulo Go temporal que requiera la referencia (`replace g.activething.com/go/DateKeys => ../datekeys-go`, `GOFLAGS=-mod=mod`): `go run ibe-go-vectors.go ../App/testdata/fixtures > ibe-vectors.json`. El mismo tratamiento tiene `src/lib/dkc/testing/tlock-vectors.json`, los valores de referencia del cifrado (paso 7 de la fase 2), que escribe `scripts/tlock-go-vectors.go` y comprueba `tlock.test.ts`:
- cifrados con sigma fijo de mensajes de 1, 16 y 32 bytes para las rondas 1000 y 1001. Go reescribe `EncryptCCAonG2` porque kyber toma sigma de `crypto/rand`, y comprueba su reescritura descifrando con `ibe.DecryptCCAonG2` y, en los de 16 bytes, con `tlock.TimeUnlock`. `encryptOnG2WithSigma` los reproduce byte a byte;
- la interoperabilidad de TypeScript a Go. `scripts/tlock-ts-samples.mjs` cifra con esta librería un cuerpo IBE y un fichero `age` escrito con `timeRecipient`, para las rondas 1000 y 1001. Go abre los cuerpos con `tlock.TimeUnlock` y los ficheros con `age.Decrypt` y `agewrap.NewTimeIdentity`, la identidad del paso 11, y obtiene la misma file key y el mismo texto. Esas muestras son aleatorias, así que se congelan con el veredicto de Go.
Para regenerarlo: `node scripts/tlock-ts-samples.mjs > ts-samples.json`, y desde el mismo módulo Go temporal, `go run tlock-go-vectors.go ts-samples.json > tlock-vectors.json`.
En `ibe-vectors.json`, H2, H3 y H4 no son públicas en kyber: el script las reescribe con sus etiquetas y las comprueba en cada fixture contra la file key de tlock y contra U = r·G2. Los cifrados de kyber usan un sigma aleatorio, así que el fichero se genera una vez y se congela. Para regenerarlo, desde un módulo Go temporal que requiera la referencia (`replace g.activething.com/go/DateKeys => ../datekeys-go`, `GOFLAGS=-mod=mod`): `go run ibe-go-vectors.go ../App/testdata/fixtures > ibe-vectors.json`.
- Todo se lee con los formatos de `testdata/README.md` (`testing/vectors.ts`): una clave desconocida o que falta, un valor de otro tipo, un código que no es de §69 o una edición fuera de su base hacen fallar el fichero con su motivo; nada se salta en silencio. - Todo se lee con los formatos de `testdata/README.md` (`testing/vectors.ts`): una clave desconocida o que falta, un valor de otro tipo, un código que no es de §69 o una edición fuera de su base hacen fallar el fichero con su motivo; nada se salta en silencio.
- Todo fichero de `testdata/` tiene que ejecutarlo algún test: un nombre nuevo exportado por Go (otro `vectors/*.json`, un fichero de fixture que ningún JSON nombra) hace fallar `testdata/ holds no file that no test runs` hasta que se le añade su bloque. - Todo fichero de `testdata/` tiene que ejecutarlo algún test: un nombre nuevo exportado por Go (otro `vectors/*.json`, un fichero de fixture que ningún JSON nombra) hace fallar `testdata/ holds no file that no test runs` hasta que se le añade su bloque.

@ -162,8 +162,8 @@ La ruta `/inspect` gana una acción "abrir": con un fixture o un `.dkc` arrastra
| 3 | `ibe.ts` de descifrado desde la semilla, `roundIdentity`, escritura del stanza en `age.ts`, `ibe.test.ts` sin la parte de cifrado | los cinco fixtures dan la file key correcta; U no canónico e identidad rechazados; cobertura 100 %. Hecho el 28-09-2026: vectores de `scripts/ibe-go-vectors.go` en `src/lib/dkc/testing/ibe-vectors.json`; `ibe.ts` al 100 %, fijado como umbral. `ibe.ts` también pasa el cuerpo `U ‖ V ‖ W` a bytes; los argumentos del stanza y su paso al `Stanza` de `age-encryption`, que guarda el tipo en `args[0]`, van al paso 7, con el `Recipient` que los usa | | 3 | `ibe.ts` de descifrado desde la semilla, `roundIdentity`, escritura del stanza en `age.ts`, `ibe.test.ts` sin la parte de cifrado | los cinco fixtures dan la file key correcta; U no canónico e identidad rechazados; cobertura 100 %. Hecho el 28-09-2026: vectores de `scripts/ibe-go-vectors.go` en `src/lib/dkc/testing/ibe-vectors.json`; `ibe.ts` al 100 %, fijado como umbral. `ibe.ts` también pasa el cuerpo `U ‖ V ‖ W` a bytes; los argumentos del stanza y su paso al `Stanza` de `age-encryption`, que guarda el tipo en `args[0]`, van al paso 7, con el `Recipient` que los usa |
| 4 | `release.ts` y `release.test.ts` | ronda real válida, alias rechazados. Hecho el 28-09-2026:<br>- `verifyRelease` con el orden y los textos de `provider.Verify`;<br>- `ReleaseSource`, con el contrato de la sección 5, y `suppliedRelease`;<br>- los casos de `TestVerifyRejects` de Go y los 7 del corpus de mutaciones que fallan en el paso 10;<br>- las firmas publicadas de las rondas 1000, 1001, 2000 y 1004, esta última obtenida restando p a la codificación x + p del corpus;<br>- cobertura del 100 %, fijada como umbral | | 4 | `release.ts` y `release.test.ts` | ronda real válida, alias rechazados. Hecho el 28-09-2026:<br>- `verifyRelease` con el orden y los textos de `provider.Verify`;<br>- `ReleaseSource`, con el contrato de la sección 5, y `suppliedRelease`;<br>- los casos de `TestVerifyRejects` de Go y los 7 del corpus de mutaciones que fallan en el paso 10;<br>- las firmas publicadas de las rondas 1000, 1001, 2000 y 1004, esta última obtenida restando p a la codificación x + p del corpus;<br>- cobertura del 100 %, fijada como umbral |
| 5 | `open.ts` con la `Identity` propia, pasos 9 a 18, `open.test.ts` | los cinco fixtures se abren y el plaintext coincide con el sidecar; el corpus de mutaciones existente reproduce código y paso.<br>5a hecho el 28-09-2026, con el texto en claro en memoria:<br>- los 65 casos del corpus pasan por `open` con el código y el paso de Go;<br>- los cinco fixtures se abren con cada credencial;<br>- los textos siguen a `capsule.Open` y `agewrap`.<br>El paso 13 exige probar cada identity contra cada stanza X25519, y `age-encryption` no expone su `X25519Identity`. Por eso `x25519.ts` abre los stanzas de uno en uno, con ChaCha20-Poly1305 de `@noble/ciphers` 2.4.0, dependencia aprobada el 28-09-2026 porque es la copia que ya usa `age-encryption`. `bech32.ts` lee las identidades `AGE-SECRET-KEY-1…`.<br>`index.ts` aún no reexporta la apertura, que metería noble en `/inspect`: el paso 8 la cargará bajo demanda.<br>5b hecho el 28-09-2026: `open` acepta un `Blob`, del que lee solo el prefijo de los pasos 1 a 8 (`prefix.ts`, movido de la página a la librería), calcula el `capsule_digest` en streaming (`digest.ts`) y descifra `PAYLOAD_AGE` en streaming. La salida puede ser un `WritableStream`, que se cierra tras el paso 18 y se aborta ante cualquier fallo. Los 65 casos del corpus pasan también así.<br>Comprobado en el navegador con un fichero OPFS (`createWritable`): `time_only` se abre con el SHA-256 del sidecar, y un fallo de STREAM deja intacto el contenido anterior del fichero.<br>La consulta de cuota (`navigator.storage.estimate()`), el fichero temporal y la descarga van con la página, en el paso 8 | | 5 | `open.ts` con la `Identity` propia, pasos 9 a 18, `open.test.ts` | los cinco fixtures se abren y el plaintext coincide con el sidecar; el corpus de mutaciones existente reproduce código y paso.<br>5a hecho el 28-09-2026, con el texto en claro en memoria:<br>- los 65 casos del corpus pasan por `open` con el código y el paso de Go;<br>- los cinco fixtures se abren con cada credencial;<br>- los textos siguen a `capsule.Open` y `agewrap`.<br>El paso 13 exige probar cada identity contra cada stanza X25519, y `age-encryption` no expone su `X25519Identity`. Por eso `x25519.ts` abre los stanzas de uno en uno, con ChaCha20-Poly1305 de `@noble/ciphers` 2.4.0, dependencia aprobada el 28-09-2026 porque es la copia que ya usa `age-encryption`. `bech32.ts` lee las identidades `AGE-SECRET-KEY-1…`.<br>`index.ts` aún no reexporta la apertura, que metería noble en `/inspect`: el paso 8 la cargará bajo demanda.<br>5b hecho el 28-09-2026: `open` acepta un `Blob`, del que lee solo el prefijo de los pasos 1 a 8 (`prefix.ts`, movido de la página a la librería), calcula el `capsule_digest` en streaming (`digest.ts`) y descifra `PAYLOAD_AGE` en streaming. La salida puede ser un `WritableStream`, que se cierra tras el paso 18 y se aborta ante cualquier fallo. Los 65 casos del corpus pasan también así.<br>Comprobado en el navegador con un fichero OPFS (`createWritable`): `time_only` se abre con el SHA-256 del sidecar, y un fallo de STREAM deja intacto el contenido anterior del fichero.<br>La consulta de cuota (`navigator.storage.estimate()`), el fichero temporal y la descarga van con la página, en el paso 8 |
| 6 | Spec, mutaciones en Go, `testdata:sync`, reproducción en TypeScript (sección 7) | texto aprobado; vectores congelados en ambos repositorios; commits en Gitea | | 6 | Spec, mutaciones en Go, `testdata:sync`, reproducción en TypeScript (sección 7) | texto aprobado; vectores congelados en ambos repositorios; commits en Gitea. Hecho: la enmienda de canonicidad entró en la v0.8.2 (`f6f2e9f`, tag `spec-v0.8.2`), y sus 10 mutaciones pasan por `open` en TypeScript desde el paso 5a |
| 7 | `encryptOnG2RFC9380`, `Recipient` propio, ida y vuelta, interoperabilidad TS → Go a nivel IBE y de fichero `age` (sección 8, puntos 4 y 5) | Go abre lo que TypeScript cifra; vectores congelados | | 7 | `encryptOnG2RFC9380`, `Recipient` propio, ida y vuelta, interoperabilidad TS → Go a nivel IBE y de fichero `age` (sección 8, puntos 4 y 5) | Go abre lo que TypeScript cifra; vectores congelados.<br>Hecho el 28-09-2026:<br>- `ibe.ts` gana `encryptOnG2RFC9380` y `encryptOnG2WithSigma` (solo para tests);<br>- `tlock.ts` gana `timeRecipient`, con las comprobaciones y textos de `NewTimeRecipient`;<br>- `scripts/tlock-go-vectors.go` reescribe `EncryptCCAonG2` con sigma fijo, lo comprueba con kyber y tlock, y abre las muestras de `scripts/tlock-ts-samples.mjs`: Go abrió los cuerpos IBE y los ficheros `age` de las rondas 1000 y 1001 con la misma file key y el mismo texto;<br>- todo congelado en `src/lib/dkc/testing/tlock-vectors.json`;<br>- cobertura del 100 % de `ibe.ts` y `tlock.ts` |
| 8 | Página (sección 9) | un fixture `time_and_key` se abre en el navegador sin red; `check-build` en verde; tamaño del bundle anotado en el README | | 8 | Página (sección 9) | un fixture `time_and_key` se abre en el navegador sin red; `check-build` en verde; tamaño del bundle anotado en el README |
Cada paso termina con `npm run verify` en verde y un commit en Gitea. El paso 6 puede ir en paralelo con el 4 y el 5. Cada paso termina con `npm run verify` en verde y un commit en Gitea. El paso 6 puede ir en paralelo con el 4 y el 5.

@ -0,0 +1,269 @@
//go:build ignore
// Prints src/lib/dkc/testing/tlock-vectors.json: the Go reference values for
// the tlock encryption of src/lib/dkc/ibe.ts and src/lib/dkc/tlock.ts (plan
// of phase 2, section 8, points 4 and 5).
//
// - encrypt: EncryptCCAonG2 of drand/kyber with the suite of tlock for
// Quicknet, restated with a fixed sigma, for messages of 1, 16 and 32
// bytes to rounds 1000 and 1001. kyber draws sigma from crypto/rand, so
// the restatement is checked: ibe.DecryptCCAonG2 opens every ciphertext
// with the published signature of its round, and tlock.BytesToCiphertext
// with tlock.TimeUnlock opens the 16-byte ones, as a tlock stanza body.
// - interop: the ciphertexts that scripts/tlock-ts-samples.mjs made with
// the TypeScript library, each opened by the reference. An IBE body goes
// through tlock.BytesToCiphertext and tlock.TimeUnlock; an age file
// through age.Decrypt with agewrap.NewTimeIdentity, the identity of
// capsule.Open at step 11. Each sample gets the verdict "ok" with what Go
// recovered, or "reject".
//
// H2, H3 and H4 are unexported in kyber; they are restated with its tags, as
// in scripts/ibe-go-vectors.go, and the decryptions above check them.
//
// Run it from a scratch module that requires the reference implementation
// (replace g.activething.com/go/DateKeys => ../datekeys-go and
// GOFLAGS=-mod=mod), passing the output of tlock-ts-samples.mjs:
//
// go run tlock-go-vectors.go ts-samples.json > tlock-vectors.json
package main
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math/big"
"os"
"runtime/debug"
"sort"
"strings"
"filippo.io/age"
"g.activething.com/go/DateKeys/agewrap"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/provider"
"github.com/drand/drand/v2/common"
"github.com/drand/drand/v2/crypto"
"github.com/drand/kyber"
bls "github.com/drand/kyber-bls12381"
"github.com/drand/kyber/encrypt/ibe"
"github.com/drand/tlock"
)
// The published Quicknet signatures of rounds 1000 and 1001, as in the
// fixtures and the mutation corpus; provider.Verify checks them below.
var published = map[uint64]string{
1000: "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39",
1001: "b33bf3667cbd5a82de3a24b4e0e9fe5513cc1a0e840368c6e31f5fcfa79bea03f73896b25883abf2853d10337fb8fa41",
}
var (
suite = bls.NewBLS12381Suite()
order, _ = new(big.Int).SetString("73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001", 16)
)
func must[T any](v T, err error) T {
if err != nil {
panic(err)
}
return v
}
func unhex(s string) []byte { return must(hex.DecodeString(s)) }
func concat(parts ...[]byte) []byte { return bytes.Join(parts, nil) }
func xor(a, b []byte) []byte {
out := make([]byte, len(a))
for i := range a {
out[i] = a[i] ^ b[i]
}
return out
}
func h2(gt []byte, n int) []byte {
sum := sha256.Sum256(concat(ibe.H2Tag(), gt))
return sum[:n]
}
func h4(sigma []byte, n int) []byte {
sum := sha256.Sum256(concat(ibe.H4Tag(), sigma))
return sum[:n]
}
func h3(sigma, msg []byte) kyber.Scalar {
base := sha256.Sum256(concat(ibe.H3Tag(), sigma, msg))
for i := uint16(1); i < 65535; i++ {
d := sha256.Sum256(concat(binary.LittleEndian.AppendUint16(nil, i), base[:]))
d[0] >>= 1
if new(big.Int).SetBytes(d[:]).Cmp(order) < 0 {
r := suite.G1().Scalar()
if err := r.UnmarshalBinary(d[:]); err != nil {
panic(err)
}
return r
}
}
panic("h3: rejection sampling failed")
}
// encrypt is EncryptCCAonG2 of kyber with the given sigma.
func encrypt(key kyber.Point, id, msg, sigma []byte) *ibe.Ciphertext {
qid := suite.G1().Point().(kyber.HashablePoint).Hash(id)
gid := suite.Pair(qid, key)
r := h3(sigma, msg)
gt := must(suite.GT().Point().Mul(r, gid).MarshalBinary())
return &ibe.Ciphertext{U: suite.G2().Point().Mul(r, nil), V: xor(sigma, h2(gt, len(msg))), W: xor(msg, h4(sigma, len(msg)))}
}
type encryptVector struct {
Name string `json:"name"`
Round uint64 `json:"round"`
ID string `json:"id"`
Msg string `json:"msg"`
Sigma string `json:"sigma"`
U string `json:"u"`
V string `json:"v"`
W string `json:"w"`
Signature string `json:"signature"`
}
type sample struct {
Name string `json:"name"`
Kind string `json:"kind"`
Round uint64 `json:"round"`
FileKey string `json:"file_key,omitempty"`
Body string `json:"body,omitempty"`
Plaintext string `json:"plaintext,omitempty"`
File string `json:"file,omitempty"`
Go string `json:"go"`
GoResult string `json:"go_result,omitempty"`
}
func main() {
scheme := must(crypto.SchemeFromName(crypto.SigsOnG1ID))
quicknet := profile.Quicknet()
key := scheme.KeyGroup.Point()
if err := key.UnmarshalBinary(quicknet.PublicKey); err != nil {
panic(err)
}
release := func(round uint64) provider.Release {
r := provider.Release{Round: round, Signature: unhex(published[round])}
if err := provider.Verify(quicknet, provider.Condition{Round: round}, r); err != nil {
panic(err)
}
return r
}
var vectors []encryptVector
for i, c := range []struct {
round uint64
n int
}{{1000, 16}, {1001, 16}, {1000, 1}, {1000, 32}} {
seed := sha256.Sum256(binary.BigEndian.AppendUint32([]byte("DateKeys tlock vector "), uint32(i)))
msgSeed := sha256.Sum256(seed[:])
msg, sigma := msgSeed[:c.n], seed[:c.n]
id := scheme.DigestBeacon(&common.Beacon{Round: c.round})
ct := encrypt(key, id, msg, sigma)
rel := release(c.round)
sig := scheme.SigGroup.Point()
if err := sig.UnmarshalBinary(rel.Signature); err != nil {
panic(err)
}
if got, err := ibe.DecryptCCAonG2(suite, sig, ct); err != nil || !bytes.Equal(got, msg) {
panic(fmt.Sprintf("kyber does not decrypt the restated encryption %d: %v", i, err))
}
u := must(ct.U.MarshalBinary())
if c.n == 16 {
body := concat(u, ct.V, ct.W)
got := must(tlock.TimeUnlock(*scheme, key, common.Beacon{Round: rel.Round, Signature: rel.Signature}, must(tlock.BytesToCiphertext(*scheme, body))))
if !bytes.Equal(got, msg) {
panic("tlock does not decrypt the restated encryption")
}
}
vectors = append(vectors, encryptVector{
Name: fmt.Sprintf("a %d-byte message for round %d", c.n, c.round), Round: c.round, ID: hex.EncodeToString(id),
Msg: hex.EncodeToString(msg), Sigma: hex.EncodeToString(sigma), U: hex.EncodeToString(u),
V: hex.EncodeToString(ct.V), W: hex.EncodeToString(ct.W), Signature: published[c.round],
})
}
var in struct {
Generator string `json:"generator"`
Samples []sample `json:"samples"`
}
if err := json.Unmarshal(must(os.ReadFile(os.Args[1])), &in); err != nil {
panic(err)
}
for i := range in.Samples {
s := &in.Samples[i]
rel := release(s.Round)
s.Go = "reject"
switch s.Kind {
case "ibe":
ct, err := tlock.BytesToCiphertext(*scheme, unhex(s.Body))
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", s.Name, err)
continue
}
fk, err := tlock.TimeUnlock(*scheme, key, common.Beacon{Round: rel.Round, Signature: rel.Signature}, ct)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", s.Name, err)
continue
}
s.Go, s.GoResult = "ok", hex.EncodeToString(fk)
case "age":
id := must(agewrap.NewTimeIdentity(quicknet, s.Round, rel))
r, err := age.Decrypt(bytes.NewReader(unhex(s.File)), id)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", s.Name, err)
continue
}
pt, err := io.ReadAll(r)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", s.Name, err)
continue
}
s.Go, s.GoResult = "ok", hex.EncodeToString(pt)
default:
panic("unknown sample kind " + s.Kind)
}
}
out := map[string]any{
"description": "Go reference values for the tlock encryption of src/lib/dkc/ibe.ts and src/lib/dkc/tlock.ts; " +
"see scripts/tlock-go-vectors.go for how each block is obtained.",
"generator": "scripts/tlock-go-vectors.go",
"libraries": libraries(),
"scheme": scheme.Name,
"public_key": hex.EncodeToString(quicknet.PublicKey),
"encrypt": vectors,
"interop": map[string]any{"generator": in.Generator, "samples": in.Samples},
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
if err := enc.Encode(out); err != nil {
panic(err)
}
}
// libraries names the versions of the libraries this program ran with.
func libraries() string {
info, ok := debug.ReadBuildInfo()
if !ok {
panic("no build info")
}
var out []string
for _, d := range info.Deps {
switch d.Path {
case "filippo.io/age", "github.com/drand/tlock", "github.com/drand/kyber", "github.com/drand/kyber-bls12381", "github.com/drand/drand/v2":
out = append(out, d.Path+" "+d.Version)
}
}
sort.Strings(out)
return strings.Join(out, ", ")
}

@ -0,0 +1,34 @@
#!/usr/bin/env node
// Writes, as JSON, tlock ciphertexts made by the TypeScript library of this
// repository, for scripts/tlock-go-vectors.go to open with the Go reference
// (plan of phase 2, section 8, points 4 and 5):
//
// - IBE: a random 16-byte file key encrypted with encryptOnG2RFC9380 for
// rounds 1000 and 1001 of Quicknet, as the tlock stanza body U || V || W;
// - age: an age file whose only stanza timeRecipient wrote, through the
// Encrypter of age-encryption, for rounds 1000 and 1001.
//
// The randomness makes every run different: the output is generated once and
// frozen, with the verdicts of Go, in src/lib/dkc/testing/tlock-vectors.json.
// Node runs the TypeScript sources directly (type stripping, Node 22.6+):
//
// node scripts/tlock-ts-samples.mjs > ts-samples.json
import { Encrypter } from 'age-encryption';
import { ciphertextToBody, encryptOnG2RFC9380, roundIdentity } from '../src/lib/dkc/ibe.ts';
import { quicknet } from '../src/lib/dkc/profile.ts';
import { timeRecipient } from '../src/lib/dkc/tlock.ts';
const hex = (b) => Buffer.from(b).toString('hex');
const p = quicknet();
const samples = [];
for (const round of [1000, 1001]) {
const fileKey = crypto.getRandomValues(new Uint8Array(16));
const body = ciphertextToBody(encryptOnG2RFC9380(p.publicKey, roundIdentity(round), fileKey));
samples.push({ name: `IBE, round ${round}`, kind: 'ibe', round, file_key: hex(fileKey), body: hex(body) });
const e = new Encrypter();
e.addRecipient(timeRecipient(p, round));
const plaintext = new TextEncoder().encode(`DateKeys: sealed by the TypeScript library for round ${round}`);
samples.push({ name: `age file, round ${round}`, kind: 'age', round, plaintext: hex(plaintext), file: hex(await e.encrypt(plaintext)) });
}
process.stdout.write(`${JSON.stringify({ generator: 'scripts/tlock-ts-samples.mjs', samples }, null, 1)}\n`);

@ -53,8 +53,8 @@ import { bytesToNumberBE } from '@noble/curves/utils.js';
import { sha256 } from '@noble/hashes/sha2.js'; import { sha256 } from '@noble/hashes/sha2.js';
import { checkCompressedPoint, type Group } from './bls12381.ts'; import { checkCompressedPoint, type Group } from './bls12381.ts';
const { G1, G2, fields, pairing } = bls12_381; const { G1, G2, fields, pairing, shortSignatures } = bls12_381;
const { Fp, Fr } = fields; const { Fp, Fp12, Fr } = fields;
/** Sizes of the compressed signature (G1) and of U (G2), and of V and W in a tlock stanza. */ /** Sizes of the compressed signature (G1) and of U (G2), and of V and W in a tlock stanza. */
export const SIGNATURE_LEN = 48; export const SIGNATURE_LEN = 48;
@ -71,6 +71,9 @@ const H3_TAG = tag('IBE-H3');
const H4_TAG = tag('IBE-H4'); const H4_TAG = tag('IBE-H4');
// The iterations of H3 end before 65535, as in kyber (a uint16 counter). // The iterations of H3 end before 65535, as in kyber (a uint16 counter).
const H3_ITERATIONS = 65534; const H3_ITERATIONS = 65534;
// The hash-to-curve DST of the identity of a round on G1 (RFC 9380), the one
// of kyber-bls12381.NewBLS12381Suite, which tlock uses for Quicknet.
const DST_G1 = 'BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_';
/** A tlock ciphertext: U compressed on G2; V and W as long as the message. */ /** A tlock ciphertext: U compressed on G2; V and W as long as the message. */
export interface Ciphertext { export interface Ciphertext {
@ -260,3 +263,47 @@ export function decryptOnG2(signature: Uint8Array, ct: Ciphertext): Uint8Array {
msg?.fill(0); msg?.fill(0);
} }
} }
/**
* Encrypts `msg`, at most 32 bytes, for the identity `id` under the public
* key of a Quicknet-style scheme, a compressed point of G2, as EncryptCCAonG2
* of kyber with the suite of tlock: Qid = H(id) on G1 with the DST of RFC
* 9380, a random sigma, r = H3(sigma, msg), U = r·G2, V = sigma XOR
* H2(e(Qid, key)^r) and W = msg XOR H4(sigma). The key passes the gate of
* the canonical encoding first.
*/
export function encryptOnG2RFC9380(publicKey: Uint8Array, id: Uint8Array, msg: Uint8Array): Ciphertext {
const sigma = crypto.getRandomValues(new Uint8Array(msg.length));
try {
return encryptOnG2WithSigma(publicKey, id, msg, sigma);
} finally {
sigma.fill(0);
}
}
/**
* encryptOnG2RFC9380 with a given sigma, so that the vectors of the Go
* reference can be reproduced byte for byte. Only for tests: a sigma that is
* not random and secret gives the message away. index.ts does not export it.
*/
export function encryptOnG2WithSigma(publicKey: Uint8Array, id: Uint8Array, msg: Uint8Array, sigma: Uint8Array): Ciphertext {
if (msg.length > MAX_MESSAGE_LEN) throw new IbeError('length', `a message of ${msg.length} bytes, want at most ${MAX_MESSAGE_LEN}`);
if (sigma.length !== msg.length) throw new IbeError('length', `sigma of ${sigma.length} bytes for a message of ${msg.length}`);
gate('G2', publicKey, 'the public key');
const key = G2.Point.fromBytes(publicKey);
key.assertValidity();
const gid = pairing(shortSignatures.hash(id, DST_G1), key);
const r = h3(sigma, msg);
// r = 0 would make U the point at infinity; H3 gives it with probability
// 2^-255, and kyber does not check it either.
/* v8 ignore next -- @preserve */
if (r === 0n) throw new IbeError('proof', 'r = 0');
const mask2 = h2(Fp12.pow(gid, r), msg.length);
const mask4 = h4(sigma, msg.length);
try {
return { U: G2.Point.BASE.multiply(r).toBytes(), V: xor(sigma, mask2), W: xor(msg, mask4) };
} finally {
mask2.fill(0);
mask4.fill(0);
}
}

@ -0,0 +1,94 @@
{
"description": "Go reference values for the tlock encryption of src/lib/dkc/ibe.ts and src/lib/dkc/tlock.ts; see scripts/tlock-go-vectors.go for how each block is obtained.",
"encrypt": [
{
"name": "a 16-byte message for round 1000",
"round": 1000,
"id": "f652498d092acd949bad74e40683bf3824fb817980504a0c7e6722cfc5a9c0a3",
"msg": "16e27bcb7ff0267cd0db9c1dc1459696",
"sigma": "3d4725349d1d12947a3195aa2cb5dd00",
"u": "80364e97a868ee0dcdcf79a71ab7901f97ae9d38069110e5eacf630842857a52685ebaaf41b3e8682664893748443a3f1137cc7e1298814bab8d47d13416083a3b32d224713eaf141bef1c3775d580a939ffb316a603b68bd3d8799a95feb355",
"v": "518c9d924cdd84ef24472ee2dae6c5f5",
"w": "3cb104bcb0c445ea0b576a9aa64bfa01",
"signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39"
},
{
"name": "a 16-byte message for round 1001",
"round": 1001,
"id": "ce43c3353a7ad7aac3408cad0bf921b6a7dda89be75d9cb2b3b5a152cefc8afd",
"msg": "4a1a048e28b2a9497373df269686925b",
"sigma": "47bd5bdca9e4341af640abf2e2624261",
"u": "84ea313521e2f15b2498e0dbb1125226648e963f7be8a1314444f7d38a92506f38acd599401bbf27f2f9249a8049734a17beff0781a1810ef7a9d53ea50372835cfa0d2260797d2bffb820f4ad4d78506667132cc4db65a483604df02332da71",
"v": "f0e5eafdb06ff7b9d7a32b607d2ee510",
"w": "d8e72ed588af9cb7ea6bb5b57d737108",
"signature": "b33bf3667cbd5a82de3a24b4e0e9fe5513cc1a0e840368c6e31f5fcfa79bea03f73896b25883abf2853d10337fb8fa41"
},
{
"name": "a 1-byte message for round 1000",
"round": 1000,
"id": "f652498d092acd949bad74e40683bf3824fb817980504a0c7e6722cfc5a9c0a3",
"msg": "bd",
"sigma": "d6",
"u": "92f597b1d29b056f019cd7afd0eb51ef2f1fc1708915f742762062ddf7e500389373638651935810699f9cab987c57390b9677f844e89fb2c8560f74ae68159dc9df9c0ee667d5c507718b51b490feb780a78cdff5146e27a77ddc36f9e45a48",
"v": "b8",
"w": "4a",
"signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39"
},
{
"name": "a 32-byte message for round 1000",
"round": 1000,
"id": "f652498d092acd949bad74e40683bf3824fb817980504a0c7e6722cfc5a9c0a3",
"msg": "3ccb765d8df56d226b3f7e20ce159302b1d3b2924e9a33345d31fd3aa0f3ec5e",
"sigma": "bdd1ba60cac27c3e6e5eea1678cf1c5586e694a9d775963153acb4402ce10d59",
"u": "841d1a934afcdbf1a6af15d6218406a8512ddc2dff465469a95719548619fc47ef9753fc3f9427066ba8e0b1791a01e80997324dd9f7b3976d340ef73f6461b330e363006519439df2ecad28ed903f48fbcf3d2616f8249b63dd4c0f8771b9a8",
"v": "5d695cd7e98b4cb4f5938941647878361ac7bf7db79843c92825cef241be9675",
"w": "19fcedeece1f38d41bbe569f06b8d3b1f0fd1cdacb1b08d8033d4202cbd7c395",
"signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39"
}
],
"generator": "scripts/tlock-go-vectors.go",
"interop": {
"generator": "scripts/tlock-ts-samples.mjs",
"samples": [
{
"name": "IBE, round 1000",
"kind": "ibe",
"round": 1000,
"file_key": "aa07feff048bdc07e5a2ea0c2e35474c",
"body": "add5cd1894c304db02a4134742163bf672b9ff44802a8f388b22bfa2702130b4044612a950b7083da8dae36dea71a86b001ae693d556092d8ccf038e667d61fe8bf5915d9fcbb3373e8d6f46799e610bc580a17aa87174d0c2429ed63b240b4feb23d3bcc12befe90d39c5ab0147a6347bd576ebc613996bc61c4338e38a21b6",
"go": "ok",
"go_result": "aa07feff048bdc07e5a2ea0c2e35474c"
},
{
"name": "age file, round 1000",
"kind": "age",
"round": 1000,
"plaintext": "446174654b6579733a207365616c6564206279207468652054797065536372697074206c69627261727920666f7220726f756e642031303030",
"file": "6167652d656e6372797074696f6e2e6f72672f76310a2d3e20746c6f636b203130303020353264623962613730653063633066366561663738303364643037343437613166353437373733356664336636363137393262613934363030633834653937310a74472b49697069425a395753487674777947497733465761555542646e514a474e36637541303051452f6c59584b4b743664686f7444745352616b362b334a780a4371654e4837436868536970523551324c486a6d6d6f32313478716657637135542b4830446f73797a5369664f5448524371637668655453505a4977797052470a755738495a50364a4d5356642f655145447768524c7834304a6972687258443442474b3273584b6761516f0a2d2d2d2034393658794d664878427677543845625a4c4f6d41345a364f4a36487245497459316171614a79726165550a0aafc6b79c266610bd43455d546ad8215ffd491c2a358b1de42751d9d032a70f8c39cd0bd384885e970020a79805b9547364b7254209f2ae5eda3eae9df7abf8d66ed004180dec058068447c010279de8624c9d06442a77a72",
"go": "ok",
"go_result": "446174654b6579733a207365616c6564206279207468652054797065536372697074206c69627261727920666f7220726f756e642031303030"
},
{
"name": "IBE, round 1001",
"kind": "ibe",
"round": 1001,
"file_key": "a3bbf76b3f17c2a7a3d0b9fd62208d60",
"body": "98a8f585fe3ded2e59b27335996af344ed5b9fb9c80b3016416ba93b9f80e610b6286cf3a0172a775b097da6646e5237039fab9ac191490f0ee900aeb739f85cf14a673b0ebea19dea85454ddd3431679b726a0015adc2c408617ba3e0d0e22c768e45f47369a53dd32cb51c25a89e57ec4ee19294e0f229f259b9293fea8277",
"go": "ok",
"go_result": "a3bbf76b3f17c2a7a3d0b9fd62208d60"
},
{
"name": "age file, round 1001",
"kind": "age",
"round": 1001,
"plaintext": "446174654b6579733a207365616c6564206279207468652054797065536372697074206c69627261727920666f7220726f756e642031303031",
"file": "6167652d656e6372797074696f6e2e6f72672f76310a2d3e20746c6f636b203130303120353264623962613730653063633066366561663738303364643037343437613166353437373733356664336636363137393262613934363030633834653937310a70584e2b37746e6f4137304e524653715a484d6f7253637456326749636a39774a6f6b506c6134772f50732b4665725872774c32554a796a67434164423075530a42595075456b42415178393473654c5a5344694a7a736f6c366e474544705a6966654c6b324c6b2f544b794c766861495037433976645554334643664563314a0a4c59685074457772702b4c4b5633354a597031484d6f524832393970646e656f787a2b674f645a6d4f6d6b0a2d2d2d20644d547177433836776b53376d4c42594b6147684b736a634b4c48725a4532346c612f6b53366c306736670aedaec2f1c62d0f0229007f0e6f730d4cbda71a2ecd781d087b5f08245abe8854f35fa7b8319db3b89270feb1306a772f118caa974ae80255597efcd3b33b1c95ca5e8f930e2338c85f91b72e035c7dac242f4118971607971b",
"go": "ok",
"go_result": "446174654b6579733a207365616c6564206279207468652054797065536372697074206c69627261727920666f7220726f756e642031303031"
}
]
},
"libraries": "filippo.io/age v1.3.2, github.com/drand/drand/v2 v2.1.7, github.com/drand/kyber v1.3.2, github.com/drand/kyber-bls12381 v0.3.4, github.com/drand/tlock v1.2.0",
"public_key": "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a",
"scheme": "bls-unchained-g1-rfc9380"
}

@ -0,0 +1,147 @@
// Tests of the tlock encryption (ibe.ts, tlock.ts) against the Go reference:
// src/lib/dkc/testing/tlock-vectors.json, written by scripts/tlock-go-vectors.go
// from the samples of scripts/tlock-ts-samples.mjs (plan of phase 2, section
// 8, points 4 and 5).
import { Decrypter, Encrypter } from 'age-encryption';
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { checkTimeStanzas, parseAgeHeader } from './age.ts';
import { DateKeysError } from './errors.ts';
import { ciphertextFromBody, decryptOnG2, encryptOnG2RFC9380, encryptOnG2WithSigma, IbeError, roundIdentity } from './ibe.ts';
import { timeIdentity } from './open.ts';
import { chainHashHex, maxRound, type Profile, quicknet } from './profile.ts';
import type { Release } from './release.ts';
import { h, hx } from './testing/testdata.ts';
import { timeRecipient } from './tlock.ts';
type Sample = { name: string; kind: 'ibe' | 'age'; round: number; file_key?: string; body?: string; plaintext?: string; file?: string; go: string; go_result?: string };
const V = JSON.parse(readFileSync(new URL('./testing/tlock-vectors.json', import.meta.url), 'utf8')) as {
scheme: string;
public_key: string;
encrypt: { name: string; round: number; id: string; msg: string; sigma: string; u: string; v: string; w: string; signature: string }[];
interop: { generator: string; samples: Sample[] };
};
const p = quicknet();
const SIGNATURES: Record<number, string> = Object.fromEntries(V.encrypt.map((v) => [v.round, v.signature]));
const release = (round: number): Release => ({ round, signature: h(SIGNATURES[round]!) });
// Opens an age file with the tlock identity of step 11.
async function openAge(file: Uint8Array, round: number): Promise<Uint8Array> {
const d = new Decrypter();
d.addIdentity(timeIdentity(p, round, release(round)));
return d.decrypt(file);
}
describe('tlock encryption', () => {
it('reads vectors of the Quicknet scheme and key', () => {
expect([V.scheme, V.public_key]).toEqual(['bls-unchained-g1-rfc9380', hx(p.publicKey)]);
expect(Object.keys(SIGNATURES).sort()).toEqual(['1000', '1001']);
});
it('encrypts as the reference, byte for byte, for a given sigma', () => {
for (const v of V.encrypt) {
expect(hx(roundIdentity(v.round)), v.name).toBe(v.id);
const ct = encryptOnG2WithSigma(p.publicKey, h(v.id), h(v.msg), h(v.sigma));
expect([hx(ct.U), hx(ct.V), hx(ct.W)], v.name).toEqual([v.u, v.v, v.w]);
expect(hx(decryptOnG2(h(v.signature), ct)), v.name).toBe(v.msg);
}
expect(V.encrypt.map((v) => h(v.msg).length).sort((a, b) => a - b)).toEqual([1, 16, 16, 32]);
});
it('made ciphertexts that the reference opened: IBE bodies with tlock.TimeUnlock, age files with agewrap.TimeIdentity', async () => {
expect(V.interop.generator).toBe('scripts/tlock-ts-samples.mjs');
expect(V.interop.samples.map((s) => `${s.kind} ${s.round}`).sort()).toEqual(['age 1000', 'age 1001', 'ibe 1000', 'ibe 1001']);
for (const s of V.interop.samples) {
expect(s.go, s.name).toBe('ok');
if (s.kind === 'ibe') {
expect(s.go_result, s.name).toBe(s.file_key);
expect(hx(decryptOnG2(release(s.round).signature, ciphertextFromBody(h(s.body!)))), s.name).toBe(s.file_key);
} else {
expect(s.go_result, s.name).toBe(s.plaintext);
expect(hx(await openAge(h(s.file!), s.round)), s.name).toBe(s.plaintext);
}
}
});
it('draws a new sigma every time, and the signature of the round opens every ciphertext', () => {
const msg = h('00112233445566778899aabbccddeeff');
const a = encryptOnG2RFC9380(p.publicKey, roundIdentity(1001), msg);
const b = encryptOnG2RFC9380(p.publicKey, roundIdentity(1001), msg);
expect(hx(a.U)).not.toBe(hx(b.U));
for (const ct of [a, b]) expect(decryptOnG2(release(1001).signature, ct)).toEqual(msg);
expect(() => decryptOnG2(release(1000).signature, a)).toThrow(IbeError);
});
it('rejects a message longer than 32 bytes, a sigma of another length and a key that is not a canonical point', () => {
const id = roundIdentity(1000);
const reason = (fn: () => unknown): [string, string] => {
try {
fn();
} catch (e) {
return [(e as IbeError).reason, (e as IbeError).message];
}
return ['none', ''];
};
const infinity = new Uint8Array(96);
infinity[0] = 0xc0;
const offCurve = p.publicKey.slice();
offCurve[95]! ^= 1;
expect(reason(() => encryptOnG2RFC9380(p.publicKey, id, new Uint8Array(33)))).toEqual(['length', 'ibe: a message of 33 bytes, want at most 32']);
expect(reason(() => encryptOnG2WithSigma(p.publicKey, id, new Uint8Array(16), new Uint8Array(15)))).toEqual([
'length',
'ibe: sigma of 15 bytes for a message of 16',
]);
expect(reason(() => encryptOnG2RFC9380(p.publicKey.subarray(1), id, new Uint8Array(16)))).toEqual(['length', 'ibe: the public key of 95 bytes, want 96']);
expect(reason(() => encryptOnG2RFC9380(infinity, id, new Uint8Array(16)))).toEqual(['identity', 'ibe: the public key is the point at infinity']);
expect(reason(() => encryptOnG2RFC9380(offCurve, id, new Uint8Array(16)))[0]).toBe('encoding');
});
});
describe('timeRecipient', () => {
it('writes the stanza of tlock, which the identity of step 11 opens', async () => {
const stanzas = await timeRecipient(p, 1000).wrapFileKey(h('0f'.repeat(16)));
expect(stanzas).toHaveLength(1);
expect(stanzas[0]!.args).toEqual(['tlock', '1000', chainHashHex(p)]);
expect(stanzas[0]!.body).toHaveLength(128);
const e = new Encrypter();
e.addRecipient(timeRecipient(p, 1000));
const file = await e.encrypt('a control sealed until round 1000');
checkTimeStanzas(parseAgeHeader(file).stanzas, p, 1000);
expect(new TextDecoder().decode(await openAge(file, 1000))).toBe('a control sealed until round 1000');
await expect(openAge(file, 1001)).rejects.toThrow(DateKeysError);
});
it('checks the profile, then the round, with the texts of NewTimeRecipient', () => {
const failure = (q: Profile, round: number): [string, string] => {
try {
timeRecipient(q, round);
} catch (e) {
return [(e as DateKeysError).code, (e as DateKeysError).message];
}
return ['none', ''];
};
const infinity = new Uint8Array(96);
infinity[0] = 0xc0;
const flags = p.publicKey.slice();
flags[0]! ^= 0x80;
expect(failure({ ...p, scheme: 'pedersen-bls-unchained' }, 1000)).toEqual([
'ERR_UNKNOWN_PROFILE',
'agewrap: profile datekeys:quicknet:v1 uses scheme pedersen-bls-unchained; only bls-unchained-g1-rfc9380 is supported here: ERR_UNKNOWN_PROFILE',
]);
expect(failure({ ...p, publicKey: flags }, 1000)).toEqual([
'ERR_UNKNOWN_PROFILE',
'agewrap: pinned public key of datekeys:quicknet:v1 is not the canonical encoding of a point of the key group: ERR_UNKNOWN_PROFILE',
]);
expect(failure({ ...p, publicKey: infinity }, 1000)).toEqual([
'ERR_UNKNOWN_PROFILE',
'agewrap: pinned public key of datekeys:quicknet:v1 is the identity element: ERR_UNKNOWN_PROFILE',
]);
for (const round of [0, maxRound(p) + 1, 1000.5]) {
expect(failure(p, round), String(round)).toEqual(['ERR_DATEKEY_INVALID', `agewrap: round ${round} outside the range of datekeys:quicknet:v1: ERR_DATEKEY_INVALID`]);
}
// The profile comes first.
expect(failure({ ...p, publicKey: infinity }, 0)[0]).toBe('ERR_UNKNOWN_PROFILE');
});
});

@ -0,0 +1,41 @@
// The tlock recipient of OUTER_TIME_AGE (spec §32, §35), as Go's
// agewrap.TimeRecipient: it wraps the file key with the IBE of ibe.ts for
// one round of a pinned profile, and writes the stanza
// "tlock <round> <chain hash>" that tlock and its tle CLI write.
//
// Go gives its recipient a random label, so that age refuses any other
// recipient in the same file. age-encryption has no labels: the writer of
// OUTER_TIME_AGE adds this recipient alone (spec §32).
import { type Recipient, Stanza } from 'age-encryption';
import { checkCompressedPoint } from './bls12381.ts';
import { DateKeysError } from './errors.ts';
import { ciphertextToBody, encryptOnG2RFC9380, roundIdentity } from './ibe.ts';
import { chainHashHex, maxRound, type Profile, QUICKNET_SCHEME } from './profile.ts';
/**
* The recipient that wraps a file key for `round` under the pinned profile
* `p`, of the scheme of Quicknet. Its checks and texts are those of Go's
* NewTimeRecipient: the profile first, then the range of the round.
*/
export function timeRecipient(p: Profile, round: number): Recipient {
if (p.scheme !== QUICKNET_SCHEME) {
throw new DateKeysError('ERR_UNKNOWN_PROFILE', `agewrap: profile ${p.id} uses scheme ${p.scheme}; only ${QUICKNET_SCHEME} is supported here`);
}
const key = checkCompressedPoint('G2', p.publicKey);
if (key === 'invalid') {
throw new DateKeysError('ERR_UNKNOWN_PROFILE', `agewrap: pinned public key of ${p.id} is not the canonical encoding of a point of the key group`);
}
if (key === 'identity') throw new DateKeysError('ERR_UNKNOWN_PROFILE', `agewrap: pinned public key of ${p.id} is the identity element`);
if (!Number.isSafeInteger(round) || round < 1 || round > maxRound(p)) {
throw new DateKeysError('ERR_DATEKEY_INVALID', `agewrap: round ${round} outside the range of ${p.id}`);
}
const args = ['tlock', String(round), chainHashHex(p)];
const id = roundIdentity(round);
const publicKey = p.publicKey.slice();
return {
wrapFileKey(fileKey: Uint8Array): Stanza[] {
return [new Stanza([...args], ciphertextToBody(encryptOnG2RFC9380(publicKey, id, fileKey)))];
},
};
}

@ -25,6 +25,8 @@ export default defineConfig({
'src/lib/dkc/x25519.ts': { 100: true }, 'src/lib/dkc/x25519.ts': { 100: true },
'src/lib/dkc/bech32.ts': { 100: true }, 'src/lib/dkc/bech32.ts': { 100: true },
'src/lib/dkc/digest.ts': { 100: true }, 'src/lib/dkc/digest.ts': { 100: true },
// The tlock recipient (step 7).
'src/lib/dkc/tlock.ts': { 100: true },
'src/lib/dkc/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 }, 'src/lib/dkc/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 },
// The page model and helpers of the inspector (plan §8, phase 1). // The page model and helpers of the inspector (plan §8, phase 1).
'src/lib/inspector/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 }, 'src/lib/inspector/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 },

Loading…
Cancel
Save

Powered by TurnKey Linux.