The sealing and the envelope of the locator, and its documentation

ageio.ts reads and writes age files as filippo.io/age v1.3.2, with its
texts and its order of random draws, which Go's locator copies; it uses
only the noble modules that x25519.ts already uses. envelope.ts is Open,
Info.OpenLocator, OpenEnvelope, Seal and NewEnvelope of package locator
at spec-v0.12, with an injectable random source read in the order of Go.

- locator-seal.json (scripts/locator-seal-go-vectors.go): with the same
  seed, seal and newEnvelope write the bytes of Go;
- locator-interop.json (scripts/locator-ts-samples.mjs and
  locator-go-verdicts.go): Go opens what this library writes, up to a
  .dkc of 16 MiB and one byte;
- vectors.test.ts runs all of testdata/vectors/locator.json with the
  texts of Go, instead of its spec field only.

Shared files: dependencies.test.ts lets ageio.ts import noble and keeps
the four locator modules out of index.ts; check-build.mjs fails when a
page loads the locator with its first load; vitest.config.ts holds them
at 100 % coverage; ibe.ts updates the comment of encryptOnG2WithSigma;
README and CHANGELOG describe the port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
dev 1 day ago
parent 0d895bf362
commit e2c51bca3a

@ -4,6 +4,23 @@ Cambios notables de la librería TypeScript y de la página. El proyecto usa ver
## Especificación 0.10, en la rama `v0.10` — sin versión
### El localizador de `datekeys.capsule` (06-10-2026)
El paquete `locator` de `datekeys-go` en `spec-v0.12` (§43 a §44.1), con los mismos checks en el mismo orden, los mismos códigos y los mismos textos de error, byte a byte, como lo portó `datekeys-dart` en sus partes 7a y 7b.
- **Lo que no necesita criptografía** (`locator.ts`, `ipaddr.ts`): los datos de la extensión (`parseInfo` e `infoExtension`); `standardExtensions`, el registro de las extensiones de la especificación, que por defecto comprueba los datos de `datekeys.capsule` como `locator.Standard`; las direcciones, con cada regla del §44.1 de la v0.12 (`checkURI`, `addressHost`, `usableAddresses`), los bloques de IANA comparados byte a byte sobre los 16 bytes de una IPv6 y los CID v1 en base32; `checkResolvedIp`, la IP a la que resuelve un nombre, como la de `datekeys-dart`, que hoy rechaza `64:ff9b::/96`; el texto en claro con su relleno (`marshalLocator`, `unmarshalLocator`, `plaintextLength`); y el resto en su host (`restIn`, `hide`).
- **La criptografía** (`ageio.ts`, `envelope.ts`): abrir un localizador sellado con el release de su ronda (`openSealed`, `openInfoLocator`), que lee como mucho 1 MiB como Go; abrir el sobre (`openEnvelope`); sellar (`seal`) y crear el sobre (`newEnvelope`), con una fuente de lo aleatorio inyectable que se lee en el orden de Go. `ageio.ts` lee y escribe ficheros `age` como `filippo.io/age` 1.3.2, con sus textos, porque `locator.Open` y `OpenEnvelope` los copian. Sin dependencias nuevas: usa los módulos de noble que ya usa `x25519.ts`, y HMAC es el HKDF-Extract de `@noble/hashes/hkdf.js`.
- **Nada entra en `/inspect`.** `index.ts` no reexporta el localizador: la nota trae las tablas de Unicode, y el sobre, noble. `dependencies.test.ts` añade `ageio.ts` a los que pueden importar noble y los cuatro módulos a los que `index.ts` no reexporta, y `check-build.mjs` falla si una página carga el localizador con su primera carga.
- **Contra Go**, todo con el resultado y el texto de Go:
- `vectors.test.ts` corre entero `testdata/vectors/locator.json`, en vez de mirar solo su campo `spec`;
- `testing/locator-uris.json` y `testing/locator-vectors.json`, de `scripts/locator-go-vectors.go`: 6 531 casos de direcciones, IP, textos en claro, localizadores sellados, sobres, datos de la extensión, el registro y la apertura de una cápsula cuya `.dkk` lleva `datekeys.capsule`, y las 20 585 bases del relleno de −4 100 a 16 484;
- `testing/locator-seal.json`, de `scripts/locator-seal-go-vectors.go`: con la misma semilla, `seal` y `newEnvelope` sacan los mismos valores que Go en el mismo orden y escriben los mismos bytes;
- `testing/locator-interop.json`: Go abre los localizadores y los sobres que escribe esta librería (`scripts/locator-ts-samples.mjs` y `scripts/locator-go-verdicts.go`), de 0 bytes a 16 MiB y un byte.
Los generadores son los de `datekeys-dart` con las semillas de este repositorio, y corren en una exportación de `datekeys-go` en `spec-v0.12`.
- **Lo que el autor tiene pendiente** se queda como en Go: un CID no canónico pasa, `https://[[2000::]/` pasa, `parseInfo` comprueba menos de lo que podría y `openSealed` lee 1 MiB.
- **Pruebas.** `locator.test.ts`, `envelope.test.ts` y `locator.interop.test.ts`, con los cuatro módulos al 100 % de cobertura: 7 901 pruebas en total.
### La especificación 0.12, aprobada (06-10-2026)
- El autor aprobó el 6 de octubre de 2026 el borrador v0.12, tal como estaba: `datekeys-go` lo cierra con el tag `spec-v0.12` (`fe405e2`). `SPEC_VERSION` pasa a `0.12`, y `testdata` se sincroniza con ese tag: solo cambia el campo `spec` de cada fichero.

