From 8473fdf70990f441c45545adec3ed95ea45fe777 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 29 Sep 2026 20:33:13 +0200 Subject: [PATCH] Phase 3, steps 3 and 4: the writer of capsule format 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit encrypt(src, opts) writes a .dkc of format 2 and, when asked, a portable .dkk (spec §61, §62, §62.1), in the order and with the texts and codes of capsule.Encrypt: - L known in advance: the size of a Uint8Array or a Blob, or the length declared with a ReadableStream; a source of another length fails with Go's texts; - reforzado padding by default, or bloque256; - 1 to 16 credentials, canonical and not of low order, a dummy in each slot left, whose scalar is wiped once its public key is derived, and a uniform order of the 16; - SEALED_CONTROL_LEN from the formula of §62.1, checked against the real seal; - the self-checks of rule 11, plus OUTER_TIME_AGE under the reader's rules and the header of PAYLOAD_AGE opened by I_PAYLOAD before anything is written. The content is streamed in pieces of 64 KiB, then the zeros of the padding, into memory (up to MAX_MEMORY_DKC) or an output that is closed only once the capsule is complete and checked and aborted on any failure. The core in writer.ts takes its random values from the caller: encrypt.ts passes crypto.getRandomValues, and only testing/encrypt.ts fixes them. Tests: the deterministic sections of the seven format 2 fixtures of Go byte for byte; round trips with open for both policies, 1 to 16 credentials and every padding boundary; the invalid options; streaming and failures of the source and the output; the internal errors with age-encryption replaced by a spy; a property loop (50 seeds per run, 500 by hand). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + README.md | 14 +- src/lib/dkc/encrypt.internal.test.ts | 168 ++++++++ src/lib/dkc/encrypt.property.test.ts | 222 ++++++++++ src/lib/dkc/encrypt.stream.test.ts | 229 ++++++++++ src/lib/dkc/encrypt.test.ts | 505 ++++++++++++++++++++++ src/lib/dkc/encrypt.ts | 43 ++ src/lib/dkc/testing/encrypt.ts | 82 ++++ src/lib/dkc/writer.ts | 621 +++++++++++++++++++++++++++ vitest.config.ts | 3 + 10 files changed, 1891 insertions(+), 2 deletions(-) create mode 100644 src/lib/dkc/encrypt.internal.test.ts create mode 100644 src/lib/dkc/encrypt.property.test.ts create mode 100644 src/lib/dkc/encrypt.stream.test.ts create mode 100644 src/lib/dkc/encrypt.test.ts create mode 100644 src/lib/dkc/encrypt.ts create mode 100644 src/lib/dkc/testing/encrypt.ts create mode 100644 src/lib/dkc/writer.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e1c7402..39ad170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Cambios notables de la librería TypeScript y de la página. El proyecto usa ver Fase 3: la escritura de cápsulas de formato 2, según `PLAN_fase3_escritura.md` (v3, en `../docs`). En curso. +### Pasos 3 y 4: el writer + +- `encrypt.ts` y `writer.ts`: `encrypt(src, opts)` escribe un `.dkc` de formato 2 y, si se pide, una `.dkk` portable, como `capsule.Encrypt`: L conocida de antemano, relleno `reforzado` por defecto, de 1 a 16 credenciales con señuelos en un orden uniforme, `SEALED_CONTROL_LEN` con la fórmula del §62.1 y las autocomprobaciones de la regla 11 y dos más. Streaming desde `Uint8Array`, `Blob` o `ReadableStream`, hacia memoria o hacia un `WritableStream` que solo se cierra con la cápsula completa y comprobada. +- Reproduce byte a byte las secciones deterministas de los siete fixtures de formato 2 de Go, y todo lo que escribe se abre con `open`. +- Tests de streaming, de errores internos con `age-encryption` sustituido y un bucle de propiedades (50 semillas en cada ejecución; 500 pasaron a mano). + ### Paso 2: piezas de apoyo - `recipient.ts`: recipients `age1…` como `age` 1.3.2, las reglas de §37 con los textos de `agewrap.CheckX25519Recipient` y la lista de recipients de una persona, con errores por número de línea. Sin noble. diff --git a/README.md b/README.md index 1e84276..4035e7f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ La implementación de referencia es la librería Go `g.activething.com/go/DateKe | Codec CBOR del subconjunto, parsers de schema, DateKey, `inspect` | `src/lib/dkc/` | 4 | hecho | | Página inspector, sin red | SvelteKit estático: `src/routes/`, `src/lib/inspector/`, `src/lib/components/` | 5 | hecho | | Apertura de cápsulas en el navegador, y la acción "abrir" de la página | fase 2 (`PLAN_fase2_ibe_noble2.md`, en `../docs`) | 6 | hecho (pasos 2 a 8 de la fase 2) | -| Escritura de cápsulas en TypeScript | fase 3 | | pendiente; el cifrado del stanza tlock ya está (`tlock.ts`) | +| Escritura de cápsulas en TypeScript | fase 3 (`PLAN_fase3_escritura.md`, en `../docs`) | 7 | la librería está hecha (`encrypt.ts`, pasos 2 a 4 del plan); falta la interoperabilidad con Go a nivel de cápsula y la página | ## Versiones @@ -52,6 +52,7 @@ La inspección (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegad | `ibe.ts` | IBE-CCA de tlock sobre G2 para Quicknet (§63 paso 11): `decryptOnG2` y `encryptOnG2RFC9380` (Qid = H(id) en G1 con el DST de RFC 9380, sigma aleatorio, U = r·G2), con la puerta de codificación canónica de `bls12381.ts` sobre la firma y U; H2 sobre GT serializado en el orden de kilic (nunca `Fp12.toBytes` de noble), H3 y H4; `roundIdentity`; el cuerpo `U ‖ V ‖ W` de 128 bytes del stanza. Errores `IbeError` con motivo (`length`, `encoding`, `identity`, `proof`) y texto fijos, sin ningún valor del cálculo; borra sigma y los hashes derivados. Sobre `@noble/curves` 2.4.0; lleva el aviso MIT de `tlock-js`, cuya estructura sigue. Lo usa la apertura (`open.ts`) | `encrypt/ibe` de drand/kyber (`DecryptCCAonG2`), `tlock.BytesToCiphertext` y `TimeUnlock` | | `release.ts` | Verificación local del release (§17, §51, §63 paso 10), en el orden y con los textos de `provider.Verify`:
1. el rango de la ronda (`ERR_DATEKEY_INVALID`);
2. la ronda del release antes que la firma (`ERR_ROUND_MISMATCH`);
3. la longitud de la firma;
4. la clave pinneada (`ERR_UNKNOWN_PROFILE`);
5. la firma: codificación canónica de un punto de G1 que no sea el infinito, y firma BLS válida de la ronda sobre `@noble/curves` 2.4.0, con el DST de RFC 9380 para G1 (`ERR_RELEASE_INVALID`).
Nada de noble se copia a los errores. Solo verifica el scheme de Quicknet: un perfil de otro scheme falla con `ERR_UNKNOWN_PROFILE` tras las comprobaciones de ronda, donde la referencia sí lo verificaría (decisión 3 del plan de la fase 2). También define `ReleaseSource`, con su contrato de fuentes de red y de la corrección 6, y `suppliedRelease`, el release que entrega quien llama | `provider` (`Verify`, `ReleaseSource`) | | `open.ts` | Los pasos 9 a 18 de §63 sobre los pasos 1 a 8 de `inspectWith`, con los checks, códigos y textos de `capsule.Open`:
- las credenciales y el release (paso 9), que cualquier fallo de la fuente convierte en `ERR_RELEASE_UNAVAILABLE` (corrección 6);
- la verificación del release (10);
- `OUTER_TIME_AGE` (11), la estructura frente a `access_policy` (12) e `INNER_ACCESS_AGE` (13);
- `CONTROL_CBOR` (14), `header_binding` (15), `I_PAYLOAD` (16), `PAYLOAD_AGE` (17) y el commit (18).
Lee los dos formatos (§22, §70). En el formato 2, `INNER_ACCESS_AGE` tiene exactamente 16 stanzas (paso 12); `CONTROL_CBOR` es de la versión de schema 2, con L y la regla de relleno (14); el paso 16 calcula P, y el 17 exige un texto en claro de exactamente P bytes con ceros tras el contenido, `ERR_INTEGRITY` en otro caso. Solo se entregan los L primeros bytes, nunca el relleno (§29.1, §56). `Opened` da el formato y, en el formato 2, L, la regla y P.
Abre los tres ficheros `age` con el `Decrypter` de `age-encryption` y con identidades propias que aplican las reglas de `agewrap`: la de tiempo, sobre `ibe.ts`; las de acceso y payload, sobre `x25519.ts`, stanza a stanza. Los fallos de `age` que no informa una identidad son `ERR_INTEGRITY` con el motivo fijo de su fase, cabecera o STREAM, sin copiar el texto de `age-encryption`.
La entrada puede ser un `Uint8Array` o un `Blob`, como un `File`. De un `Blob` solo se lee el prefijo de los pasos 1 a 8 (`prefix.ts`), el `capsule_digest` de la `.dkk` se calcula sobre su stream (`digest.ts`) y `PAYLOAD_AGE` se descifra en streaming.
El texto en claro va a memoria o a `output`, un `WritableStream`. Se escribe a medida que `age` autentica cada chunk, se cierra solo tras el paso 18 y se aborta ante cualquier fallo, en cualquier paso (§56). Un fallo del stream de salida es `ERR_INTEGRITY` con su texto, como en Go. El `WritableStream` de un fichero OPFS guarda lo escrito en un fichero de intercambio hasta el cierre: comprobado en el navegador, un fallo de STREAM deja intacto el contenido anterior | `capsule.Open`, `agewrap` (`TimeIdentity`, `AccessIdentity`, `PayloadIdentity`) | +| `encrypt.ts`, `writer.ts` | El writer de la fase 3: `encrypt(src, opts)` escribe un `.dkc` de formato 2 y, si se pide, una `.dkk` portable (§61, §62, §62.1), en el orden y con los textos y códigos de `capsule.Encrypt`:
- el formato 2 siempre; L conocida de antemano (el tamaño de un `Uint8Array` o un `Blob`, o `length` con un `ReadableStream`), y una fuente que da más o menos bytes falla con los textos de Go;
- el relleno `reforzado` por defecto, o `bloque256`;
- de 1 a 16 credenciales, canónicas y no de orden bajo, un señuelo en cada hueco libre, cuyo escalar se borra al derivar su clave pública, y un orden uniforme de los 16 (`random.ts`);
- `SEALED_CONTROL_LEN` con la fórmula del §62.1, comprobada con el sellado real;
- las autocomprobaciones de la regla 11 y dos más: `OUTER_TIME_AGE` con las reglas del lector, y la cabecera de `PAYLOAD_AGE`, que `I_PAYLOAD` abre antes de escribir nada.
Nada se escribe hasta que todo lo anterior al contenido está comprobado. El contenido va en trozos de 64 KiB, seguido de los ceros del relleno, con presión inversa, hacia memoria (hasta `MAX_MEMORY_DKC`, 1 GiB) o hacia `output`, que se cierra solo con la cápsula completa y comprobada y se aborta ante cualquier fallo. Los errores de la fuente y de la salida se relanzan tal cual.
El núcleo, `writer.ts`, recibe la aleatoriedad de quien lo llama: `encrypt.ts` le da la de `crypto.getRandomValues`, y solo `testing/encrypt.ts` la fija, para reproducir los fixtures de Go | `capsule.Encrypt`, `accesskey.Encode` | | `tlock.ts` | `timeRecipient`, el `Recipient` de `age-encryption` para `OUTER_TIME_AGE` (§32, §35), como `agewrap.TimeRecipient`: cifra la file key con `ibe.ts` para una ronda de un perfil pinneado y escribe el stanza `tlock ` de tlock. Comprueba el perfil y luego el rango de la ronda, con los textos de `NewTimeRecipient`. `age-encryption` no tiene etiquetas, así que quien escriba `OUTER_TIME_AGE` (fase 3) lo añade como único recipient | `agewrap.TimeRecipient` | | `padding.ts` | El relleno del formato 2 (§29.1): los códigos 1 (`bloque256`) y 2 (`reforzado`), `paddedLength`, exacta hasta L_MAX = 2⁵³ − 2⁴⁶ (`bitlen` con `BigInt` y los redondeos con `ceil`, exactos en doubles; nunca operaciones de 32 bits, `Math.clz32` ni `Math.log2`), y la longitud de `PAYLOAD_AGE` | `capsule/padding.go` | | `digest.ts` | SHA-256 incremental con `@noble/hashes` (`sha256Hasher`, `sha256Stream`), para el `capsule_digest` de un `.dkc` que no está en memoria o que se está escribiendo (Web Crypto solo calcula el hash de buffers enteros) | | @@ -187,7 +188,7 @@ npm run build:check # solo la comprobación del sitio ya construido npm run verify # check, typecheck, coverage y build (con su comprobación) ``` -Umbrales de cobertura (`vitest.config.ts`): `cbor.ts`, `ibe.ts`, `release.ts`, `tlock.ts`, `x25519.ts`, `bech32.ts`, `digest.ts`, `padding.ts`, `agefile.ts`, `recipient.ts` y `random.ts` al 100 % en líneas, ramas, funciones y sentencias; el conjunto de `src/lib/dkc` al 95/90/95/95, y el de `src/lib/inspector` también. +Umbrales de cobertura (`vitest.config.ts`): `cbor.ts`, `ibe.ts`, `release.ts`, `tlock.ts`, `x25519.ts`, `bech32.ts`, `digest.ts`, `padding.ts`, `agefile.ts`, `recipient.ts`, `random.ts`, `writer.ts` y `encrypt.ts` al 100 % en líneas, ramas, funciones y sentencias; el conjunto de `src/lib/dkc` al 95/90/95/95, y el de `src/lib/inspector` también. `vitest.config.ts` es la configuración de los tests; `vite.config.ts`, la del sitio con el plugin de SvelteKit. Vitest prefiere la primera, así que los tests de `src/lib` corren sin SvelteKit, y `src/lib/inspector` importa la librería por rutas relativas, sin el alias `$lib`. `tsconfig.json` extiende el que genera `svelte-kit sync` (por eso `typecheck` y `check` lo ejecutan antes, y `npm install` también, con `prepare`). @@ -218,6 +219,15 @@ Umbrales de cobertura (`vitest.config.ts`): `cbor.ts`, `ibe.ts`, `release.ts`, ` Para regenerarlo: `node scripts/tlock-ts-samples.mjs > ts-samples.json`, y desde el mismo módulo Go temporal, `go run tlock-go-vectors.go ts-samples.json > tlock-vectors.json`. En `ibe-vectors.json`, H2, H3 y H4 no son públicas en kyber: el script las reescribe con sus etiquetas y las comprueba en cada fixture contra la file key de tlock y contra U = r·G2. Los cifrados de kyber usan un sigma aleatorio, así que el fichero se genera una vez y se congela. Para regenerarlo, desde un módulo Go temporal que requiera la referencia (`replace g.activething.com/go/DateKeys => ../datekeys-go`, `GOFLAGS=-mod=mod`, y la directiva `go` de la referencia, para que se use su toolchain): `go run ibe-go-vectors.go ../datekeys-ts/testdata/fixtures > ibe-vectors.json`. Los siete fixtures del formato 2 se añadieron el 29-09-2026 así, sobre `spec-v0.9`, tomando solo el bloque `fixtures`: los valores de los cinco anteriores salieron idénticos, y el resto del fichero no cambió. +- El writer (`encrypt.test.ts`, `encrypt.stream.test.ts`, `encrypt.internal.test.ts`, `encrypt.property.test.ts`): + - con los valores de su registro, reproduce byte a byte el PRELUDE, PUBLIC_HEADER, `header_binding` y CONTROL_CBOR de los siete fixtures de formato 2 de Go, con las mismas longitudes; cada credencial cae en el hueco del registro y la `.dkk` sale igual, salvo su `capsule_digest`; + - lo que escribe se abre con `open`: las dos políticas, de 1 a 16 credenciales, cada una sola y todas juntas; contenidos en todos los bordes de trozo y de relleno, hasta 5 000 000 de bytes, con las dos reglas; rondas 1000, 1001 y 2000; + - las opciones inválidas dan los textos y códigos de `capsule.Encrypt` sin escribir nada y con la salida abortada; + - streaming desde `Uint8Array`, `Blob` y `ReadableStream` con cualquier troceado; una fuente de otra longitud, una fuente o una salida que fallan y un `progress` que lanza dejan la salida abortada; + - las autocomprobaciones, con entradas malas y con un `age-encryption` sustituido que falla, alarga, cambia o corta lo que sella; + - un bucle de propiedades con opciones aleatorias, a veces inválidas: 50 semillas en cada ejecución y 500 con `DATEKEYS_PROPERTY_SEEDS=500` (pasó el 29-09-2026, en 203 s). + + Rendimiento en Node 24.9, informativo: `time_only`, unos 120 ms por cápsula en caliente (470 ms la primera, con la carga del código); `time_and_key` con clave portable, unos 200 ms; 64 MiB desde un `Blob` hacia una salida, 50 MiB/s con el SHA-256 en la misma pasada. - Todo se lee con los formatos de `testdata/README.md` (`testing/vectors.ts`): una clave desconocida o que falta, un valor de otro tipo, un código que no es de §69 o una edición fuera de su base hacen fallar el fichero con su motivo; nada se salta en silencio. - Todo fichero de `testdata/` tiene que ejecutarlo algún test: un nombre nuevo exportado por Go (otro `vectors/*.json`, un fichero de fixture que ningún JSON nombra) hace fallar `testdata/ holds no file that no test runs` hasta que se le añade su bloque. diff --git a/src/lib/dkc/encrypt.internal.test.ts b/src/lib/dkc/encrypt.internal.test.ts new file mode 100644 index 0000000..51e153d --- /dev/null +++ b/src/lib/dkc/encrypt.internal.test.ts @@ -0,0 +1,168 @@ +// Tests of the internal errors of writer.ts, which no correct encoder or +// version of age-encryption reaches: age-encryption is replaced by a spy that +// fails, lengthens, retypes or cuts what it seals. Each failure gives its +// text, writes nothing that is not aborted, and never closes the output. + +import { Encrypter, type ReadableStreamWithSize } from 'age-encryption'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { concatBytes } from './bytes.ts'; +import { parseRFC3339 } from './datekey.ts'; +import { encrypt, type EncryptOptions } from './encrypt.ts'; +import { errorCode } from './errors.ts'; +import { TIME_ONLY } from './header.ts'; +import { quicknet } from './profile.ts'; +import { formatX25519Recipient } from './recipient.ts'; +import { newX25519Identity, x25519PublicKey } from './x25519.ts'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const original = Encrypter.prototype.encrypt; +// The two overloads of the original, called on an Encrypter. +const sealBytes = (self: Encrypter, file: Uint8Array): Promise => + (original as (this: Encrypter, f: Uint8Array) => Promise).call(self, file); +const sealStream = (self: Encrypter, file: ReadableStream): Promise => + (original as (this: Encrypter, f: ReadableStream) => Promise).call(self, file); +const options = (output: WritableStream, extra: Partial = {}): EncryptOptions => ({ + profile: quicknet(), + unlockAt: parseRFC3339('2023-08-23T15:59:24Z'), + policy: TIME_ONLY, + now: () => parseRFC3339('2023-08-23T15:09:27Z'), + output, + ...extra, +}); + +function recorder(): { stream: WritableStream; chunks: Uint8Array[]; state: { closed: boolean; aborted: unknown } } { + const chunks: Uint8Array[] = []; + const state: { closed: boolean; aborted: unknown } = { closed: false, aborted: undefined }; + const stream = new WritableStream({ + write: (c) => void chunks.push(c.slice()), + close: () => void (state.closed = true), + abort: (reason) => void (state.aborted = reason ?? 'aborted'), + }); + return { stream, chunks, state }; +} + +async function failure(p: Promise): Promise { + try { + await p; + } catch (err) { + return err as Error; + } + throw new Error('expected a failure'); +} + +// Replaces call `n` of Encrypter.encrypt (1: OUTER_TIME_AGE of a time_only +// capsule, 2: its PAYLOAD_AGE) with `replace`, which may call the original. +function onCall(n: number, replace: (self: Encrypter, file: Uint8Array | ReadableStream) => Promise): void { + let call = 0; + vi.spyOn(Encrypter.prototype, 'encrypt').mockImplementation(async function (this: Encrypter, file: Uint8Array | ReadableStream) { + if (++call === n) return replace(this, file); + return file instanceof Uint8Array ? sealBytes(this, file) : sealStream(this, file); + } as typeof Encrypter.prototype.encrypt); +} + +// The stream of PAYLOAD_AGE with its first piece, the age header, and then +// `rest`: nothing, or a failure. +async function headerOnly(self: Encrypter, file: ReadableStream, fail?: Error): Promise { + const real = await sealStream(self, file); + const reader = real.getReader(); + const first = await reader.read(); + await reader.cancel(); + let sent = false; + const cut = new ReadableStream({ + pull(c) { + if (!sent) { + sent = true; + c.enqueue(first.value!); + } else if (fail !== undefined) c.error(fail); + else c.close(); + }, + }) as ReadableStreamWithSize; + cut.size = (n: number) => real.size(n); + return cut; +} + +describe('the internal errors of the writer', () => { + it('reports a failure of age-encryption while sealing with a fixed text, the original as its cause', async () => { + const gone = new Error('Web Crypto is gone'); + onCall(1, () => Promise.reject(gone)); + const out = recorder(); + const err = await failure(encrypt(new Uint8Array(1), options(out.stream))); + expect([err.message, err.cause, errorCode(err)]).toEqual(['capsule: age failed to seal', gone, '']); + expect([out.chunks.length, out.state.closed, out.state.aborted]).toEqual([0, false, err]); + }); + + it('refuses a SEALED_CONTROL that does not measure what the formula gives', async () => { + onCall(1, async (self, file) => concatBytes(await sealBytes(self, file as Uint8Array), new Uint8Array(1))); + const out = recorder(); + const err = await failure(encrypt(new Uint8Array(1), options(out.stream))); + expect(err.message).toBe('capsule: internal error: SEALED_CONTROL is 459 bytes, measured 458'); + expect([out.chunks.length, out.state.aborted]).toEqual([0, err]); + }); + + it('refuses an OUTER_TIME_AGE that the reader would reject', async () => { + onCall(1, (_self, file) => { + const other = new Encrypter(); + other.addRecipient(formatX25519Recipient(x25519PublicKey(newX25519Identity()))); + return sealBytes(other, file as Uint8Array); + }); + const out = recorder(); + const err = await failure(encrypt(new Uint8Array(1), options(out.stream))); + expect(err.message).toMatch(/^capsule: self-check: OUTER_TIME_AGE: agewrap: /); + expect(errorCode(err)).not.toBe(''); + expect([out.chunks.length, out.state.aborted]).toEqual([0, err]); + }); + + it('refuses a PAYLOAD_AGE whose length is not the one P gives, before and after writing it', async () => { + onCall(2, async (self, file) => { + const s = await sealStream(self, file as ReadableStream); + s.size = () => 0; + return s; + }); + const out = recorder(); + const err = await failure(encrypt(new Uint8Array(1), options(out.stream))); + expect(err.message).toBe('capsule: internal error: PAYLOAD_AGE would be 0 bytes, P = 256 gives 456'); + expect([out.chunks.length, out.state.aborted]).toEqual([0, err]); + vi.restoreAllMocks(); + + onCall(2, (self, file) => headerOnly(self, file as ReadableStream)); + const out2 = recorder(); + const err2 = await failure(encrypt(new Uint8Array(1), options(out2.stream))); + expect(err2.message).toBe('capsule: self-check: PAYLOAD_AGE is 168 bytes, P = 256 gives 456'); + expect([out2.state.closed, out2.state.aborted]).toEqual([false, err2]); + }); + + it('refuses a PAYLOAD_AGE without an age header', async () => { + onCall(2, async (self, file) => { + const real = await sealStream(self, file as ReadableStream); + await real.cancel(); + const empty = new ReadableStream({ start: (c) => c.close() }) as ReadableStreamWithSize; + empty.size = (n: number) => real.size(n); + return empty; + }); + const out = recorder(); + const err = await failure(encrypt(new Uint8Array(1), options(out.stream))); + expect(err.message).toBe('capsule: self-check: the age header of PAYLOAD_AGE does not parse'); + expect([out.chunks.length, out.state.aborted]).toEqual([0, err]); + }); + + it('reports a failure of age-encryption in the STREAM of PAYLOAD_AGE with a fixed text', async () => { + const broke = new Error('the STREAM broke'); + onCall(2, (self, file) => headerOnly(self, file as ReadableStream, broke)); + const out = recorder(); + const err = await failure(encrypt(new Uint8Array(1), options(out.stream))); + expect([err.message, err.cause]).toEqual(['capsule: age failed to seal', broke]); + expect([out.state.closed, out.state.aborted]).toEqual([false, err]); + }); + + it('refuses a SEALED_CONTROL above 64 MiB before sealing anything (§57)', async () => { + const out = recorder(); + const big = { id: 'org.example.big', version: 1, data: new Uint8Array(64 << 20) }; + const err = await failure(encrypt(new Uint8Array(1), options(out.stream, { controlNoncritical: [big] }))); + expect(err.message).toMatch(/^capsule: SEALED_CONTROL of \d+ bytes exceeds 67108864: ERR_INTEGRITY$/); + expect(errorCode(err)).toBe('ERR_INTEGRITY'); + expect([out.chunks.length, out.state.aborted]).toEqual([0, err]); + }); +}); diff --git a/src/lib/dkc/encrypt.property.test.ts b/src/lib/dkc/encrypt.property.test.ts new file mode 100644 index 0000000..0bb6e43 --- /dev/null +++ b/src/lib/dkc/encrypt.property.test.ts @@ -0,0 +1,222 @@ +// The property loop of the writer (plan of phase 3, section 8, point 7), as +// FuzzEncodeImpliesDecode of the Go reference: random options, sometimes made +// invalid on purpose. Each case either fails, exactly when it was made +// invalid, without writing anything and with its output aborted; or writes a +// capsule that inspect accepts, that open opens with all its credentials, in +// which each credential opens exactly one of the 16 stanzas, whose lengths +// follow the formulas of §62.1, and whose .dkk decodes and encodes back the +// same. 50 seeds in every run; DATEKEYS_PROPERTY_SEEDS=500 for the run by +// hand of each step. The seed is in the name of each case. + +import { describe, expect, it } from 'vitest'; +import { decodeAccessKey, encodeAccessKey } from './accesskey.ts'; +import { ACCESS_SLOTS, ageStanzas } from './age.ts'; +import { decryptAll } from './agefile.ts'; +import { sha256 } from './bytes.ts'; +import { type Instant, parseRFC3339 } from './datekey.ts'; +import { encrypt, type EncryptOptions } from './encrypt.ts'; +import type { Extension } from './extension.ts'; +import { TIME_AND_KEY, TIME_ONLY } from './header.ts'; +import { inspect } from './inspect.ts'; +import { open, timeIdentity } from './open.ts'; +import { paddedLength, payloadAgeLength, REFORZADO } from './padding.ts'; +import { quicknet } from './profile.ts'; +import { type Release, suppliedRelease } from './release.ts'; +import { split } from './testing/capsule.ts'; +import { h, hx, readJSON } from './testing/testdata.ts'; +import { sealedControlLength } from './writer.ts'; +import { newX25519Identity, unwrapX25519, x25519PublicKey } from './x25519.ts'; + +const SEEDS = Number(process.env.DATEKEYS_PROPERTY_SEEDS ?? 50); +const releaseOf = (fixture: string): Release => { + const r = readJSON<{ release: { round: number; signature: string } }>(`fixtures/${fixture}.json`).release; + return { round: r.round, signature: h(r.signature) }; +}; +const RELEASES = [releaseOf('time_only'), releaseOf('empty_payload'), releaseOf('time_only_extensions')]; +const GENESIS: Instant = parseRFC3339('2023-08-23T15:09:27Z'); +const roundAt = (r: number): Instant => ({ seconds: GENESIS.seconds + (r - 1) * 3, nanos: 0 }); + +// mulberry32: the generator of the loop. +function generator(seed: number): { int: (n: number) => number; chance: (p: number) => boolean; pick: (list: readonly T[]) => T } { + let a = seed >>> 0; + const next = (): number => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 2 ** 32; + }; + return { + int: (n) => Math.floor(next() * n), + chance: (p) => next() < p, + pick: (list) => list[Math.floor(next() * list.length)]!, + }; +} + +// Extension ids of 1 to 4 bytes per character, U+FF61 and U+10000 among them, +// whose order by bytes differs from their order by UTF-16 units. +const ID_CHARS = ['a', 'z', '0', '.', 'é', '。', '𐀀']; + +interface Case { + opts: EncryptOptions; + body: Uint8Array; + release: Release; + ids: Uint8Array[]; + invalid: string | undefined; +} + +function makeCase(seed: number): Case { + const g = generator(seed); + const release = g.pick(RELEASES); + const policy = g.chance(0.04) ? 7 : g.chance(0.45) ? TIME_ONLY : TIME_AND_KEY; + let invalid: string | undefined = policy === 7 ? 'policy' : undefined; + const bad = (why: string): void => void (invalid ??= why); + + let recipients: Uint8Array[] = []; + let ids: Uint8Array[] = []; + let portable = false; + if (policy === TIME_AND_KEY) { + const count = g.chance(0.1) ? g.pick([15, 16, 17]) : g.int(5); + ids = Array.from({ length: count }, newX25519Identity); + recipients = ids.map(x25519PublicKey); + portable = g.chance(0.6); + if (count + (portable ? 1 : 0) === 0) bad('no credentials'); + if (count + (portable ? 1 : 0) > ACCESS_SLOTS) bad('too many credentials'); + if (count > 0 && g.chance(0.08)) { + const which = g.int(4); + const i = g.int(count); + if (which === 0) recipients[i] = new Uint8Array(31); + else if (which === 1) recipients[i]![31]! |= 0x80; + else if (which === 2) recipients[i] = Uint8Array.of(1, ...new Uint8Array(31)); + else recipients.push(recipients[i]!); + bad('a bad recipient'); + if (which === 3 && count + 1 + (portable ? 1 : 0) > ACCESS_SLOTS) bad('too many credentials'); + } + } else if (policy === TIME_ONLY && g.chance(0.05)) { + portable = true; + bad('time_only with a key'); + } + + const extensions = (count: number, taken: Set): Extension[] => { + const out: Extension[] = []; + while (out.length < count) { + const id = Array.from({ length: 1 + g.int(6) }, () => g.pick(ID_CHARS)).join(''); + if (taken.has(id)) continue; + taken.add(id); + const version = g.chance(0.1) ? 2 ** 32 - 1 - g.int(3) : g.int(10); + out.push({ id, version, data: g.chance(0.3) ? undefined : Uint8Array.from({ length: 1 + g.int(2048) }, () => g.int(256)) }); + } + return out; + }; + const arrays = (): [Extension[], Extension[]] => { + const taken = new Set(); + const size = (): number => (g.chance(0.05) ? 65 : g.chance(0.2) ? g.int(64) : g.int(3)); + const a = extensions(size(), taken); + const b = extensions(size(), taken); + if (a.length > 64 || b.length > 64) bad('more than 64 extensions'); + return [a, b]; + }; + const [critical, noncritical] = arrays(); + const [controlCritical, controlNoncritical] = arrays(); + // A critical extension is fine to write: only a reader that does not know + // it rejects the capsule, and inspect and open below know every one. + if (g.chance(0.04) && noncritical.length > 0) { + noncritical[0] = { ...noncritical[0]!, version: 2 ** 32 }; + bad('an extension version above 2^32 - 1'); + } + + const length = g.chance(0.08) + ? 1_000_000 + g.int(2_000_000) + : g.pick([0, 1, 255, 256, 257, 8191, 8192, 8193, 65535, 65536, 65537, g.int(300_000)]); + const body = Uint8Array.from({ length: Math.min(length, 4096) }, () => g.int(256)); + const full = new Uint8Array(length); + for (let at = 0; at < length; at += body.length || 1) full.set(body.subarray(0, Math.min(body.length, length - at)), at); + const padding = g.pick([undefined, 1, 2] as const); + + return { + opts: { + profile: quicknet(), + unlockAt: roundAt(release.round), + policy, + recipients, + newPortableKey: portable, + ...(padding === undefined ? {} : { padding }), + critical, + noncritical, + controlCritical, + controlNoncritical, + now: () => GENESIS, + }, + body: full, + release, + ids, + invalid, + }; +} + +// A registry that knows every extension, so that critical ones do not fail. +const everything = { known: () => true, validateData: () => undefined }; + +describe('the property loop of the writer', () => { + it.each(Array.from({ length: SEEDS }, (_, i) => [20260929 + i]))('seed %i', async (seed) => { + const c = makeCase(seed); + const chunks: Uint8Array[] = []; + const state = { closed: false, aborted: undefined as unknown }; + const output = new WritableStream({ + write: (b) => void chunks.push(b.slice()), + close: () => void (state.closed = true), + abort: (r) => void (state.aborted = r), + }); + let res; + try { + res = await encrypt(c.body, { ...c.opts, output }); + } catch (err) { + expect(c.invalid, `unexpected failure: ${(err as Error).message}`).toBeDefined(); + expect([chunks.length, state.closed, state.aborted]).toEqual([0, false, err]); + return; + } + expect(c.invalid, 'an invalid case was written').toBeUndefined(); + const dkc = new Uint8Array(chunks.reduce((n, b) => n + b.length, 0)); + let at = 0; + for (const b of chunks) { + dkc.set(b, at); + at += b.length; + } + expect([state.closed, res.size]).toEqual([true, dkc.length]); + const P = paddedLength(c.body.length, c.opts.padding ?? REFORZADO); + expect(res.paddedLength).toBe(P); + + // inspect, with every extension known. + const insp = await inspect(dkc, { extensions: everything }); + expect(insp.error?.message).toBeUndefined(); + + // The lengths of §62.1, and the slots. + const parts = split(dkc); + expect(parts.payload.length).toBe(payloadAgeLength(P)); + const sealed = await decryptAll(parts.sealed, timeIdentity(quicknet(), c.release.round, c.release), 'age'); + const keyed = c.opts.policy === TIME_AND_KEY; + const credentials = [...c.ids, ...(res.portableKey === undefined ? [] : [res.portableKey.material])]; + if (keyed) { + const stanzas = ageStanzas(sealed); + expect(stanzas).toHaveLength(ACCESS_SLOTS); + for (const id of credentials) expect(stanzas.filter((s) => unwrapX25519(id, s.args, s.body) !== null)).toHaveLength(1); + } else { + expect(parts.sealed.length).toBe(sealedControlLength(TIME_ONLY, sealed.length, c.release.round)); + } + + // open, with every credential, gives the content back. + const dkk = res.portableKey === undefined ? undefined : encodeAccessKey(res.portableKey); + const opened = await open(dkc, { + source: suppliedRelease(c.release), + now: () => roundAt(c.release.round), + extensions: everything, + identities: c.ids, + ...(dkk === undefined ? {} : { accessKeyFile: dkk.slice() }), + }); + expect(opened.error?.message).toBeUndefined(); + expect(hx(await sha256(opened.plaintext!))).toBe(hx(await sha256(c.body))); + + // The .dkk decodes and encodes back the same. + if (dkk !== undefined) expect(hx(encodeAccessKey(decodeAccessKey(dkk)))).toBe(hx(dkk)); + }); +}); diff --git a/src/lib/dkc/encrypt.stream.test.ts b/src/lib/dkc/encrypt.stream.test.ts new file mode 100644 index 0000000..46fb009 --- /dev/null +++ b/src/lib/dkc/encrypt.stream.test.ts @@ -0,0 +1,229 @@ +// Tests of encrypt.ts and writer.ts, step 4 of the plan of phase 3: +// streaming from every kind of source into memory or an output, the +// capsule_digest of what is written, the failures of the source and of the +// output, and mixes of capsules written here. + +import { describe, expect, it } from 'vitest'; +import { concatBytes, sha256 } from './bytes.ts'; +import { type Instant, parseRFC3339 } from './datekey.ts'; +import { encrypt, type EncryptOptions } from './encrypt.ts'; +import { errorCode } from './errors.ts'; +import { TIME_AND_KEY, TIME_ONLY } from './header.ts'; +import { open, type OpenOptions } from './open.ts'; +import { quicknet } from './profile.ts'; +import { type Release, suppliedRelease } from './release.ts'; +import { split } from './testing/capsule.ts'; +import { h, hx, readJSON } from './testing/testdata.ts'; + +const R1000: Release = (() => { + const r = readJSON<{ release: { round: number; signature: string } }>('fixtures/time_only.json').release; + return { round: r.round, signature: h(r.signature) }; +})(); +const GENESIS: Instant = parseRFC3339('2023-08-23T15:09:27Z'); +const T1000: Instant = parseRFC3339('2023-08-23T15:59:24Z'); +const te = new TextEncoder(); + +const options = (extra: Partial = {}): EncryptOptions => ({ + profile: quicknet(), + unlockAt: T1000, + policy: TIME_ONLY, + now: () => GENESIS, + ...extra, +}); +const opening = (extra: Partial = {}): OpenOptions => ({ source: suppliedRelease(R1000), now: () => T1000, ...extra }); + +const content = (n: number, seed = 1): Uint8Array => { + const b = new Uint8Array(n); + let x = seed; + for (let i = 0; i < n; i++) { + x = (x * 1103515245 + 12345) >>> 0; + b[i] = x >>> 24; + } + return b; +}; + +function recorder(): { stream: WritableStream; chunks: Uint8Array[]; state: { closed: boolean; aborted: unknown } } { + const chunks: Uint8Array[] = []; + const state: { closed: boolean; aborted: unknown } = { closed: false, aborted: undefined }; + const stream = new WritableStream({ + write: (c) => void chunks.push(c.slice()), + close: () => void (state.closed = true), + abort: (reason) => void (state.aborted = reason ?? 'aborted'), + }); + return { stream, chunks, state }; +} + +async function failure(p: Promise): Promise { + try { + await p; + } catch (err) { + return err as Error; + } + throw new Error('expected a failure'); +} + +// A stream that yields `b` in pieces of the sizes given in turn, then the +// `extra` pieces, then ends, or fails with `fail`. +function chunked(b: Uint8Array, sizes: readonly number[], opts: { extra?: Uint8Array[]; fail?: unknown } = {}): ReadableStream { + const pieces: Uint8Array[] = []; + let at = 0; + for (let i = 0; at < b.length; i++) { + const n = sizes[i % sizes.length]!; + pieces.push(b.slice(at, at + n)); + at += n; + } + pieces.push(...(opts.extra ?? [])); + return new ReadableStream({ + pull(c) { + const next = pieces.shift(); + if (next !== undefined) c.enqueue(next); + else if (opts.fail !== undefined) c.error(opts.fail); + else c.close(); + }, + }); +} + +describe('streaming', () => { + it('seals a Uint8Array, a Blob and a ReadableStream of any chunking to the same content, into an output', async () => { + for (const n of [0, 1, 65536, 200_000, 5_000_000]) { + const body = content(n, 3); + const sources: [string, () => Uint8Array | Blob | ReadableStream][] = [ + ['Uint8Array', () => body], + ['Blob', () => new Blob([body as Uint8Array])], + ['irregular chunks', () => chunked(body, [1, 7, 65535, 65537, 3], { extra: [new Uint8Array(0)] })], + ['one chunk', () => chunked(body, [Math.max(1, n)])], + ]; + for (const [label, src] of sources) { + const out = recorder(); + const progress: [number, number][] = []; + const res = await encrypt(src(), options({ length: n, output: out.stream, progress: (w, t) => void progress.push([w, t]) })); + const dkc = concatBytes(...out.chunks); + expect([res.dkc, res.size, out.state.closed, out.state.aborted], `${n}, ${label}`).toEqual([undefined, dkc.length, true, undefined]); + expect(progress[0], label).toEqual([0, res.size]); + expect(progress.at(-1), label).toEqual([res.size, res.size]); + expect(progress.every(([w], i) => i === 0 || w > progress[i - 1]![0]), label).toBe(true); + const r = await open(dkc, opening()); + expect(r.error, `${n}, ${label}`).toBeUndefined(); + expect(hx(await sha256(r.plaintext!)), `${n}, ${label}`).toBe(hx(await sha256(body))); + } + } + }); + + it('digests exactly what it writes into the capsule_digest of the .dkk', async () => { + const out = recorder(); + const res = await encrypt(chunked(content(100_000), [4096]), options({ length: 100_000, output: out.stream, policy: TIME_AND_KEY, newPortableKey: true })); + expect(hx(res.portableKey!.verification!.capsuleDigest)).toBe(hx(await sha256(concatBytes(...out.chunks)))); + }); + + it('fails a source of another length with the texts of Go, and aborts the output', async () => { + const body = content(100); + for (const [label, src, text] of [ + ['one byte short', chunked(body.subarray(0, 99), [10]), 'capsule: the source ended after 99 bytes, and EncryptOptions.Length is 100'], + ['one byte more', chunked(content(101), [10]), 'capsule: the source delivers more than the 100 bytes of EncryptOptions.Length'], + ['a piece that goes past L', chunked(content(128), [64]), 'capsule: the source delivers more than the 100 bytes of EncryptOptions.Length'], + [ + 'one more piece after L', + chunked(body, [100], { extra: [new Uint8Array(0), new Uint8Array(1)] }), + 'capsule: the source delivers more than the 100 bytes of EncryptOptions.Length', + ], + ] as const) { + const out = recorder(); + const err = await failure(encrypt(src, options({ length: 100, output: out.stream }))); + expect([err.message, errorCode(err)], label).toEqual([text, '']); + expect([out.state.closed, out.state.aborted], label).toEqual([false, err]); + } + }); + + it('rethrows the error of the source and of the output as it is, and aborts the output', async () => { + const boom = new Error('disk read failed'); + const out = recorder(); + const err = await failure(encrypt(chunked(content(300_000), [65536], { fail: boom }), options({ length: 400_000, output: out.stream }))); + expect(err).toBe(boom); + expect([out.state.closed, out.state.aborted]).toEqual([false, boom]); + expect(out.chunks.length).toBeGreaterThan(0); + + const full = new Error('disk full'); + let writes = 0; + const failing = new WritableStream({ write: () => (++writes === 3 ? Promise.reject(full) : undefined) }); + expect(await failure(encrypt(content(300_000), options({ output: failing })))).toBe(full); + + const late = new Error('no more room'); + const out2 = recorder(); + const err2 = await failure( + encrypt( + content(300_000), + options({ + output: out2.stream, + progress: (w) => { + if (w > 100_000) throw late; + }, + }), + ), + ); + expect(err2).toBe(late); + expect([out2.state.closed, out2.state.aborted]).toEqual([false, late]); + + // A source whose cancel fails does not change the error either. + const endless = new ReadableStream({ + pull: (c) => c.enqueue(new Uint8Array(65536)), + cancel: () => { + throw new Error('cannot cancel'); + }, + }); + const refused = new WritableStream({ write: () => Promise.reject(full) }); + expect(await failure(encrypt(endless, options({ length: 1 << 20, output: refused })))).toBe(full); + + // A stream that another reader holds fails as the caller's own error. + const locked = chunked(content(10), [10]); + locked.getReader(); + const out3 = recorder(); + const err3 = await failure(encrypt(locked, options({ length: 10, output: out3.stream }))); + expect(err3.name).toBe('TypeError'); + expect(err3.message).not.toBe('capsule: age failed to seal'); + expect([out3.chunks.length, out3.state.aborted]).toEqual([0, err3]); + }); + + it('writes nothing when progress refuses the total, and an abort that fails does not change the error', async () => { + const out = recorder(); + const refused = new Error('quota'); + const err = await failure( + encrypt( + content(10), + options({ + output: out.stream, + progress: () => { + throw refused; + }, + }), + ), + ); + expect(err).toBe(refused); + expect([out.chunks.length, out.state.closed, out.state.aborted]).toEqual([0, false, refused]); + const stubborn = new WritableStream({ abort: () => Promise.reject(new Error('cannot abort')) }); + expect((await failure(encrypt(content(10), options({ output: stubborn, policy: 7 })))).message).toBe('capsule: unknown access policy 7'); + }); +}); + +describe('mixes of capsules written here', () => { + it('gives the code and the step of the reader for each mix of two capsules of one round', async () => { + const a = split((await encrypt(te.encode('A'), options())).dkc!); + const b = split((await encrypt(te.encode('B'), options())).dkc!); + const k = split((await encrypt(te.encode('K'), options({ policy: TIME_AND_KEY, newPortableKey: true }))).dkc!); + const frame = (prelude: Uint8Array, header: Uint8Array, sealed: Uint8Array, payload: Uint8Array): Uint8Array => { + const out = concatBytes(prelude, header, sealed, payload); + const v = new DataView(out.buffer); + v.setUint32(8, header.length); + v.setUint32(12, sealed.length); + return out; + }; + for (const [label, dkc, code, step] of [ + ['the header of A with the rest of B', frame(a.prelude, a.header, b.sealed, b.payload), 'ERR_HEADER_BINDING', 15], + ['the control of B inside A', frame(a.prelude, a.header, b.sealed, a.payload), 'ERR_HEADER_BINDING', 15], + ['the payload of B inside A', frame(a.prelude, a.header, a.sealed, b.payload), 'ERR_INTEGRITY', 17], + ['a time_only header over the control of a time_and_key capsule', frame(a.prelude, a.header, k.sealed, k.payload), 'ERR_POLICY_STRUCTURE_MISMATCH', 12], + ] as const) { + const r = await open(dkc, opening()); + expect([r.error?.code, r.inspection.checks.at(-1)?.step], label).toEqual([code, step]); + } + }); +}); diff --git a/src/lib/dkc/encrypt.test.ts b/src/lib/dkc/encrypt.test.ts new file mode 100644 index 0000000..fa8cf9e --- /dev/null +++ b/src/lib/dkc/encrypt.test.ts @@ -0,0 +1,505 @@ +// Tests of encrypt.ts and writer.ts (plan of phase 3, section 8): capsules of +// format 2 written here open with open to their content, reproduce the +// deterministic sections of the fixtures of the Go reference byte for byte, +// and fail with the texts and codes of capsule.Encrypt, without writing +// anything and with their output aborted. + +import { Encrypter } from 'age-encryption'; +import { describe, expect, it } from 'vitest'; +import { type AccessKey, decodeAccessKey, encodeAccessKey, wipeAccessKey } from './accesskey.ts'; +import { ACCESS_SLOTS, ageStanzas } from './age.ts'; +import { decryptAll } from './agefile.ts'; +import { concatBytes, sha256 } from './bytes.ts'; +import { decodeControl } from './control.ts'; +import { type Instant, parseRFC3339 } from './datekey.ts'; +import { encrypt, type EncryptOptions, MAX_MEMORY_DKC } from './encrypt.ts'; +import { errorCode } from './errors.ts'; +import type { Extension } from './extension.ts'; +import { FORMAT_2 } from './framing.ts'; +import { TIME_AND_KEY, TIME_ONLY } from './header.ts'; +import { inspect } from './inspect.ts'; +import { accessIdentity, open, type OpenOptions, timeIdentity } from './open.ts'; +import { BLOQUE256, MAX_PAYLOAD_LENGTH, type Padding, paddedLength, payloadAgeLength, REFORZADO } from './padding.ts'; +import { quicknet } from './profile.ts'; +import { formatX25519Recipient } from './recipient.ts'; +import { type Release, suppliedRelease } from './release.ts'; +import { split } from './testing/capsule.ts'; +import { encryptWith, wordsFor } from './testing/encrypt.ts'; +import { h, hx, listTestdata, readBytes, readJSON } from './testing/testdata.ts'; +import { sealedControlLength, selfCheckInner, selfCheckPayloadHeader } from './writer.ts'; +import { newX25519Identity, parseX25519Identity, unwrapX25519, x25519PublicKey } from './x25519.ts'; + +interface FixtureRecord { + format: number; + release: { round: number; signature: string }; + unlock_at: string; + access_policy: 'time_only' | 'time_and_key'; + prelude: string; + public_header: string; + control_cbor: string; + capsule_id: string; + payload_identity: string; + payload_length: number; + padding?: Padding; + plaintext_file: string; + identities?: string[]; + identity_stanzas?: number[]; + access_key_file?: string; + access_key_stanza?: number; + header_extensions?: { critical: boolean; id: string; version: number; data?: string }[]; + control_extensions?: { critical: boolean; id: string; version: number; data?: string }[]; +} + +const releaseOf = (fx: FixtureRecord): Release => ({ round: fx.release.round, signature: h(fx.release.signature) }); +const record = (name: string): FixtureRecord => readJSON(`fixtures/${name}.json`); +const R1000 = releaseOf(record('time_only')); +const R1001 = releaseOf(record('empty_payload')); +const R2000 = releaseOf(record('time_only_extensions')); +const GENESIS: Instant = parseRFC3339('2023-08-23T15:09:27Z'); +// Round r opens at genesis + (r - 1)·3 s. +const roundAt = (r: number): Instant => ({ seconds: GENESIS.seconds + (r - 1) * 3, nanos: 0 }); +const te = new TextEncoder(); + +const options = (extra: Partial = {}): EncryptOptions => ({ + profile: quicknet(), + unlockAt: roundAt(1000), + policy: TIME_ONLY, + now: () => GENESIS, + ...extra, +}); +const opening = (r: Release, extra: Partial = {}): OpenOptions => ({ source: suppliedRelease(r), now: () => roundAt(r.round), ...extra }); + +// A deterministic content of n bytes. +const content = (n: number, seed = 1): Uint8Array => { + const b = new Uint8Array(n); + let x = seed; + for (let i = 0; i < n; i++) { + x = (x * 1103515245 + 12345) >>> 0; + b[i] = x >>> 24; + } + return b; +}; + +// An output that records what it receives and how it ended. +function recorder(): { stream: WritableStream; chunks: Uint8Array[]; state: { closed: boolean; aborted: unknown } } { + const chunks: Uint8Array[] = []; + const state: { closed: boolean; aborted: unknown } = { closed: false, aborted: undefined }; + const stream = new WritableStream({ + write: (c) => void chunks.push(c.slice()), + close: () => void (state.closed = true), + abort: (reason) => void (state.aborted = reason ?? 'aborted'), + }); + return { stream, chunks, state }; +} + +async function failure(p: Promise): Promise { + try { + await p; + } catch (err) { + return err as Error; + } + throw new Error('expected a failure'); +} + +// What SEALED_CONTROL holds under the release: CONTROL_CBOR, or INNER_ACCESS_AGE. +async function sealedPlaintext(dkc: Uint8Array, r: Release): Promise { + return decryptAll(split(dkc).sealed, timeIdentity(quicknet(), r.round, r), 'age'); +} + +describe('encrypt', () => { + it('writes a time_only capsule of format 2 that open opens to its content', async () => { + const text = te.encode('una carta para el futuro'); + const res = await encrypt(text, options()); + expect([res.format, res.length, res.padding, res.paddedLength]).toEqual([2, text.length, REFORZADO, 256]); + expect([res.dateKey.round, res.unlockAt, res.portableKey, res.size]).toEqual([1000, roundAt(1000), undefined, res.dkc!.length]); + expect(res.dkc![4]).toBe(FORMAT_2); + const r = await open(res.dkc!, opening(R1000)); + expect(r.error).toBeUndefined(); + expect(r.plaintext).toEqual(text); + expect([r.format, r.payloadLength, r.padding, r.paddedLength]).toEqual([2, text.length, REFORZADO, 256]); + }); + + it('writes time_and_key with 1 to 16 credentials, each of which opens it alone, and all of them together', async () => { + const body = content(1000); + for (const [recipients, portable] of [ + [0, true], + [1, false], + [3, true], + [15, true], + [16, false], + ] as const) { + const ids = Array.from({ length: recipients }, newX25519Identity); + const res = await encrypt(body, options({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), recipients: ids.map(x25519PublicKey), newPortableKey: portable })); + const label = `${recipients} recipients${portable ? ' and a portable key' : ''}`; + expect(res.portableKey === undefined, label).toBe(!portable); + const dkk = portable ? encodeAccessKey(res.portableKey!) : undefined; + if (portable) { + expect(hx(decodeAccessKey(dkk!).verification!.capsuleDigest), label).toBe(hx(await sha256(res.dkc!))); + wipeAccessKey(res.portableKey!); + } + const each: Partial[] = [...ids.map((id) => ({ identities: [id] })), ...(dkk === undefined ? [] : [{ accessKeyFile: dkk.slice() }])]; + for (const creds of [...each, { identities: ids, ...(dkk === undefined ? {} : { accessKeyFile: dkk.slice() }) }]) { + const r = await open(res.dkc!, opening(R1001, creds)); + expect(r.error, label).toBeUndefined(); + expect(r.plaintext, label).toEqual(body); + expect(r.inspection.checks.find((c) => c.step === 12)?.detail, label).toBe('time_and_key: INNER_ACCESS_AGE with 16 X25519 stanzas'); + } + // A stranger opens nothing. + const stranger = await open(res.dkc!, opening(R1001, { identities: [newX25519Identity()] })); + expect([stranger.error?.code, stranger.inspection.checks.at(-1)?.step], label).toEqual(['ERR_ACCESS_INVALID', 13]); + } + }); + + it('pads with both rules around every boundary, and open gives back L, the rule and P', async () => { + for (const n of [0, 1, 255, 256, 257, 8192, 8193, 65535, 65536, 65537, 78000, 320000, 5000000]) { + for (const code of [BLOQUE256, REFORZADO] as const) { + const body = content(n, n + code); + const res = await encrypt(body, options({ padding: code })); + const p = paddedLength(n, code); + expect([res.length, res.padding, res.paddedLength], `${n}, ${code}`).toEqual([n, code, p]); + expect(split(res.dkc!).payload.length, `${n}, ${code}`).toBe(payloadAgeLength(p)); + const r = await open(res.dkc!, opening(R1000)); + expect(r.error, `${n}, ${code}`).toBeUndefined(); + expect(r.plaintext!.length).toBe(n); + expect(hx(await sha256(r.plaintext!)), `${n}, ${code}`).toBe(hx(await sha256(body))); + expect([r.payloadLength, r.padding, r.paddedLength], `${n}, ${code}`).toEqual([n, code, p]); + } + } + }); + + it('resolves the requested instant to the first round at or after it, for rounds 1000, 1001 and 2000', async () => { + for (const [requested, round, r] of [ + [roundAt(1000), 1000, R1000], + [{ seconds: roundAt(1000).seconds, nanos: 1 }, 1001, R1001], + [{ seconds: roundAt(2000).seconds - 1, nanos: 999_999_999 }, 2000, R2000], + ] as const) { + const res = await encrypt(te.encode('x'), options({ unlockAt: requested })); + expect([res.dateKey.round, res.unlockAt]).toEqual([round, roundAt(round)]); + expect((await open(res.dkc!, opening(r))).plaintext).toEqual(te.encode('x')); + } + }); +}); + +describe('the fixtures of the Go reference', () => { + const names = listTestdata('fixtures', '.dkc') + .map((p) => p.slice('fixtures/'.length, -'.dkc'.length)) + .filter((n) => record(n).format === 2); + + it('has the seven fixtures of format 2', () => { + expect(names).toHaveLength(7); + }); + + const exts = (list: FixtureRecord['header_extensions'], critical: boolean): Extension[] => + (list ?? []).filter((e) => e.critical === critical).map((e) => ({ id: e.id, version: e.version, data: e.data === undefined ? undefined : h(e.data) })); + + // With the values of the record, the deterministic sections come out byte + // for byte, and each credential lands in the slot of the record. + it.each(names)('%s: reproduces PRELUDE, PUBLIC_HEADER, header_binding and CONTROL_CBOR, the lengths, the slots and the .dkk', async (name) => { + const fx = record(name); + const body = readBytes(`fixtures/${fx.plaintext_file}`); + const ids = (fx.identities ?? []).map(parseX25519Identity); + const dkk = fx.access_key_file === undefined ? undefined : decodeAccessKey(readBytes(`fixtures/${fx.access_key_file}`)); + const slots = [...(fx.identity_stanzas ?? []), ...(fx.access_key_stanza === undefined ? [] : [fx.access_key_stanza])]; + const keyed = fx.access_policy === 'time_and_key'; + const res = await encryptWith( + body, + options({ + unlockAt: parseRFC3339(fx.unlock_at), + policy: keyed ? TIME_AND_KEY : TIME_ONLY, + recipients: ids.map(x25519PublicKey), + newPortableKey: dkk !== undefined, + padding: fx.padding!, + critical: exts(fx.header_extensions, true), + noncritical: exts(fx.header_extensions, false), + controlCritical: exts(fx.control_extensions, true), + controlNoncritical: exts(fx.control_extensions, false), + }), + { + capsuleId: h(fx.capsule_id), + payloadIdentity: h(fx.payload_identity), + ...(dkk === undefined ? {} : { accessIdentity: dkk.material, credentialId: dkk.credentialId }), + ...(keyed ? { words: wordsFor(slots) } : {}), + }, + ); + const written = split(res.dkc!); + const original = split(readBytes(`fixtures/${name}.dkc`)); + expect(hx(written.prelude)).toBe(fx.prelude); + expect(hx(written.header)).toBe(fx.public_header); + expect([written.sealed.length, written.payload.length]).toEqual([original.sealed.length, original.payload.length]); + const r = releaseOf(fx); + const sealed = await sealedPlaintext(res.dkc!, r); + if (!keyed) { + expect(hx(sealed)).toBe(fx.control_cbor); + } else { + const stanzas = ageStanzas(sealed); + expect(stanzas).toHaveLength(ACCESS_SLOTS); + const credentials = [...ids, ...(dkk === undefined ? [] : [dkk.material])]; + for (const [i, id] of credentials.entries()) { + const hits = stanzas.flatMap((s, k) => (unwrapX25519(id, s.args, s.body) === null ? [] : [k])); + expect(hits, `credential ${i}`).toEqual([slots[i]]); + } + expect(hx(await decryptAll(sealed, accessIdentity([credentials[0]!], ACCESS_SLOTS), 'age'))).toBe(fx.control_cbor); + } + if (dkk !== undefined) { + const want: AccessKey = { ...dkk, verification: { capsuleDigest: await sha256(res.dkc!) } }; + expect(hx(encodeAccessKey(res.portableKey!))).toBe(hx(encodeAccessKey(want))); + } + // The .dkk of the fixture binds the capsule of the fixture; the one the + // writer returns binds the capsule it wrote. + const opened = await open(res.dkc!, opening(r, { identities: ids, ...(res.portableKey === undefined ? {} : { accessKey: res.portableKey }) })); + expect(opened.error).toBeUndefined(); + expect(opened.plaintext).toEqual(body); + }); +}); + +describe('invalid options', () => { + const p = quicknet(); + const recipient = x25519PublicKey(newX25519Identity()); + const le = (n: bigint): Uint8Array => Uint8Array.from({ length: 32 }, (_, i) => Number((n >> BigInt(8 * i)) & 0xffn)); + const P = 2n ** 255n - 19n; + const bit255 = le(9n); + bit255[31]! |= 0x80; + const stream = (): ReadableStream => new ReadableStream({ start: (c) => c.close() }); + const cases: [string, unknown, Record, string, string | RegExp, string][] = [ + ['no profile', te.encode('x'), { profile: undefined }, 'TypeError', 'capsule: EncryptOptions.Profile is required', ''], + ['no clock', te.encode('x'), { now: undefined }, 'TypeError', 'capsule: EncryptOptions.Now is required', ''], + ['no policy', te.encode('x'), { policy: undefined }, 'TypeError', 'encrypt: EncryptOptions.policy is required', ''], + ['a malformed instant', te.encode('x'), { unlockAt: { seconds: 1.5, nanos: 0 } }, 'TypeError', /unlockAt is not an Instant/, ''], + ['a clock that is not an instant', te.encode('x'), { now: () => ({ seconds: 0, nanos: -1 }) }, 'TypeError', /now\(\) did not return an Instant/, ''], + ['an instant in the past', te.encode('x'), { now: () => roundAt(1001) }, 'Error', 'capsule: unlock time 2023-08-23T15:59:24Z is not in the future', ''], + ['an instant equal to now', te.encode('x'), { now: () => roundAt(1000) }, 'Error', 'capsule: unlock time 2023-08-23T15:59:24Z is not in the future', ''], + ['time_only with recipients', te.encode('x'), { recipients: [recipient] }, 'Error', 'capsule: time_only takes no recipients and no portable key', ''], + ['time_only with a portable key', te.encode('x'), { newPortableKey: true }, 'Error', 'capsule: time_only takes no recipients and no portable key', ''], + ['time_and_key without credentials', te.encode('x'), { policy: TIME_AND_KEY }, 'Error', 'capsule: time_and_key needs at least one recipient or a portable key', ''], + [ + '16 recipients and a portable key', + te.encode('x'), + { policy: TIME_AND_KEY, recipients: Array.from({ length: 16 }, () => x25519PublicKey(newX25519Identity())), newPortableKey: true }, + 'Error', + 'capsule: time_and_key takes at most 16 credentials, recipients and portable key together; 17 given', + '', + ], + [ + '17 recipients', + te.encode('x'), + { policy: TIME_AND_KEY, recipients: Array.from({ length: 17 }, () => x25519PublicKey(newX25519Identity())) }, + 'Error', + 'capsule: time_and_key takes at most 16 credentials, recipients and portable key together; 17 given', + '', + ], + ['a recipient of 31 bytes', te.encode('x'), { policy: TIME_AND_KEY, recipients: [new Uint8Array(31)] }, 'TypeError', 'encrypt: recipient 0 is not a 32-byte X25519 public key', ''], + [ + 'a recipient with bit 255', + te.encode('x'), + { policy: TIME_AND_KEY, recipients: [recipient, bit255] }, + 'Error', + `capsule: recipient 1: agewrap: recipient ${formatX25519Recipient(bit255)} is not canonical: bit 255 is set`, + '', + ], + [ + 'a recipient u = p', + te.encode('x'), + { policy: TIME_AND_KEY, recipients: [le(P)] }, + 'Error', + `capsule: recipient 0: agewrap: recipient ${formatX25519Recipient(le(P))} is not canonical: u is not below 2^255 - 19`, + '', + ], + [ + 'a recipient of low order', + te.encode('x'), + { policy: TIME_AND_KEY, recipients: [le(1n)] }, + 'Error', + `capsule: recipient 0: agewrap: recipient ${formatX25519Recipient(le(1n))} is a point of low order: the shared secret would be zero`, + '', + ], + [ + 'a recipient listed twice', + te.encode('x'), + { policy: TIME_AND_KEY, recipients: [recipient, recipient] }, + 'Error', + `capsule: recipient ${formatX25519Recipient(recipient)} listed twice; INNER_ACCESS_AGE holds one stanza per recipient`, + '', + ], + ['policy 7', te.encode('x'), { policy: 7 }, 'Error', 'capsule: unknown access policy 7', ''], + ['padding code 3', te.encode('x'), { padding: 3 }, 'Error', 'capsule: padding code 3 is not defined', ''], + ['padding code 0', te.encode('x'), { padding: 0 }, 'Error', 'capsule: padding code 0 is not defined', ''], + ['L = L_MAX + 1', stream(), { length: MAX_PAYLOAD_LENGTH + 1 }, 'Error', `capsule: content of ${MAX_PAYLOAD_LENGTH + 1} bytes exceeds L_MAX = ${MAX_PAYLOAD_LENGTH}`, ''], + ['a length that is not the size of a Blob', new Blob(['abc']), { length: 4 }, 'TypeError', 'encrypt: EncryptOptions.length 4 is not the 3 bytes of the source', ''], + ['a negative length', te.encode('x'), { length: -1 }, 'TypeError', /length -1 is not a non-negative safe integer/, ''], + ['a stream without a length', stream(), {}, 'TypeError', 'encrypt: a ReadableStream needs EncryptOptions.length', ''], + ['a source of another type', 'text', {}, 'TypeError', 'encrypt: the source is a Uint8Array, a Blob or a ReadableStream', ''], + ['a chain hash with a bit changed', te.encode('x'), { profile: { ...p, chainHash: p.chainHash.map((b, i) => (i === 0 ? b ^ 1 : b)) } }, 'DateKeysError', /chain/, 'ERR_PROFILE_MISMATCH'], + [ + 'a repeated header extension', + te.encode('x'), + { critical: [{ id: 'a', version: 1 }, { id: 'a', version: 1 }] }, + 'DateKeysError', + /extension a/, + 'ERR_NON_CANONICAL_CBOR', + ], + [ + 'a control extension in both arrays', + te.encode('x'), + { controlCritical: [{ id: 'a', version: 1 }], controlNoncritical: [{ id: 'a', version: 1 }] }, + 'DateKeysError', + /extension a: both critical and noncritical/, + 'ERR_NON_CANONICAL_CBOR', + ], + ]; + + it.each(cases)('%s: fails with the text and code of Go, writes nothing and aborts the output', async (_label, src, extra, type, message, code) => { + const out = recorder(); + const err = await failure(encrypt(src as Uint8Array, { ...options(), output: out.stream, ...extra } as EncryptOptions)); + expect(err.name === 'Error' && errorCode(err) !== '' ? 'DateKeysError' : err.name).toBe(type); + if (typeof message === 'string') expect(err.message).toBe(code === '' ? message : `${message}: ${code}`); + else expect(err.message).toMatch(message); + expect(errorCode(err)).toBe(code); + expect(out.chunks).toHaveLength(0); + expect([out.state.closed, out.state.aborted]).toEqual([false, err]); + }); + + it('keeps in memory a capsule of at most MAX_MEMORY_DKC bytes, and needs an output above', async () => { + const err = await failure(encrypt(stream(), { ...options(), length: MAX_MEMORY_DKC })); + expect([err.name, err.message]).toEqual([ + 'TypeError', + `encrypt: a capsule of ${16 + 121 + 458 + payloadAgeLength(paddedLength(MAX_MEMORY_DKC, REFORZADO))} bytes needs EncryptOptions.output; in memory the limit is ${MAX_MEMORY_DKC}`, + ]); + }); + + it('rejects options that are not an object, and an output that is not a WritableStream', async () => { + expect((await failure(encrypt(te.encode('x'), undefined as unknown as EncryptOptions))).message).toBe('encrypt: options are required'); + expect((await failure(encrypt(te.encode('x'), { ...options(), output: {} as WritableStream }))).message).toMatch(/output is not a WritableStream/); + expect((await failure(encrypt(te.encode('x'), { ...options(), progress: 3 as unknown as () => void }))).message).toMatch(/progress is not a function/); + }); + + it('rejects a random draw of the wrong length', async () => { + for (const [what, fixed] of [ + ['capsule_id', { capsuleId: new Uint8Array(15) }], + ['I_PAYLOAD', { payloadIdentity: new Uint8Array(31) }], + ] as const) { + expect((await failure(encryptWith(te.encode('x'), options(), fixed))).message).toBe( + `encrypt: the random draw of ${what} is not ${what === 'capsule_id' ? 16 : 32} bytes`, + ); + } + }); +}); + +describe('properties of the Go reference', () => { + it('never reuses a portable key, a capsule_id, an I_PAYLOAD or a dummy', async () => { + const one = x25519PublicKey(newX25519Identity()); + const a = await encrypt(te.encode('x'), options({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), recipients: [one], newPortableKey: true })); + const b = await encrypt(te.encode('x'), options({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), recipients: [one], newPortableKey: true })); + expect(hx(a.portableKey!.material)).not.toBe(hx(b.portableKey!.material)); + expect(hx(a.portableKey!.credentialId)).not.toBe(hx(b.portableKey!.credentialId)); + expect(hx(a.capsuleId)).not.toBe(hx(b.capsuleId)); + // The same credential in two capsules: 32 distinct shares. + const shares = new Set(); + const payloadIds = new Set(); + for (const c of [a, b]) { + const inner = await sealedPlaintext(c.dkc!, R1001); + for (const s of ageStanzas(inner)) shares.add(s.args[0]!); + const control = decodeControl(await decryptAll(inner, accessIdentity([c.portableKey!.material], ACCESS_SLOTS), 'age'), FORMAT_2); + payloadIds.add(hx(control.payloadIdentity)); + } + expect(shares.size).toBe(32); + expect(payloadIds.size).toBe(2); + // Without a portable key, no key comes back. + expect((await encrypt(te.encode('x'), options({ policy: TIME_AND_KEY, recipients: [one] }))).portableKey).toBeUndefined(); + }); + + it('opens a .dkk only on its capsule, before any request', async () => { + const a = await encrypt(te.encode('a'), options({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), newPortableKey: true })); + const b = await encrypt(te.encode('b'), options({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), newPortableKey: true })); + let calls = 0; + const source = { fetch: () => (calls++, Promise.resolve(R1001)) }; + const withDkk = await open(b.dkc!, { source, now: () => roundAt(1001), accessKeyFile: encodeAccessKey(a.portableKey!) }); + expect([withDkk.error?.code, withDkk.inspection.checks.at(-1)?.step, calls]).toEqual(['ERR_ACCESS_INVALID', 9, 0]); + const withId = await open(b.dkc!, { source, now: () => roundAt(1001), identities: [a.portableKey!.material] }); + expect([withId.error?.code, withId.inspection.checks.at(-1)?.step, calls]).toEqual(['ERR_ACCESS_INVALID', 13, 1]); + }); + + it('seals for the first round at or after now + 1 h, which nobody can open yet', async () => { + const now = { seconds: roundAt(5000).seconds + 1, nanos: 7 }; + const requested = { seconds: now.seconds + 3600, nanos: now.nanos }; + const res = await encrypt(te.encode('pronto'), options({ unlockAt: requested, now: () => now })); + const effective = res.unlockAt; + const period = quicknet().period; + expect(effective.seconds * 1e9 + effective.nanos).toBeGreaterThanOrEqual(requested.seconds * 1e9 + requested.nanos); + expect(effective.seconds).toBeLessThan(requested.seconds + period + 1); + let calls = 0; + const r = await open(res.dkc!, { source: { fetch: () => (calls++, Promise.resolve(R1000)) }, now: () => now }); + expect([r.error?.code, calls]).toEqual(['ERR_RELEASE_UNAVAILABLE', 0]); + expect((await inspect(res.dkc!)).header!.dateKey).toEqual(res.dateKey); + }); + + it('seals a control whose length depends on neither L nor the rule, as §55.2 asks, and matches the formula', async () => { + const lengths = new Set(); + for (const n of [0, 1, 300, 70000]) { + for (const code of [BLOQUE256, REFORZADO] as const) { + for (const policy of [TIME_ONLY, TIME_AND_KEY]) { + const res = await encrypt(content(n), options({ padding: code, policy, ...(policy === TIME_AND_KEY ? { newPortableKey: true } : {}) })); + const sealed = split(res.dkc!).sealed.length; + expect(sealed).toBe(sealedControlLength(policy, 103, 1000)); + lengths.add(sealed); + } + } + } + expect([...lengths].sort((x, y) => x - y)).toEqual([458, 2128]); + }); +}); + +describe('the self-checks of the writer', () => { + const control = te.encode('a control that is not CBOR, as far as age cares'); + const sealFor = async (recipients: Uint8Array[], plaintext = control): Promise => { + const e = new Encrypter(); + for (const r of recipients) e.addRecipient(formatX25519Recipient(r)); + return e.encrypt(plaintext); + }; + const others = (n: number): Uint8Array[] => Array.from({ length: n }, () => x25519PublicKey(newX25519Identity())); + + it('checks INNER_ACCESS_AGE as selfCheckInner: 16 stanzas, the portable key opening exactly one, the control inside', async () => { + const key = newX25519Identity(); + const mine = x25519PublicKey(key); + await expect(selfCheckInner(await sealFor([mine, ...others(15)]), control, key)).resolves.toBeUndefined(); + await expect(selfCheckInner(await sealFor(others(16)), control, undefined)).resolves.toBeUndefined(); + const text = async (inner: Uint8Array, c = control): Promise => (await failure(selfCheckInner(inner, c, key))).message; + expect(await text(await sealFor([mine, ...others(14)]))).toBe( + 'capsule: self-check: agewrap: INNER_ACCESS_AGE has 15 stanzas, want exactly 16: ERR_POLICY_STRUCTURE_MISMATCH', + ); + expect(await text(await sealFor([mine, mine, ...others(14)]))).toBe( + 'capsule: self-check: the portable key does not open INNER_ACCESS_AGE: capsule: age: agewrap: one identity opens 2 INNER_ACCESS_AGE stanzas, want one per recipient: ERR_POLICY_STRUCTURE_MISMATCH', + ); + expect(await text(await sealFor(others(16)))).toBe( + 'capsule: self-check: the portable key does not open INNER_ACCESS_AGE: capsule: age: agewrap: no supplied identity is a recipient of INNER_ACCESS_AGE: ERR_ACCESS_INVALID', + ); + expect(await text(await sealFor([mine, ...others(15)]), te.encode('another control'))).toBe('capsule: self-check: INNER_ACCESS_AGE does not hold the control'); + expect(await text(te.encode('not an age file'))).toMatch(/^capsule: self-check: INNER_ACCESS_AGE: agewrap: .*: ERR_INTEGRITY$/); + }); + + it('checks the header of PAYLOAD_AGE as selfCheckPayload: alone, parsed, and opened by I_PAYLOAD', async () => { + const key = newX25519Identity(); + const header = (file: Uint8Array): Uint8Array => file.subarray(0, file.length - 16 - 16 - 1); + const file = await sealFor([x25519PublicKey(key)], new Uint8Array(1)); + await expect(selfCheckPayloadHeader(header(file), key)).resolves.toBeUndefined(); + const text = async (first: Uint8Array): Promise => (await failure(selfCheckPayloadHeader(first, key))).message; + expect(await text(header(await sealFor(others(1), new Uint8Array(1))))).toBe('capsule: self-check: I_PAYLOAD does not open the header of PAYLOAD_AGE'); + expect(await text(te.encode('garbage'))).toBe('capsule: self-check: the age header of PAYLOAD_AGE does not parse'); + expect(await text(concatBytes(header(file), new Uint8Array(1)))).toBe('capsule: internal error: the first piece of PAYLOAD_AGE is not its age header alone'); + }); + + it('wipes every secret it was handed, whether it succeeds or fails', async () => { + for (const fails of [false, true]) { + const handed: Uint8Array[] = []; + const run = encryptWith( + te.encode('x'), + options({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), newPortableKey: true, ...(fails ? { progress: () => { throw new Error('no room'); } } : {}) }), + { handed }, + ); + if (fails) expect((await failure(run)).message).toBe('no room'); + else await run; + // I_ACCESS, I_PAYLOAD and 15 dummies. + expect(handed).toHaveLength(17); + expect(handed.every((b) => b.every((x) => x === 0))).toBe(true); + } + }); +}); diff --git a/src/lib/dkc/encrypt.ts b/src/lib/dkc/encrypt.ts new file mode 100644 index 0000000..92efec3 --- /dev/null +++ b/src/lib/dkc/encrypt.ts @@ -0,0 +1,43 @@ +// The writer of phase 3: encrypt writes a .dkc of capsule format 2 and, when +// asked, a portable .dkk (spec §61, §62, §62.1), as capsule.Encrypt of the Go +// reference at spec-v0.9. Its random values all come from +// crypto.getRandomValues (§62.1 rule 5); the core of writer.ts, which takes +// them from its caller, is imported only here and by the tests. Loaded on +// demand: index.ts does not re-export it. + +import { cryptoWords } from './random.ts'; +import { type Draws, type EncryptOptions, type Encrypted, type EncryptSource, MAX_MEMORY_DKC, writeCapsule } from './writer.ts'; +import { newX25519Identity } from './x25519.ts'; + +export type { EncryptOptions, Encrypted, EncryptSource }; +export { MAX_MEMORY_DKC }; + +/** + * Writes a .dkc of format 2 for the content of `src`, which must be exactly + * L bytes (§62.1 rule 6): the size of a Uint8Array or a Blob, or + * `opts.length` for a stream. It needs no network: the round is resolved + * locally, and tlock uses only the pinned public key. + * + * Without `opts.output` the .dkc is returned in memory, up to + * MAX_MEMORY_DKC. With it, the .dkc is streamed into the output, which is + * closed only once the capsule is complete and checked, and aborted on any + * failure; the content is read in pieces of 64 KiB and never held whole. + * The checks, codes and texts are those of capsule.Encrypt; the errors of + * the source and of the output are rethrown as they are. + */ +export function encrypt(src: EncryptSource, opts: EncryptOptions): Promise { + return writeCapsule(src, opts, cryptoDraws()); +} + +// Every random value of the writer from crypto.getRandomValues. +function cryptoDraws(): Draws { + const bytes = (n: number) => (): Uint8Array => crypto.getRandomValues(new Uint8Array(n)); + return { + capsuleId: bytes(16), + payloadIdentity: newX25519Identity, + accessIdentity: newX25519Identity, + dummy: newX25519Identity, + words: cryptoWords(), + credentialId: bytes(16), + }; +} diff --git a/src/lib/dkc/testing/encrypt.ts b/src/lib/dkc/testing/encrypt.ts new file mode 100644 index 0000000..24278d6 --- /dev/null +++ b/src/lib/dkc/testing/encrypt.ts @@ -0,0 +1,82 @@ +// Tests only: the writer with its random values fixed by name (plan of phase +// 3, decision 4), to reproduce the deterministic sections of the fixtures of +// the Go reference and to reach the checks of the writer. A value that is +// not given is drawn from crypto.getRandomValues, as encrypt does. The draws +// hand the writer copies, and the writer wipes them. + +import { cryptoWords, type RandomWords } from '../random.ts'; +import { type Draws, type EncryptOptions, type Encrypted, type EncryptSource, writeCapsule } from '../writer.ts'; +import { newX25519Identity } from '../x25519.ts'; + +/** The random values to fix; any other is drawn at random. */ +export interface FixedDraws { + readonly capsuleId?: Uint8Array; + readonly payloadIdentity?: Uint8Array; + readonly accessIdentity?: Uint8Array; + readonly credentialId?: Uint8Array; + /** The scalars of the dummies, in the order they are drawn. */ + readonly dummies?: readonly Uint8Array[]; + /** The words of the permutation of the slots; see wordsFor. */ + readonly words?: readonly number[]; + /** + * Where to record every secret handed to the writer: I_PAYLOAD, I_ACCESS + * and the scalars of the dummies, which the writer must leave zeroed. + */ + readonly handed?: Uint8Array[]; +} + +/** Draws that return copies of the fixed values, and random ones otherwise. */ +export function fixedDraws(f: FixedDraws): Draws { + const bytes = (n: number) => (): Uint8Array => crypto.getRandomValues(new Uint8Array(n)); + const fixed = (v: Uint8Array | undefined, otherwise: () => Uint8Array) => (): Uint8Array => (v === undefined ? otherwise() : v.slice()); + const dummies = [...(f.dummies ?? [])]; + let words: RandomWords = cryptoWords(); + if (f.words !== undefined) { + const list = [...f.words]; + words = () => { + const w = list.shift(); + if (w === undefined) throw new Error('testing: the fixed words are exhausted'); + return w; + }; + } + const secret = (draw: () => Uint8Array) => (): Uint8Array => { + const b = draw(); + f.handed?.push(b); + return b; + }; + return { + capsuleId: fixed(f.capsuleId, bytes(16)), + payloadIdentity: secret(fixed(f.payloadIdentity, newX25519Identity)), + accessIdentity: secret(fixed(f.accessIdentity, newX25519Identity)), + dummy: secret(() => dummies.shift()?.slice() ?? newX25519Identity()), + words, + credentialId: fixed(f.credentialId, bytes(16)), + }; +} + +/** encrypt with some of its random values fixed. */ +export function encryptWith(src: EncryptSource, opts: EncryptOptions, f: FixedDraws): Promise { + return writeCapsule(src, opts, fixedDraws(f)); +} + +/** + * The words that make the permutation of the writer leave credential i, the + * item at position i before it, in slot `slots[i]`, the dummies filling the + * slots left in their order. The permutation is Fisher–Yates from the last + * position down: at position p it swaps with an index j ≤ p, drawn as the + * word j, which randomIndex accepts as it is. + */ +export function wordsFor(slots: readonly number[], n = 16): number[] { + const target = new Array(n).fill(-1); + for (const [i, s] of slots.entries()) target[s] = i; + let next = slots.length; + for (let p = 0; p < n; p++) if (target[p] === -1) target[p] = next++; + const items = Array.from({ length: n }, (_, i) => i); + const words: number[] = []; + for (let p = n - 1; p > 0; p--) { + const j = items.indexOf(target[p]!); + words.push(j); + [items[p], items[j]] = [items[j]!, items[p]!]; + } + return words; +} diff --git a/src/lib/dkc/writer.ts b/src/lib/dkc/writer.ts new file mode 100644 index 0000000..9d28bf8 --- /dev/null +++ b/src/lib/dkc/writer.ts @@ -0,0 +1,621 @@ +// The core of the writer of phase 3: a .dkc of capsule format 2 and, when +// asked, a portable .dkk (spec §61, §62, §62.1), in the order and with the +// texts of capsule.Encrypt of the Go reference at spec-v0.9. Its random values +// come from the caller (plan of phase 3, decision 4): encrypt.ts passes those +// of crypto.getRandomValues, and only the tests of testing/ fix them. +// Internal: only encrypt.ts and testing/ import it, which a guard checks. +// +// Nothing is written before every check that can precede the content has +// passed, the header of PAYLOAD_AGE included. The output is closed only once +// the capsule is complete and checked, and aborted on any failure, so that +// nothing partial is ever presented as a capsule (§62.1 rule 9). The content +// is streamed in pieces of 64 KiB, followed by the zeros of its padding. + +import { Decrypter, Encrypter, type ReadableStreamWithSize } from 'age-encryption'; +import { ACCESS_TYPE_X25519, type AccessKey } from './accesskey.ts'; +import { ACCESS_SLOTS, ageStanzas, checkAccessStanzas, checkTimeStanzas, parseAgeHeader } from './age.ts'; +import { decryptAll } from './agefile.ts'; +import { copyBytes, equalBytes } from './bytes.ts'; +import { decodeControl, encodeControl } from './control.ts'; +import { compareInstants, type DateKey, formatRFC3339Nano, type Instant, isInstant, resolveDateKey, roundTime } from './datekey.ts'; +import { sha256Hasher } from './digest.ts'; +import { DateKeysError } from './errors.ts'; +import type { Extension } from './extension.ts'; +import { DKC_PRELUDE_SIZE, FORMAT_2, headerBinding, MAX_SEALED_CONTROL_LEN, preludeBytes } from './framing.ts'; +import { decodeHeader, encodeHeader, type Policy, TIME_AND_KEY, TIME_ONLY } from './header.ts'; +import { accessIdentity, payloadIdentity } from './open.ts'; +import { isPadding, MAX_PAYLOAD_LENGTH, paddedLength, type Padding, payloadAgeLength, REFORZADO } from './padding.ts'; +import { cloneProfile, type Profile, validateProfile } from './profile.ts'; +import { permute, type RandomWords } from './random.ts'; +import { checkX25519Recipient, formatX25519Recipient } from './recipient.ts'; +import { timeRecipient } from './tlock.ts'; +import { x25519PublicKey } from './x25519.ts'; + +/** The largest .dkc that encrypt returns in memory; a larger one needs an output stream. */ +export const MAX_MEMORY_DKC = 1 << 30; + +/** What a capsule seals: bytes in memory, a Blob such as a File, or a stream of declared length. */ +export type EncryptSource = Uint8Array | Blob | ReadableStream; + +/** Options of encrypt, field by field those of capsule.EncryptOptions. */ +export interface EncryptOptions { + /** The pinned Provider Profile. Required. */ + readonly profile: Profile; + /** The requested instant: the capsule opens at the first round at or after it (§15), and it must be after now(). */ + readonly unlockAt: Instant; + /** TIME_ONLY or TIME_AND_KEY (§25). Required. */ + readonly policy: Policy; + /** Raw 32-byte X25519 public keys of the known holders, for time_and_key (§37, §39). */ + readonly recipients?: readonly Uint8Array[]; + /** Generates a fresh I_ACCESS for this capsule only, returned as a .dkk (§38). */ + readonly newPortableKey?: boolean; + /** L, the exact number of bytes of the source: its size for a Blob or a Uint8Array, required for a stream (§62.1 rule 6). */ + readonly length?: number; + /** The padding rule; reforzado when omitted (§29.1, §62.1 rule 10). */ + readonly padding?: Padding; + /** The PUBLIC_HEADER extensions, visible to anyone holding the .dkc. */ + readonly critical?: readonly Extension[]; + readonly noncritical?: readonly Extension[]; + /** The CONTROL_CBOR extensions, sealed with the control. */ + readonly controlCritical?: readonly Extension[]; + readonly controlNoncritical?: readonly Extension[]; + /** The clock. Required, and called once. */ + readonly now: () => Instant; + /** + * Where the .dkc goes instead of Encrypted.dkc. It is closed only once the + * capsule is complete and checked, and aborted on any failure. + */ + readonly output?: WritableStream; + /** Called before the first write with (0, total), then after each write; if it throws, the writing stops. */ + readonly progress?: (written: number, total: number) => void; +} + +/** The result of encrypt, as capsule.Result. */ +export interface Encrypted { + readonly dateKey: DateKey; + /** The effective unlock time: the time of the round, never before the requested instant. */ + readonly unlockAt: Instant; + readonly capsuleId: Uint8Array; + /** The format written, always 2. */ + readonly format: typeof FORMAT_2; + /** L, the padding rule and P = rule(L), the length of the plaintext of PAYLOAD_AGE (§29.1). */ + readonly length: number; + readonly padding: Padding; + readonly paddedLength: number; + /** The .dkk, when newPortableKey is set: encode it with encodeAccessKey and wipe it with wipeAccessKey. */ + readonly portableKey?: AccessKey; + /** The size of the .dkc. */ + readonly size: number; + /** The .dkc, only without an output. */ + readonly dkc?: Uint8Array; +} + +/** The random values of the writer, one method per value (§62.1 rule 5). */ +export interface Draws { + /** capsule_id, 16 bytes (§21). */ + readonly capsuleId: () => Uint8Array; + /** I_PAYLOAD, a raw X25519 identity of 32 bytes (§29). */ + readonly payloadIdentity: () => Uint8Array; + /** I_ACCESS, a raw X25519 identity of 32 bytes (§38). */ + readonly accessIdentity: () => Uint8Array; + /** The scalar of a dummy, 32 bytes, dropped once its public key is derived (§39). */ + readonly dummy: () => Uint8Array; + /** The 32-bit words of the permutation of the 16 slots (§39). */ + readonly words: RandomWords; + /** credential_id, 16 bytes (§42). */ + readonly credentialId: () => Uint8Array; +} + +const CHUNK = 64 << 10; +const chunks = (n: number): number => Math.max(1, Math.ceil(n / 65536)); + +/** + * SEALED_CONTROL_LEN from the lengths of spec §62.1 (informative note), with + * C the length of CONTROL_CBOR: INNER_ACCESS_AGE holds 16 X25519 stanzas of + * 98 bytes, and the tlock stanza of OUTER_TIME_AGE is 249 bytes plus the + * digits of the round. The real seal is checked to measure exactly this. + */ +export function sealedControlLength(policy: Policy, controlLength: number, round: number): number { + const n = policy === TIME_AND_KEY ? 86 + 98 * ACCESS_SLOTS + controlLength + 16 * chunks(controlLength) : controlLength; + return 335 + String(round).length + n + 16 * chunks(n); +} + +/** + * Writes a .dkc of format 2 for the content of `src` with the random values + * of `draws`, as capsule.Encrypt. See encrypt. + */ +export async function writeCapsule(src: EncryptSource, opts: EncryptOptions, draws: Draws): Promise { + const state: WriteState = {}; + try { + return await write(src, opts, draws, state); + } catch (err) { + // Nothing partial is presented as a capsule: the output is aborted + // after any failure, even a TypeError of the options (§62.1 rule 9). + const output = (opts as Partial | null | undefined)?.output; + try { + if (state.writer !== undefined) await state.writer.abort(err); + else if (output instanceof WritableStream) await output.abort(err); + } catch { + // An output whose abort fails does not change the error. + } + throw err; + } +} + +interface WriteState { + writer?: WritableStreamDefaultWriter; +} + +async function write(src: EncryptSource, opts: EncryptOptions, draws: Draws, state: WriteState): Promise { + // Step 1: the inputs, before anything is used. + if (typeof opts !== 'object' || opts === null) throw new TypeError('encrypt: options are required'); + if (opts.profile === undefined || opts.profile === null) throw new TypeError('capsule: EncryptOptions.Profile is required'); + if (typeof opts.now !== 'function') throw new TypeError('capsule: EncryptOptions.Now is required'); + if (typeof opts.policy !== 'number') throw new TypeError('encrypt: EncryptOptions.policy is required'); + if (!isInstant(opts.unlockAt)) throw new TypeError('encrypt: EncryptOptions.unlockAt is not an Instant'); + if (opts.output !== undefined && !(opts.output instanceof WritableStream)) throw new TypeError('encrypt: EncryptOptions.output is not a WritableStream'); + if (opts.progress !== undefined && typeof opts.progress !== 'function') throw new TypeError('encrypt: EncryptOptions.progress is not a function'); + const length = sourceLength(src, opts.length); + + // Step 2: copies, since the caller could change its inputs during an await. + const recipients = (opts.recipients ?? []).map((r, i) => { + if (!(r instanceof Uint8Array) || r.length !== 32) throw new TypeError(`encrypt: recipient ${i} is not a 32-byte X25519 public key`); + return copyBytes(r); + }); + const profile = cloneProfile(opts.profile); + const unlockAt: Instant = { seconds: opts.unlockAt.seconds, nanos: opts.unlockAt.nanos }; + const critical = copyExtensions(opts.critical); + const noncritical = copyExtensions(opts.noncritical); + const controlCritical = copyExtensions(opts.controlCritical); + const controlNoncritical = copyExtensions(opts.controlNoncritical); + const { policy, output, progress } = opts; + const wantsPortableKey = opts.newPortableKey === true; + const code = opts.padding === undefined ? REFORZADO : opts.padding; + + // Step 3: the profile. + await validateProfile(profile); + + // Step 4: the clock, once (§62.1 rule 2). + const now = opts.now(); + if (!isInstant(now)) throw new TypeError('encrypt: EncryptOptions.now() did not return an Instant'); + if (compareInstants(unlockAt, now) <= 0) throw new Error(`capsule: unlock time ${formatRFC3339Nano(unlockAt)} is not in the future`); + + // Step 5: the padding rule and P = rule(L), with the texts of PaddedLength. + if (!isPadding(code)) throw new Error(`capsule: padding code ${String(code)} is not defined`); + if (length > MAX_PAYLOAD_LENGTH) throw new Error(`capsule: content of ${length} bytes exceeds L_MAX = ${MAX_PAYLOAD_LENGTH}`); + const padded = paddedLength(length, code); + + // Step 6: the DateKey, resolved locally (§15). + const dateKey = resolveDateKey(profile, unlockAt); + const unlock = roundTime(profile, dateKey.round); + /* v8 ignore next 3 -- @preserve: resolveDateKey never resolves to a round before the instant (§15) */ + if (compareInstants(unlock, unlockAt) < 0) { + throw new DateKeysError('ERR_ROUND_MISMATCH', `capsule: resolved round ${dateKey.round} opens before the requested time`); + } + + const wipe: Uint8Array[] = []; + try { + // Step 7: the credentials, and I_ACCESS when asked, the last of them. + const credentials = accessRecipients(policy, recipients, wantsPortableKey); + let portable: Uint8Array | undefined; + if (wantsPortableKey) { + portable = drawn(draws.accessIdentity(), 32, 'I_ACCESS'); + wipe.push(portable); + credentials.push(x25519PublicKey(portable)); + } + + // Step 8: capsule_id and I_PAYLOAD. + const capsuleId = copyBytes(drawn(draws.capsuleId(), 16, 'capsule_id')); + const payloadId = drawn(draws.payloadIdentity(), 32, 'I_PAYLOAD'); + wipe.push(payloadId); + const payloadRecipient = x25519PublicKey(payloadId); + + // Step 9: the 16 slots of INNER_ACCESS_AGE, a dummy in each one left, + // whose scalar is dropped at once, in a uniformly random order (§39). + const slots: Uint8Array[] = []; + if (policy === TIME_AND_KEY) { + slots.push(...credentials); + while (slots.length < ACCESS_SLOTS) { + const scalar = drawn(draws.dummy(), 32, 'a dummy'); + try { + slots.push(x25519PublicKey(scalar)); + } finally { + scalar.fill(0); + } + } + permute(slots, draws.words); + } + + // Step 10: PUBLIC_HEADER, checked with the decoder of the reader. + const header = encodeHeader({ capsuleId, dateKey, policy, critical, noncritical }); + selfCheck('capsule: self-check: the reader rejects this PUBLIC_HEADER', () => decodeHeader(header)); + + // Step 11: the length of CONTROL_CBOR, which does not depend on the + // binding, I_PAYLOAD, L or the rule (§62.1 rule 7), and from it + // SEALED_CONTROL_LEN. The provisional control holds L and the control + // extensions, hidden until the date (§55.2): it is wiped at once. + const provisional = encodeControl( + { headerBinding: new Uint8Array(32), payloadIdentity: new Uint8Array(32), critical: controlCritical, noncritical: controlNoncritical, payloadLength: length, padding: code }, + FORMAT_2, + ); + const controlLength = provisional.length; + provisional.fill(0); + const sealedLength = sealedControlLength(policy, controlLength, dateKey.round); + if (sealedLength > MAX_SEALED_CONTROL_LEN) { + throw new DateKeysError('ERR_INTEGRITY', `capsule: SEALED_CONTROL of ${sealedLength} bytes exceeds ${MAX_SEALED_CONTROL_LEN}`); + } + const total = DKC_PRELUDE_SIZE + header.length + sealedLength + payloadAgeLength(padded); + if (output === undefined && total > MAX_MEMORY_DKC) { + throw new TypeError(`encrypt: a capsule of ${total} bytes needs EncryptOptions.output; in memory the limit is ${MAX_MEMORY_DKC}`); + } + + // Step 12: PRELUDE and header_binding over the exact bytes (§26). + const prelude = preludeBytes({ format: FORMAT_2, publicHeaderLen: header.length, sealedControlLen: sealedLength }); + const binding = await headerBinding(prelude, header); + + // Step 13: CONTROL_CBOR, schema version 2, checked with the decoder of + // the reader; the copy of I_PAYLOAD it decodes is wiped at once. + const control = encodeControl( + { headerBinding: binding, payloadIdentity: payloadId, critical: controlCritical, noncritical: controlNoncritical, payloadLength: length, padding: code }, + FORMAT_2, + ); + wipe.push(control); + selfCheck('capsule: self-check: the reader rejects this CONTROL_CBOR', () => decodeControl(control, FORMAT_2).payloadIdentity.fill(0)); + + // Step 14: SEALED_CONTROL. INNER_ACCESS_AGE for the 16 slots in their + // order, then OUTER_TIME_AGE with the tlock recipient alone: each age + // file draws its own file key, so the three are independent (§62). + let plaintext = control; + if (policy === TIME_AND_KEY) { + const e = new Encrypter(); + for (const r of slots) e.addRecipient(formatX25519Recipient(r)); + const inner = await sealWith(() => e.encrypt(control)); + await selfCheckInner(inner, control, portable); + plaintext = inner; + } + const recipient = timeRecipient(profile, dateKey.round); + const outer = new Encrypter(); + outer.addRecipient(recipient); + const sealed = await sealWith(() => outer.encrypt(plaintext)); + selfCheck('capsule: self-check: OUTER_TIME_AGE', () => checkTimeStanzas(ageStanzas(sealed), profile, dateKey.round)); + if (sealed.length !== sealedLength) throw new Error(`capsule: internal error: SEALED_CONTROL is ${sealed.length} bytes, measured ${sealedLength}`); + control.fill(0); + + // Step 15: PAYLOAD_AGE for R_PAYLOAD, over the content and its padding. + // Its first piece is its age header alone, which I_PAYLOAD must open + // before anything is written; then I_PAYLOAD is wiped. + // The source is read through contentStream, built first: a source that + // cannot be read, such as a locked stream, fails as the caller's error. + const source: SourceState = {}; + const input = contentStream(src, length, padded, source); + const pe = new Encrypter(); + pe.addRecipient(formatX25519Recipient(payloadRecipient)); + const payload = (await sealWith(() => pe.encrypt(input))) as ReadableStreamWithSize; + const reader = payload.getReader(); + try { + if (payload.size(padded) !== payloadAgeLength(padded)) { + throw new Error(`capsule: internal error: PAYLOAD_AGE would be ${payload.size(padded)} bytes, P = ${padded} gives ${payloadAgeLength(padded)}`); + } + const first = await readPiece(reader, source); + await selfCheckPayloadHeader(first.done ? new Uint8Array(0) : first.value, payloadId); + payloadId.fill(0); + + // Step 16: the total, and the room for it. + progress?.(0, total); + const sink = output === undefined ? memorySink(total) : streamSink(output, state); + + // Step 17: PRELUDE, PUBLIC_HEADER, SEALED_CONTROL and PAYLOAD_AGE, each + // piece hashed for the capsule_digest before it is written. + const hasher = sha256Hasher(); + let written = 0; + const put = async (b: Uint8Array): Promise => { + hasher.update(b); + await sink.write(b); + written += b.length; + progress?.(written, total); + }; + await put(prelude); + await put(header); + await put(sealed); + let payloadBytes = 0; + for (let r = first; !r.done; r = await readPiece(reader, source)) { + payloadBytes += r.value.length; + await put(r.value); + } + + // Step 18: P bytes handed to age, and PAYLOAD_AGE of the length P gives. + if (payloadBytes !== payloadAgeLength(padded)) { + throw new Error(`capsule: self-check: PAYLOAD_AGE is ${payloadBytes} bytes, P = ${padded} gives ${payloadAgeLength(padded)}`); + } + const dkc = await sink.close(); + + // Step 19: the .dkk, with the digest of the whole .dkc (§62, step 17). + let portableKey: AccessKey | undefined; + if (portable !== undefined) { + portableKey = { + credentialId: copyBytes(drawn(draws.credentialId(), 16, 'credential_id')), + capsuleId: copyBytes(capsuleId), + type: ACCESS_TYPE_X25519, + material: copyBytes(portable), + verification: { capsuleDigest: hasher.digest() }, + critical: [], + noncritical: [], + }; + } + return { + dateKey, + unlockAt: unlock, + capsuleId, + format: FORMAT_2, + length, + padding: code, + paddedLength: padded, + ...(portableKey === undefined ? {} : { portableKey }), + size: total, + ...(dkc === undefined ? {} : { dkc }), + }; + } catch (err) { + await reader.cancel(err).catch(() => undefined); + throw err; + } + } finally { + for (const b of wipe) b.fill(0); + } +} + +// L: the size of a Uint8Array or a Blob, which a declared length must match, +// or the declared length of a stream (§62.1 rule 6). +function sourceLength(src: EncryptSource, length: number | undefined): number { + if (length !== undefined && (!Number.isSafeInteger(length) || length < 0)) { + throw new TypeError(`encrypt: EncryptOptions.length ${String(length)} is not a non-negative safe integer`); + } + if (src instanceof Uint8Array || src instanceof Blob) { + const size = src instanceof Uint8Array ? src.length : src.size; + if (length !== undefined && length !== size) throw new TypeError(`encrypt: EncryptOptions.length ${length} is not the ${size} bytes of the source`); + return size; + } + if (src instanceof ReadableStream) { + if (length === undefined) throw new TypeError('encrypt: a ReadableStream needs EncryptOptions.length'); + return length; + } + throw new TypeError('encrypt: the source is a Uint8Array, a Blob or a ReadableStream'); +} + +const copyExtensions = (list: readonly Extension[] | undefined): Extension[] => + (list ?? []).map((e) => ({ id: e.id, version: e.version, data: e.data === undefined ? undefined : copyBytes(e.data) })); + +// A random value of the length it must have. +function drawn(b: Uint8Array, n: number, what: string): Uint8Array { + if (!(b instanceof Uint8Array) || b.length !== n) throw new Error(`encrypt: the random draw of ${what} is not ${n} bytes`); + return b; +} + +// The credentials of INNER_ACCESS_AGE, as accessRecipients of the Go +// reference: from 1 to 16, X25519, canonical, not of low order, none twice +// (§37, §39, §62.1 rule 3). I_ACCESS is added by the caller. +function accessRecipients(policy: Policy, recipients: readonly Uint8Array[], portable: boolean): Uint8Array[] { + if (policy === TIME_ONLY) { + if (recipients.length !== 0 || portable) throw new Error('capsule: time_only takes no recipients and no portable key'); + return []; + } + if (policy !== TIME_AND_KEY) throw new Error(`capsule: unknown access policy ${policy}`); + const n = recipients.length + (portable ? 1 : 0); + if (n === 0) throw new Error('capsule: time_and_key needs at least one recipient or a portable key'); + if (n > ACCESS_SLOTS) { + throw new Error(`capsule: time_and_key takes at most ${ACCESS_SLOTS} credentials, recipients and portable key together; ${n} given`); + } + const out: Uint8Array[] = []; + for (const [i, r] of recipients.entries()) { + try { + checkX25519Recipient(r); + } catch (err) { + throw new Error(`capsule: recipient ${i}: ${(err as Error).message}`); + } + if (out.some((x) => equalBytes(x, r))) { + throw new Error(`capsule: recipient ${formatX25519Recipient(r)} listed twice; INNER_ACCESS_AGE holds one stanza per recipient`); + } + out.push(r); + } + return out; +} + +// Runs a self-check; its failure keeps the normative code of the reader, if +// any, after `prefix`. +function selfCheck(prefix: string, check: () => void): void { + try { + check(); + } catch (err) { + throw selfCheckError(prefix, err); + } +} + +// Every check of the reader fails with a DateKeysError; anything else is a +// bug and propagates, as in open.ts. +function selfCheckError(prefix: string, err: unknown): Error { + /* v8 ignore next -- @preserve */ + if (!(err instanceof DateKeysError)) throw err; + return err.wrap(prefix); +} + +// INNER_ACCESS_AGE with the rules of the reader, as selfCheckInner: 16 X25519 +// stanzas with distinct shares, and, with a portable key, I_ACCESS opens +// exactly one of them and yields the control (§62.1 rule 11). +export async function selfCheckInner(inner: Uint8Array, control: Uint8Array, portable: Uint8Array | undefined): Promise { + const stanzas = stanzasOf(inner); + selfCheck('capsule: self-check', () => checkAccessStanzas(stanzas, ACCESS_SLOTS)); + if (portable === undefined) return; + let got: Uint8Array; + try { + got = await decryptAll(inner, accessIdentity([portable], ACCESS_SLOTS), 'age'); + } catch (err) { + throw selfCheckError('capsule: self-check: the portable key does not open INNER_ACCESS_AGE', err); + } + const same = equalBytes(got, control); + got.fill(0); + if (!same) throw new Error('capsule: self-check: INNER_ACCESS_AGE does not hold the control'); +} + +// The stanzas of INNER_ACCESS_AGE, with the text of Go when its header does +// not parse. +function stanzasOf(inner: Uint8Array): ReturnType { + try { + return ageStanzas(inner); + } catch (err) { + throw selfCheckError('capsule: self-check: INNER_ACCESS_AGE', err); + } +} + +// The age header of PAYLOAD_AGE, the first piece of its stream: it is the +// whole piece, and I_PAYLOAD opens it and verifies its MAC, as +// selfCheckPayload with age.DecryptHeader (§62.1 rule 11). The file key it +// yields is wiped. +export async function selfCheckPayloadHeader(first: Uint8Array, payloadId: Uint8Array): Promise { + let length: number; + try { + length = parseAgeHeader(first).length; + } catch { + throw new Error('capsule: self-check: the age header of PAYLOAD_AGE does not parse'); + } + if (length !== first.length) throw new Error('capsule: internal error: the first piece of PAYLOAD_AGE is not its age header alone'); + const d = new Decrypter(); + d.addIdentity(payloadIdentity(payloadId)); + let fileKey: Uint8Array; + try { + fileKey = await d.decryptHeader(first); + } catch { + throw new Error('capsule: self-check: I_PAYLOAD does not open the header of PAYLOAD_AGE'); + } + fileKey.fill(0); +} + +// A failure of age-encryption while sealing: a fixed text, the original in +// its cause (plan of phase 3, decision 9). +async function sealWith(seal: () => Promise): Promise { + try { + return await seal(); + } catch (err) { + throw new Error('capsule: age failed to seal', { cause: err }); + } +} + +interface SourceState { + /** The error of the source, or of its length, to rethrow as it is. */ + error?: unknown; +} + +// Reads a piece of PAYLOAD_AGE. A failure of the source reaches age as the +// error of its input, and comes back here as it was. +async function readPiece(reader: ReadableStreamDefaultReader, source: SourceState): Promise> { + try { + return await reader.read(); + } catch (err) { + if ('error' in source) throw source.error; + throw new Error('capsule: age failed to seal', { cause: err }); + } +} + +// The plaintext of PAYLOAD_AGE: exactly L bytes of the source, in pieces of +// at most 64 KiB, then the P - L zeros of the padding in fresh pieces +// (§29.1). A source that ends before L, or delivers more than L, fails with +// the texts of writeContent of the Go reference; empty chunks count for +// nothing. The bytes are counted for every source, since the view of a +// resizable or transferred ArrayBuffer can shrink during an await. +function contentStream(src: EncryptSource, length: number, padded: number, state: SourceState): ReadableStream { + const reader = src instanceof Uint8Array ? arrayReader(src) : (src instanceof Blob ? src.stream() : src).getReader(); + let read = 0; + let pending: Uint8Array = new Uint8Array(0); + let ended = false; + let padding = padded - length; + const more = (): Error => new Error(`capsule: the source delivers more than the ${length} bytes of EncryptOptions.Length`); + const next = async (): Promise => { + for (;;) { + const r = await reader.read(); + if (r.done) return undefined; + if (r.value.length > 0) return r.value; + } + }; + return new ReadableStream({ + async pull(controller) { + try { + if (read < length) { + if (pending.length === 0) { + const chunk = await next(); + if (chunk === undefined) throw new Error(`capsule: the source ended after ${read} bytes, and EncryptOptions.Length is ${length}`); + pending = chunk; + } + const take = Math.min(CHUNK, pending.length); + if (read + take > length) throw more(); + controller.enqueue(pending.subarray(0, take)); + pending = pending.subarray(take); + read += take; + return; + } + if (!ended) { + if (pending.length > 0 || (await next()) !== undefined) throw more(); + ended = true; + } + if (padding > 0) { + const zeros = Math.min(CHUNK, padding); + controller.enqueue(new Uint8Array(zeros)); + padding -= zeros; + return; + } + controller.close(); + } catch (err) { + state.error = err; + controller.error(err); + await reader.cancel(err).catch(() => undefined); + } + }, + async cancel(reason) { + await reader.cancel(reason).catch(() => undefined); + }, + }); +} + +// A reader of a Uint8Array in pieces of 64 KiB. +function arrayReader(b: Uint8Array): Pick, 'read' | 'cancel'> { + let at = 0; + return { + read: async () => { + if (at >= b.length) return { done: true, value: undefined }; + const value = b.subarray(at, at + CHUNK); + at += value.length; + return { done: false, value }; + }, + cancel: async () => undefined, + }; +} + +interface Sink { + write(b: Uint8Array): Promise; + /** Commits what was written; the .dkc when it was held in memory. */ + close(): Promise; +} + +// The .dkc in memory, in one buffer of its exact size, reserved only now. +function memorySink(total: number): Sink { + const buf = new Uint8Array(total); + let at = 0; + return { + write: async (b) => { + buf.set(b, at); + at += b.length; + }, + close: async () => buf, + }; +} + +// The .dkc into the output; a failure of the output keeps its own error, as +// Go returns the error of dst. +function streamSink(output: WritableStream, state: WriteState): Sink { + const w = output.getWriter(); + state.writer = w; + return { + write: (b) => w.write(b), + close: async () => { + await w.close(); + return undefined; + }, + }; +} diff --git a/vitest.config.ts b/vitest.config.ts index 882c146..fbd1992 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -40,6 +40,9 @@ export default defineConfig({ 'src/lib/dkc/random.ts': { 100: true }, 'src/lib/dkc/agefile.ts': { 100: true }, 'src/lib/dkc/padding.ts': { 100: true }, + // The writer (plan of phase 3, steps 3 and 4). + 'src/lib/dkc/writer.ts': { 100: true }, + 'src/lib/dkc/encrypt.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 },