Phase 3, step 2: the pieces of the writer and the new guards

- recipient.ts: age1… recipients as age 1.3.2 reads and writes them, the
  rules of spec §37 with the texts of agewrap.CheckX25519Recipient (the
  five low-order u checked by list, the twist accepted as in Go), and a
  recipient list read line by line. No noble.
- random.ts: an index without bias and the Fisher-Yates permutation of
  the 16 slots, with Go's uniformity test.
- agefile.ts: the whole-age-file helpers of the opening, shared with the
  writer's self-checks.
- x25519.ts: newX25519Identity and x25519PublicKey (RFC 7748 vectors);
  digest.ts: sha256Hasher; datekey.ts: compareInstants, used by open.ts,
  and isInstant.
- tempfile.ts: an area for the opening and one for creating capsules.
- Guards: age-encryption and the writer core have import allowlists, and
  index.ts re-exports neither the opening nor the writer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
dev 1 week ago
parent 8c08c97040
commit 8838c7dd05

@ -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`).

@ -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`:<br>- las credenciales y el release (paso 9), que cualquier fallo de la fuente convierte en `ERR_RELEASE_UNAVAILABLE` (corrección 6);<br>- la verificación del release (10);<br>- `OUTER_TIME_AGE` (11), la estructura frente a `access_policy` (12) e `INNER_ACCESS_AGE` (13);<br>- `CONTROL_CBOR` (14), `header_binding` (15), `I_PAYLOAD` (16), `PAYLOAD_AGE` (17) y el commit (18).<br>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.<br>Abre los tres ficheros `age` con el `Decrypter` de `age-encryption` y con identidades propias que aplican las reglas de `agewrap`: la de tiempo, sobre `ibe.ts`; las de acceso y payload, sobre `x25519.ts`, stanza a stanza. Los fallos de `age` que no informa una identidad son `ERR_INTEGRITY` con el motivo fijo de su fase, cabecera o STREAM, sin copiar el texto de `age-encryption`.<br>La entrada puede ser un `Uint8Array` o un `Blob`, como un `File`. De un `Blob` solo se lee el prefijo de los pasos 1 a 8 (`prefix.ts`), el `capsule_digest` de la `.dkk` se calcula sobre su stream (`digest.ts`) y `PAYLOAD_AGE` se descifra en streaming.<br>El texto en claro va a memoria o a `output`, un `WritableStream`. Se escribe a medida que `age` autentica cada chunk, se cierra solo tras el paso 18 y se aborta ante cualquier fallo, en cualquier paso (§56). Un fallo del stream de salida es `ERR_INTEGRITY` con su texto, como en Go. El `WritableStream` de un fichero OPFS guarda lo escrito en un fichero de intercambio hasta el cierre: comprobado en el navegador, un fallo de STREAM deja intacto el contenido anterior | `capsule.Open`, `agewrap` (`TimeIdentity`, `AccessIdentity`, `PayloadIdentity`) |
| `tlock.ts` | `timeRecipient`, el `Recipient` de `age-encryption` para `OUTER_TIME_AGE` (§32, §35), como `agewrap.TimeRecipient`: cifra la file key con `ibe.ts` para una ronda de un perfil pinneado y escribe el stanza `tlock <ronda> <chain hash>` de tlock. Comprueba el perfil y luego el rango de la ronda, con los textos de `NewTimeRecipient`. `age-encryption` no tiene etiquetas, así que quien escriba `OUTER_TIME_AGE` (fase 3) lo añade como único recipient | `agewrap.TimeRecipient` |
| `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.

@ -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<string, string> };
type Source = { name: string; text: string };
@ -102,12 +127,26 @@ function lockProblems(packages: Record<string, LockEntry>): { 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([]);

@ -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<Uint8Array> =>
new ReadableStream<Uint8Array>({
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<Uint8Array>, identity: Identity, what: string): Promise<ReadableStream<Uint8Array>> {
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<Uint8Array> {
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<Uint8Array>, max: number, what: string): Promise<Uint8Array> {
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);
}

@ -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);
}
});
});

@ -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_

@ -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)));
});
});

@ -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<Uint8Array>): Promise<Uint8Array> {
const h = sha256.create();
const h = sha256Hasher();
const reader = stream.getReader();
for (;;) {
const { done, value } = await reader.read();

@ -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<T> = { -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<Uint8Array> =>
new ReadableStream<Uint8Array>({
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<Uint8Array>, identity: Identity, what: string): Promise<ReadableStream<Uint8Array>> {
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<Uint8Array> {
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<Uint8Array>, max: number, what: string): Promise<Uint8Array> {
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<Uint8Array>, 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

@ -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<number>(n).fill(0);
const last = new Array<number>(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);
});
});

@ -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<T>(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;
}
}

@ -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');
});
});

@ -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<Record<RecipientProblem, string>> = {
'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;
}

@ -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<string>();
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);
});
});

@ -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.

@ -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);

@ -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/<random id>, 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, <area root>/<random id>, 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<Uint8Array>, cancelled: () => bool
});
}
/** Creates the directory of this tab, with its lock, and the file open for writing. */
export async function createTempFile(pf: TempPlatform): Promise<TempFile> {
/** 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<TempFile> {
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<Uint8Array>;
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<TempFile> {
}
/**
* 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<number> {
export async function removeStaleTempFiles(pf: TempPlatform, area: TempArea = OPEN_AREA): Promise<number> {
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<number> {
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<boolean> {
async function isStale(root: TempDirectory, name: string, fileName: string, now: number): Promise<boolean> {
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;

@ -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 },

Loading…
Cancel
Save

Powered by TurnKey Linux.