@ -1,6 +1,6 @@
# datekeys-ts
Implementación en TypeScript del protocolo DateKeys (formato 3 de la v0.10; de la v0.11, la firma de clave propia, la firma con certificados y el sello de tiempo, que lee y verifica con el perfil del certificado y los textos del borrador v0.12, y el escritor con el área de 32 KiB y la nota pública; el localizador y firmar al escribir están pendientes) y página de prueba en el navegador. Sustituye al prototipo, archivado en `../archive/prototype` (API Quicknet en Go, CLI tlock y cliente Svelte, commit `4d2b0a1`).
Implementación en TypeScript del protocolo DateKeys (formato 3 de la v0.10; de la v0.11, la firma de clave propia, la firma con certificados y el sello de tiempo, que lee y verifica con el perfil del certificado y los textos del borrador v0.12, y el escritor con el área de 32 KiB y la nota pública; de la v0.12, el localizador de `datekeys.capsule`; firmar al escribir está pendiente) y página de prueba en el navegador. Sustituye al prototipo, archivado en `../archive/prototype` (API Quicknet en Go, CLI tlock y cliente Svelte, commit `4d2b0a1`).
La implementación de referencia es la librería Go `g.activething.com/go/DateKeys`, en `../datekeys-go`. Los planes y el estado del trabajo están en `../docs`, el repositorio privado de documentación del proyecto.
@ -37,7 +37,7 @@ La versión actual es `0.2.0-dev`: la fase 3 añade la escritura de cápsulas de
## `src/lib/dkc`
La inspección (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegadores y en Node 20+: solo usa `Uint8Array`, `DataView`, `TextEncoder`/`TextDecoder`, `BigInt` y `crypto.subtle` (SHA-256). La apertura (fase 2) y la escritura usan las dependencias de ejecución de su sección: noble solo lo importan `author.ts`, `cms.ts`, `digest.ts`, `ed25519strict.ts`, `ibe.ts`, `release.ts` y `x25519.ts`, y `age-encryption` solo `agefile.ts`, `open.ts`, `tlock.ts` y `writer.ts`.
La inspección (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegadores y en Node 20+: solo usa `Uint8Array`, `DataView`, `TextEncoder`/`TextDecoder`, `BigInt` y `crypto.subtle` (SHA-256). La apertura (fase 2) y la escritura usan las dependencias de ejecución de su sección: noble solo lo importan `ageio.ts`, `author.ts`, `cms.ts`, `digest.ts`, `ed25519strict.ts`, `ibe.ts`, `release.ts` y `x25519.ts`, y `age-encryption` solo `agefile.ts`, `open.ts`, `tlock.ts` y `writer.ts`.
`crypto.subtle` solo existe en contextos seguros: `https`, o `http` en `localhost`. La página del paso 5 servida por `http` desde una IP de la red local (por ejemplo `vite --host` para probar en un móvil) no lo tiene, y `inspect` rechaza entonces con un `Error` que lo dice (`SHA-256 needs Web Crypto (crypto.subtle), …`) en vez de dar un veredicto. El registro por defecto no memoriza ese fallo: la siguiente llamada lo vuelve a intentar.
@ -78,8 +78,10 @@ La inspección (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegad
| `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, con los 16 de `INNER_ACCESS_AGE` en el formato 2 (`ACCESS_SLOTS`); `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`), con la nota pública (`public_note`) o el aviso de que no se muestra (`public_note_unusable`). `inspect` lee la nota bajo demanda, solo si la cabecera lleva una, así que las tablas de Unicode nunca se cargan con `/inspect` | `capsule/inspect.go`, `internal/inspectview` |
| `prefix.ts` | Lecturas acotadas de un `Blob`: el prefijo de un `.dkc` que necesitan los pasos 1 a 8 (`readCapsule`) y, de una `.dkk`, como mucho 12 bytes + 16 MiB + 1 (`readAccessKey`), que dan el mismo resultado que el fichero entero | |
| `index.ts` | Reexporta todo salvo la fase 2 (`ibe.ts`, `release.ts`, `open.ts`, `tlock.ts`, `x25519.ts`, `bech32.ts`, `digest.ts` y `agefile.ts`), la fase 3 (`encrypt.ts`, `writer.ts`, `recipient.ts` y `random.ts`), el formato 3 (`open3.ts`, `sink.ts`, `head.ts`, `pathrule.ts` y sus tablas, que pesan 136 KB) y la v0.11 (`author.ts`, `ed25519strict.ts`, `der.ts`, `cms.ts`, `securitycms.ts` y `note.ts`), y una guarda lo comprueba. La página importa `index.ts`, y reexportarlos metería noble, `age-encryption` o las tablas en la primera carga de `/inspect` aunque no los use, porque noble ejecuta código al cargarse. La página carga la apertura bajo demanda (`src/lib/inspector/opener.ts`) | |
| `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, firmas y tokens de prueba (`cmsbuild.ts`), y el writer como generador de vectores (`encrypt.ts`), el único que escribe el formato 2 u otra área. Solo lo importan los tests y `testing/` mismo: una guarda de `dependencies.test.ts` lo comprueba en `src/`, y `check-build.mjs` en el bundle de las páginas | |
| `locator.ts`, `ipaddr.ts` | El localizador de `datekeys.capsule` sin criptografía (§43 a §44.1 de la v0.12), con los checks, el orden y los textos del paquete `locator` de Go en `spec-v0.12`:<br>- los datos de la extensión: `parseInfo`, con `ERR_EXTENSION_DATA_INVALID` como único código, e `infoExtension`, que relee lo que escribe (§72);<br>- `standardExtensions`, el `extension.Standard` de Go: con el validador por defecto comprueba los datos de `datekeys.capsule` como `locator.Standard`, y con `null` solo que haya datos;<br>- las direcciones: `checkURI` con cada regla del §44.1 de la v0.12, `addressHost` y `usableAddresses`. Una dirección se lee como sus bytes, y un sustituto suelto como los tres bytes de su punto de código (WTF-8), que no son UTF-8 y se rechazan como Go rechaza esos bytes. Los bloques de IANA se comparan byte a byte sobre los 4 o 16 bytes de la dirección, nunca como números (`ipaddr.ts`, que lee como `netip.ParseAddr` y escribe como su `String`);<br>- `checkResolvedIp`, la IP a la que resuelve un nombre, que Go no tiene: es la de `datekeys-dart`, con su texto, y rechaza hoy `64:ff9b::/96` como el §44.1 de la v0.12;<br>- el texto en claro: `marshalLocator`, `unmarshalLocator` y `plaintextLength`, con el relleno de la clave 6 hasta el menor múltiplo de 4096 que puede llenar;<br>- el resto: `restIn` y `hide`.<br>Los errores sin código son `LocatorError`, con el texto de Go | `locator` (`locator.go`, `open.go` y `hide.go`), `net/netip` |
| `ageio.ts`, `envelope.ts` | La criptografía del localizador, con los textos de Go: `openSealed` (`Open`), que lee como mucho 1 MiB del texto como Go con `io.LimitReader`; `openInfoLocator` (`Info.OpenLocator`); `openEnvelope`; `seal` y `newEnvelope`, con una fuente inyectable de lo aleatorio (`RandomFill`, `crypto.getRandomValues` por defecto) que se lee en el orden de Go: con los mismos valores escriben los mismos bytes. `ageio.ts` lee y escribe ficheros `age` como `filippo.io/age` 1.3.2, con sus textos, porque `locator.Open` y `OpenEnvelope` los copian: la cabecera con el parser de `age.ts`, la identidad X25519, el MAC, el nonce y STREAM; la cápsula sigue leyendo y escribiendo los suyos con `age-encryption`. Solo el esquema de Quicknet, como la apertura | `locator` (`Seal`, `NewEnvelope`, `Open`, `OpenEnvelope`), `filippo.io/age` |
| `index.ts` | Reexporta todo salvo la fase 2 (`ibe.ts`, `release.ts`, `open.ts`, `tlock.ts`, `x25519.ts`, `bech32.ts`, `digest.ts` y `agefile.ts`), la fase 3 (`encrypt.ts`, `writer.ts`, `recipient.ts` y `random.ts`), el formato 3 (`open3.ts`, `sink.ts`, `head.ts`, `pathrule.ts` y sus tablas, que pesan 136 KB), la v0.11 (`author.ts`, `ed25519strict.ts`, `der.ts`, `cms.ts`, `securitycms.ts` y `note.ts`) y el localizador (`locator.ts`, `ipaddr.ts`, `ageio.ts` y `envelope.ts`), y una guarda lo comprueba. La página importa `index.ts`, y reexportarlos metería noble, `age-encryption` o las tablas en la primera carga de `/inspect` aunque no los use, porque noble ejecuta código al cargarse. La página carga la apertura bajo demanda (`src/lib/inspector/opener.ts`) | |
| `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, firmas y tokens de prueba (`cmsbuild.ts`), el writer como generador de vectores (`encrypt.ts`), el único que escribe el formato 2 u otra área, y lo del localizador: la lectura de sus vectores (`locator.ts`), una fuente de lo aleatorio de semilla fija con ChaCha20 propio (`seeded.ts`) y las recetas de su interoperabilidad (`locator-interop.ts`). Solo lo importan los tests y `testing/` mismo: una guarda de `dependencies.test.ts` lo comprueba en `src/`, y `check-build.mjs` en el bundle de las páginas | |
Los tests (`*.test.ts`) están junto a cada fichero.
@ -103,6 +105,24 @@ UTF-8 inválido en el JSON de un `dk1_` hace fallar el paso 2 de §19 (`ERR_DATE
Secretos: `access_material` de un `.dkk` e `I_PAYLOAD` de CONTROL_CBOR se borran en todos los caminos, también cuando la decodificación falla a medias o `unmarshal` rechaza el valor, como el `clear` diferido de Go.
### El localizador
El localizador sigue al paquete `locator` de Go en `spec-v0.12` también donde el autor tiene decisiones pendientes (apartado de la sesión del 5 y 6 de octubre de `../docs/HANDOFF.md`), para que las dos librerías den lo mismo hasta que Go cambie:
- un CID con un carácter de más cuyos bits sobran a cero pasa, porque Go solo mira que los bits sobrantes sean cero y no cuántos son;
- `https://[[2000::]/` pasa, por el `strings.Trim(host, "[]")` de Go;
- `parseInfo` no mira la cadena del stanza sellado, acepta una cabecera `age` sin cuerpo, e `infoExtension` no comprueba que el perfil esté pinneado;
- `openSealed` lee como mucho 1 MiB del texto del localizador, y un defecto posterior queda oculto tras el error de `unmarshalLocator`.
`checkResolvedIp` rechaza hoy `64:ff9b::/96`, la de NAT64, como el §44.1 de la v0.12; es una función aparte, para cambiarla sola si una versión siguiente del spec lo cambia.
Diferencias de forma con Go, sin efecto en lo que se lee o se escribe:
- `openInfoLocator` recibe el registro de perfiles, como Go, y sin él falla con el texto de Go para `nil`; la página usará `defaultRegistry()`;
- `standardExtensions(null)` es el `extension.Standard` de Go sin `ValidateCapsule`;
- `seal` saca la etiqueta de 16 bytes que Go saca para que `age` no mezcle el recipient tlock con otros, y la descarta: este escritor tiene un recipient solo, y sacarla mantiene el orden de los valores de Go;
- como en la apertura, solo se verifica el esquema de Quicknet: un perfil de otro esquema falla con `ERR_UNKNOWN_PROFILE` donde Go lo usaría.
## Página inspector
Sitio SvelteKit estático (`@sveltejs/adapter-static`, `strict`): las tres páginas se prerenderizan a HTML y no hay código de servidor.
@ -251,7 +271,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`), al 100 % en líneas, ramas, funciones y sentencias: de la librería, `cbor.ts`, `ibe.ts`, `release.ts`, `x25519.ts`, `bech32.ts`, `digest.ts`, `tlock.ts`, `prefix.ts`, `recipient.ts`, `random.ts`, `agefile.ts`, `padding.ts`, `writer.ts`, `encrypt.ts` y `lengths.ts`, y los del formato 3, `pathrule.ts`, `body.ts`, `security.ts`, `head.ts`, `open3.ts` y `sink.ts`; de la página, `fixtures.ts`, `opener.ts`, `opening.ts`, `release-input.ts`, `tempfile.ts`, `localtime.ts`, `create-input.ts` y `creator.ts`, y los del formato 3, `crc32.ts`, `zip.ts`, `zipsink.ts`, `files.ts`, `create-files.ts` y `create-check.ts`. 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`), al 100 % en líneas, ramas, funciones y sentencias: de la librería, `cbor.ts`, `ibe.ts`, `release.ts`, `x25519.ts`, `bech32.ts`, `digest.ts`, `tlock.ts`, `prefix.ts`, `recipient.ts`, `random.ts`, `agefile.ts`, `padding.ts`, `writer.ts`, `encrypt.ts` y `lengths.ts`, los del formato 3, `pathrule.ts`, `body.ts`, `security.ts`, `head.ts`, `open3.ts` y `sink.ts`, y los del localizador, `locator.ts`, `ipaddr.ts`, `ageio.ts` y `envelope.ts`; de la página, `fixtures.ts`, `opener.ts`, `opening.ts`, `release-input.ts`, `tempfile.ts`, `localtime.ts`, `create-input.ts` y `creator.ts`, y los del formato 3, `crc32.ts`, `zip.ts`, `zipsink.ts`, `files.ts`, `create-files.ts` y `create-check.ts`. 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`).
@ -271,7 +291,15 @@ Umbrales de cobertura (`vitest.config.ts`), al 100 % en líneas, ramas, funcione
- `testdata/vectors/head_schema.json` y `security.json`: cada head pasa por `decodeHead` con el código de la primera capa que falla y el texto exacto de `ERR_HEAD_INVALID`, y un head válido se reescribe a sus bytes; cada área de seguridad da, en el contexto del fichero, sus veredictos y sus líneas.
- `testdata/vectors/security_cms.json`: los 135 casos de `alg` 2 y de `seal_type` 2 dan, en su contexto, los veredictos, el resultado de cada firmante exigido y de cada ajeno, la autoridad y la hora del sello y las líneas de Go, byte a byte. `ed25519_strict.json` lo corre `ed25519strict.test.ts`.
- `testdata/vectors/note.json`: cada nota pasa por `checkNoteData`, con su resultado y el texto exacto de la regla que incumple, y por `publicNote`, `unusableNote` y `newNote`.
- `testdata/vectors/locator.json`: solo se comprueba que nombra esta especificación, porque el localizador aún no está portado.
- `testdata/vectors/locator.json`: se corre entero, como `TestLocatorVectors` de Go, con los textos de Go de `testing/locator-vectors.json` y `testing/locator-uris.json`: la extensión se lee, el localizador se abre con el release de su ronda y da su texto en claro de 4096 bytes y sus campos, el resto se encuentra en el host en su desplazamiento y abre el sobre; las 36 bases de relleno; las 247 direcciones, con el veredicto del fichero y el texto de Go; el localizador mixto, del que se usa solo la dirección que se acepta y que un escritor no escribe; los 5 restos, los 8 datos de la extensión, con `ERR_EXTENSION_DATA_INVALID` como único código, y los 16 textos en claro.
- `src/lib/dkc/testing/locator-uris.json` y `locator-vectors.json`: el localizador sin su escritura contra Go en `spec-v0.12`, que comprueban `locator.test.ts` y `envelope.test.ts`, todo con el resultado y el texto de Go. Los escribe `scripts/locator-go-vectors.go`, el generador de la etapa 7a de `datekeys-dart` con las semillas de este repositorio:
- `CheckURI` y `Host` en las 247 direcciones de `locator.json` y en 2 800 hechas en cada borde del §44.1 y sacadas de una semilla, y `CheckURI` sobre 4 cadenas con un sustituto suelto; `netip.ParseAddr` y `String` en 1 700 cadenas; y `publicIP` en los bytes de 868 direcciones, que `checkResolvedIp` da igual;
- `PlaintextLength` en cada base de −4 100 a 16 484; `Unmarshal` en 482 textos en claro de tres bases, válidos y rotos byte a byte y campo a campo; `Marshal` en 49 localizadores en cada límite y cada borde del relleno;
- `Open` en 118 localizadores sellados de cuatro rondas, con los releases de otras, perfiles rotos y ficheros editados, y en 14 ficheros cuyo texto pasa de 1 MiB, que Go lee con `io.LimitReader`; `OpenEnvelope` en 34 sobres y restos; `RestIn`, `Hide` y 11 cortes de `NewEnvelope`; `Info.Extension` en 29 datos, `Info.OpenLocator` en 7 y `ParseInfo` en 127; el registro de `locator.Standard` con `CheckNoncriticalIn`, `CheckCriticalIn` y `CheckWrite`; y `capsule.Open` de un fixture cuya `.dkk` lleva `datekeys.capsule`, con sus checks.
Son 6 531 casos, además de las 20 585 bases del relleno, y en todos coinciden el resultado, el código y el texto. Lo aleatorio de `age`, tlock y `NewEnvelope` sale de un ChaCha8 de semilla fija: el fichero sale igual en cada ejecución. El generador solo importa paquetes públicos de la referencia y llega a `publicIP` y `headerEnd` con `go:linkname`. Para regenerarlo, en una exportación de `datekeys-go` en `spec-v0.12`, para no leer cambios en curso: `git -C ../datekeys-go archive spec-v0.12 | tar -x -C DIR`, y desde `DIR`, `go run .../scripts/locator-go-vectors.go -testdata .../testdata -out .../src/lib/dkc/testing`.
- `src/lib/dkc/testing/locator-seal.json`: lo que escriben `Seal` y `NewEnvelope` de Go mientras `crypto/rand` lee el keystream de una semilla (ChaCha20 bajo el SHA-256 de la semilla, nonce a cero), con cada valor que saca. Lo escribe `scripts/locator-seal-go-vectors.go`, y `envelope.test.ts` lo comprueba con `seededFill` de `testing/seeded.ts`, la misma fuente: `seal` y `newEnvelope` sacan los mismos valores en el mismo orden y escriben los mismos bytes en los 10 sellados, de uno a tres bloques y de la ronda 1 a la última de Quicknet, en los 8 sobres, de 0 bytes a 1 MiB, y en el camino entero; y rechazan las 12 entradas que rechaza Go, con su texto y antes de sacar nada. Se regenera como el anterior, con `-source spec-v0.12`.
- `src/lib/dkc/testing/locator-interop.json`: Go abre lo que escribe esta librería. `scripts/locator-ts-samples.mjs` escribe los ficheros de las recetas de `testing/locator-interop.ts`, siete localizadores sellados con sus sobres, de un `.dkc` de 0 bytes a uno de 16 MiB y un byte, en el que el contador del nonce de STREAM pasa de un byte; `scripts/locator-go-verdicts.go` los abre con `locator.Open`, comprueba que `Marshal` da el texto sellado y que se usan todas sus direcciones, abre el sobre con `OpenEnvelope` y busca el resto escondido con `Hide` y `RestIn`. `locator.interop.test.ts` vuelve a escribir cada fichero de su receta, exige el SHA-256 que leyó Go y lo abre también. Para regenerarlo: `node scripts/locator-ts-samples.mjs DIR`, y desde la exportación de `datekeys-go`, `go run .../scripts/locator-go-verdicts.go -source spec-v0.12 -testdata .../testdata -samples DIR > locator-interop.json`.
- `src/lib/dkc/testing/zip-vectors.json`: cómo lee `archive/zip` de Go los ZIP de la página. `scripts/zip-ts-samples.mjs` escribe con `ZipSink` las muestras de `testing/zip.ts` en un directorio: ficheros en carpetas con nombres fuera de ASCII y todas las clases de fecha (1970, 2³¹ − 1, 2³¹, 9999 y ninguna, que toma la hora de la ronda), un fichero dentro de una carpeta, y 65 535 ficheros, que hacen el ZIP64 por número de entradas. `scripts/zip-go-read.go`, solo con la biblioteca estándar, registra el SHA-256 de cada ZIP y una línea por entrada: nombre, bit 11, método, CRC-32, tamaños, fecha leída de los campos extra y SHA-256 del contenido, leído con el CRC comprobado. `zipsink.test.ts` vuelve a escribir cada muestra, exige su SHA-256 y calcula las líneas que Go tiene que dar. Para regenerarlo: `node scripts/zip-ts-samples.mjs DIR > zip-samples.json` y `go run scripts/zip-go-read.go DIR zip-samples.json > zip-vectors.json`.
- `src/lib/dkc/testing/mutation-texts.json`: el texto del error de `capsule.Open` para cada caso de `mutations.json`, u `ok`. Lo escribe `scripts/mutation-go-texts.go`, que reproduce los casos como `internal/testkit` de la referencia, que un módulo de fuera no puede importar: el perfil de Quicknet o ninguno, una fuente con el release del caso, la `.dkk` ya decodificada, las identidades, las extensiones del caso con los textos de `testkit.KnownExtensions` y un sumidero que descarta. Comprueba cada código contra el corpus. Para regenerarlo, desde un módulo Go temporal como el de abajo: `go run mutation-go-texts.go ../datekeys-ts/testdata > mutation-texts.json`.
- `src/lib/dkc/testing/ibe-vectors.json`: los valores de referencia de `ibe.ts`. Los escribe `scripts/ibe-go-vectors.go` con kyber, tlock y `age`, las librerías de la referencia Go, y `ibe.test.ts` los comprueba todos:
@ -334,7 +362,7 @@ Guardas de `src/lib/dependencies.test.ts`, en cada `npm test`:
- `package.json` declara exactamente estas cuatro dependencias, con versión exacta;
- `package-lock.json` no contiene `tlock-js` ni `drand-client`, ningún noble 1.x, ni más copias 2.x de `@noble/curves` o `@noble/hashes` que la 2.4.0 de la raíz y la 2.0.1 bajo `@noble/post-quantum`;
- ningún fichero de `src/` importa `tlock-js` ni `drand-client`;
- solo `author.ts`, `cms.ts`, `digest.ts`, `ed25519strict.ts`, `ibe.ts`, `release.ts`, `x25519.ts` y los tests nombran `@noble/`, siempre con subrutas de `@noble/curves`, `@noble/hashes` y `@noble/ciphers` que resuelven a la copia 2.4.0 de la raíz;
- solo `ageio.ts`, `author.ts`, `cms.ts`, `digest.ts`, `ed25519strict.ts`, `ibe.ts`, `release.ts`, `x25519.ts` y los tests nombran `@noble/`, siempre con subrutas de `@noble/curves`, `@noble/hashes` y `@noble/ciphers` que resuelven a la copia 2.4.0 de la raíz;
- solo `agefile.ts`, `open.ts`, `tlock.ts`, `writer.ts` y los tests importan `age-encryption`; solo `encrypt.ts`, `testing/` y los tests importan `writer.ts`, cuyo núcleo recibe la aleatoriedad de quien lo llama (plan de la fase 3, decisiones 4 y 13); e `index.ts` no reexporta la apertura, el writer, los módulos de la v0.11 ni nada de `testing/`;
- solo los tests y `testing/` mismo importan `testing/`, cuyos ayudantes escriben el formato 2 y otra área, lo que solo puede escribir un generador de vectores;
- cada comprobación se ejecuta también sobre entradas malas, así que una guarda que dejara de detectar algo fallaría.

@ -22,6 +22,8 @@
// of format 3) is anywhere in the build;
// - a page loads the Unicode tables of the paths of format 3 with its first
// load, not on demand;
// - a page loads the locator of datekeys.capsule (locator.ts, envelope.ts,
// ageio.ts, ipaddr.ts) with its first load, not on demand;
// - the client bundle holds tlock-js, drand-client or Babel's helpers, or a
// nested copy of a package other than the noble copy under
// @noble/post-quantum (plan of phase 2, section 3 and decision 5), as
@ -362,6 +364,11 @@ for (const f of htmlFiles.filter(existsSync)) {
// 120 KB) come with the opening and the writer, never with the page.
const holdsTables = (c) => Object.keys(chunks[c]?.modules ?? {}).some((id) => id.replaceAll('\\', '/').endsWith('src/lib/dkc/pathrule-tables.ts'));
if ([...eager].some(holdsTables)) fail(`${rel(f)} loads the tables of pathrule-tables.ts with the page, not on demand`);
// The locator, whose data brings the rules of the note and whose envelope
// brings noble, is never part of the first load of a page.
const holdsLocator = (c) =>
Object.keys(chunks[c]?.modules ?? {}).some((id) => /src\/lib\/dkc\/(?:locator|envelope|ageio|ipaddr)\.ts$/.test(id.replaceAll('\\', '/')));
if ([...eager].some(holdsLocator)) fail(`${rel(f)} loads the locator with the page, not on demand`);
if (ON_DEMAND[basename(f)] !== undefined && ![...lazy].some(holdsTables)) fail(`${rel(f)}: the code loaded on demand lacks pathrule-tables.ts`);
const later = bundled(lazy);
for (const n of ON_DEMAND[basename(f)] ?? []) {

@ -0,0 +1,174 @@
//go:build ignore
// Opens with Go what the locator of datekeys-ts writes, and prints
// src/lib/dkc/testing/locator-interop.json: each recipe of
// src/lib/dkc/testing/locator-interop.ts, written by
// scripts/locator-ts-samples.mjs, with the SHA-256 of each of its files and
// the verdict of Go:
//
// - locator.Open of the sealed locator with Quicknet, its round and the
// published release of the round, from the fixtures;
// - Marshal of the locator that Open gives, which must be the plaintext
// that TypeScript sealed, and Usable, which must keep every address;
// - OpenEnvelope of the rest, which must give the .dkc;
// - RestIn of the rest hidden after a host with Hide, at its offset,
// which must give the rest again.
//
// A verdict is "ok" or the text of the first thing that failed.
//
// It imports only public packages of the reference implementation, and runs
// in a module of it without changing anything there: an export of the tag
// spec-v0.12, so that no change in progress in datekeys-go is read.
//
// node scripts/locator-ts-samples.mjs /tmp/samples
// git -C ../datekeys-go archive spec-v0.12 | tar -x -C /tmp/dkgo
// (cd /tmp/dkgo && go run .../datekeys-ts/scripts/locator-go-verdicts.go \
// -source spec-v0.12 -testdata .../datekeys-ts/testdata -samples /tmp/samples > .../locator-interop.json)
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"g.activething.com/go/DateKeys/locator"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/provider"
)
type obj = map[string]any
func h(b []byte) string { return hex.EncodeToString(b) }
func check(err error) {
if err != nil {
_, file, line, _ := runtime.Caller(1)
log.Fatalf("%s:%d: %v", filepath.Base(file), line, err)
}
}
func pattern(n int) []byte {
b := make([]byte, n)
for i := range b {
b[i] = byte(31*i + 7)
}
return b
}
func releases(testdata string) map[uint64]provider.Release {
out := map[uint64]provider.Release{}
files, err := filepath.Glob(filepath.Join(testdata, "fixtures", "*.json"))
check(err)
sort.Strings(files)
for _, f := range files {
raw, err := os.ReadFile(f)
check(err)
var v struct {
Release *struct {
Round uint64 `json:"round"`
Signature string `json:"signature"`
} `json:"release"`
}
if err := json.Unmarshal(raw, &v); err != nil || v.Release == nil {
continue
}
sig, err := hex.DecodeString(v.Release.Signature)
check(err)
out[v.Release.Round] = provider.Release{Round: v.Release.Round, Signature: sig}
}
return out
}
func verdict(r obj, files map[string][]byte, rel map[uint64]provider.Release) error {
round := uint64(r["round"].(float64))
release, ok := rel[round]
if !ok {
return fmt.Errorf("no release of round %d", round)
}
loc, err := locator.Open(profile.Quicknet(), round, release, files["sealed"])
if err != nil {
return err
}
pt, err := loc.Marshal()
if err != nil {
return err
}
if !bytes.Equal(pt, files["plaintext"]) {
return errors.New("another plaintext")
}
if len(loc.Usable()) != len(r["addresses"].([]any)) || len(loc.Addresses) != len(loc.Usable()) {
return errors.New("an address that a reader does not use")
}
dkc, err := loc.OpenEnvelope(files["rest"])
if err != nil {
return err
}
if !bytes.Equal(dkc, files["dkc"]) || !bytes.Equal(dkc, pattern(int(r["dkc_length"].(float64)))) {
return errors.New("another .dkc")
}
file, offset := locator.Hide([]byte("GIF89a, a host of some kind"), files["rest"])
rest, err := loc.RestIn(append(file, "and more"...), offset)
if err != nil {
return err
}
if !bytes.Equal(rest, files["rest"]) {
return errors.New("another rest in the host")
}
return nil
}
func main() {
samples := flag.String("samples", "", "the directory of the samples")
src := flag.String("source", "", "the commit of datekeys-go")
testdata := flag.String("testdata", "", "the testdata of this repository")
flag.Parse()
if *samples == "" || *src == "" || *testdata == "" {
log.Fatal("usage: -source <commit> -testdata <dir> -samples <dir>")
}
rel := releases(*testdata)
raw, err := os.ReadFile(filepath.Join(*samples, "samples.json"))
check(err)
var doc struct {
Samples []obj `json:"samples"`
}
check(json.Unmarshal(raw, &doc))
result := []obj{}
for i, r := range doc.Samples {
names, err := filepath.Glob(filepath.Join(*samples, fmt.Sprintf("%d.*", i)))
check(err)
files := map[string][]byte{}
digests := obj{}
for _, n := range names {
b, err := os.ReadFile(n)
check(err)
suffix := strings.TrimPrefix(filepath.Ext(n), ".")
files[suffix] = b
s := sha256.Sum256(b)
digests[suffix] = h(s[:])
}
v := "ok"
if err := verdict(r, files, rel); err != nil {
v = err.Error()
log.Printf("%s: %s", r["name"], v)
}
r["files"] = digests
r["verdict"] = v
result = append(result, r)
}
var buf bytes.Buffer
e := json.NewEncoder(&buf)
e.SetEscapeHTML(false)
e.SetIndent("", " ")
check(e.Encode(obj{"source": *src, "go": runtime.Version(), "description": "What Go makes of the sealed locators and envelopes that datekeys-ts writes from a seed, from the recipes of src/lib/dkc/testing/locator-interop.ts, by scripts/locator-go-verdicts.go: the SHA-256 of each file and the verdict of Go, ok or the text of what failed.", "samples": result}))
os.Stdout.Write(buf.Bytes())
}

@ -0,0 +1,388 @@
//go:build ignore
// Prints src/lib/dkc/testing/locator-seal.json, the vectors of the sealing
// of the locator and of the envelope of datekeys-ts (envelope.ts): Seal and
// NewEnvelope of package locator of datekeys-go at the tag spec-v0.12, while
// crypto/rand reads the keystream of a seed (ChaCha20 under SHA-256(seed),
// zero nonce, as seededFill of src/lib/dkc/testing/seeded.ts), with each
// draw, so that seal and newEnvelope, with the same seed, must draw the same
// values in the same order and write the same bytes:
//
// - seal: Seal of locators of 1 to 8 addresses, with offsets and headers
// of 1 to 1024 bytes, whose plaintext takes one, two or three blocks of
// 4096 bytes, for rounds from 1 to the last one of Quicknet. Go opens
// each with Open and the published release of its round when the
// fixtures have it, 1000, 1001, 1004 or 2000, and gets the locator
// back. A small file is stored whole; every file with its length and
// SHA-256;
// - seal_errors: what Seal refuses, with its text: locators that Marshal
// refuses, rounds out of the range of Quicknet, and both at once,
// where Marshal goes first;
// - envelope: NewEnvelope of .dkc of 0 bytes to 1 MiB: the
// addresses, and the rest, whole when
// small, with its length and SHA-256. Go opens each with OpenEnvelope;
// - flow: NewEnvelope, the addresses, Seal, Open and OpenEnvelope in one
// seed, as a writer and a reader do.
//
// The plaintext of a .dkc of n bytes has (31·i + 7) mod 256 as byte i. It is
// the generator of the stage 7b of datekeys-dart, with the seeds of this
// repository.
//
// It imports only public packages of the reference implementation, and runs
// in a module of it without changing anything there: an export of the tag
// spec-v0.12, so that no change in progress in datekeys-go is read.
//
// git -C ../datekeys-go archive spec-v0.12 | tar -x -C /tmp/dkgo
// (cd /tmp/dkgo && go run .../datekeys-ts/scripts/locator-seal-go-vectors.go \
// -source spec-v0.12 -testdata .../datekeys-ts/testdata -out .../datekeys-ts/src/lib/dkc/testing)
//
// The output is the same on every run.
package main
import (
"bytes"
cryptorand "crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"golang.org/x/crypto/chacha20"
"g.activething.com/go/DateKeys/locator"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/provider"
)
type obj = map[string]any
func h(b []byte) string { return hex.EncodeToString(b) }
func sum(b []byte) string {
s := sha256.Sum256(b)
return h(s[:])
}
func check(err error) {
if err != nil {
_, file, line, _ := runtime.Caller(1)
log.Fatalf("%s:%d: %v", filepath.Base(file), line, err)
}
}
func mustHex(s string) []byte {
b, err := hex.DecodeString(s)
check(err)
return b
}
func label(s string) []byte {
b := sha256.Sum256([]byte("datekeys-ts locator seal: " + s))
return b[:]
}
func pattern(n int) []byte {
b := make([]byte, n)
for i := range b {
b[i] = byte(31*i + 7)
}
return b
}
// ---------------------------------------------------------------------------
// crypto/rand from a seed
type seeded struct {
c *chacha20.Cipher
draws [][]byte
}
func (s *seeded) Read(p []byte) (int, error) {
clear(p)
s.c.XORKeyStream(p, p)
s.draws = append(s.draws, bytes.Clone(p))
return len(p), nil
}
func with(seed string, f func()) [][]byte {
key := sha256.Sum256([]byte(seed))
c, err := chacha20.NewUnauthenticatedCipher(key[:], make([]byte, chacha20.NonceSize))
check(err)
s := &seeded{c: c}
old := cryptorand.Reader
cryptorand.Reader = s
defer func() { cryptorand.Reader = old }()
f()
return s.draws
}
func drawsOf(d [][]byte) []obj {
out := []obj{}
for _, b := range d {
out = append(out, obj{"n": len(b), "hex": h(b)})
}
return out
}
// small is the largest file stored whole.
const small = 16 << 10
func fileObj(b []byte) obj {
o := obj{"length": len(b), "sha256": sum(b)}
if len(b) <= small {
o["hex"] = h(b)
}
return o
}
func locObj(l *locator.Locator) obj {
addrs := []obj{}
for _, a := range l.Addresses {
addrs = append(addrs, obj{"uri": a.URI, "offset": a.Offset})
}
return obj{"addresses": addrs, "envelope_key": h(l.EnvelopeKey[:]), "envelope_header": h(l.EnvelopeHeader), "rest_digest": h(l.RestDigest[:]), "rest_size": l.RestSize, "capsule_digest": h(l.CapsuleDigest[:])}
}
// newLoc is a locator of n addresses, the i-th of uri length about
// uriLen, with offsets, and a header of headerLen bytes.
func newLoc(name string, n, uriLen, headerLen int, offsets bool) *locator.Locator {
l := &locator.Locator{EnvelopeHeader: label(name + " header")}
for len(l.EnvelopeHeader) < headerLen {
l.EnvelopeHeader = append(l.EnvelopeHeader, label(fmt.Sprintf("%s header %d", name, len(l.EnvelopeHeader)))...)
}
l.EnvelopeHeader = l.EnvelopeHeader[:headerLen]
copy(l.EnvelopeKey[:], label(name+" key"))
copy(l.RestDigest[:], label(name+" rest"))
copy(l.CapsuleDigest[:], label(name+" capsule"))
l.RestSize = uint64(len(name)) * 1000003
for i := 0; i < n; i++ {
uri := fmt.Sprintf("https://example.com/%s/%d/", strings.ReplaceAll(name, " ", "-"), i)
for len(uri) < uriLen {
uri += "a"
}
var off uint64
if offsets && i%2 == 1 {
off = uint64(i) * 4099
}
l.Addresses = append(l.Addresses, locator.Address{URI: uri, Offset: off})
}
return l
}
// ---------------------------------------------------------------------------
var releases map[uint64]provider.Release
// readReleases reads the releases of the fixtures, public data of drand.
func readReleases(testdata string) map[uint64]provider.Release {
out := map[uint64]provider.Release{}
files, err := filepath.Glob(filepath.Join(testdata, "fixtures", "*.json"))
check(err)
slices.Sort(files)
for _, f := range files {
raw, err := os.ReadFile(f)
check(err)
var v struct {
Release *struct {
Round uint64 `json:"round"`
Signature string `json:"signature"`
} `json:"release"`
}
if err := json.Unmarshal(raw, &v); err != nil || v.Release == nil {
continue
}
out[v.Release.Round] = provider.Release{Round: v.Release.Round, Signature: mustHex(v.Release.Signature)}
}
return out
}
// opens reports whether Go opens sealed for round and gets l back; null
// when no release of the round is known.
func opens(p *profile.Profile, round uint64, l *locator.Locator, sealed []byte) any {
rel, ok := releases[round]
if !ok {
return nil
}
got, err := locator.Open(p, round, rel, sealed)
check(err)
a, err := l.Marshal()
check(err)
b, err := got.Marshal()
check(err)
if !bytes.Equal(a, b) {
log.Fatalf("round %d: Open gives another locator", round)
}
return true
}
func sealSection() []obj {
p := profile.Quicknet()
type c struct {
name string
round uint64
loc *locator.Locator
}
cases := []c{
{"one address", 1000, newLoc("one address", 1, 30, 200, false)},
{"two addresses with offsets", 1001, newLoc("two addresses", 2, 60, 300, true)},
{"eight addresses, two blocks", 2000, newLoc("eight addresses", 8, 500, 1024, true)},
{"eight long addresses, three blocks", 1004, newLoc("eight long", 8, 1024, 1024, true)},
{"a header of one byte", 1000, newLoc("one byte", 1, 20, 1, false)},
{"round 1", 1, newLoc("round 1", 3, 100, 500, true)},
{"the last round of Quicknet", p.MaxRound(), newLoc("last round", 1, 40, 900, false)},
{"round 12345678901", 12345678901, newLoc("eleven digits", 4, 400, 700, true)},
}
// The plaintext of one block exactly, and of one byte more before key 6.
for _, cut := range []int{1, 0} {
l := newLoc("edge", 4, 750, 100, false)
for {
pt, err := l.Marshal()
check(err)
if len(pt) > 4096 {
break
}
l.EnvelopeHeader = append(l.EnvelopeHeader, 'h')
}
l.EnvelopeHeader = l.EnvelopeHeader[:len(l.EnvelopeHeader)-cut]
cases = append(cases, c{[]string{"a block and one byte", "one block exactly"}[cut], 1000, l})
}
out := []obj{}
for _, k := range cases {
seed := "datekeys-ts locator seal " + k.name
var sealed []byte
d := with(seed, func() {
var err error
sealed, err = locator.Seal(p, k.round, k.loc)
check(err)
})
pt, err := k.loc.Marshal()
check(err)
o := obj{"name": k.name, "seed": seed, "round": k.round, "locator": locObj(k.loc), "plaintext_length": len(pt), "draws": drawsOf(d), "sealed": fileObj(sealed), "opens": opens(p, k.round, k.loc, sealed)}
out = append(out, o)
}
return out
}
func sealErrorsSection() []obj {
p := profile.Quicknet()
out := []obj{}
add := func(name string, round uint64, l *locator.Locator) {
var err error
d := with("datekeys-ts locator seal error "+name, func() { _, err = locator.Seal(p, round, l) })
if err == nil {
log.Fatalf("%s: no error", name)
}
out = append(out, obj{"name": name, "round": round, "locator": locObj(l), "error": err.Error(), "draws": len(d)})
}
good := newLoc("good", 1, 30, 100, false)
add("round 0", 0, good)
add("a round after the last one", p.MaxRound()+1, good)
none := newLoc("none", 0, 0, 100, false)
add("no address", 1000, none)
add("no address and round 0", 0, none)
add("nine addresses", 1000, newLoc("nine", 9, 30, 100, false))
add("an address of 1025 bytes", 1000, newLoc("long uri", 1, 1025, 100, false))
add("an empty header", 1000, newLoc("empty header", 1, 30, 0, false))
add("a header of 1025 bytes", 1000, newLoc("long header", 1, 30, 1025, false))
bad := newLoc("bad", 2, 30, 100, false)
bad.Addresses[1].URI = "http://example.com/"
add("an address of http", 1000, bad)
bad2 := newLoc("bad2", 1, 30, 100, false)
bad2.Addresses[0].URI = "https://192.168.1.1/x"
add("a private address", 1000, bad2)
add("a private address and round 0", 0, bad2)
big := newLoc("big", 1, 30, 100, false)
big.RestSize = 1 << 53
add("a rest of 2^53 bytes", 1000, big)
return out
}
func envelopeSection() []obj {
out := []obj{}
for _, n := range []int{0, 1, 1000, 65535, 65536, 65537, 200000, 1 << 20} {
seed := fmt.Sprintf("datekeys-ts locator envelope %d", n)
dkc := pattern(n)
var loc *locator.Locator
var rest []byte
d := with(seed, func() {
var err error
loc, rest, err = locator.NewEnvelope(dkc)
check(err)
})
back, err := loc.OpenEnvelope(rest)
check(err)
if !bytes.Equal(back, dkc) {
log.Fatal("OpenEnvelope")
}
out = append(out, obj{"seed": seed, "dkc_length": n, "locator": locObj(loc), "rest": fileObj(rest), "draws": drawsOf(d)})
}
return out
}
func flowSection() obj {
p := profile.Quicknet()
seed := "datekeys-ts locator flow"
dkc := pattern(5000)
var sealed, rest, file []byte
var offset uint64
var loc *locator.Locator
d := with(seed, func() {
var err error
loc, rest, err = locator.NewEnvelope(dkc)
check(err)
file, offset = locator.Hide([]byte("GIF89a, a host of some kind"), rest)
loc.Addresses = []locator.Address{{URI: "https://example.com/capsule"}, {URI: "https://example.org/host.gif", Offset: offset}}
sealed, err = locator.Seal(p, 1000, loc)
check(err)
})
got, err := locator.Open(p, 1000, releases[1000], sealed)
check(err)
r, err := got.RestIn(file, got.Addresses[1].Offset)
check(err)
back, err := got.OpenEnvelope(r)
check(err)
if !bytes.Equal(back, dkc) {
log.Fatal("the flow")
}
return obj{"seed": seed, "dkc_length": len(dkc), "host": h([]byte("GIF89a, a host of some kind")), "round": 1000, "draws": drawsOf(d), "locator": locObj(loc), "rest": fileObj(rest), "sealed": fileObj(sealed)}
}
func main() {
out := flag.String("out", "", "where the vectors go")
src := flag.String("source", "", "the commit of datekeys-go")
testdata := flag.String("testdata", "", "the testdata of this repository")
flag.Parse()
if *out == "" || *src == "" || *testdata == "" {
log.Fatal("usage: -source <commit> -testdata <dir> -out <dir>")
}
releases = readReleases(*testdata)
rel := obj{}
for r, v := range releases {
rel[fmt.Sprint(r)] = h(v.Signature)
}
doc := obj{
"source": *src,
"go": runtime.Version(),
"description": "Seal and NewEnvelope of package locator while crypto/rand reads the keystream of SeededRandomSource (ChaCha20 under SHA-256(seed), zero nonce), as seededFill of src/lib/dkc/testing/seeded.ts, by scripts/locator-seal-go-vectors.go. A file is {length, sha256} and its hex when it is small. The .dkc of n bytes has (31·i + 7) mod 256 as byte i. opens is true when Go opened the sealed locator with the release of its round, which releases holds, and null when no release is known. draws lists every value that crypto/rand gave, in order.",
"releases": rel,
"seal": sealSection(),
"seal_errors": sealErrorsSection(),
"envelope": envelopeSection(),
"flow": flowSection(),
}
var buf bytes.Buffer
e := json.NewEncoder(&buf)
e.SetEscapeHTML(false)
e.SetIndent("", " ")
check(e.Encode(doc))
path := filepath.Join(*out, "locator-seal.json")
check(os.WriteFile(path, buf.Bytes(), 0o644))
fmt.Printf("wrote %s, %d bytes\n", path, buf.Len())
}

@ -0,0 +1,30 @@
#!/usr/bin/env node
// Writes the samples of the interoperability of the locator, TypeScript to
// Go: the files of the recipes of src/lib/dkc/testing/locator-interop.ts,
// sealed locators with their envelopes, which this library writes from a
// seed, and samples.json, the recipes, into a directory.
// scripts/locator-go-verdicts.go then opens them with Go and writes
// src/lib/dkc/testing/locator-interop.json; locator.interop.test.ts writes
// the files again from the recipes and compares them with what Go read.
// Node runs the TypeScript sources directly (type stripping, Node 22.6+):
//
// node scripts/locator-ts-samples.mjs <directory>
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { interopFiles, interopRecipes } from '../src/lib/dkc/testing/locator-interop.ts';
const dir = process.argv[2];
if (dir === undefined) {
console.error('usage: node scripts/locator-ts-samples.mjs <directory>');
process.exit(2);
}
mkdirSync(dir, { recursive: true });
const recipes = interopRecipes();
for (const [i, r] of recipes.entries()) {
const files = interopFiles(r);
for (const [suffix, b] of Object.entries(files)) writeFileSync(join(dir, `${i}.${suffix}`), b);
console.log(`${r.name}: ${Object.keys(files).join(', ')}`);
}
writeFileSync(join(dir, 'samples.json'), `${JSON.stringify({ samples: recipes }, null, 1)}\n`);
console.log(`wrote ${recipes.length} samples to ${dir}`);

@ -15,8 +15,8 @@
// another 2.x copy anywhere but under @noble/post-quantum, which pins
// ~2.0.0 and uses its copy for ML-KEM only (plan decision 5);
// - a file of src/ imports tlock-js or drand-client;
// - a file of src/ other than author.ts, cms.ts, digest.ts, ed25519strict.ts, ibe.ts,
// release.ts, x25519.ts and the tests names @noble/, or a noble import is not a subpath of @noble/curves,
// - a file of src/ other than ageio.ts, author.ts, cms.ts, digest.ts, ed25519strict.ts,
// ibe.ts, release.ts, x25519.ts and the tests names @noble/, or a noble import is not a subpath of @noble/curves,
// @noble/hashes or @noble/ciphers, the root copies that those files
// resolve to;
// - a file of src/ other than the tests and src/lib/dkc/testing/ itself
@ -52,6 +52,7 @@ const NOBLE = ['@noble/ciphers', '@noble/curves', '@noble/hashes'];
const FORBIDDEN = ['tlock-js', 'drand-client'];
// The only files besides the tests that may import noble.
const NOBLE_IMPORTERS = [
'src/lib/dkc/ageio.ts',
'src/lib/dkc/author.ts',
'src/lib/dkc/cms.ts',
'src/lib/dkc/digest.ts',
@ -72,9 +73,12 @@ const WRITER_IMPORTER_DIRS = ['src/lib/dkc/testing/'];
const TESTING_DIR = 'src/lib/dkc/testing/';
// The modules that index.ts must not re-export: the opening and the writer,
// which would bring noble or age-encryption into the first load of a page,
// and the internals of both; and the head of format 3 and the rules of its
// paths, whose Unicode tables load with the opening and the writer.
// and the internals of both; the head of format 3 and the rules of its
// paths, whose Unicode tables load with the opening and the writer; and the
// locator, whose data brings the rules of the note and whose envelope brings
// noble.
const NOT_IN_INDEX = [
'ageio.ts',
'agefile.ts',
'author.ts',
'bech32.ts',
@ -83,8 +87,11 @@ const NOT_IN_INDEX = [
'digest.ts',
'ed25519strict.ts',
'encrypt.ts',
'envelope.ts',
'head.ts',
'ibe.ts',
'ipaddr.ts',
'locator.ts',
'note.ts',
'open.ts',
'open3.ts',
@ -227,7 +234,7 @@ describe('runtime dependencies', () => {
}
});
it('only author.ts, cms.ts, digest.ts, ed25519strict.ts, ibe.ts, release.ts, x25519.ts and the tests import noble, only from @noble/curves, @noble/hashes and @noble/ciphers, and nothing imports tlock-js or drand-client', () => {
it('only ageio.ts, author.ts, cms.ts, digest.ts, ed25519strict.ts, ibe.ts, release.ts, x25519.ts and the tests import noble, only from @noble/curves, @noble/hashes and @noble/ciphers, and nothing imports tlock-js or drand-client', () => {
expect(importProblems(sources())).toEqual([]);
});
@ -250,6 +257,7 @@ describe('runtime dependencies', () => {
importProblems([
{ name: 'src/lib/dkc/ibe.ts', text: noble },
{ name: 'src/lib/dkc/x25519.ts', text: chacha },
{ name: 'src/lib/dkc/ageio.ts', text: chacha },
{ name: 'src/lib/dkc/x.test.ts', text: noble },
{ name: 'src/lib/dkc/writer.ts', text: "import { Encrypter } from 'age-encryption';" },
{ name: 'src/lib/dkc/encrypt.ts', text: "import { seal } from './writer.ts';" },
@ -265,6 +273,8 @@ describe('runtime dependencies', () => {
['noble outside the allowlist', 'src/lib/dkc/open.ts', noble],
['noble from a page', 'src/routes/inspect/+page.svelte', noble],
['ciphers outside the allowlist', 'src/lib/dkc/open.ts', chacha],
['ciphers in the locator', 'src/lib/dkc/envelope.ts', chacha],
['noble in a helper of the tests', 'src/lib/dkc/testing/seeded.ts', chacha],
['another noble package', 'src/lib/dkc/x25519.ts', "import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';"],
['the nested copy', 'src/lib/dkc/release.ts', "import { bls12_381 } from '../../../node_modules/@noble/post-quantum/node_modules/@noble/curves/bls12-381.js';"],
['tlock-js', 'src/lib/dkc/open.ts', "import { timelockDecrypt } from 'tlock-js';"],
@ -279,6 +289,8 @@ describe('runtime dependencies', () => {
['the opening in index.ts', 'src/lib/dkc/index.ts', "export { open } from './open.ts';"],
['recipient.ts in index.ts', 'src/lib/dkc/index.ts', "export * from './recipient.ts';"],
['note.ts in index.ts', 'src/lib/dkc/index.ts', "export * from './note.ts';"],
['the locator in index.ts', 'src/lib/dkc/index.ts', "export * from './locator.ts';"],
['the envelope in index.ts', 'src/lib/dkc/index.ts', "export { seal } from './envelope.ts';"],
['a helper of the tests in index.ts', 'src/lib/dkc/index.ts', "export * from './testing/encrypt.ts';"],
['a helper of the tests from the library', 'src/lib/dkc/encrypt.ts', "import { encryptWith } from './testing/encrypt.ts';"],
['a helper of the tests from a page', 'src/lib/inspector/creator.ts', "const t = await import('../dkc/testing/encrypt.ts');"],

@ -0,0 +1,412 @@
// age files read and written as filippo.io/age v1.3.2 reads and writes them,
// with its texts, its order of checks and its order of random draws, for
// the locator of datekeys.capsule (locator.ts, envelope.ts):
//
// - ageDecrypt is age.Decrypt followed by io.ReadAll, optionally through
// io.LimitReader: the header parsed by age.ts, the file key unwrapped by
// an identity, the header MAC, the nonce and the STREAM, each failure with
// the text of age's error;
// - ageEncrypt is age.Encrypt for one recipient: the file key, the stanzas
// of the recipient, the header MAC, the nonce and the STREAM, with every
// random value taken from the source given, in the order of Go, so that
// with the same values it writes the bytes of Go.
//
// The capsule (open.ts, writer.ts) reads and writes its own age files with
// age-encryption instead, whose texts the reference never copies (spec §69).
// The locator has no normative code, and Go's locator.Open and OpenEnvelope
// copy the texts of age, so this module reproduces them.
//
// Internal: index.ts does not re-export it.
import { chacha20poly1305 } from '@noble/ciphers/chacha.js';
import { x25519 } from '@noble/curves/ed25519.js';
import { extract, hkdf } from '@noble/hashes/hkdf.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { parseAgeHeader, type Stanza } from './age.ts';
import { goBase64 } from './datekey.ts';
/** The size of an age file key. */
export const FILE_KEY_LEN = 16;
const NONCE_LEN = 16;
const CHUNK = 64 * 1024;
const TAG = 16;
const ENC_CHUNK = CHUNK + TAG;
const X25519_LABEL = new TextEncoder().encode('age-encryption.org/v1/X25519');
const HEADER_INFO = new TextEncoder().encode('header');
const PAYLOAD_INFO = new TextEncoder().encode('payload');
const INTRO = 'age-encryption.org/v1\n';
/**
* A failure of age, with the text of the error of Go's filippo.io/age, such
* as "bad header MAC".
*/
export class AgeError extends Error {
constructor(message: string) {
super(message);
this.name = 'AgeError';
}
}
/** The text of age's ErrIncorrectIdentity. */
export const INCORRECT_IDENTITY = 'incorrect identity for recipient block';
/** age's ErrIncorrectIdentity: the identity is not a recipient of the stanzas. */
export class IncorrectIdentity extends AgeError {
constructor() {
super(INCORRECT_IDENTITY);
}
}
/**
* An age identity, as Go's age.Identity: it returns the file key of the
* stanzas, or throws IncorrectIdentity, an AgeError with the text of age, or
* the error of its own checks, which age.Decrypt returns as it is.
*/
export interface AgeIdentity {
unwrap(stanzas: readonly Stanza[]): Uint8Array;
}
/** A source of random bytes: it fills `b`, as crypto.getRandomValues does. */
export type RandomFill = (b: Uint8Array) => void;
/** The random bytes of crypto.getRandomValues. */
export const cryptoFill: RandomFill = (b) => {
crypto.getRandomValues(b);
};
/** `n` bytes drawn from `random`. */
export function draw(random: RandomFill, n: number): Uint8Array {
const b = new Uint8Array(n);
random(b);
return b;
}
// ---------------------------------------------------------------------------
// Base64 of age: standard alphabet, no padding
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
/** age's format.EncodeToString: standard Base64 without padding. */
export function b64Encode(b: Uint8Array): string {
let out = '';
let i = 0;
for (; i + 3 <= b.length; i += 3) {
const v = (b[i]! << 16) | (b[i + 1]! << 8) | b[i + 2]!;
out += B64[v >> 18]! + B64[(v >> 12) & 63]! + B64[(v >> 6) & 63]! + B64[v & 63]!;
}
if (b.length - i === 1) {
const v = b[i]! << 16;
out += B64[v >> 18]! + B64[(v >> 12) & 63]!;
} else if (b.length - i === 2) {
const v = (b[i]! << 16) | (b[i + 1]! << 8);
out += B64[v >> 18]! + B64[(v >> 12) & 63]! + B64[(v >> 6) & 63]!;
}
return out;
}
// age's format.DecodeString on an argument of a stanza: canonical unpadded
// standard Base64. Returns the bytes or Go's error text. The parser of age.ts
// has already refused a line feed or a carriage return in an argument.
function b64Decode(s: string): Uint8Array | string {
const r = goBase64(new TextEncoder().encode(s), false, false, true);
return typeof r === 'number' ? `illegal base64 data at input byte ${r}` : r;
}
// ---------------------------------------------------------------------------
// The header
// age's format.Stanza.Marshal: "->", the type and the arguments, then the
// body in lines of 64 columns ended by a shorter line, possibly empty.
function marshalStanza(s: Stanza): string {
let out = `-> ${[s.type, ...s.args].join(' ')}\n`;
const body = b64Encode(s.body);
for (let i = 0; i < body.length; i += 64) out += `${body.slice(i, i + 64)}\n`;
if (body.length % 64 === 0) out += '\n';
return out;
}
// The header as format.Header.MarshalWithoutMAC writes it: up to "---".
function headerWithoutMac(stanzas: readonly Stanza[]): Uint8Array {
return new TextEncoder().encode(`${INTRO}${stanzas.map(marshalStanza).join('')}---`);
}
// age's headerMAC: HMAC-SHA-256 of the header up to "---", keyed with
// HKDF-SHA-256 of the file key, no salt, info "header". HKDF-Extract with a
// salt is HMAC keyed with the salt.
function headerMac(fileKey: Uint8Array, stanzas: readonly Stanza[]): Uint8Array {
const key = hkdf(sha256, fileKey, undefined, HEADER_INFO, 32);
const mac = extract(sha256, headerWithoutMac(stanzas), key);
key.fill(0);
return mac;
}
// age's streamKey: HKDF-SHA-256 of the file key, salted with the nonce, info "payload".
const streamKey = (fileKey: Uint8Array, nonce: Uint8Array): Uint8Array => hkdf(sha256, fileKey, nonce, PAYLOAD_INFO, 32);
// Two MACs of 32 bytes, compared in time that does not depend on where they
// differ.
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
let acc = a.length ^ b.length;
for (let i = 0; i < a.length && i < b.length; i++) acc |= a[i]! ^ b[i]!;
return acc === 0;
}
// ---------------------------------------------------------------------------
// X25519
/**
* age's X25519Identity for the raw 32-byte identity `secret`, which it
* copies: each X25519 stanza in turn, the first that opens gives the file
* key; a stanza of another type, or one that does not authenticate, is for
* another identity; a malformed one stops the unwrap with the text of age.
*/
export function x25519Identity(secret: Uint8Array): AgeIdentity & { wipe(): void } {
if (secret.length !== 32) throw new RangeError('ageio: an X25519 identity is 32 bytes');
const key = secret.slice();
const ours = x25519.getPublicKey(key);
const unwrapOne = (s: Stanza): Uint8Array => {
if (s.type !== 'X25519') throw new IncorrectIdentity();
if (s.args.length !== 1) throw new AgeError('invalid X25519 recipient block');
const share = b64Decode(s.args[0]!);
if (typeof share === 'string') throw new AgeError(`failed to parse X25519 recipient: ${share}`);
if (share.length !== 32) throw new AgeError('invalid X25519 recipient block');
let shared: Uint8Array;
try {
shared = x25519.getSharedSecret(key, share);
} catch {
// noble refuses exactly the shares of low order, whose secret is zero.
throw new AgeError('invalid X25519 recipient: crypto/ecdh: bad X25519 remote ECDH input: low order point');
}
const salt = new Uint8Array(64);
salt.set(share);
salt.set(ours, 32);
const wrapping = hkdf(sha256, shared, salt, X25519_LABEL, 32);
shared.fill(0);
try {
if (s.body.length !== FILE_KEY_LEN + TAG) throw new AgeError('invalid X25519 recipient block: incorrect file key size');
try {
return chacha20poly1305(wrapping, new Uint8Array(12)).decrypt(s.body);
} catch {
throw new IncorrectIdentity();
}
} finally {
wrapping.fill(0);
}
};
return {
unwrap(stanzas: readonly Stanza[]): Uint8Array {
// age's multiUnwrap.
for (const s of stanzas) {
try {
return unwrapOne(s);
} catch (err) {
if (err instanceof IncorrectIdentity) continue;
throw err;
}
}
throw new IncorrectIdentity();
},
wipe(): void {
key.fill(0);
},
};
}
/** The X25519 public key, the recipient, of the raw identity `secret`, as age's X25519Identity.Recipient. */
export const x25519Recipient = (secret: Uint8Array): Uint8Array => x25519.getPublicKey(secret);
/**
* The stanza that age's X25519Recipient.Wrap writes for `fileKey` to the
* public key `recipient`: an ephemeral scalar of 32 bytes drawn from
* `random`, its share, and the file key wrapped with the key that HKDF
* derives from the shared secret.
*/
export function wrapX25519(recipient: Uint8Array, fileKey: Uint8Array, random: RandomFill): Stanza {
const ephemeral = draw(random, 32);
try {
const share = x25519.getPublicKey(ephemeral);
const shared = x25519.getSharedSecret(ephemeral, recipient);
const salt = new Uint8Array(64);
salt.set(share);
salt.set(recipient, 32);
const wrapping = hkdf(sha256, shared, salt, X25519_LABEL, 32);
shared.fill(0);
const body = chacha20poly1305(wrapping, new Uint8Array(12)).encrypt(fileKey);
wrapping.fill(0);
return { type: 'X25519', args: [b64Encode(share)], body };
} finally {
ephemeral.fill(0);
}
}
// ---------------------------------------------------------------------------
// Decryption
/**
* The plaintext of the age file `file` for `identity`, as age.Decrypt and
* io.ReadAll of its reader, through io.LimitReader when `limit` is given:
*
* - the header, as age's format.Parse: "failed to read header: " and the
* reason of the parser;
* - the file key of the identity, whose own error is returned as it is, or,
* when it is not a recipient, "identity did not match any of the
* recipients: incorrect identity for recipient block";
* - "bad header MAC";
* - the nonce: "failed to read nonce: EOF" or "...: unexpected EOF";
* - the STREAM, as age's DecryptReader: "unexpected EOF" without a last
* chunk, "failed to decrypt and authenticate payload chunk, file may be
* corrupted or tampered with", "last chunk is empty, try age v1.0.0, and
* please consider reporting this", and "trailing data after end of
* encrypted file".
*
* As io.ReadAll through io.LimitReader, it decrypts a chunk only while it has
* read fewer than `limit` bytes of plaintext, and a failure that the reader
* finds after the last chunk it needed, such as trailing data, is reported
* only if it reads again. The plaintext is cut at `limit`. Throws an
* AgeError, or the error of the identity.
*/
export function ageDecrypt(file: Uint8Array, identity: AgeIdentity, limit = Number.POSITIVE_INFINITY): Uint8Array {
let header: ReturnType<typeof parseAgeHeader>;
try {
header = parseAgeHeader(file);
} catch (err) {
// parseAgeHeader keeps the text of age.Decrypt in its cause.
throw new AgeError(((err as Error).cause as Error).message);
}
let fileKey: Uint8Array;
try {
fileKey = identity.unwrap(header.stanzas);
} catch (err) {
if (err instanceof IncorrectIdentity) throw new AgeError(`identity did not match any of the recipients: ${err.message}`);
throw err;
}
try {
if (!constantTimeEqual(headerMac(fileKey, header.stanzas), header.mac)) throw new AgeError('bad header MAC');
const rest = file.length - header.length;
if (rest < NONCE_LEN) throw new AgeError(`failed to read nonce: ${rest === 0 ? 'EOF' : 'unexpected EOF'}`);
const key = streamKey(fileKey, file.subarray(header.length, header.length + NONCE_LEN));
try {
return readStream(key, file.subarray(header.length + NONCE_LEN), limit);
} finally {
key.fill(0);
}
} finally {
fileKey.fill(0);
}
}
// The reading of age's DecryptReader by io.ReadAll through io.LimitReader:
// a chunk is read only once the plaintext of the previous one is consumed
// and fewer than limit bytes were read; the check after the last chunk,
// trailing data or the end, is made with it and reported at the next read.
function readStream(key: Uint8Array, data: Uint8Array, limit: number): Uint8Array {
const nonce = new Uint8Array(12);
const parts: Uint8Array[] = [];
let total = 0;
let pos = 0;
let after: 'eof' | 'trailing' | undefined;
try {
while (total < limit) {
if (after === 'eof') break;
if (after === 'trailing') throw new AgeError('trailing data after end of encrypted file');
// readChunk.
const rem = data.length - pos;
if (rem === 0) throw new AgeError('unexpected EOF');
let last = false;
let n = ENC_CHUNK;
if (rem < ENC_CHUNK) {
// The last chunk can be short, but not empty unless it is the first
// and only one.
if (!nonce.every((b) => b === 0) && rem === TAG) {
throw new AgeError('last chunk is empty, try age v1.0.0, and please consider reporting this');
}
n = rem;
last = true;
nonce[11] = 1;
}
const input = data.subarray(pos, pos + n);
pos += n;
let out = open(key, nonce, input);
if (out === undefined && !last) {
// A full-length last chunk.
last = true;
nonce[11] = 1;
out = open(key, nonce, input);
}
if (out === undefined) throw new AgeError('failed to decrypt and authenticate payload chunk, file may be corrupted or tampered with');
incNonce(nonce);
const take = Math.min(out.length, limit - total);
parts.push(take === out.length ? out : out.slice(0, take));
if (take !== out.length) out.fill(0);
total += take;
if (last) after = pos < data.length ? 'trailing' : 'eof';
}
} catch (err) {
for (const p of parts) p.fill(0);
throw err;
}
const plain = new Uint8Array(total);
let at = 0;
for (const p of parts) {
plain.set(p, at);
at += p.length;
p.fill(0);
}
return plain;
}
function open(key: Uint8Array, nonce: Uint8Array, input: Uint8Array): Uint8Array | undefined {
try {
return chacha20poly1305(key, nonce).decrypt(input);
} catch {
return undefined;
}
}
// The 11-byte big-endian counter of the nonce, plus one. A file of 2^88
// chunks does not exist.
function incNonce(nonce: Uint8Array): void {
for (let i = 10; i >= 0; i--) {
nonce[i] = (nonce[i]! + 1) & 0xff;
if (nonce[i] !== 0) return;
}
}
// ---------------------------------------------------------------------------
// Encryption
/**
* age.Encrypt of `plaintext` for one recipient, `wrap`, which returns its
* stanzas for a file key: the file key, 16 bytes drawn from `random`; the
* stanzas, which draw what the recipient draws; the header MAC; the nonce,
* 16 more bytes; and the STREAM, in chunks of 64 KiB, the last one marked,
* an empty one only for an empty plaintext. With the same values, it writes
* the bytes of Go.
*/
export function ageEncrypt(plaintext: Uint8Array, wrap: (fileKey: Uint8Array) => readonly Stanza[], random: RandomFill): Uint8Array {
const fileKey = draw(random, FILE_KEY_LEN);
try {
const stanzas = wrap(fileKey);
const mac = headerMac(fileKey, stanzas);
const head = new TextEncoder().encode(`${new TextDecoder().decode(headerWithoutMac(stanzas))} ${b64Encode(mac)}\n`);
const nonce = draw(random, NONCE_LEN);
const key = streamKey(fileKey, nonce);
const chunks = Math.max(1, Math.ceil(plaintext.length / CHUNK));
const out = new Uint8Array(head.length + NONCE_LEN + plaintext.length + chunks * TAG);
out.set(head);
out.set(nonce, head.length);
let at = head.length + NONCE_LEN;
const counter = new Uint8Array(12);
for (let i = 0; i < chunks; i++) {
if (i === chunks - 1) counter[11] = 1;
const ct = chacha20poly1305(key, counter).encrypt(plaintext.subarray(i * CHUNK, Math.min((i + 1) * CHUNK, plaintext.length)));
out.set(ct, at);
at += ct.length;
incNonce(counter);
}
key.fill(0);
return out;
} finally {
fileKey.fill(0);
}
}

@ -0,0 +1,391 @@
// The cryptography of the locator against Go (spec §44.1):
//
// - testing/locator-seal.json, from scripts/locator-seal-go-vectors.go,
// holds what Seal and NewEnvelope of Go write while crypto/rand reads the
// keystream of a seed. With the same seed, seal and newEnvelope draw the
// same values in the same order and write the same bytes; the sealed
// locators open with the release of their round, and the envelopes with
// their locator. seal refuses what Go refuses, with its text, before
// drawing anything.
// - testing/locator-vectors.json, from scripts/locator-go-vectors.go: Open
// on sealed locators of four rounds, with other releases and profiles and
// edited files; Open of files whose plaintext passes 1 MiB, which Go reads
// through io.LimitReader; OpenEnvelope on envelopes and rests, valid and
// edited; the split of NewEnvelope; Info.OpenLocator. Each with the text
// of Go.
import { describe, expect, it } from 'vitest';
import { chacha20poly1305 } from '@noble/ciphers/chacha.js';
import { hkdf } from '@noble/hashes/hkdf.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { cryptoFill, ageDecrypt, ageEncrypt, b64Encode, wrapX25519, x25519Identity, x25519Recipient } from './ageio.ts';
import { fromHex, toHex } from './bytes.ts';
import { DateKeysError } from './errors.ts';
import { headerEnd, newEnvelope, openEnvelope, openInfoLocator, openSealed, seal } from './envelope.ts';
import { hide, type Locator, LocatorError, marshalLocator, restIn, unmarshalLocator, wipeLocator } from './locator.ts';
import { defaultRegistry, quicknet } from './profile.ts';
import {
applyEdits,
errorText,
expandSummary,
type Json,
openRelease,
pattern,
profileOf,
readVectors,
releaseOf,
rows,
sha256Hex,
summaryOf,
textOf,
} from './testing/locator.ts';
import { seededFill } from './testing/seeded.ts';
const sealVectors = readVectors('locator-seal.json');
const v = readVectors('locator-vectors.json');
const q = quicknet();
const list = (x: unknown): Json[] => x as Json[];
function locatorOf(j: Json): Locator {
return {
addresses: list(j.addresses).map((a) => ({ uri: a.uri as string, offset: a.offset as number })),
envelopeKey: fromHex(j.envelope_key as string),
envelopeHeader: fromHex(j.envelope_header as string),
restDigest: fromHex(j.rest_digest as string),
restSize: j.rest_size as number,
capsuleDigest: fromHex(j.capsule_digest as string),
};
}
const jsonOf = (l: Locator): Json => ({
addresses: l.addresses.map((a) => ({ uri: a.uri, offset: a.offset })),
envelope_key: toHex(l.envelopeKey),
envelope_header: toHex(l.envelopeHeader),
rest_digest: toHex(l.restDigest),
rest_size: l.restSize,
capsule_digest: toHex(l.capsuleDigest),
});
function expectFile(b: Uint8Array, want: Json): void {
expect(b.length).toBe(want.length);
expect(sha256Hex(b)).toBe(want.sha256);
if (want.hex !== undefined) expect(toHex(b)).toBe(want.hex);
}
const drawsOf = (draws: readonly Uint8Array[]): Json[] => draws.map((d) => ({ n: d.length, hex: toHex(d) }));
const sealRelease = (round: number) => releaseOf(sealVectors, round);
describe('seal and newEnvelope write the bytes of Go (locator-seal.json)', () => {
it('a seed gives the keystream of ChaCha20 that Go reads', () => {
// RFC 8439, 2.4.2: the keystream of a known key, nonce and counter is
// checked by the vectors themselves; here, two draws continue one
// stream.
const a = seededFill('x');
const one = new Uint8Array(100);
a(one);
const b = seededFill('x');
const x = new Uint8Array(37);
const y = new Uint8Array(63);
b(x);
b(y);
expect(toHex(one)).toBe(toHex(x) + toHex(y));
expect(b.draws.map((d) => d.length)).toEqual([37, 63]);
});
for (const c of list(sealVectors.seal)) {
it(`Seal: ${c.name as string}`, () => {
const round = c.round as number;
const loc = locatorOf(c.locator as Json);
expect(marshalLocator(loc).length).toBe(c.plaintext_length);
const r = seededFill(c.seed as string);
const sealed = seal(q, round, loc, r);
expectFile(sealed, c.sealed as Json);
expect(drawsOf(r.draws)).toEqual(c.draws);
if (c.opens === true) {
expect(jsonOf(openSealed(q, round, sealRelease(round), sealed))).toEqual(c.locator);
// Another round does not open it.
const other = round === 1000 ? 1001 : 1000;
expect(() => openSealed(q, other, sealRelease(other), sealed)).toThrow(LocatorError);
}
});
}
it('Seal refuses what Go refuses, with its text and its code, before drawing anything', () => {
const cases = list(sealVectors.seal_errors);
expect(cases.length).toBe(12);
for (const c of cases) {
const r = seededFill('unused');
let err: unknown;
try {
seal(q, c.round as number, locatorOf(c.locator as Json), r);
} catch (e) {
err = e;
}
expect((err as Error).message, c.name as string).toBe(c.error);
// A round out of range is the error of NewTimeRecipient, with its
// code; a locator that Marshal refuses has none.
expect(err instanceof DateKeysError, c.name as string).toBe((c.error as string).startsWith('agewrap: '));
expect(r.draws.length).toBe(c.draws);
}
});
for (const c of list(sealVectors.envelope)) {
it(`NewEnvelope of ${c.dkc_length as number} bytes`, () => {
const dkc = pattern(c.dkc_length as number);
const r = seededFill(c.seed as string);
const e = newEnvelope(dkc, r);
expect(jsonOf(e.locator)).toEqual(c.locator);
expectFile(e.rest, c.rest as Json);
expect(drawsOf(r.draws)).toEqual(c.draws);
expect(openEnvelope(e.locator, e.rest)).toEqual(dkc);
});
}
it('a writer and a reader, as Go: envelope, addresses, seal, open', () => {
const c = sealVectors.flow as Json;
const dkc = pattern(c.dkc_length as number);
const r = seededFill(c.seed as string);
const e = newEnvelope(dkc, r);
const hidden = hide(fromHex(c.host as string), e.rest);
const loc: Locator = {
...e.locator,
addresses: [{ uri: 'https://example.com/capsule', offset: 0 }, { uri: 'https://example.org/host.gif', offset: hidden.offset }],
};
const round = c.round as number;
const sealed = seal(q, round, loc, r);
expect(drawsOf(r.draws)).toEqual(c.draws);
expect(jsonOf(loc)).toEqual(c.locator);
expectFile(e.rest, c.rest as Json);
expectFile(sealed, c.sealed as Json);
const got = openSealed(q, round, sealRelease(round), sealed);
expect(openEnvelope(got, restIn(got, hidden.file, got.addresses[1]!.offset))).toEqual(dkc);
});
it('sealing leaves the locator as it was, and wipes nothing of it', () => {
const loc = locatorOf(list(sealVectors.seal)[0]!.locator as Json);
const before = marshalLocator(loc);
seal(q, 1000, loc, seededFill('as it was'));
expect(marshalLocator(loc)).toEqual(before);
});
it('the default source is crypto.getRandomValues: two seals differ and both open', () => {
const loc = locatorOf(list(sealVectors.seal)[0]!.locator as Json);
const a = seal(q, 1000, loc);
const b = seal(q, 1000, loc);
expect(toHex(a)).not.toBe(toHex(b));
expect(jsonOf(openSealed(q, 1000, sealRelease(1000), b))).toEqual(jsonOf(loc));
const e = newEnvelope(pattern(10));
expect(openEnvelope(e.locator, e.rest)).toEqual(pattern(10));
const f = new Uint8Array(8);
cryptoFill(f);
expect(f.some((x) => x !== 0) || f.every((x) => x === 0)).toBe(true);
});
});
describe('the opening against Go (locator-vectors.json)', () => {
const envelope = v.envelope as Json;
const plainBases = (v.plaintext_bases as string[]).map(fromHex);
const sealedBases = list(v.sealed_bases).map((s) => fromHex(s.sealed as string));
function base(over: Partial<Locator> = {}): Locator {
return {
addresses: [{ uri: 'https://ejemplo.org/foto.jpg', offset: 3000 }],
envelopeKey: fromHex(envelope.key as string),
envelopeHeader: fromHex(envelope.header as string),
restDigest: fromHex(envelope.rest_digest as string),
restSize: envelope.rest_size as number,
capsuleDigest: fromHex(envelope.capsule_digest as string),
...over,
};
}
it('openSealed opens a sealed locator with the release of its round, as Open', () => {
const cases = rows(v, 'open');
expect(cases.length).toBe(118);
for (const c of cases) {
const round = c[1] as number;
const b = applyEdits(sealedBases[c[0] as number]!, c[4]);
let l: Locator | undefined;
expect(errorText(() => (l = openSealed(profileOf(c[3] as string), round, openRelease(v, round, c[2]), b))), JSON.stringify(c.slice(0, 4))).toBe(
textOf(v, c[5]),
);
if (c[6] !== null) expect(summaryOf(l!)).toEqual(expandSummary(c[6]));
}
});
it('openSealed reads at most 1 MiB of plaintext, as Go through io.LimitReader: what follows is neither decrypted nor checked', () => {
const lim = v.limit as Json;
const header = fromHex(lim.header as string);
const nonce = fromHex(lim.nonce as string);
const streamKey = hkdf(sha256, fromHex(lim.file_key as string), nonce, new TextEncoder().encode('payload'), 32);
expect(toHex(streamKey)).toBe(lim.stream_key);
const plain = plainBases[0]!;
const release = releaseOf(v, lim.round as number);
const cases = list(lim.cases);
expect(cases.length).toBe(14);
for (const c of cases) {
const chunks = c.chunks as [number, boolean, boolean][];
const total = chunks.reduce((n, ch) => n + ch[0], 0);
const content = new Uint8Array(total);
content.set(plain.subarray(0, Math.min(plain.length, total)));
const parts: Uint8Array[] = [header, nonce];
let at = 0;
for (const [i, [n, last, corrupt]] of chunks.entries()) {
const chunkNonce = new Uint8Array(12);
new DataView(chunkNonce.buffer).setUint32(7, i);
chunkNonce[11] = last ? 1 : 0;
const ct = chacha20poly1305(streamKey, chunkNonce).encrypt(content.subarray(at, at + n));
if (corrupt) ct[0]! ^= 1;
parts.push(ct);
at += n;
}
parts.push(new Uint8Array(c.trailing as number));
const file = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let o = 0;
for (const p of parts) {
file.set(p, o);
o += p.length;
}
expect([file.length, sha256Hex(file)]).toEqual([c.length, c.sha256]);
expect(errorText(() => openSealed(q, 1000, release, file)), c.name as string).toBe(textOf(v, c.text));
}
});
it('openInfoLocator opens the locator of an extension with the default registry, as Info.OpenLocator', async () => {
const reg = await defaultRegistry();
const cases = rows(v, 'open_info');
expect(cases.length).toBe(7);
for (const c of cases) {
const s = c[2];
const info = { note: '', dateKey: { profileId: c[0] as string, round: c[1] as number }, sealed: s === null ? undefined : sealedBases[s as number] };
let l: Locator | undefined;
expect(errorText(() => (l = openInfoLocator(info, reg, releaseOf(v, c[3] as number)))), JSON.stringify(c.slice(0, 4))).toBe(textOf(v, c[4]));
if (c[5] !== null) expect(summaryOf(l!)).toEqual(expandSummary(c[5]));
}
// Go's nil registry.
const info = { note: '', dateKey: { profileId: 'datekeys:quicknet:v1', round: 1000 }, sealed: sealedBases[0] };
expect(errorText(() => openInfoLocator(info, undefined, releaseOf(v, 1000)))).toBe('locator: no registry of pinned profiles');
});
it('openEnvelope checks the rest and the .dkc as OpenEnvelope', () => {
const rest = fromHex(envelope.rest as string);
const header = fromHex(envelope.header as string);
const cases = rows(v, 'envelopes');
expect(cases.length).toBe(34);
for (const c of cases) {
const l = base({
...(c[1] === '' ? {} : { envelopeKey: fromHex(c[1] as string) }),
envelopeHeader: applyEdits(header, c[2]),
...(c[3] === '' ? {} : { restDigest: fromHex(c[3] as string) }),
restSize: c[4] as number,
...(c[5] === '' ? {} : { capsuleDigest: fromHex(c[5] as string) }),
});
let dkc: Uint8Array | undefined;
const r = applyEdits(rest, c[6]);
expect(errorText(() => (dkc = openEnvelope(l, r))), c[0] as string).toBe(textOf(v, c[7]));
if (dkc !== undefined) {
expect(sha256Hex(dkc)).toBe(c[8]);
expect(toHex(dkc)).toBe(envelope.dkc);
}
}
});
it('headerEnd splits an age file where NewEnvelope does', () => {
const dkc = fromHex(envelope.dkc as string);
const cases = rows(v, 'split');
expect(cases.length).toBe(11);
for (const c of cases) {
const file = fromHex(c[0] as string);
let end: number | undefined;
expect(errorText(() => (end = headerEnd(file)))).toBe(textOf(v, c[1]));
if (end !== undefined) expect(end).toBe(c[2]);
}
// The envelope of NewEnvelope: its header and rest are those of the vectors.
const file = fromHex(cases[0]![0] as string);
const end = headerEnd(file);
expect(toHex(file.subarray(0, end))).toBe(envelope.header);
expect(toHex(file.subarray(end))).toBe(envelope.rest);
expect(openEnvelope(base(), file.slice(end))).toEqual(dkc);
});
it('a locator without addresses, from newEnvelope, does not marshal, and wipeLocator clears its key', () => {
const e = newEnvelope(pattern(3), seededFill('wipe'));
expect(errorText(() => marshalLocator(e.locator))).toBe('locator: 0 addresses, not 1 to 8');
wipeLocator(e.locator);
expect(e.locator.envelopeKey.every((b) => b === 0)).toBe(true);
expect(errorText(() => openEnvelope(e.locator, e.rest))).toMatch(/^locator: the envelope: identity did not match any of the recipients/);
});
it('openSealed fails with the text of a profile of another scheme, which this library does not verify', () => {
const p = { ...q, scheme: 'pedersen-bls-chained' };
expect(errorText(() => openSealed(p, 1000, releaseOf(v, 1000), sealedBases[0]!))).toBe(
'locator: agewrap: profile datekeys:quicknet:v1 uses scheme pedersen-bls-chained; only bls-unchained-g1-rfc9380 is supported here: ERR_UNKNOWN_PROFILE',
);
const loc = unmarshalLocator(plainBases[0]!);
expect(() => seal(p, 1000, loc, seededFill('s'))).toThrow(DateKeysError);
});
});
describe('the age files of the locator (ageio.ts)', () => {
it('ageEncrypt and ageDecrypt for an X25519 identity, of every length around a chunk', () => {
const r = seededFill('ageio');
const secret = new Uint8Array(32);
r(secret);
const pub = x25519Recipient(secret);
for (const n of [0, 1, 65535, 65536, 65537, 131072]) {
const plain = pattern(n);
const file = ageEncrypt(plain, (fk) => [wrapX25519(pub, fk, r)], r);
const id = x25519Identity(secret);
expect(ageDecrypt(file, id)).toEqual(plain);
// Cut at the limit, as io.LimitReader.
expect(ageDecrypt(file, id, 5)).toEqual(plain.subarray(0, Math.min(5, n)));
}
});
it('the stanzas of X25519: another type and another recipient are for another identity, a malformed one fails with the text of age', () => {
const r = seededFill('stanzas');
const secret = new Uint8Array(32);
r(secret);
const pub = x25519Recipient(secret);
const plain = pattern(10);
// Stanzas of another type, with bodies whose Base64 ends in a short
// line, in a full one and in an empty one.
const others = [3, 5, 48, 0].map((n) => ({ type: 'other', args: ['a'], body: new Uint8Array(n).fill(n) }));
const file = ageEncrypt(plain, (fk) => [...others, wrapX25519(pub, fk, r)], r);
const head = new TextDecoder().decode(file.subarray(0, headerEnd(file)));
expect(head).toContain('-> other a\nAwMD\n-> other a\nBQUFBQU\n-> other a\n' + 'MDAw'.repeat(16) + '\n\n-> other a\n\n-> X25519 ');
expect(ageDecrypt(file, x25519Identity(secret))).toEqual(plain);
const text = (stanza: { type: string; args: string[]; body: Uint8Array }): string =>
errorText(() => ageDecrypt(ageEncrypt(plain, () => [stanza], r), x25519Identity(secret)));
const share = b64Encode(x25519Recipient(new Uint8Array(32).fill(7)));
expect(text({ type: 'X25519', args: [], body: new Uint8Array(32) })).toBe('invalid X25519 recipient block');
expect(text({ type: 'X25519', args: ['A*'], body: new Uint8Array(32) })).toBe('failed to parse X25519 recipient: illegal base64 data at input byte 1');
expect(text({ type: 'X25519', args: ['AAAA'], body: new Uint8Array(32) })).toBe('invalid X25519 recipient block');
expect(text({ type: 'X25519', args: [b64Encode(new Uint8Array(32))], body: new Uint8Array(32) })).toBe(
'invalid X25519 recipient: crypto/ecdh: bad X25519 remote ECDH input: low order point',
);
expect(text({ type: 'X25519', args: [share], body: new Uint8Array(31) })).toBe('invalid X25519 recipient block: incorrect file key size');
expect(text({ type: 'X25519', args: [share], body: new Uint8Array(32) })).toBe(
'identity did not match any of the recipients: incorrect identity for recipient block',
);
expect(() => x25519Identity(new Uint8Array(31))).toThrow(RangeError);
});
it('a header that is not one, a bad MAC and a short nonce, with the texts of age', () => {
const r = seededFill('header');
const secret = new Uint8Array(32);
r(secret);
const pub = x25519Recipient(secret);
const file = ageEncrypt(pattern(3), (fk) => [wrapX25519(pub, fk, r)], r);
const id = x25519Identity(secret);
expect(errorText(() => ageDecrypt(new Uint8Array(0), id))).toBe('failed to read header: parsing age header: file is empty');
const end = headerEnd(file);
const bad = file.slice();
bad[end - 3]! ^= 1;
expect(errorText(() => ageDecrypt(bad, id))).toBe('bad header MAC');
expect(errorText(() => ageDecrypt(file.subarray(0, end), id))).toBe('failed to read nonce: EOF');
expect(errorText(() => ageDecrypt(file.subarray(0, end + 3), id))).toBe('failed to read nonce: unexpected EOF');
expect(errorText(() => ageDecrypt(file.subarray(0, end + 16), id))).toBe('unexpected EOF');
});
});

@ -0,0 +1,267 @@
// The cryptography of the locator of datekeys.capsule (spec §44.1), as Open,
// Info.OpenLocator, OpenEnvelope, Seal and NewEnvelope of package locator of
// datekeys-go at the tag spec-v0.12, with the same checks in the same order
// and the same texts:
//
// - openSealed opens a sealed locator with the release of its round, and
// openInfoLocator the one of an extension, in the profile its DateKey
// names;
// - openEnvelope joins the header of a locator and the rest that a reader
// fetched, checks both digests and decrypts the .dkc;
// - seal seals a locator with tlock for a round, and newEnvelope encrypts a
// .dkc for a new X25519 identity, I_SOBRE, and splits the age file into
// the header, which the locator carries, and the rest.
//
// The age files are read and written by ageio.ts, with the texts of age.
// Every random value of seal and newEnvelope comes from the RandomFill
// given, crypto.getRandomValues by default, in the order of Go: with the
// same values, they write the bytes of Go. The tlock encryption is not
// constant time (ibe.ts).
//
// Internal: index.ts does not re-export it.
import { checkTimeStanzas, type Stanza } from './age.ts';
import { AgeError, ageDecrypt, ageEncrypt, type AgeIdentity, cryptoFill, draw, type RandomFill, wrapX25519, x25519Identity, x25519Recipient } from './ageio.ts';
import { checkCompressedPoint } from './bls12381.ts';
import { equalBytes } from './bytes.ts';
import { sha256Hasher } from './digest.ts';
import { DateKeysError } from './errors.ts';
import { ciphertextFromBody, ciphertextToBody, decryptOnG2, encryptOnG2WithSigma, IbeError, roundIdentity, TLOCK_BODY_LEN } from './ibe.ts';
import { type Info, type Locator, LocatorError, MAX_SEALED, marshalLocator, unmarshalLocator } from './locator.ts';
import { chainHashHex, maxRound, type Profile, type ProfileRegistry, QUICKNET_SCHEME } from './profile.ts';
import { type Release, verifyRelease } from './release.ts';
export { cryptoFill, type RandomFill } from './ageio.ts';
const fail = (detail: string): LocatorError => new LocatorError(`locator: ${detail}`);
const sha256 = (b: Uint8Array): Uint8Array => {
const h = sha256Hasher();
h.update(b);
return h.digest();
};
// Go's pinned of agewrap for the scheme of Quicknet, the only one this
// library verifies (decision 3 of the plan of phase 2), with the texts of
// tlock.ts: the profile, then its public key.
function pinned(p: Profile): void {
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`);
}
// Go's agewrap.TimeIdentity once pinned(p) passed: the complete stanza set
// and its arguments, the release, the length of the body, U and then the
// IBE, each failure with its normative code and its text.
function timeIdentity(p: Profile, round: number, release: Release): AgeIdentity {
return {
unwrap(stanzas: readonly Stanza[]): Uint8Array {
checkTimeStanzas(stanzas, p, round);
verifyRelease(p, round, release);
const body = stanzas[0]!.body;
if (body.length !== TLOCK_BODY_LEN) {
throw new DateKeysError('ERR_INTEGRITY', `agewrap: tlock stanza body of ${body.length} bytes, want ${TLOCK_BODY_LEN}`);
}
try {
return decryptOnG2(release.signature, ciphertextFromBody(body));
} catch (err) {
/* v8 ignore next -- @preserve: every failure of the IBE is an IbeError */
if (!(err instanceof IbeError)) throw err;
if (err.reason === 'encoding') {
throw new DateKeysError('ERR_INTEGRITY', 'agewrap: U of the tlock stanza is not the canonical encoding of a point of the key group');
}
if (err.reason === 'identity') throw new DateKeysError('ERR_INTEGRITY', 'agewrap: U of the tlock stanza is the point at infinity');
throw new DateKeysError('ERR_INTEGRITY', 'agewrap: the tlock stanza body does not decrypt under the verified release (IBE check r·G == U)');
}
},
};
}
// The text of a failure of age or of an identity, which Go's locator copies
// with %v; anything else is a bug and propagates.
function textOf(err: unknown): string {
/* v8 ignore next -- @preserve: age and the identities throw nothing else */
if (!(err instanceof AgeError || err instanceof DateKeysError)) throw err;
return err.message;
}
/**
* Opens the sealed locator `sealed` with `release`, the release of its
* `round` in the pinned profile `p`, and reads its plaintext, as Open of Go.
* A locator for another round or another chain does not open: it is
* unusable (spec §44.1). Its errors carry no normative code: a LocatorError,
* whose text is that of Go, the texts of the checks of the profile, the
* stanza, the release and age included.
*
* As Go, it reads at most 1 MiB of plaintext, through io.LimitReader: what
* follows is neither decrypted nor checked, and the plaintext read is then
* not the length of a locator.
*/
export function openSealed(p: Profile, round: number, release: Release, sealed: Uint8Array): Locator {
try {
pinned(p);
} catch (err) {
throw fail(textOf(err));
}
let plain: Uint8Array;
try {
plain = ageDecrypt(sealed, timeIdentity(p, round, release), MAX_SEALED);
} catch (err) {
throw fail(textOf(err));
}
try {
return unmarshalLocator(plain);
} finally {
plain.fill(0);
}
}
/**
* The locator of the extension `info`, opened with `release`, the release of
* the round of its own DateKey, in the profile of `reg` that the DateKey
* names, as Info.OpenLocator of Go: a locator for another round or another
* chain does not open, and is unusable (spec §44.1). Throws a LocatorError,
* also when the extension has no locator or there is no registry.
*/
export function openInfoLocator(info: Info, reg: ProfileRegistry | undefined, release: Release): Locator {
if (info.sealed === undefined) throw fail('the extension has no locator');
if (reg === undefined) throw fail('no registry of pinned profiles');
const p = reg.lookup(info.dateKey.profileId);
if (p === undefined) throw fail('the profile of the DateKey is not pinned');
return openSealed(p, info.dateKey.round, release, info.sealed);
}
/**
* Joins the header of the locator `l` and `rest`, which a reader got from an
* address, and decrypts the .dkc, as OpenEnvelope of Go. It checks the size
* and the SHA-256 of the rest, and the SHA-256 of the .dkc, before the
* caller uses it (spec §44.1): they protect against whoever stores the rest,
* not against whoever wrote the .dkk. Throws a LocatorError.
*/
export function openEnvelope(l: Locator, rest: Uint8Array): Uint8Array {
if (rest.length !== l.restSize) throw fail(`the rest is ${rest.length} bytes, not ${l.restSize}`);
if (!equalBytes(sha256(rest), l.restDigest)) throw fail('the SHA-256 of the rest is not the one of the locator');
const id = x25519Identity(l.envelopeKey);
const file = new Uint8Array(l.envelopeHeader.length + rest.length);
file.set(l.envelopeHeader);
file.set(rest, l.envelopeHeader.length);
let dkc: Uint8Array;
try {
dkc = ageDecrypt(file, id);
} catch (err) {
throw fail(`the envelope: ${textOf(err)}`);
} finally {
id.wipe();
}
if (!equalBytes(sha256(dkc), l.capsuleDigest)) {
dkc.fill(0);
throw fail('the SHA-256 of the .dkc is not the capsule_digest of the locator');
}
return dkc;
}
// Go's NewTimeRecipient: the profile, then the range of the round, with
// their codes.
function checkTimeRecipient(p: Profile, round: number): void {
pinned(p);
if (!Number.isSafeInteger(round) || round < 1 || round > maxRound(p)) {
throw new DateKeysError('ERR_DATEKEY_INVALID', `agewrap: round ${round} outside the range of ${p.id}`);
}
}
// Go's TimeRecipient.WrapWithLabels: tlock.TimeLock with a sigma of the
// length of the file key, then the random label of 16 bytes that makes age
// refuse another recipient in the file; age-encryption writes no labels, and
// this writer has one recipient, so the label is drawn and dropped.
function wrapTlock(p: Profile, round: number, fileKey: Uint8Array, random: RandomFill): Stanza {
const sigma = draw(random, fileKey.length);
let body: Uint8Array;
try {
body = ciphertextToBody(encryptOnG2WithSigma(p.publicKey, roundIdentity(round), fileKey, sigma));
} finally {
sigma.fill(0);
}
draw(random, 16).fill(0);
return { type: 'tlock', args: [String(round), chainHashHex(p)], body };
}
/**
* The locator `l` as an age file with a single tlock stanza for `round` of
* the pinned profile `p`, the round of the DateKey (spec §44.1): nobody
* reads it before the date, the holder of the key included. As Seal of Go,
* it marshals the locator first, a LocatorError when it breaks the rules of
* marshalLocator, then makes the tlock recipient of `round`, a
* DateKeysError as NewTimeRecipient (ERR_UNKNOWN_PROFILE or
* ERR_DATEKEY_INVALID), and encrypts, drawing the file key, sigma, the label
* and the nonce from `random`, in that order. A failure comes before any
* draw. The plaintext, which holds I_SOBRE, is wiped.
*/
export function seal(p: Profile, round: number, l: Locator, random: RandomFill = cryptoFill): Uint8Array {
const plain = marshalLocator(l);
try {
checkTimeRecipient(p, round);
return ageEncrypt(plain, (fileKey) => [wrapTlock(p, round, fileKey, random)], random);
} finally {
plain.fill(0);
}
}
/**
* The envelope of the .dkc `dkc`, as NewEnvelope of Go: `dkc` encrypted with
* age for a new X25519 identity, I_SOBRE, the first 32 bytes drawn from
* `random`; then what age draws, the file key, the ephemeral scalar of the
* stanza and the nonce. The age file is split after the line feed of its MAC
* line: the locator it returns has the key, the header, the SHA-256 and the
* length of the rest and the SHA-256 of the .dkc, and no address yet; the
* rest, with no mark, is what the person keeps outside. A caller adds the
* addresses where it stored the rest, alone or inside another file (hide),
* and then seals the locator (seal). The locator holds I_SOBRE: the caller
* wipes it (wipeLocator) once sealed.
*/
export function newEnvelope(dkc: Uint8Array, random: RandomFill = cryptoFill): { locator: Locator; rest: Uint8Array } {
const secret = draw(random, 32);
try {
const recipient = x25519Recipient(secret);
const file = ageEncrypt(dkc, (fileKey) => [wrapX25519(recipient, fileKey, random)], random);
const end = headerEnd(file);
const rest = file.slice(end);
const locator: Locator = {
addresses: [],
envelopeKey: secret.slice(),
envelopeHeader: file.slice(0, end),
restDigest: sha256(rest),
restSize: rest.length,
capsuleDigest: sha256(dkc),
};
file.fill(0, 0, end);
return { locator, rest };
} finally {
secret.fill(0);
}
}
/**
* The length of the age header of `file`, up to and including the line feed
* after the MAC line: the line that starts with "--- ", as headerEnd of Go's
* package locator. No line of the header before it starts so, and the lines
* of the body of a stanza are Base64, which has no '-'. Throws a LocatorError
* with the text of Go.
*/
export function headerEnd(file: Uint8Array): number {
let i = -1;
for (let at = 0; at + 5 <= file.length; at++) {
if (file[at] === 0x0a && file[at + 1] === 0x2d && file[at + 2] === 0x2d && file[at + 3] === 0x2d && file[at + 4] === 0x20) {
i = at;
break;
}
}
if (i < 0) throw fail('the age file has no MAC line');
const j = file.indexOf(0x0a, i + 1);
if (j < 0) throw fail('the MAC line of the age file does not end');
return j + 1;
}

