diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ae63f5..c949901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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; - `@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. + - 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. ### 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). diff --git a/README.md b/README.md index 90bad40..16663b2 100644 --- a/README.md +++ b/README.md @@ -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` | | `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` | -| `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`:
1. el rango de la ronda (`ERR_DATEKEY_INVALID`);
2. la ronda del release antes que la firma (`ERR_ROUND_MISMATCH`);
3. la longitud de la firma;
4. la clave pinneada (`ERR_UNKNOWN_PROFILE`);
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`).
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`:
- las credenciales y el release (paso 9), que cualquier fallo de la fuente convierte en `ERR_RELEASE_UNAVAILABLE` (corrección 6);
- la verificación del release (10);
- `OUTER_TIME_AGE` (11), la estructura frente a `access_policy` (12) e `INNER_ACCESS_AGE` (13);
- `CONTROL_CBOR` (14), `header_binding` (15), `I_PAYLOAD` (16), `PAYLOAD_AGE` (17) y el commit (18).
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`.
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.
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 ` 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) | | | `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` | @@ -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` | | `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` | -| `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 | | 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) ``` -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`). @@ -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; - 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 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. diff --git a/docs/PLAN_fase2_ibe_noble2.md b/docs/PLAN_fase2_ibe_noble2.md index 546ac83..c382c3f 100644 --- a/docs/PLAN_fase2_ibe_noble2.md +++ b/docs/PLAN_fase2_ibe_noble2.md @@ -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 | | 4 | `release.ts` y `release.test.ts` | ronda real válida, alias rechazados. Hecho el 28-09-2026:
- `verifyRelease` con el orden y los textos de `provider.Verify`;
- `ReleaseSource`, con el contrato de la sección 5, y `suppliedRelease`;
- los casos de `TestVerifyRejects` de Go y los 7 del corpus de mutaciones que fallan en el paso 10;
- las firmas publicadas de las rondas 1000, 1001, 2000 y 1004, esta última obtenida restando p a la codificación x + p del corpus;
- 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.
5a hecho el 28-09-2026, con el texto en claro en memoria:
- los 65 casos del corpus pasan por `open` con el código y el paso de Go;
- los cinco fixtures se abren con cada credencial;
- los textos siguen a `capsule.Open` y `agewrap`.
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…`.
`index.ts` aún no reexporta la apertura, que metería noble en `/inspect`: el paso 8 la cargará bajo demanda.
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í.
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.
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 | -| 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 | +| 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.
Hecho el 28-09-2026:
- `ibe.ts` gana `encryptOnG2RFC9380` y `encryptOnG2WithSigma` (solo para tests);
- `tlock.ts` gana `timeRecipient`, con las comprobaciones y textos de `NewTimeRecipient`;
- `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;
- todo congelado en `src/lib/dkc/testing/tlock-vectors.json`;
- 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 | 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. diff --git a/scripts/tlock-go-vectors.go b/scripts/tlock-go-vectors.go new file mode 100644 index 0000000..ca39e93 --- /dev/null +++ b/scripts/tlock-go-vectors.go @@ -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, ", ") +} diff --git a/scripts/tlock-ts-samples.mjs b/scripts/tlock-ts-samples.mjs new file mode 100644 index 0000000..9d325c6 --- /dev/null +++ b/scripts/tlock-ts-samples.mjs @@ -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`); diff --git a/src/lib/dkc/ibe.ts b/src/lib/dkc/ibe.ts index 5fdcbb6..2877781 100644 --- a/src/lib/dkc/ibe.ts +++ b/src/lib/dkc/ibe.ts @@ -53,8 +53,8 @@ import { bytesToNumberBE } from '@noble/curves/utils.js'; import { sha256 } from '@noble/hashes/sha2.js'; import { checkCompressedPoint, type Group } from './bls12381.ts'; -const { G1, G2, fields, pairing } = bls12_381; -const { Fp, Fr } = fields; +const { G1, G2, fields, pairing, shortSignatures } = bls12_381; +const { Fp, Fp12, Fr } = fields; /** Sizes of the compressed signature (G1) and of U (G2), and of V and W in a tlock stanza. */ export const SIGNATURE_LEN = 48; @@ -71,6 +71,9 @@ const H3_TAG = tag('IBE-H3'); const H4_TAG = tag('IBE-H4'); // The iterations of H3 end before 65535, as in kyber (a uint16 counter). 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. */ export interface Ciphertext { @@ -260,3 +263,47 @@ export function decryptOnG2(signature: Uint8Array, ct: Ciphertext): Uint8Array { 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); + } +} diff --git a/src/lib/dkc/testing/tlock-vectors.json b/src/lib/dkc/testing/tlock-vectors.json new file mode 100644 index 0000000..133cc87 --- /dev/null +++ b/src/lib/dkc/testing/tlock-vectors.json @@ -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" +} diff --git a/src/lib/dkc/tlock.test.ts b/src/lib/dkc/tlock.test.ts new file mode 100644 index 0000000..630bfae --- /dev/null +++ b/src/lib/dkc/tlock.test.ts @@ -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 = 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 { + 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'); + }); +}); diff --git a/src/lib/dkc/tlock.ts b/src/lib/dkc/tlock.ts new file mode 100644 index 0000000..54c1afc --- /dev/null +++ b/src/lib/dkc/tlock.ts @@ -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 " 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)))]; + }, + }; +} diff --git a/vitest.config.ts b/vitest.config.ts index 7b1c460..42a7488 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,6 +25,8 @@ export default defineConfig({ 'src/lib/dkc/x25519.ts': { 100: true }, 'src/lib/dkc/bech32.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 }, // The page model and helpers of the inspector (plan §8, phase 1). 'src/lib/inspector/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 },