diff --git a/CHANGELOG.md b/CHANGELOG.md index d91ee3a..e1c7402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ 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. +### 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. +- `random.ts`: el índice sin sesgo y la permutación de Fisher–Yates del orden de los 16 huecos, con la prueba de uniformidad de Go. +- `agefile.ts`: los ficheros `age` enteros que antes eran privados de la apertura, para compartirlos con el writer. +- `x25519.ts`: `newX25519Identity` y `x25519PublicKey`, con los vectores de RFC 7748. `digest.ts`: `sha256Hasher`. `datekey.ts`: `compareInstants`, que usa `open.ts`, e `isInstant`. +- `tempfile.ts`: la zona de la apertura y la de crear (`OPEN_AREA`, `CREATE_AREA`), con su limpieza por separado. +- Guardas: solo `agefile.ts`, `open.ts`, `tlock.ts`, `writer.ts` y los tests importan `age-encryption`; solo `encrypt.ts` y `testing/` importan `writer.ts`; `index.ts` no reexporta la apertura ni el writer. + ## 0.1.0 — 29 de septiembre de 2026 Implementa la especificación DateKeys 0.9 (tag `spec-v0.9` de `datekeys-go`) para el perfil Quicknet, y pasa todos los vectores y fixtures compartidos de `datekeys-go` en `7e2d83c`. Hasta el 29-09-2026 implementaba la 0.8.2 (`9ac9cd9`). diff --git a/README.md b/README.md index efa45bd..1e84276 100644 --- a/README.md +++ b/README.md @@ -54,16 +54,19 @@ La inspección (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegad | `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`) | | `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 de un stream, con `@noble/hashes`, para el `capsule_digest` de un `.dkc` que no está en memoria (Web Crypto solo calcula el hash de buffers enteros) | | -| `x25519.ts` | El stanza X25519 de `age`, abierto de uno en uno como `X25519Identity.Unwrap` de `age`: argumentos, share, acuerdo de claves, longitud del cuerpo y autenticación, en ese orden, con las primitivas que usa `age-encryption` (X25519 de `@noble/curves`, HKDF-SHA-256 de `@noble/hashes` y ChaCha20-Poly1305 de `@noble/ciphers`). También lee identidades `AGE-SECRET-KEY-1…` | `filippo.io/age` (`X25519Identity`), `agewrap` | +| `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) | | +| `agefile.ts` | Ficheros `age` enteros con el `Decrypter` de `age-encryption`, compartidos por la apertura y las autocomprobaciones del writer: los errores de una identidad conservan su código, y cualquier otro fallo de `age` es `ERR_INTEGRITY` con el motivo fijo de su fase, cabecera o STREAM. Lo que se lee en memoria se borra trozo a trozo | `capsule.Open` | +| `recipient.ts` | Recipients `age1…` como los lee y escribe `age` 1.3.2, con sus textos, y las reglas de §37 y §62.1 (regla 3) con los de `agewrap.CheckX25519Recipient`: se rechazan los no canónicos (bit 255, u ≥ p) y los de orden bajo, comprobados con la lista de sus cinco coordenadas u, sin aritmética de curva; un punto del twist se acepta, como en Go. También lee la lista de recipients que escribe una persona, como un fichero `-R` de `age` algo más tolerante, con errores por número de línea que nunca citan su contenido. Sin noble, para que una página valide las líneas sin cargar el writer | `age` (`ParseX25519Recipient`), `agewrap.CheckX25519Recipient` | +| `random.ts` | Un índice uniforme sin sesgo (rechaza las palabras de 32 bits desde ⌊2³²/n⌋·n) y la permutación de Fisher–Yates, para el orden de los 16 huecos de `INNER_ACCESS_AGE` (§39) | `capsule.permute` | +| `x25519.ts` | El stanza X25519 de `age`, abierto de uno en uno como `X25519Identity.Unwrap` de `age`: argumentos, share, acuerdo de claves, longitud del cuerpo y autenticación, en ese orden, con las primitivas que usa `age-encryption` (X25519 de `@noble/curves`, HKDF-SHA-256 de `@noble/hashes` y ChaCha20-Poly1305 de `@noble/ciphers`). También lee identidades `AGE-SECRET-KEY-1…`, genera identidades nuevas en bytes crudos (`newX25519Identity`) y deriva su clave pública (`x25519PublicKey`) | `filippo.io/age` (`X25519Identity`), `agewrap` | | `bech32.ts` | Bech32 (BIP 173) tal como `internal/bech32` de `age`, que la referencia copia como `codec/bech32`; conserva su aviso MIT | `codec/bech32` | -| `datekey.ts` | `dk1_` canónico con las reglas de lectura de §19 (CR, LF y todo carácter fuera del alfabeto fallan el paso 1; números JSON por su valor decimal exacto), ronda desde una fecha con precisión de nanosegundos y cota de 9999-12-31T23:59:59Z (§15), parser RFC 3339 equivalente a `time.Parse(time.RFC3339Nano, …)` | `datekey` | +| `datekey.ts` | `dk1_` canónico con las reglas de lectura de §19 (CR, LF y todo carácter fuera del alfabeto fallan el paso 1; números JSON por su valor decimal exacto), ronda desde una fecha con precisión de nanosegundos y cota de 9999-12-31T23:59:59Z (§15), parser RFC 3339 equivalente a `time.Parse(time.RFC3339Nano, …)`; `compareInstants` e `isInstant` | `datekey` | | `header.ts`, `control.ts`, `accesskey.ts` | PUBLIC_HEADER, CONTROL_CBOR y `.dkk` (cuerpo y trama), decodificar y codificar, con las capas de §69.1. CONTROL_CBOR se lee y se escribe para un formato: versión de schema 1 sin las claves 6 y 7, o 2 con `payload_length` (8 bytes, hasta L_MAX) y `padding` (1 o 2); 103 bytes sin extensiones sea cual sea L | `capsule`, `accesskey` | | `framing.ts` | Prelude DKC1 (16 bytes), con el formato de la cápsula, 1 o 2, y DKK1 (12 bytes) en el orden de §23 y §40, longitudes de 1 byte hasta los límites de §57, y troceo de secciones | `capsule/framing.go` | | `age.ts` | Parser estricto de la cabecera `age` v1 (§28.1) sobre los ficheros binarios, con los textos de error de `age`; reglas de stanzas, con los 16 de `INNER_ACCESS_AGE` en el formato 2 (`ACCESS_SLOTS`); `MAX_AGE_HEADER_LEN` (2 MiB), el límite que usa la página para leer solo el prefijo de un `.dkc` grande | `agewrap`, `filippo.io/age/internal/format` | | `inspect.ts` | Pasos 1 a 8 de §63 y la vista JSON de `datekeys inspect -json` (`inspectView`, `inspectJSON`) | `capsule/inspect.go`, `internal/inspectview` | | `prefix.ts` | Lecturas acotadas de un `Blob`: el prefijo de un `.dkc` que necesitan los pasos 1 a 8 (`readCapsule`) y, de una `.dkk`, como mucho 12 bytes + 16 MiB + 1 (`readAccessKey`), que dan el mismo resultado que el fichero entero | | -| `index.ts` | Reexporta todo salvo la fase 2 (`ibe.ts`, `release.ts`, `open.ts`, `tlock.ts`, `x25519.ts`, `bech32.ts` y `digest.ts`). La página importa `index.ts`, y reexportarlos metería noble en la primera carga de `/inspect` aunque no los use, porque noble ejecuta código al cargarse. La página carga la apertura bajo demanda (`src/lib/inspector/opener.ts`) | | +| `index.ts` | Reexporta todo salvo la fase 2 (`ibe.ts`, `release.ts`, `open.ts`, `tlock.ts`, `x25519.ts`, `bech32.ts`, `digest.ts` y `agefile.ts`) y la fase 3 (`encrypt.ts`, `writer.ts`, `recipient.ts` y `random.ts`), y una guarda lo comprueba. La página importa `index.ts`, y reexportarlos metería noble o `age-encryption` en la primera carga de `/inspect` aunque no los use, porque noble ejecuta código al cargarse. La página carga la apertura bajo demanda (`src/lib/inspector/opener.ts`) | | | `testing/` | Solo para tests: lectura de `testdata/` y de sus formatos (`vectors.ts`: ediciones, vectores), constructores de CBOR en hex, cirugía de cápsulas | | Los tests (`*.test.ts`) están junto a cada fichero. @@ -184,7 +187,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` y `digest.ts` al 100 % en líneas, ramas, funciones y sentencias; el conjunto de `src/lib/dkc` al 95/90/95/95, y el de `src/lib/inspector` también. +Umbrales de cobertura (`vitest.config.ts`): `cbor.ts`, `ibe.ts`, `release.ts`, `tlock.ts`, `x25519.ts`, `bech32.ts`, `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. `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`). @@ -236,6 +239,7 @@ Guardas de `src/lib/dependencies.test.ts`, en cada `npm test`: - `package-lock.json` no contiene `tlock-js` ni `drand-client`, ningún noble 1.x, ni más copias 2.x de `@noble/curves` o `@noble/hashes` que la 2.4.0 de la raíz y la 2.0.1 bajo `@noble/post-quantum`; - ningún fichero de `src/` importa `tlock-js` ni `drand-client`; - solo `digest.ts`, `ibe.ts`, `release.ts`, `x25519.ts` y los tests nombran `@noble/`, siempre con subrutas de `@noble/curves`, `@noble/hashes` y `@noble/ciphers` que resuelven a la copia 2.4.0 de la raíz; +- solo `agefile.ts`, `open.ts`, `tlock.ts`, `writer.ts` y los tests importan `age-encryption`; solo `encrypt.ts`, `testing/` y los tests importan `writer.ts`, cuyo núcleo recibe la aleatoriedad de quien lo llama (plan de la fase 3, decisiones 4 y 13); e `index.ts` no reexporta la apertura ni el writer; - cada comprobación se ejecuta también sobre entradas malas, así que una guarda que dejara de detectar algo fallaría. `check-build.mjs` hace la misma comprobación sobre el bundle del cliente. diff --git a/src/lib/dependencies.test.ts b/src/lib/dependencies.test.ts index 15477b1..c531466 100644 --- a/src/lib/dependencies.test.ts +++ b/src/lib/dependencies.test.ts @@ -47,6 +47,31 @@ const NOBLE = ['@noble/ciphers', '@noble/curves', '@noble/hashes']; const FORBIDDEN = ['tlock-js', 'drand-client']; // The only files besides the tests that may import noble. const NOBLE_IMPORTERS = ['src/lib/dkc/digest.ts', 'src/lib/dkc/ibe.ts', 'src/lib/dkc/release.ts', 'src/lib/dkc/x25519.ts']; +// The only files besides the tests that may import age-encryption (plan of +// phase 3, decision 13): the opening, the tlock recipient and the writer. +const AGE_IMPORTERS = ['src/lib/dkc/agefile.ts', 'src/lib/dkc/open.ts', 'src/lib/dkc/tlock.ts', 'src/lib/dkc/writer.ts']; +// The only files besides the tests that may import the core of the writer, +// which takes its random draws from the caller (decision 4): encrypt.ts, with +// crypto.getRandomValues, and the test helpers of testing/. +const WRITER_IMPORTERS = ['src/lib/dkc/encrypt.ts']; +const WRITER_IMPORTER_DIRS = ['src/lib/dkc/testing/']; +// The modules that index.ts must not re-export: the opening and the writer, +// which would bring noble or age-encryption into the first load of a page, +// and the internals of both. +const NOT_IN_INDEX = [ + 'agefile.ts', + 'bech32.ts', + 'digest.ts', + 'encrypt.ts', + 'ibe.ts', + 'open.ts', + 'random.ts', + 'recipient.ts', + 'release.ts', + 'tlock.ts', + 'writer.ts', + 'x25519.ts', +]; type LockEntry = { version?: string; dev?: boolean; dependencies?: Record }; type Source = { name: string; text: string }; @@ -102,12 +127,26 @@ function lockProblems(packages: Record): { problems: string[] return { problems, nested }; } +// Whether the module specifier `s`, written in the file `from`, is the file +// `target` of src/lib/dkc: './writer.ts' in a file of src/lib/dkc, or a path +// that ends in /dkc/writer.ts from anywhere else. +const reaches = (from: string, s: string, target: string): boolean => + (from.startsWith('src/lib/dkc/') && !from.slice('src/lib/dkc/'.length).includes('/') && s === `./${target}`) || + s.endsWith(`/dkc/${target}`) || + (from.startsWith('src/lib/dkc/testing/') && s === `../${target}`); + // The problems of the imports of the files of src/. function importProblems(files: Source[]): string[] { const problems: string[] = []; for (const { name, text } of files) { - if (!name.endsWith('.test.ts') && !NOBLE_IMPORTERS.includes(name) && text.includes('@noble/')) problems.push(`${name} names @noble/`); + const test = name.endsWith('.test.ts'); + if (!test && !NOBLE_IMPORTERS.includes(name) && text.includes('@noble/')) problems.push(`${name} names @noble/`); for (const s of specifiers(text)) { + if (!test && packageOf(s) === 'age-encryption' && !AGE_IMPORTERS.includes(name)) problems.push(`${name} imports age-encryption`); + if (!test && reaches(name, s, 'writer.ts') && !WRITER_IMPORTERS.includes(name) && !WRITER_IMPORTER_DIRS.some((d) => name.startsWith(d))) { + problems.push(`${name} imports the core of the writer`); + } + if (name === 'src/lib/dkc/index.ts' && NOT_IN_INDEX.some((m) => s === `./${m}`)) problems.push(`index.ts re-exports ${s}`); if (FORBIDDEN.includes(packageOf(s))) problems.push(`${name}: ${s} is forbidden`); else if (s.includes('node_modules')) problems.push(`${name}: ${s} reaches into node_modules`); else if (s.startsWith('@noble/') && !/^@noble\/(?:curves|hashes|ciphers)\/[\w./-]+\.js$/.test(s)) problems.push(`${name}: ${s}`); @@ -164,6 +203,12 @@ describe('runtime dependencies', () => { expect(importProblems(sources())).toEqual([]); }); + it('only agefile.ts, open.ts, tlock.ts, writer.ts and the tests import age-encryption; only encrypt.ts, testing/ and the tests import writer.ts; and index.ts re-exports neither the opening nor the writer', () => { + // The same check as above, whose problems include these three rules. + expect(importProblems(sources())).toEqual([]); + expect(AGE_IMPORTERS.filter((f) => f !== 'src/lib/dkc/writer.ts').every((f) => sources().some((s) => s.name === f))).toBe(true); + }); + it('the import check rejects each forbidden import', () => { const noble = "import { bls12_381 } from '@noble/curves/bls12-381.js';"; const chacha = "import { chacha20poly1305 } from '@noble/ciphers/chacha.js';"; @@ -172,6 +217,11 @@ describe('runtime dependencies', () => { { name: 'src/lib/dkc/ibe.ts', text: noble }, { name: 'src/lib/dkc/x25519.ts', text: chacha }, { name: 'src/lib/dkc/x.test.ts', text: noble }, + { name: 'src/lib/dkc/writer.ts', text: "import { Encrypter } from 'age-encryption';" }, + { name: 'src/lib/dkc/encrypt.ts', text: "import { seal } from './writer.ts';" }, + { name: 'src/lib/dkc/testing/encrypt.ts', text: "import { seal } from '../writer.ts';" }, + { name: 'src/lib/dkc/writer.test.ts', text: "import { seal } from './writer.ts';\nimport { Decrypter } from 'age-encryption';" }, + { name: 'src/lib/dkc/index.ts', text: "export * from './padding.ts';" }, ]), ).toEqual([]); const bad: [label: string, name: string, text: string][] = [ @@ -183,6 +233,14 @@ describe('runtime dependencies', () => { ['tlock-js', 'src/lib/dkc/open.ts', "import { timelockDecrypt } from 'tlock-js';"], ['drand-client, dynamically', 'src/lib/dkc/open.test.ts', "const c = await import('drand-client');"], ['tlock-js, re-exported', 'src/lib/dkc/index.ts', "export * from 'tlock-js/drand/timelock-decrypter';"], + ['age-encryption outside the allowlist', 'src/lib/dkc/encrypt.ts', "import { Encrypter } from 'age-encryption';"], + ['age-encryption from a page', 'src/lib/inspector/opener.ts', "const age = await import('age-encryption');"], + ['the writer core from a page', 'src/lib/inspector/creator.ts', "import { seal } from '../dkc/writer.ts';"], + ['the writer core from the opening', 'src/lib/dkc/open.ts', "import { seal } from './writer.ts';"], + ['the writer core from a component', 'src/lib/components/CreatePanel.svelte', "import { seal } from '$lib/dkc/writer.ts';"], + ['the writer in index.ts', 'src/lib/dkc/index.ts', "export * from './encrypt.ts';"], + ['the opening in index.ts', 'src/lib/dkc/index.ts', "export { open } from './open.ts';"], + ['recipient.ts in index.ts', 'src/lib/dkc/index.ts', "export * from './recipient.ts';"], ]; for (const [label, name, text] of bad) { expect(importProblems([{ name, text }]), label).not.toEqual([]); diff --git a/src/lib/dkc/agefile.ts b/src/lib/dkc/agefile.ts new file mode 100644 index 0000000..c2bf4d0 --- /dev/null +++ b/src/lib/dkc/agefile.ts @@ -0,0 +1,73 @@ +// Whole age files through the Decrypter of age-encryption, for the opening +// (open.ts) and for the self-checks of the writer (phase 3): a failure that +// an identity of this module reports keeps its normative error, and any other +// failure of age is ERR_INTEGRITY with the fixed reason of its phase, the +// header or the STREAM, never the text of age-encryption. Plaintexts read +// into memory are wiped chunk by chunk as they are copied, and on failure. +// Internal: index.ts does not re-export it. + +import { Decrypter, type Identity } from 'age-encryption'; +import { DateKeysError } from './errors.ts'; + +// The failures of age that no identity reports, by phase: the header, with +// its MAC checked once an identity unwrapped the file key, and the STREAM. +export const HEADER_FAILURE = 'the age header is malformed or truncated, or its MAC does not verify'; +export const STREAM_FAILURE = 'the age payload is truncated, has trailing data or fails STREAM authentication'; + +/** A stream that yields `b` in one chunk. */ +export const streamOf = (b: Uint8Array): ReadableStream => + new ReadableStream({ + start(c) { + c.enqueue(b); + c.close(); + }, + }); + +/** + * The error of `what` for a failure of age: the normative error an identity + * reported, prefixed, or ERR_INTEGRITY with `reason`. + */ +export function classify(what: string, reason: string, err: unknown): DateKeysError { + if (err instanceof DateKeysError) return err.wrap(`capsule: ${what}`); + return new DateKeysError('ERR_INTEGRITY', `capsule: ${what}: ${reason}`, err); +} + +/** The plaintext of an age file with one identity, as a stream, once its header opened. */ +export async function decrypt(file: ReadableStream, identity: Identity, what: string): Promise> { + const d = new Decrypter(); + d.addIdentity(identity); + try { + return await d.decrypt(file); + } catch (err) { + throw classify(what, HEADER_FAILURE, err); + } +} + +/** Opens a bounded age file in memory with one identity. The caller wipes the result. */ +export async function decryptAll(file: Uint8Array, identity: Identity, what: string): Promise { + return readAll(await decrypt(streamOf(file), identity, what), file.length, what); +} + +/** + * Reads a plaintext of at most `max` bytes, the length of its ciphertext, + * into one buffer, wiping every chunk it copies; on a failure of the STREAM + * the buffer is wiped too. + */ +export async function readAll(plain: ReadableStream, max: number, what: string): Promise { + const out = new Uint8Array(max); + let n = 0; + const reader = plain.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + out.set(value, n); + n += value.length; + value.fill(0); + } + } catch (err) { + out.fill(0); + throw classify(what, STREAM_FAILURE, err); + } + return out.subarray(0, n); +} diff --git a/src/lib/dkc/datekey.test.ts b/src/lib/dkc/datekey.test.ts index 17e6da1..ba38924 100644 --- a/src/lib/dkc/datekey.test.ts +++ b/src/lib/dkc/datekey.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { compareInstants, isInstant } from './datekey.ts'; import { base64RawURL, canonicalJSON, @@ -370,3 +371,34 @@ describe('rounds', () => { expectCode(() => resolveDateKey({ ...q, period: 1.5 }, { seconds: q.genesisTime, nanos: 0 }), 'ERR_UNKNOWN_PROFILE'); }); }); + +describe('instants', () => { + it('orders instants by seconds, then nanoseconds', () => { + const t = (seconds: number, nanos: number) => ({ seconds, nanos }); + expect(compareInstants(t(1, 0), t(1, 0))).toBe(0); + expect(compareInstants(t(1, 0), t(1, 1))).toBe(-1); + expect(compareInstants(t(1, 999_999_999), t(2, 0))).toBe(-1); + expect(compareInstants(t(2, 0), t(1, 999_999_999))).toBe(1); + expect(compareInstants(t(-5, 3), t(-5, 2))).toBe(1); + }); + + it('recognizes an instant: safe integer seconds and nanoseconds 0..999 999 999', () => { + for (const ok of [{ seconds: 0, nanos: 0 }, { seconds: -62135596800, nanos: 999_999_999 }, { seconds: 253402300799, nanos: 1 }]) { + expect(isInstant(ok), JSON.stringify(ok)).toBe(true); + } + for (const bad of [ + null, + undefined, + 1692806364, + { seconds: 1.5, nanos: 0 }, + { seconds: 0, nanos: -1 }, + { seconds: 0, nanos: 1_000_000_000 }, + { seconds: 0, nanos: 0.5 }, + { seconds: 2 ** 53, nanos: 0 }, + { seconds: '0', nanos: 0 }, + { seconds: 0 }, + ]) { + expect(isInstant(bad), JSON.stringify(bad)).toBe(false); + } + }); +}); diff --git a/src/lib/dkc/datekey.ts b/src/lib/dkc/datekey.ts index cbabc99..2cb55cd 100644 --- a/src/lib/dkc/datekey.ts +++ b/src/lib/dkc/datekey.ts @@ -34,6 +34,19 @@ export interface Instant { readonly nanos: number; } +/** Whether `t` is an Instant: safe integer seconds and integer nanoseconds 0..999 999 999. */ +export function isInstant(t: unknown): t is Instant { + if (typeof t !== 'object' || t === null) return false; + const { seconds, nanos } = t as { seconds?: unknown; nanos?: unknown }; + return Number.isSafeInteger(seconds) && Number.isInteger(nanos) && (nanos as number) >= 0 && (nanos as number) <= 999_999_999; +} + +/** Negative, zero or positive as `a` is before, equal to or after `b`. */ +export function compareInstants(a: Instant, b: Instant): number { + if (a.seconds !== b.seconds) return a.seconds < b.seconds ? -1 : 1; + return a.nanos === b.nanos ? 0 : a.nanos < b.nanos ? -1 : 1; +} + // --------------------------------------------------------------------------- // dk1_ diff --git a/src/lib/dkc/digest.test.ts b/src/lib/dkc/digest.test.ts index ffb198c..edd6d16 100644 --- a/src/lib/dkc/digest.test.ts +++ b/src/lib/dkc/digest.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { sha256 } from './bytes.ts'; -import { sha256Stream } from './digest.ts'; +import { sha256Hasher, sha256Stream } from './digest.ts'; import { readBytes } from './testing/testdata.ts'; describe('sha256Stream', () => { @@ -18,3 +18,17 @@ describe('sha256Stream', () => { expect(await sha256Stream(new Blob([]).stream())).toEqual(await sha256(new Uint8Array(0))); }); }); + +describe('sha256Hasher', () => { + it('hashes pieces as Web Crypto hashes the whole buffer, however they are cut', async () => { + const dkc = readBytes('fixtures/format2_time_only.dkc'); + for (const cut of [1, 7, 4096, 65536, dkc.length]) { + const hasher = sha256Hasher(); + for (let at = 0; at < dkc.length; at += cut) hasher.update(dkc.subarray(at, at + cut)); + expect(hasher.digest(), String(cut)).toEqual(await sha256(dkc)); + } + const empty = sha256Hasher(); + empty.update(new Uint8Array(0)); + expect(empty.digest()).toEqual(await sha256(new Uint8Array(0))); + }); +}); diff --git a/src/lib/dkc/digest.ts b/src/lib/dkc/digest.ts index 63e879d..6046697 100644 --- a/src/lib/dkc/digest.ts +++ b/src/lib/dkc/digest.ts @@ -1,12 +1,30 @@ -// SHA-256 of a stream, for the capsule_digest of a .dkk over a .dkc that is -// not held in memory (spec §43, §63 step 9.a). Web Crypto digests only whole -// buffers; @noble/hashes 2.4.0 hashes incrementally. +// Incremental SHA-256: for the capsule_digest of a .dkk over a .dkc that is +// not held in memory (spec §43, §63 step 9.a), and over what the writer +// writes, as Go's io.MultiWriter with sha256 (phase 3). Web Crypto digests +// only whole buffers; @noble/hashes 2.4.0 hashes incrementally. import { sha256 } from '@noble/hashes/sha2.js'; +/** An incremental SHA-256: update with each piece, then digest once. */ +export interface Sha256Hasher { + update(b: Uint8Array): void; + digest(): Uint8Array; +} + +/** A new incremental SHA-256. */ +export function sha256Hasher(): Sha256Hasher { + const h = sha256.create(); + return { + update(b) { + h.update(b); + }, + digest: () => h.digest(), + }; +} + /** SHA-256 of everything `stream` yields. */ export async function sha256Stream(stream: ReadableStream): Promise { - const h = sha256.create(); + const h = sha256Hasher(); const reader = stream.getReader(); for (;;) { const { done, value } = await reader.read(); diff --git a/src/lib/dkc/open.ts b/src/lib/dkc/open.ts index cb5b0fd..cad7622 100644 --- a/src/lib/dkc/open.ts +++ b/src/lib/dkc/open.ts @@ -27,12 +27,13 @@ // the content followed by zeros up to P = rule(L), of which only the first L // bytes are delivered. -import { Decrypter, type Identity, type Stanza as AgeStanza } from 'age-encryption'; +import { type Identity, type Stanza as AgeStanza } from 'age-encryption'; import { type AccessKey, checkAccessKeyMaterial, decodeAccessKey, wipeAccessKey } from './accesskey.ts'; import { ACCESS_SLOTS, ageStanzas, checkAccessStanzas, checkPayloadStanzas, checkTimeStanzas, type Stanza } from './age.ts'; +import { classify, decrypt, decryptAll, STREAM_FAILURE, streamOf } from './agefile.ts'; import { equalBytes, sha256, toHex } from './bytes.ts'; import { type Control, decodeControl } from './control.ts'; -import { formatRFC3339, type Instant } from './datekey.ts'; +import { compareInstants, formatRFC3339, type Instant } from './datekey.ts'; import { sha256Stream } from './digest.ts'; import { DateKeysError } from './errors.ts'; import { checkCritical, checkNoncritical, type Extension, type ExtensionRegistry, type Unusable } from './extension.ts'; @@ -126,15 +127,10 @@ export interface Opened { type Mutable = { -readonly [K in keyof T]: T[K] }; -// The failures of age that no identity reports, by phase: the header, with -// its MAC checked once an identity unwrapped the file key, and the STREAM. -const HEADER_FAILURE = 'the age header is malformed or truncated, or its MAC does not verify'; -const STREAM_FAILURE = 'the age payload is truncated, has trailing data or fails STREAM authentication'; // The form of an X25519 stanza that age requires. const X25519_FORM = 'one argument, a 32-byte ephemeral share not of low order, and a 32-byte body'; const unusable = (u: readonly Unusable[]): string => (u.length === 0 ? '' : `, ${u.length} unusable noncritical extensions`); -const before = (a: Instant, b: Instant): boolean => a.seconds < b.seconds || (a.seconds === b.seconds && a.nanos < b.nanos); /** * Runs the whole flow of spec §63 on a .dkc, in memory or a Blob, and @@ -248,7 +244,7 @@ async function openCapsule( // Step 9: the release, never before its round time. const round = h.dateKey.round; const now = opts.now(); - if (before(now, inspection.unlockAt!)) { + if (compareInstants(now, inspection.unlockAt!) < 0) { return fail( 9, 'release', @@ -449,55 +445,6 @@ function looksLikeAge(b: Uint8Array): boolean { return b.length >= intro.length && [...intro].every((c, i) => b[i] === c.charCodeAt(0)); } -const streamOf = (b: Uint8Array): ReadableStream => - new ReadableStream({ - start(c) { - c.enqueue(b); - c.close(); - }, - }); - -// The plaintext of an age file with one identity, as a stream, once its -// header opened. A failure that the identity reports keeps its normative -// error; any other failure of age is ERR_INTEGRITY with the fixed reason of -// its phase, never the text of age-encryption. -async function decrypt(file: ReadableStream, identity: Identity, what: string): Promise> { - const d = new Decrypter(); - d.addIdentity(identity); - try { - return await d.decrypt(file); - } catch (err) { - throw classify(what, HEADER_FAILURE, err); - } -} - -// Opens a bounded age file in memory with one identity. -async function decryptAll(file: Uint8Array, identity: Identity, what: string): Promise { - return readAll(await decrypt(streamOf(file), identity, what), file.length, what); -} - -// Reads a plaintext of at most `max` bytes, the length of its ciphertext, -// into one buffer, wiping every chunk it copies; on a failure of the STREAM -// the buffer is wiped too. -async function readAll(plain: ReadableStream, max: number, what: string): Promise { - const out = new Uint8Array(max); - let n = 0; - const reader = plain.getReader(); - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - out.set(value, n); - n += value.length; - value.fill(0); - } - } catch (err) { - out.fill(0); - throw classify(what, STREAM_FAILURE, err); - } - return out.subarray(0, n); -} - /** L and P of the PAYLOAD_AGE of a format 2 capsule. */ interface Padded { readonly l: number; @@ -569,10 +516,6 @@ async function write(w: WritableStreamDefaultWriter, piece: Uint8Arr const writing = (what: string, err: unknown): DateKeysError => new DateKeysError('ERR_INTEGRITY', `capsule: ${what}: writing the plaintext: ${err instanceof Error ? err.message : String(err)}`, err); -function classify(what: string, reason: string, err: unknown): DateKeysError { - if (err instanceof DateKeysError) return err.wrap(`capsule: ${what}`); - return new DateKeysError('ERR_INTEGRITY', `capsule: ${what}: ${reason}`, err); -} // The stanzas of age-encryption, whose type is their first argument: its // parser makes a stanza only of a line "->" followed by at least one diff --git a/src/lib/dkc/random.test.ts b/src/lib/dkc/random.test.ts new file mode 100644 index 0000000..543fa87 --- /dev/null +++ b/src/lib/dkc/random.test.ts @@ -0,0 +1,93 @@ +// Tests of random.ts: the index without bias and the uniform permutation of +// the 16 slots of INNER_ACCESS_AGE (spec §39, §62.1 rule 4). + +import { describe, expect, it } from 'vitest'; +import { cryptoWords, permute, randomIndex, type RandomWords } from './random.ts'; + +// A seeded source of 32-bit words (mulberry32), so that the statistical +// test is deterministic; its seed is in the test name. +function seeded(seed: number): RandomWords { + let a = seed >>> 0; + return () => { + 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; + }; +} +const sequence = (words: number[]): RandomWords => { + let i = 0; + return () => { + if (i >= words.length) throw new Error('sequence exhausted'); + return words[i++]!; + }; +}; + +describe('randomIndex', () => { + it('draws again exactly the words from ⌊2^32 / n⌋·n up', () => { + // n = 3: 2^32 = 3·1431655765 + 1, so only 4294967295 is rejected. + expect(randomIndex(3, sequence([4294967295, 7]))).toBe(1); + expect(randomIndex(3, sequence([4294967294]))).toBe(4294967294 % 3); + // n = 16 divides 2^32: nothing is rejected. + expect(randomIndex(16, sequence([4294967295]))).toBe(15); + // n = 10: the limit is 4294967290. + expect(randomIndex(10, sequence([4294967290, 4294967295, 4294967289]))).toBe(9); + expect(randomIndex(1, sequence([123]))).toBe(0); + expect(randomIndex(2 ** 32, sequence([4294967295]))).toBe(4294967295); + }); + + it('rejects an n without a uniform index, and words that are not 32-bit', () => { + for (const n of [0, -1, 1.5, 2 ** 32 + 1, Number.NaN]) expect(() => randomIndex(n, sequence([0])), String(n)).toThrow(RangeError); + for (const w of [-1, 2 ** 32, 0.5]) expect(() => randomIndex(5, sequence([w])), String(w)).toThrow(RangeError); + }); + + it('draws words from crypto.getRandomValues', () => { + const words = cryptoWords(); + const drawn = Array.from({ length: 64 }, words); + expect(drawn.every((w) => Number.isInteger(w) && w >= 0 && w < 2 ** 32)).toBe(true); + expect(new Set(drawn).size).toBeGreaterThan(60); + }); +}); + +describe('permute', () => { + it('permutes in place and keeps every element', () => { + const items = Array.from({ length: 16 }, (_, i) => i); + permute(items, seeded(1)); + expect([...items].sort((a, b) => a - b)).toEqual(Array.from({ length: 16 }, (_, i) => i)); + const one = ['a']; + permute(one, sequence([])); + expect(one).toEqual(['a']); + const none: string[] = []; + permute(none, sequence([])); + expect(none).toEqual([]); + }); + + // As TestStanzaOrderIsUniform of the Go reference: the positions of the + // first and of the last element over 32 000 permutations of 16, each a + // chi-square with 15 degrees of freedom under 60 (p ≈ 10⁻⁷ by chance). + it.each([[20260929], [7]])('puts the first and the last element in every position uniformly (seed %i)', (seed) => { + const words = seeded(seed); + const n = 16; + const rounds = 32000; + const first = new Array(n).fill(0); + const last = new Array(n).fill(0); + for (let r = 0; r < rounds; r++) { + const items = Array.from({ length: n }, (_, i) => i); + permute(items, words); + first[items.indexOf(0)]!++; + last[items.indexOf(n - 1)]!++; + } + const chi2 = (counts: number[]): number => counts.reduce((s, c) => s + (c - rounds / n) ** 2 / (rounds / n), 0); + expect(chi2(first)).toBeLessThan(60); + expect(chi2(last)).toBeLessThan(60); + }); + + // The modulo of a word alone would be biased: with words that favour + // small values the frequencies drift, which the rejection prevents. + it('does not take the modulo of a word that would bias the result', () => { + // For n = 3 the only rejected word is 2^32 - 1, whose modulo would give 0. + const draws = [4294967295, 1]; + expect(randomIndex(3, sequence(draws))).toBe(1); + }); +}); diff --git a/src/lib/dkc/random.ts b/src/lib/dkc/random.ts new file mode 100644 index 0000000..ea650d3 --- /dev/null +++ b/src/lib/dkc/random.ts @@ -0,0 +1,42 @@ +// Uniform random choices for the writer: an index without bias and the +// Fisher–Yates permutation of the 16 recipients of INNER_ACCESS_AGE (spec +// §39, §62.1 rules 4 and 5), as permute of the Go reference with +// crypto/rand.Int. The source of 32-bit words is crypto.getRandomValues in +// production; the tests pass their own. + +/** A source of uniform 32-bit words. */ +export type RandomWords = () => number; + +/** 32-bit words from crypto.getRandomValues. */ +export function cryptoWords(): RandomWords { + const buf = new Uint32Array(1); + return () => { + crypto.getRandomValues(buf); + return buf[0]!; + }; +} + +/** + * A uniform integer in [0, n), for 1 ≤ n ≤ 2^32. A word from ⌊2^32 / n⌋·n + * up would favour the small results, so it is drawn again: the modulo of a + * 32-bit word alone is biased for every n that does not divide 2^32. + */ +export function randomIndex(n: number, words: RandomWords): number { + if (!Number.isSafeInteger(n) || n < 1 || n > 2 ** 32) throw new RangeError(`random: no uniform index in [0, ${n})`); + const limit = Math.floor(2 ** 32 / n) * n; + for (;;) { + const w = words(); + if (!Number.isInteger(w) || w < 0 || w >= 2 ** 32) throw new RangeError('random: a word is an integer in [0, 2^32)'); + if (w < limit) return w % n; + } +} + +/** Puts `items` in a uniformly random order, in place: Fisher–Yates with randomIndex. */ +export function permute(items: T[], words: RandomWords): void { + for (let i = items.length - 1; i > 0; i--) { + const j = randomIndex(i + 1, words); + const t = items[i]!; + items[i] = items[j]!; + items[j] = t; + } +} diff --git a/src/lib/dkc/recipient.test.ts b/src/lib/dkc/recipient.test.ts new file mode 100644 index 0000000..23eee9a --- /dev/null +++ b/src/lib/dkc/recipient.test.ts @@ -0,0 +1,150 @@ +// Tests of recipient.ts: the age1… strings of age 1.3.2 and the rules of +// spec §37 with the texts of agewrap.CheckX25519Recipient. noble is only the +// oracle of the low-order list here; recipient.ts never imports it. + +import { x25519 } from '@noble/curves/ed25519.js'; +import { generateX25519Identity, identityToRecipient } from 'age-encryption'; +import { describe, expect, it } from 'vitest'; +import { bech32Encode } from './bech32.ts'; +import { + checkX25519Recipient, + formatX25519Recipient, + parseRecipientList, + parseX25519Recipient, + RecipientListError, + recipientProblem, +} from './recipient.ts'; +import { h, hx } from './testing/testdata.ts'; +import { newX25519Identity, parseX25519Identity, x25519PublicKey } from './x25519.ts'; + +const P = 2n ** 255n - 19n; +const le = (n: bigint): Uint8Array => { + const b = new Uint8Array(32); + for (let i = 0; i < 32; i++) b[i] = Number((n >> BigInt(8 * i)) & 0xffn); + return b; +}; +const ORDER8A = 325606250916557431795983626356110631294008115727848805560023387167927233504n; +const ORDER8B = 39382357235489614581723060781553021112529911719440698176882885853963445705823n; +const failure = (fn: () => unknown): string => { + try { + fn(); + } catch (err) { + return (err as Error).message; + } + return 'no error'; +}; + +describe('age1… recipients', () => { + it('formats and parses the recipient of an identity as age-encryption does', async () => { + for (let i = 0; i < 20; i++) { + const s = await generateX25519Identity(); + const r = await identityToRecipient(s); + const raw = x25519PublicKey(parseX25519Identity(s)); + expect(formatX25519Recipient(raw)).toBe(r); + expect(parseX25519Recipient(r)).toEqual(raw); + } + }); + + it('rejects what age.ParseX25519Recipient rejects, with its texts', () => { + const raw = x25519PublicKey(newX25519Identity()); + const good = formatX25519Recipient(raw); + expect(failure(() => parseX25519Recipient(good.toUpperCase()))).toBe(`malformed recipient "${good.toUpperCase()}": invalid type "AGE"`); + const mixed = good.slice(0, 10) + good.slice(10).toUpperCase(); + expect(failure(() => parseX25519Recipient(mixed))).toBe(`malformed recipient "${mixed}": mixed case`); + for (const hrp of ['age1pq', 'age1tag', 'age1tagpq']) { + const other = bech32Encode(hrp, raw); + expect(failure(() => parseX25519Recipient(other))).toBe(`malformed recipient "${other}": invalid type "${hrp}"`); + } + for (const n of [31, 33]) { + const s = bech32Encode('age', new Uint8Array(n).fill(9)); + expect(failure(() => parseX25519Recipient(s))).toBe(`malformed recipient "${s}": invalid X25519 public key`); + } + const flipped = good.slice(0, -1) + (good.endsWith('q') ? 'p' : 'q'); + expect(failure(() => parseX25519Recipient(flipped))).toBe(`malformed recipient "${flipped}": invalid checksum`); + expect(() => formatX25519Recipient(new Uint8Array(31))).toThrow(TypeError); + }); +}); + +describe('the rules of §37', () => { + it('accepts fresh keys and rejects the non-canonical ones and those of low order, with the texts of Go', () => { + for (let i = 0; i < 50; i++) { + const raw = x25519PublicKey(newX25519Identity()); + expect(recipientProblem(raw)).toBeUndefined(); + expect(() => checkX25519Recipient(raw)).not.toThrow(); + } + const text = (raw: Uint8Array): string => failure(() => checkX25519Recipient(raw)); + const bit255 = le(9n); + bit255[31]! |= 0x80; + expect(text(bit255)).toBe(`agewrap: recipient ${formatX25519Recipient(bit255)} is not canonical: bit 255 is set`); + for (const u of [P, P + 1n, P + 18n]) { + expect(text(le(u)), String(u)).toBe(`agewrap: recipient ${formatX25519Recipient(le(u))} is not canonical: u is not below 2^255 - 19`); + } + for (const u of [0n, 1n, ORDER8A, ORDER8B, P - 1n]) { + expect(text(le(u)), String(u)).toBe(`agewrap: recipient ${formatX25519Recipient(le(u))} is a point of low order: the shared secret would be zero`); + } + // p - 2, 2 (a point of the twist, accepted as Go does) and 9 are canonical and not of low order. + for (const u of [P - 2n, 2n, 9n]) expect(recipientProblem(le(u)), String(u)).toBeUndefined(); + expect(() => checkX25519Recipient(new Uint8Array(33))).toThrow(TypeError); + }); + + // The list is the probe of Go: X25519 of a clamped scalar and u is all zero + // exactly for these five canonical u. noble rejects the zero secret. + it('lists exactly the canonical u whose shared secret is zero', () => { + const scalar = new Uint8Array(32); + scalar[0] = 1; + const zero = (u: bigint): boolean => { + try { + return x25519.scalarMult(scalar, le(u)).every((b) => b === 0); + } catch { + return true; + } + }; + for (const u of [0n, 1n, ORDER8A, ORDER8B, P - 1n]) expect(zero(u), String(u)).toBe(true); + let checked = 0; + for (let i = 0; i < 200; i++) { + const raw = crypto.getRandomValues(new Uint8Array(32)); + raw[31]! &= 0x7f; + const u = BigInt(`0x${hx(raw.slice().reverse())}`); + if (u >= P) continue; + expect(zero(u), hx(raw)).toBe(recipientProblem(raw) === 'low order'); + checked++; + } + expect(checked).toBeGreaterThan(150); + }); +}); + +describe('parseRecipientList', () => { + const r1 = formatX25519Recipient(x25519PublicKey(h('77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a'))); + const r2 = formatX25519Recipient(x25519PublicKey(h('5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb'))); + + it('reads an age recipients file, trimmed, with comments and blank lines', () => { + const list = parseRecipientList(`# friends\r\n ${r1} \n\n\t# not a recipient\n${r2}\n`); + expect(list.map(formatX25519Recipient)).toEqual([r1, r2]); + expect(parseRecipientList('')).toEqual([]); + expect(parseRecipientList('# only a comment\n\n')).toEqual([]); + }); + + it('names the first bad line and why, never its content', () => { + const bad = (text: string): [number, string, string] => { + try { + parseRecipientList(text); + } catch (err) { + const e = err as RecipientListError; + expect(e).toBeInstanceOf(RecipientListError); + return [e.line, e.problem, e.message]; + } + return [0, 'none', '']; + }; + const secret = 'AGE-SECRET-KEY-1QQQ'; + expect(bad(`${r1}\n${secret}`)).toEqual([2, 'identity', 'recipient list: line 2: identity']); + expect(bad(`\n\nage1nope`)).toEqual([3, 'malformed', 'recipient list: line 3: malformed']); + expect(bad(formatX25519Recipient(le(P)))).toEqual([1, 'not canonical', 'recipient list: line 1: not canonical']); + const high = le(9n); + high[31]! |= 0x80; + expect(bad(formatX25519Recipient(high))[1]).toBe('not canonical'); + expect(bad(`${r2}\n${formatX25519Recipient(le(1n))}`)).toEqual([2, 'low order', 'recipient list: line 2: low order']); + expect(bad(`${r1}\n${r2}\n${r1}`)).toEqual([3, 'duplicate', 'recipient list: line 3: duplicate']); + // The message never holds the line. + expect(bad(secret)[2]).not.toContain('SECRET'); + }); +}); diff --git a/src/lib/dkc/recipient.ts b/src/lib/dkc/recipient.ts new file mode 100644 index 0000000..48eab6c --- /dev/null +++ b/src/lib/dkc/recipient.ts @@ -0,0 +1,144 @@ +// X25519 recipients of age written as strings, age1…, and the rules a writer +// applies to them (spec §37, §62.1 rule 3), as ParseX25519Recipient and +// X25519Recipient.String of age 1.3.2 and agewrap.CheckX25519Recipient of the +// Go reference, with their texts. +// +// No noble: a page checks the lines of a list as they are typed, before it +// loads the writer. Canonicity is a comparison of bytes, and the low order a +// list of the five canonical u-coordinates whose shared secret is all zero. +// A canonical point of the twist is accepted, as Go does: §37 leaves its +// rejection to the writer (MAY), and nobody could open its stanza. + +import { bech32Decode, bech32Encode } from './bech32.ts'; +import { equalBytes, goQuote } from './bytes.ts'; + +/** The size of an X25519 public key. */ +export const X25519_RECIPIENT_LEN = 32; +const HRP = 'age'; + +const le32 = (n: bigint): Uint8Array => { + const b = new Uint8Array(X25519_RECIPIENT_LEN); + for (let i = 0; i < b.length; i++) b[i] = Number((n >> BigInt(8 * i)) & 0xffn); + return b; +}; +const P = 2n ** 255n - 19n; + +// The canonical u-coordinates of the points of low order of Curve25519 and +// its twist: 0, 1, the two points of order 8 and p - 1. The scalars of +// X25519 are clamped to multiples of 8, so its result with one of these is +// all zero whatever the scalar, and with any other canonical u it never is: +// the probe of agewrap.CheckX25519Recipient, which uses the scalar 1. +const LOW_ORDER: readonly Uint8Array[] = [ + 0n, + 1n, + 325606250916557431795983626356110631294008115727848805560023387167927233504n, + 39382357235489614581723060781553021112529911719440698176882885853963445705823n, + P - 1n, +].map(le32); + +function checkLength(raw: Uint8Array): void { + if (!(raw instanceof Uint8Array) || raw.length !== X25519_RECIPIENT_LEN) { + throw new TypeError(`recipient: an X25519 public key is ${X25519_RECIPIENT_LEN} bytes`); + } +} + +/** The age1… string of a raw X25519 public key, as X25519Recipient.String of age. */ +export function formatX25519Recipient(raw: Uint8Array): string { + checkLength(raw); + return bech32Encode(HRP, raw); +} + +/** + * The raw 32 bytes of an age1… recipient, as age.ParseX25519Recipient reads + * it, with its texts: lowercase Bech32 with the HRP age. AGE1…, mixed case, + * age1pq1…, age1tag1… and any other length are rejected. It does not check + * the rules of §37: see checkX25519Recipient. + */ +export function parseX25519Recipient(s: string): Uint8Array { + let decoded: { hrp: string; data: Uint8Array }; + try { + decoded = bech32Decode(s); + } catch (err) { + throw new Error(`malformed recipient ${goQuote(s)}: ${(err as Error).message}`); + } + if (decoded.hrp !== HRP) throw new Error(`malformed recipient ${goQuote(s)}: invalid type ${goQuote(decoded.hrp)}`); + if (decoded.data.length !== X25519_RECIPIENT_LEN) throw new Error(`malformed recipient ${goQuote(s)}: invalid X25519 public key`); + return decoded.data; +} + +/** Why a raw X25519 public key is not a recipient a writer may use (spec §37). */ +export type RecipientProblem = 'bit 255' | 'not below p' | 'low order'; + +/** The problem of a raw X25519 public key under §37, or undefined when a writer may use it. */ +export function recipientProblem(raw: Uint8Array): RecipientProblem | undefined { + checkLength(raw); + if ((raw[31]! & 0x80) !== 0) return 'bit 255'; + // p = 2^255 - 19 is 0xed, then 30 bytes 0xff, then 0x7f, little-endian. + if (raw[31] === 0x7f && raw[0]! >= 0xed && raw.subarray(1, 31).every((b) => b === 0xff)) return 'not below p'; + if (LOW_ORDER.some((u) => equalBytes(u, raw))) return 'low order'; + return undefined; +} + +/** + * Rejects a raw X25519 public key that a writer MUST NOT encrypt to (spec + * §37, §62.1 rule 3), with the texts of agewrap.CheckX25519Recipient: a + * non-canonical one, whose stanza no identity opens, and one of low order, + * for which the shared secret is zero. A key that is not 32 bytes is a + * TypeError. + */ +export function checkX25519Recipient(raw: Uint8Array): void { + const problem = recipientProblem(raw); + if (problem !== undefined) throw new Error(`agewrap: recipient ${formatX25519Recipient(raw)} ${PROBLEM_TEXTS[problem]}`); +} + +// The texts of agewrap.CheckX25519Recipient after "agewrap: recipient age1… ". +const PROBLEM_TEXTS: Readonly> = { + 'bit 255': 'is not canonical: bit 255 is set', + 'not below p': 'is not canonical: u is not below 2^255 - 19', + 'low order': 'is a point of low order: the shared secret would be zero', +}; + +/** Why a line of a recipient list was rejected. */ +export type RecipientLineProblem = 'malformed' | 'identity' | 'not canonical' | 'low order' | 'duplicate'; + +/** A rejected line of a recipient list: its number from 1 and why, never its content. */ +export class RecipientListError extends Error { + readonly line: number; + readonly problem: RecipientLineProblem; + + constructor(line: number, problem: RecipientLineProblem) { + super(`recipient list: line ${line}: ${problem}`); + this.name = 'RecipientListError'; + this.line = line; + this.problem = problem; + } +} + +/** + * The recipients of a list written by a person, as an age recipients file + * (age -R), a little more lenient: lines end in LF or CRLF, each line is + * trimmed, and blank lines and lines starting with # are skipped. As age + * does, a line starting with AGE- is rejected: it is a secret identity pasted + * by mistake. Each line must be a recipient a writer may use (§37) and none + * may repeat an earlier one. The first bad line throws a RecipientListError, + * which names its number and never its content. + */ +export function parseRecipientList(text: string): Uint8Array[] { + const out: Uint8Array[] = []; + for (const [i, raw] of text.split(/\r?\n/).entries()) { + const line = raw.trim(); + if (line === '' || line.startsWith('#')) continue; + if (line.startsWith('AGE-')) throw new RecipientListError(i + 1, 'identity'); + let key: Uint8Array; + try { + key = parseX25519Recipient(line); + } catch { + throw new RecipientListError(i + 1, 'malformed'); + } + const problem = recipientProblem(key); + if (problem !== undefined) throw new RecipientListError(i + 1, problem === 'low order' ? 'low order' : 'not canonical'); + if (out.some((k) => equalBytes(k, key))) throw new RecipientListError(i + 1, 'duplicate'); + out.push(key); + } + return out; +} diff --git a/src/lib/dkc/x25519.test.ts b/src/lib/dkc/x25519.test.ts index 8b5a243..40fa238 100644 --- a/src/lib/dkc/x25519.test.ts +++ b/src/lib/dkc/x25519.test.ts @@ -6,8 +6,8 @@ import { describe, expect, it } from 'vitest'; import { parseAgeHeader } from './age.ts'; import { bech32Encode } from './bech32.ts'; import { splitCapsule } from './framing.ts'; -import { h, readBytes, readJSON } from './testing/testdata.ts'; -import { MalformedX25519Stanza, parseX25519Identity, unwrapX25519 } from './x25519.ts'; +import { h, hx, readBytes, readJSON } from './testing/testdata.ts'; +import { MalformedX25519Stanza, newX25519Identity, parseX25519Identity, unwrapX25519, x25519PublicKey } from './x25519.ts'; // The one stanza of an age file, as the header parser of this module reads it. const stanzaOf = (file: Uint8Array): { args: readonly string[]; body: Uint8Array } => { @@ -89,3 +89,27 @@ describe('parseX25519Identity', () => { } }); }); + +describe('new identities and their public keys', () => { + it('derives the public keys of RFC 7748, section 6.1', () => { + expect(hx(x25519PublicKey(h('77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a')))).toBe( + '8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a', + ); + expect(hx(x25519PublicKey(h('5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb')))).toBe( + 'de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f', + ); + expect(() => x25519PublicKey(new Uint8Array(31))).toThrow(RangeError); + }); + + it('draws fresh identities whose recipient is the one age-encryption derives', async () => { + const seen = new Set(); + for (let i = 0; i < 20; i++) { + const id = newX25519Identity(); + expect(id).toHaveLength(32); + seen.add(hx(id)); + const s = bech32Encode('AGE-SECRET-KEY-', id); + expect(await identityToRecipient(s)).toBe(bech32Encode('age', x25519PublicKey(id))); + } + expect(seen.size).toBe(20); + }); +}); diff --git a/src/lib/dkc/x25519.ts b/src/lib/dkc/x25519.ts index a950970..33bf657 100644 --- a/src/lib/dkc/x25519.ts +++ b/src/lib/dkc/x25519.ts @@ -74,6 +74,25 @@ export function unwrapX25519(identity: Uint8Array, args: readonly string[], body } } +/** + * A fresh raw X25519 identity: 32 bytes of crypto.getRandomValues, stored + * unclamped as age.GenerateX25519Identity does (spec §29, §38, §39, §62.1 + * rule 5). The caller wipes it once it is no longer needed. + */ +export function newX25519Identity(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(X25519_KEY_LEN)); +} + +/** + * The public key, the recipient, of a raw X25519 identity: X25519 of the + * clamped scalar and the base point (RFC 7748). noble converts the scalar + * to a bigint that cannot be wiped. + */ +export function x25519PublicKey(identity: Uint8Array): Uint8Array { + if (identity.length !== X25519_KEY_LEN) throw new RangeError(`x25519: an identity is ${X25519_KEY_LEN} bytes`); + return x25519.getPublicKey(identity); +} + /** * The raw 32 bytes of an age X25519 identity written AGE-SECRET-KEY-1..., * uppercase as age writes it and as age.ParseX25519Identity requires. diff --git a/src/lib/inspector/tempfile.test.ts b/src/lib/inspector/tempfile.test.ts index ee47d8a..ce949fe 100644 --- a/src/lib/inspector/tempfile.test.ts +++ b/src/lib/inspector/tempfile.test.ts @@ -3,7 +3,9 @@ import { browserPlatform, cancellable, createTempFile, + CREATE_AREA, freeSpace, + OPEN_AREA, removeStaleTempFiles, STALE_MS, TEMP_FILE, @@ -135,6 +137,27 @@ describe('createTempFile', () => { expect(await (await t.file()).text()).toBe('hello, world'); }); + it('keeps the capsules being written in an area of their own, with its own locks and clean-up', async () => { + const { pf, root, locks } = platform({ locks: false, now: 5 * STALE_MS }); + const opening = await createTempFile(pf); + const creating = await createTempFile(pf, CREATE_AREA); + await write(creating.writable, 'DKC1'); + expect(OPEN_AREA).toEqual({ root: TEMP_ROOT, file: TEMP_FILE }); + const dir = root.entries.get(CREATE_AREA.root) as FakeDir; + expect([...dir.entries.keys()]).toEqual(['tab-2']); + expect(((await (await dir.getDirectoryHandle('tab-2')).getFileHandle(CREATE_AREA.file)) as unknown as FakeFile).content).toEqual(te.encode('DKC1')); + expect([...(root.entries.get(TEMP_ROOT) as FakeDir).entries.keys()]).toEqual(['tab-1']); + // A day later, without locks, each area is cleaned on its own. + const later = platform({ locks: false, now: 7 * STALE_MS }); + later.root.entries.set(CREATE_AREA.root, dir); + later.root.entries.set(TEMP_ROOT, root.entries.get(TEMP_ROOT)!); + expect(await removeStaleTempFiles(later.pf, CREATE_AREA)).toBe(1); + expect(dir.entries.size).toBe(0); + expect((later.root.entries.get(TEMP_ROOT) as FakeDir).entries.size).toBe(1); + expect(locks.held.size).toBe(0); + await opening.remove(); + }); + it('keeps nothing of an aborted output', async () => { const { pf } = platform(); const t = await createTempFile(pf); diff --git a/src/lib/inspector/tempfile.ts b/src/lib/inspector/tempfile.ts index 8bd5298..23bccff 100644 --- a/src/lib/inspector/tempfile.ts +++ b/src/lib/inspector/tempfile.ts @@ -1,22 +1,31 @@ // The private temporary file of an opening (spec §56; plan of phase 2, -// decision 7). PAYLOAD_AGE is decrypted into a file of the origin private -// file system (OPFS) through FileSystemFileHandle.createWritable, which keeps -// the writes in a swap file until close() and discards them on abort(): open -// closes it only after step 18, so nothing is committed before the whole -// payload is authenticated. The page offers the file for download once the -// capsule opened, and deletes it when the person asks, when another capsule -// is loaded or opened, when the page is left and, if the browser ended -// first, on the next visit. +// decision 7) and, in phase 3, of a capsule being written (§62.1 rule 9). +// The output goes into a file of the origin private file system (OPFS) +// through FileSystemFileHandle.createWritable, which keeps the writes in a +// swap file until close() and discards them on abort(): open closes it only +// after step 18, and the writer only once the capsule is complete, so nothing +// is committed before then. The page offers the file for download, and +// deletes it when the person asks, when another capsule is loaded or opened, +// when the page is left and, if the browser ended first, on the next visit. // -// Each tab writes into its own directory, datekeys-open/, and -// holds a Web Lock of that name while the directory exists, so that the -// clean-up of another tab never deletes a file in use, nor its swap file. -// Without the Web Locks API a directory is deleted only once it is a day old. +// Each tab writes into its own directory, /, and holds +// a Web Lock of that name while the directory exists, so that the clean-up of +// another tab never deletes a file in use, nor its swap file. Without the Web +// Locks API a directory is deleted only once it is a day old. -/** The directory of the temporary files, in the root of the OPFS. */ +/** Where one kind of temporary file lives: its directory in the root of the OPFS, and its name in the directory of a tab. */ +export interface TempArea { + readonly root: string; + readonly file: string; +} +/** The directory of the temporary files of an opening, in the root of the OPFS. */ export const TEMP_ROOT = 'datekeys-open'; -/** The name of the file inside the directory of a tab. */ +/** The name of the file of an opening inside the directory of a tab. */ export const TEMP_FILE = 'plaintext'; +/** The plaintext of an opening, the default area. */ +export const OPEN_AREA: TempArea = { root: TEMP_ROOT, file: TEMP_FILE }; +/** A capsule being written (phase 3). */ +export const CREATE_AREA: TempArea = { root: 'datekeys-create', file: 'capsule' }; /** Without Web Locks, a directory older than this is left over. */ export const STALE_MS = 24 * 3600_000; @@ -106,17 +115,17 @@ export function cancellable(w: WritableStream, cancelled: () => bool }); } -/** Creates the directory of this tab, with its lock, and the file open for writing. */ -export async function createTempFile(pf: TempPlatform): Promise { +/** Creates the directory of this tab in `area`, with its lock, and the file open for writing. */ +export async function createTempFile(pf: TempPlatform, area: TempArea = OPEN_AREA): Promise { const id = pf.randomId(); - const release = pf.locks === undefined ? () => undefined : await hold(pf.locks, `${TEMP_ROOT}/${id}`); + const release = pf.locks === undefined ? () => undefined : await hold(pf.locks, `${area.root}/${id}`); let root: TempDirectory | undefined; let handle: TempFileHandle; let writable: WritableStream; try { - root = await (await pf.getDirectory()).getDirectoryHandle(TEMP_ROOT, { create: true }); + root = await (await pf.getDirectory()).getDirectoryHandle(area.root, { create: true }); const dir = await root.getDirectoryHandle(id, { create: true }); - handle = await dir.getFileHandle(TEMP_FILE, { create: true }); + handle = await dir.getFileHandle(area.file, { create: true }); writable = await handle.createWritable(); } catch (err) { await root?.removeEntry(id, { recursive: true }).catch(() => undefined); @@ -142,13 +151,13 @@ export async function createTempFile(pf: TempPlatform): Promise { } /** - * Deletes the directories that no tab holds: left by a tab that ended - * before deleting its file. Returns how many it deleted. + * Deletes the directories of `area` that no tab holds: left by a tab that + * ended before deleting its file. Returns how many it deleted. */ -export async function removeStaleTempFiles(pf: TempPlatform): Promise { +export async function removeStaleTempFiles(pf: TempPlatform, area: TempArea = OPEN_AREA): Promise { let root: TempDirectory; try { - root = await (await pf.getDirectory()).getDirectoryHandle(TEMP_ROOT); + root = await (await pf.getDirectory()).getDirectoryHandle(area.root); } catch { // No such directory: nothing was ever left. return 0; @@ -159,7 +168,7 @@ export async function removeStaleTempFiles(pf: TempPlatform): Promise { for (const name of names) { // A tab takes the lock before it creates its directory and keeps it // until it deletes it, so a free lock means the tab has ended. - const left = pf.locks === undefined ? await isStale(root, name, pf.now()) : await isFree(pf.locks, `${TEMP_ROOT}/${name}`); + const left = pf.locks === undefined ? await isStale(root, name, area.file, pf.now()) : await isFree(pf.locks, `${area.root}/${name}`); removed += left && (await tryRemove(root, name)) ? 1 : 0; } return removed; @@ -186,9 +195,9 @@ function hold(locks: TempLocks, name: string): Promise<() => void> { // Whether the directory `name` is at least a day old, by its file. Anything // that is not a directory of a tab is left over too. -async function isStale(root: TempDirectory, name: string, now: number): Promise { +async function isStale(root: TempDirectory, name: string, fileName: string, now: number): Promise { try { - const file = await (await (await root.getDirectoryHandle(name)).getFileHandle(TEMP_FILE)).getFile(); + const file = await (await (await root.getDirectoryHandle(name)).getFileHandle(fileName)).getFile(); return now - file.lastModified >= STALE_MS; } catch { return true; diff --git a/vitest.config.ts b/vitest.config.ts index 3b30efc..882c146 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -34,6 +34,12 @@ export default defineConfig({ 'src/lib/inspector/opening.ts': { 100: true }, 'src/lib/inspector/release-input.ts': { 100: true }, 'src/lib/inspector/tempfile.ts': { 100: true }, + // The pieces of the writer (plan of phase 3, step 2) and the age + // files shared by the opening and the writer. + 'src/lib/dkc/recipient.ts': { 100: true }, + 'src/lib/dkc/random.ts': { 100: true }, + 'src/lib/dkc/agefile.ts': { 100: true }, + 'src/lib/dkc/padding.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 },