@ -283,8 +283,10 @@ export function encryptOnG2RFC9380(publicKey: Uint8Array, id: Uint8Array, msg: U
/**
* 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.
* reference can be reproduced byte for byte. The sealing of a locator
* (envelope.ts) draws sigma from its random source, as Go draws it from
* crypto/rand: 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}`);

@ -0,0 +1,39 @@
// The locator against Go in the other direction: what this library writes,
// opened by Go. testing/locator-interop.json holds the recipes of
// testing/locator-interop.ts, the SHA-256 of each file that this library
// wrote from them with a seed, and the verdict of Go
// (scripts/locator-go-verdicts.go): the sealed locators open with the
// release of their round to the plaintext that was sealed, their envelopes
// to the .dkc, and their rests are found in a host. Here each file is
// written again and must be the one that Go read, and this library opens it
// too.
import { describe, expect, it } from 'vitest';
import { equalBytes } from './bytes.ts';
import { openEnvelope, openSealed } from './envelope.ts';
import { marshalLocator } from './locator.ts';
import { quicknet } from './profile.ts';
import { pattern, readVectors, releaseOf } from './testing/locator.ts';
import { interopDigests, interopFiles, interopRecipes, type Recipe } from './testing/locator-interop.ts';
const vectors = readVectors('locator-interop.json');
const samples = vectors.samples as (Recipe & { files: Record<string, string>; verdict: string })[];
const releases = readVectors('locator-seal.json');
describe('Go opens what this library writes (locator-interop.json)', () => {
it('the recipes are those that the script writes', () => {
expect(samples.map(({ files: _f, verdict: _v, ...r }) => r)).toEqual(JSON.parse(JSON.stringify(interopRecipes())));
});
for (const s of samples) {
it(`Go reads it: ${s.name}`, () => {
expect(s.verdict).toBe('ok');
const files = interopFiles(s);
expect(interopDigests(files)).toEqual(s.files);
const loc = openSealed(quicknet(), s.round, releaseOf(releases, s.round), files.sealed!);
expect(marshalLocator(loc)).toEqual(files.plaintext);
// Compared by bytes: a diff of 16 MiB does not fit in the heap of a test.
expect(equalBytes(openEnvelope(loc, files.rest!), pattern(s.dkc_length))).toBe(true);
});
}
});

@ -0,0 +1,200 @@
{
"description": "What Go makes of the sealed locators and envelopes that datekeys-ts writes from a seed, from the recipes of src/lib/dkc/testing/locator-interop.ts, by scripts/locator-go-verdicts.go: the SHA-256 of each file and the verdict of Go, ok or the text of what failed.",
"go": "go1.26.8",
"samples": [
{
"addresses": [
{
"offset": 0,
"uri": "https://example.com/1000/0"
}
],
"dkc_length": 0,
"files": {
"dkc": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"plaintext": "1aec3d3e8e9b6b6749c44fec8905ec894097b678c917d66f03a9339921dc9f86",
"rest": "d8cda1ea53f68b1e8922be5938a14a08f21467260673b571f3c742de7a3d6469",
"sealed": "b9268c4f27d923d143972167b69b7b59723a9a24b0e8d13e95726eb422abb7ac"
},
"name": "a locator of round 1000, a .dkc of 0 bytes",
"round": 1000,
"seed": "datekeys-ts interop locator 1000 0",
"verdict": "ok"
},
{
"addresses": [
{
"offset": 0,
"uri": "https://example.com/1001/0"
},
{
"offset": 0,
"uri": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/1"
}
],
"dkc_length": 100,
"files": {
"dkc": "c22e490daa445fb2fba44278c022df135310fd278cabca4ad7919eddcccd1dce",
"plaintext": "65ca53f016dcd7d2db0f35fdc7c6ab2933e33f52a42b3c8e964449d7271a7d3b",
"rest": "81d78a3fadd75b0c84ff106e9bf256b92ee951788665b3a98328fe4b99e8b48c",
"sealed": "a7dc4b5a778b6c01283c32940d8f3e8887abe0ff2cd4bd07476868b34ea2c851"
},
"name": "a locator of round 1001, a .dkc of 100 bytes",
"round": 1001,
"seed": "datekeys-ts interop locator 1001 100",
"verdict": "ok"
},
{
"addresses": [
{
"offset": 0,
"uri": "https://example.com/1004/0"
},
{
"offset": 0,
"uri": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/1"
},
{
"offset": 2000,
"uri": "https://example.com/1004/2"
},
{
"offset": 0,
"uri": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/3"
},
{
"offset": 4000,
"uri": "https://example.com/1004/4"
},
{
"offset": 0,
"uri": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/5"
},
{
"offset": 6000,
"uri": "https://example.com/1004/6"
},
{
"offset": 0,
"uri": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/7"
}
],
"dkc_length": 70000,
"files": {
"dkc": "3500f58cfd1bd88e231edf56dca995542a702bd54525804e5a8604c8aa5cb52e",
"plaintext": "4500f9dc88860e14e421122d9086427bb267cc856b55ac431cae614e5e20ac06",
"rest": "4526806502068a3b07b09317a32e0b1e69803fb28f966cdbd72ff2e4ca22427a",
"sealed": "e0eb4c76dca3af03b842af85e0b3b4e321ca2b650b2c4a7a147050ee409de90d"
},
"name": "a locator of round 1004, a .dkc of 70000 bytes",
"round": 1004,
"seed": "datekeys-ts interop locator 1004 70000",
"verdict": "ok"
},
{
"addresses": [
{
"offset": 0,
"uri": "https://example.com/2000/0"
},
{
"offset": 0,
"uri": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/1"
},
{
"offset": 2000,
"uri": "https://example.com/2000/2"
}
],
"dkc_length": 5000,
"files": {
"dkc": "1e92fd98f113aba0a78e0830ca06e2775912370feab112dfc57bf3258b810595",
"plaintext": "cd76882c64ba5e2553810a67a435c2e19396e25bd741f5c6d881ec5114532aa8",
"rest": "79d055a8733d870d1e9e44bbef4a34f2e8ae9738f9566728f1d4f95ed513ae7b",
"sealed": "03770a0f84b79b209faa553badce2a80c1a44ecb85e368768cf57b1d3daee3b5"
},
"name": "a locator of round 2000, a .dkc of 5000 bytes",
"round": 2000,
"seed": "datekeys-ts interop locator 2000 5000",
"verdict": "ok"
},
{
"addresses": [
{
"offset": 0,
"uri": "https://example.com/1000/0"
},
{
"offset": 0,
"uri": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/1"
},
{
"offset": 2000,
"uri": "https://example.com/1000/2"
},
{
"offset": 0,
"uri": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/3"
},
{
"offset": 4000,
"uri": "https://example.com/1000/4"
}
],
"dkc_length": 65536,
"files": {
"dkc": "ef4636928161808e87035fa51983821677527ccd9661991c5d0126a778b2268a",
"plaintext": "c31b6e23910283a1a10df2d87ee7ef13b17f259671a44ef3883d6fe64da4840b",
"rest": "65bc4f31a49be1ff53c183df4c4100751c2d1592b8810aac00d330cca360b98e",
"sealed": "307248a7b2ab341024125d64d0ab5cdaf140bc1129be75825b576b130269d94d"
},
"name": "a locator of round 1000, a .dkc of 65536 bytes",
"round": 1000,
"seed": "datekeys-ts interop locator 1000 65536",
"verdict": "ok"
},
{
"addresses": [
{
"offset": 0,
"uri": "https://example.com/1001/0"
}
],
"dkc_length": 1048576,
"files": {
"dkc": "06b7bbfb7824aa03382051691630eb26de85102d1b08a81e907ec0744cd8a286",
"plaintext": "367977042146dff7e7726d6a70c33763fcd5549ab09dddfcd99ef20e3d0ff08a",
"rest": "120165f0e2d2478058d36e72ecb098d16eed252e5ea0c7a4c19d881286179ce3",
"sealed": "e5c5c26f4a9bd91a509952147232c66fe28c4044ec77e399b4cc6bd3012fd04a"
},
"name": "a locator of round 1001, a .dkc of 1048576 bytes",
"round": 1001,
"seed": "datekeys-ts interop locator 1001 1048576",
"verdict": "ok"
},
{
"addresses": [
{
"offset": 0,
"uri": "https://example.com/2000/0"
},
{
"offset": 0,
"uri": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/1"
}
],
"dkc_length": 16777217,
"files": {
"dkc": "3e3fa12980a65996ef0ae93e16fd7dc9bc896d8e2dd8c0b0d68d286d0bb97291",
"plaintext": "336e38df6e71a6bc0e31956774d6470bcb02b775e9b4774a5f086bc1766f185f",
"rest": "5224c5d7891d7975c6901fed2e9cf146cc407def331376c0d77d9ec0c4c87b97",
"sealed": "d5880493571b21c36a5e52db74fdb0527c79ae32bce990dfb0c6439649c4358e"
},
"name": "a locator of round 2000, a .dkc of 16777217 bytes",
"round": 2000,
"seed": "datekeys-ts interop locator 2000 16777217",
"verdict": "ok"
}
],
"source": "spec-v0.12"
}

@ -0,0 +1,62 @@
// The recipes of the interoperability of the locator, TypeScript to Go:
// sealed locators with their envelopes, which this library writes from a
// seed (seeded.ts). scripts/locator-ts-samples.mjs writes their files,
// scripts/locator-go-verdicts.go opens them with Go and writes
// testing/locator-interop.json, and locator.interop.test.ts writes them
// again and compares them with what Go read.
import { newEnvelope, seal } from '../envelope.ts';
import { type Locator, marshalLocator } from '../locator.ts';
import { quicknet } from '../profile.ts';
import { pattern, sha256Hex } from './locator.ts';
import { seededFill } from './seeded.ts';
/** A recipe: a .dkc of dkc_length bytes in an envelope whose locator, with its addresses, is sealed for round. */
export interface Recipe {
readonly name: string;
readonly seed: string;
readonly round: number;
readonly dkc_length: number;
readonly addresses: readonly { readonly uri: string; readonly offset: number }[];
}
/** The recipes, in their order. */
export function interopRecipes(): Recipe[] {
const out: Recipe[] = [];
for (const [round, n, addresses] of [
[1000, 0, 1],
[1001, 100, 2],
[1004, 70000, 8],
[2000, 5000, 3],
[1000, 65536, 5],
[1001, 1 << 20, 1],
// More than 256 chunks: the counter of the nonce carries.
[2000, (1 << 24) + 1, 2],
] as const) {
out.push({
name: `a locator of round ${round}, a .dkc of ${n} bytes`,
seed: `datekeys-ts interop locator ${round} ${n}`,
round,
dkc_length: n,
addresses: Array.from({ length: addresses }, (_, i) =>
i % 2 === 0 ? { uri: `https://example.com/${round}/${i}`, offset: i * 1000 } : { uri: `ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/${i}`, offset: 0 },
),
});
}
return out;
}
/** The files of a recipe, by the suffix of their name, and what Go must find in them. */
export function interopFiles(r: Recipe): Record<string, Uint8Array> {
const random = seededFill(r.seed);
const dkc = pattern(r.dkc_length);
const e = newEnvelope(dkc, random);
const loc: Locator = { ...e.locator, addresses: r.addresses.map((a) => ({ ...a })) };
const sealed = seal(quicknet(), r.round, loc, random);
return { sealed, rest: e.rest, plaintext: marshalLocator(loc), dkc };
}
/** The SHA-256 of each file, as the vectors write them. */
export function interopDigests(files: Record<string, Uint8Array>): Record<string, string> {
return Object.fromEntries(Object.entries(files).map(([k, b]) => [k, sha256Hex(b)]));
}

File diff suppressed because one or more lines are too long

@ -0,0 +1,66 @@
// A deterministic source of random bytes for the tests of the locator: the
// keystream of ChaCha20 (RFC 8439) under the SHA-256 of a seed, with a zero
// nonce and the counter from 0, read draw after draw. It is the source that
// scripts/locator-seal-go-vectors.go puts in crypto/rand.Reader of Go and
// SeededRandomSource of datekeys-dart: with the same seed, Go, Dart and this
// library draw the same values, so that the vectors compare the bytes they
// write. ChaCha20 is written here, so that testing/ names no package.
import type { RandomFill } from '../ageio.ts';
import { sha256Hasher } from '../digest.ts';
const rotl = (x: number, n: number): number => ((x << n) | (x >>> (32 - n))) >>> 0;
// The block of ChaCha20 for the key words k, the counter and a zero nonce.
function block(k: Uint32Array, counter: number): Uint8Array {
const s = new Uint32Array([0x61707865, 0x3320646e, 0x79622d32, 0x6b206574, ...k, counter, 0, 0, 0]);
const x = s.slice();
const qr = (a: number, b: number, c: number, d: number): void => {
x[a] = (x[a]! + x[b]!) >>> 0;
x[d] = rotl(x[d]! ^ x[a]!, 16);
x[c] = (x[c]! + x[d]!) >>> 0;
x[b] = rotl(x[b]! ^ x[c]!, 12);
x[a] = (x[a]! + x[b]!) >>> 0;
x[d] = rotl(x[d]! ^ x[a]!, 8);
x[c] = (x[c]! + x[d]!) >>> 0;
x[b] = rotl(x[b]! ^ x[c]!, 7);
};
for (let i = 0; i < 10; i++) {
qr(0, 4, 8, 12);
qr(1, 5, 9, 13);
qr(2, 6, 10, 14);
qr(3, 7, 11, 15);
qr(0, 5, 10, 15);
qr(1, 6, 11, 12);
qr(2, 7, 8, 13);
qr(3, 4, 9, 14);
}
const out = new Uint8Array(64);
const v = new DataView(out.buffer);
for (let i = 0; i < 16; i++) v.setUint32(4 * i, (x[i]! + s[i]!) >>> 0, true);
return out;
}
/** The RandomFill of `seed`, which records every draw it gives, in order. */
export function seededFill(seed: string): RandomFill & { readonly draws: Uint8Array[] } {
const h = sha256Hasher();
h.update(new TextEncoder().encode(seed));
const key = h.digest();
const k = new Uint32Array(8);
for (let i = 0; i < 8; i++) k[i] = new DataView(key.buffer, key.byteOffset).getUint32(4 * i, true);
let counter = 0;
let buf: Uint8Array = new Uint8Array(0);
let at = 0;
const draws: Uint8Array[] = [];
const fill = (b: Uint8Array): void => {
for (let i = 0; i < b.length; i++) {
if (at === buf.length) {
buf = block(k, counter++);
at = 0;
}
b[i] = buf[at++]!;
}
draws.push(b.slice());
};
return Object.assign(fill, { draws });
}

@ -42,10 +42,23 @@ import { type Format, FORMAT_1, FORMAT_2, FORMAT_3, isFormat } from './framing.t
import { BLOQUE256, MAX_PAYLOAD_LENGTH, padmeParameters, paddedLength, payloadAgeLength, REFORZADO } from './padding.ts';
import { canonicalJSON, compactDateKey, formatRFC3339, formatRFC3339Nano, type Instant, parseDateKey, parseRFC3339, resolveDateKey, unlockAt } from './datekey.ts';
import { DateKeysError, errorCode } from './errors.ts';
import type { Extension, ExtensionRegistry } from './extension.ts';
import { openEnvelope, openSealed } from './envelope.ts';
import { CAPSULE_ID, type Extension, type ExtensionRegistry } from './extension.ts';
import { decodeHead, encodeHead, type HeadFile } from './head.ts';
import { decodeHeader, encodeHeader } from './header.ts';
import { inspect, inspectJSON, inspectView } from './inspect.ts';
import {
addressHost,
BLOCK,
checkURI,
LocatorError,
marshalLocator,
parseInfo,
plaintextLength,
restIn,
unmarshalLocator,
usableAddresses,
} from './locator.ts';
import { checkNoteData, newNote, NOTE_ID, publicNote, unusableNote } from './note.ts';
import { open } from './open.ts';
import { checkPath, nfd, pathKey } from './pathrule.ts';
@ -63,6 +76,7 @@ import {
import type { Release, ReleaseSource } from './release.ts';
import { evaluateSecurity, type Verdict, verdictLines } from './security.ts';
import { MemorySink } from './sink.ts';
import { readVectors as readLocatorVectors } from './testing/locator.ts';
import { kinds } from './testing/verdicts.ts';
import { hasTestdata, hx, listTestdata, readBytes, readJSON } from './testing/testdata.ts';
import {
@ -1327,6 +1341,215 @@ describe('vectors/wordkey.json', () => {
});
});
// ---------------------------------------------------------------------------
// vectors/locator.json
describe('vectors/locator.json', () => {
// The texts of Go for the cases of the file, which it does not hold, are
// those that scripts/locator-go-vectors.go wrote with the reference.
const goUris = readLocatorVectors('locator-uris.json');
const goTexts = readLocatorVectors('locator-vectors.json');
const goText = (table: Record<string, unknown>, i: unknown): string => (table.texts as string[])[i as number]!;
const textOfError = (body: () => unknown): string => {
try {
body();
return '';
} catch (err) {
return (err as Error).message;
}
};
const f = load('vectors/locator.json', (json) => {
const o = object(json, 'locator.json');
keys(o, 'locator.json', [
'description',
'spec',
'round',
'datekey',
'note',
'dkc',
'rest',
'envelope_header',
'host',
'host_offset',
'locator_plaintext',
'locator_sealed',
'extension_data',
'addresses',
'envelope_key',
'rest_digest',
'rest_size',
'capsule_digest',
'padding_cases',
'uri_cases',
'mixed',
'rest_cases',
'extension_cases',
'plaintext_cases',
]);
checkSpec(o, 'locator.json');
const list = <T>(key: string, fields: readonly string[], read: (c: Record<string, unknown>, at: string) => T): T[] =>
array(o[key], key).map((v, i) => {
const at = `${key}[${i}]`;
const c = object(v, at);
keys(c, at, fields);
return read(c, at);
});
const mixed = object(o.mixed, 'mixed');
keys(mixed, 'mixed', ['locator_plaintext', 'locator_sealed', 'addresses']);
return {
round: int(o.round, 'round'),
datekey: str(o.datekey, 'datekey'),
note: str(o.note, 'note'),
dkc: hexBytes(o.dkc, 'dkc'),
rest: hexBytes(o.rest, 'rest'),
header: hexBytes(o.envelope_header, 'envelope_header'),
host: hexBytes(o.host, 'host'),
hostOffset: int(o.host_offset, 'host_offset'),
plaintext: hexBytes(o.locator_plaintext, 'locator_plaintext'),
sealed: hexBytes(o.locator_sealed, 'locator_sealed'),
extension: hexBytes(o.extension_data, 'extension_data'),
addresses: list('addresses', ['uri', 'offset', 'host'], (c, at) => ({
uri: str(c.uri, `${at}.uri`),
offset: int(c.offset, `${at}.offset`),
host: str(c.host, `${at}.host`),
})),
envelopeKey: hexBytes(o.envelope_key, 'envelope_key'),
restDigest: hexBytes(o.rest_digest, 'rest_digest'),
restSize: int(o.rest_size, 'rest_size'),
capsuleDigest: hexBytes(o.capsule_digest, 'capsule_digest'),
padding: list('padding_cases', ['base', 'total'], (c, at) => ({ base: int(c.base, `${at}.base`), total: int(c.total, `${at}.total`) })),
uris: list('uri_cases', ['uri', 'ok'], (c, at) => ({ uri: str(c.uri, `${at}.uri`), ok: bool(c.ok, `${at}.ok`) })),
mixed: {
plaintext: hexBytes(mixed.locator_plaintext, 'mixed.locator_plaintext'),
sealed: hexBytes(mixed.locator_sealed, 'mixed.locator_sealed'),
addresses: array(mixed.addresses, 'mixed.addresses').map((v, i) => {
const at = `mixed.addresses[${i}]`;
const c = object(v, at);
keys(c, at, ['uri', 'offset', 'usable']);
return { uri: str(c.uri, `${at}.uri`), offset: int(c.offset, `${at}.offset`), usable: bool(c.usable, `${at}.usable`) };
}),
},
rests: list('rest_cases', ['name', 'resource', 'offset', 'opens'], (c, at) => ({
name: str(c.name, `${at}.name`),
resource: hexBytes(c.resource, `${at}.resource`),
offset: int(c.offset, `${at}.offset`),
opens: bool(c.opens, `${at}.opens`),
})),
extensions: list('extension_cases', ['name', 'extension_data', 'ok'], (c, at) => ({
name: str(c.name, `${at}.name`),
data: hexBytes(c.extension_data, `${at}.extension_data`),
ok: bool(c.ok, `${at}.ok`),
})),
plaintexts: list('plaintext_cases', ['name', 'locator_plaintext', 'ok'], (c, at) => ({
name: str(c.name, `${at}.name`),
plaintext: hexBytes(c.locator_plaintext, `${at}.locator_plaintext`),
ok: bool(c.ok, `${at}.ok`),
})),
};
});
if (f === undefined) return;
const p = quicknet();
const release: Release = { round: f.round, signature: hexOf((goTexts.releases as Record<string, string>)[String(f.round)]) };
const goCases = goTexts.testdata as Record<string, unknown>;
it('has the cases of README: 247 addresses, a mixed locator, and the bases of the padding', () => {
expect([f.uris.length, f.padding.length, f.rests.length, f.extensions.length, f.plaintexts.length]).toEqual([247, 36, 5, 8, 16]);
expect(f.mixed.addresses.filter((a) => a.usable)).toHaveLength(1);
});
it('the extension reads, its locator opens with the release of its round, and the rest in the host opens the envelope', () => {
const info = parseInfo({ id: CAPSULE_ID, version: 1, data: f.extension });
expect([info.note, compactDateKey(info.dateKey), info.dateKey.round, toHex(info.sealed!)]).toEqual([f.note, f.datekey, f.round, toHex(f.sealed)]);
const loc = openSealed(p, f.round, release, f.sealed);
const plain = marshalLocator(loc);
expect(plain.length).toBe(BLOCK);
expect(toHex(plain)).toBe(toHex(f.plaintext));
expect([toHex(loc.envelopeKey), toHex(loc.restDigest), loc.restSize, toHex(loc.capsuleDigest), toHex(loc.envelopeHeader)]).toEqual([
toHex(f.envelopeKey),
toHex(f.restDigest),
f.restSize,
toHex(f.capsuleDigest),
toHex(f.header),
]);
expect(loc.addresses.map((a) => ({ uri: a.uri, offset: a.offset, host: addressHost(a) }))).toEqual(f.addresses);
// The rest, from the host at the offset of the first address, opens the envelope.
const rest = restIn(loc, f.host, f.hostOffset);
expect(toHex(rest)).toBe(toHex(f.rest));
expect(toHex(openEnvelope(loc, rest))).toBe(toHex(f.dkc));
});
it('the padding of each base', () => {
for (const c of f.padding) {
expect(plaintextLength(c.base), String(c.base)).toBe(c.total);
expect(c.total % BLOCK).toBe(0);
}
});
it('every address, with the verdict of the file and the text of Go', () => {
const texts = goUris.testdata as unknown[][];
expect(texts).toHaveLength(f.uris.length);
for (const [i, c] of f.uris.entries()) {
const got = textOfError(() => checkURI(c.uri));
expect(texts[i]![0]).toBe(c.uri);
expect(got === '', c.uri).toBe(c.ok);
expect(got, c.uri).toBe(goText(goUris, texts[i]![1]));
expect(addressHost({ uri: c.uri, offset: 0 })).toBe(texts[i]![2]);
}
});
it('the mixed locator reads, and a reader uses the address it accepts and no other', () => {
const mixed = openSealed(p, f.round, release, f.mixed.sealed);
const back = unmarshalLocator(f.mixed.plaintext);
expect(back.addresses).toEqual(mixed.addresses);
expect(mixed.addresses).toEqual(f.mixed.addresses.map((a) => ({ uri: a.uri, offset: a.offset })));
const usable = usableAddresses(mixed);
expect(usable).toEqual(f.mixed.addresses.filter((a) => a.usable).map((a) => ({ uri: a.uri, offset: a.offset })));
// A writer never writes an address that a reader rejects.
expect(() => marshalLocator(mixed)).toThrow(LocatorError);
expect(toHex(openEnvelope(mixed, restIn(mixed, f.host, usable[0]!.offset)))).toBe(toHex(f.dkc));
});
it('the rests: rest_size bytes from the offset, used only when their SHA-256 is resto_digest, with the texts of Go', () => {
const loc = openSealed(p, f.round, release, f.sealed);
const texts = goCases.rest_cases as number[][];
for (const [i, c] of f.rests.entries()) {
let r: Uint8Array | undefined;
expect(textOfError(() => (r = restIn(loc, c.resource, c.offset))), c.name).toBe(goText(goTexts, texts[i]![0]));
let opens = false;
if (r !== undefined) {
let dkc: Uint8Array | undefined;
expect(textOfError(() => (dkc = openEnvelope(loc, r!))), c.name).toBe(goText(goTexts, texts[i]![1]));
opens = dkc !== undefined && equalBytes(dkc, f.dkc);
}
expect(opens, c.name).toBe(c.opens);
}
});
it('the data of the extension: what a reader cannot use has ERR_EXTENSION_DATA_INVALID only, with the text of Go', () => {
const texts = goCases.extension_cases as number[];
for (const [i, c] of f.extensions.entries()) {
let err: unknown;
try {
parseInfo({ id: CAPSULE_ID, version: 1, data: c.data });
} catch (e) {
err = e;
}
expect(err === undefined, c.name).toBe(c.ok);
expect(err === undefined ? '' : (err as Error).message, c.name).toBe(goText(goTexts, texts[i]));
if (err !== undefined) expect(errorCode(err)).toBe('ERR_EXTENSION_DATA_INVALID');
}
});
it('the plaintexts of the locator, with the texts of Go', () => {
const texts = goCases.plaintext_cases as number[];
for (const [i, c] of f.plaintexts.entries()) {
const got = textOfError(() => unmarshalLocator(c.plaintext));
expect(got === '', c.name).toBe(c.ok);
expect(got, c.name).toBe(goText(goTexts, texts[i]));
}
});
});
// The vector files the blocks above run; a new export needs its own block.
const VECTOR_FILES = [
'vectors/cbor.json',
@ -1446,10 +1669,8 @@ describe('testdata/', () => {
});
});
it('locator.json and ed25519_strict.json name this specification (the locator is not ported yet; ed25519strict.test.ts runs the other)', () => {
for (const name of ['vectors/locator.json', 'vectors/ed25519_strict.json']) {
expect(object(readJSON(name), name).spec, name).toBe(SPEC_VERSION);
}
it('ed25519_strict.json names this specification (ed25519strict.test.ts runs it)', () => {
expect(object(readJSON('vectors/ed25519_strict.json'), 'ed25519_strict.json').spec).toBe(SPEC_VERSION);
});
});

@ -67,6 +67,13 @@ export default defineConfig({
'src/lib/inspector/create-check.ts': { 100: true },
'src/lib/inspector/drand.ts': { 100: true },
'src/lib/dkc/wordkey.ts': { 100: true },
// The locator of datekeys.capsule: its data, its addresses and their
// IP addresses, its plaintext, and the age files of its sealing and of
// the envelope.
'src/lib/dkc/locator.ts': { 100: true },
'src/lib/dkc/ipaddr.ts': { 100: true },
'src/lib/dkc/ageio.ts': { 100: true },
'src/lib/dkc/envelope.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 },

Loading…
Cancel
Save

Powered by TurnKey Linux.