Format 3, step 4: encryptFiles, the writer of capsule format 3

encryptFiles(files, opts) writes a .dkc of format 3 as
capsule.EncryptFiles at spec-v0.10, on the sealer of the previous
commit:

- newHead: the comment, with CR LF and a lone CR turned into LF, the
  declared author and every path checked with the rules of the reader,
  in the words of a writer (spec 62.1 rule 15); the files in the byte
  order of their paths, not the UTF-16 order of JavaScript strings; and
  the mtime in seconds from 1970 to 9999, none outside.
- L measured with a head whose salt and SHA-256 are zero, at most 16 MiB
  and L_MAX; the first reading hashes each file; the head with a fresh
  salt, the empty security area and the frame are checked with the rules
  of the reader before anything is written.
- BODY is the content of seal: the frame, the area, the head and the
  files read a second time, which fail if a size or a SHA-256 changed
  (rule 18), with the texts of readSource.

FileSource describes a file (path, size, mtime in milliseconds, open),
and fileSource makes the one of a File or a Blob. The draws gain the
salt of the head. lengths.ts gains bodyLength and headLength, which
measure the head from the sizes of its CBOR items without the Unicode
tables, and mtimeSeconds and headComment, which the writer shares.

Tests: the five fixtures that EncryptFiles wrote are reproduced byte for
byte, PRELUDE, PUBLIC_HEADER, CONTROL_CBOR, HEAD_CBOR and BODY, and
their .dkk; capsuleLength with bodyLength gives the size written, and
headLength agrees with encodeHead on 300 random heads around every
boundary of the CBOR heads; the invalid inputs give the texts that
capsule.EncryptFiles gives to the same inputs, taken from the reference
with a scratch program. writer.ts, encrypt.ts and lengths.ts stay at
100 %.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
dev 1 week ago
parent 3daa1f770c
commit 4c67b07640

@ -6,6 +6,13 @@ Cambios notables de la librería TypeScript y de la página. El proyecto usa ver
El formato 3 de la especificación 0.10, según `PLAN_formato3_ts.md` (en `../docs`). La versión que lo publique la decide el autor.
### Paso 4: la escritura del formato 3
- `writer.ts` se parte como el writer de Go: `newSealer` comprueba las opciones que no dependen del contenido, y `seal` escribe la cápsula de un formato alrededor de un contenido dado en trozos. El formato 2 no cambia.
- `encryptFiles(files, opts)` escribe un `.dkc` de formato 3 con sus ficheros, el comentario y el autor declarado, y las extensiones del head, como `capsule.EncryptFiles`: lee cada fichero dos veces y falla, con el texto de Go, si cambió entre las dos lecturas. `fileSource` hace la fuente de un `File`. Los sorteos ganan la sal del head.
- `lengths.ts`: `bodyLength`, `headLength`, `mtimeSeconds` y `headComment`, para dar el tamaño exacto del `.dkc` antes de escribirlo.
- Pruebas: los cinco fixtures que escribió `EncryptFiles`, byte a byte; los tamaños frente a lo escrito y 300 heads aleatorios frente a `encodeHead`; y las entradas inválidas, con los textos que da `capsule.EncryptFiles` a las mismas entradas.
### Paso 3: la lectura del formato 3, con `testdata` en `spec-v0.10`
- `testdata` se sincroniza con el tag `spec-v0.10` de `datekeys-go` (`cc35d2c`), y `SPEC_VERSION` pasa a `0.10`.

@ -52,9 +52,9 @@ La inspección (pasos 1 a 8) no importa ninguna dependencia. Funciona en navegad
| `ibe.ts` | IBE-CCA de tlock sobre G2 para Quicknet (§63 paso 11): `decryptOnG2` y `encryptOnG2RFC9380` (Qid = H(id) en G1 con el DST de RFC 9380, sigma aleatorio, U = r·G2), con la puerta de codificación canónica de `bls12381.ts` sobre la firma y U; H2 sobre GT serializado en el orden de kilic (nunca `Fp12.toBytes` de noble), H3 y H4; `roundIdentity`; el cuerpo `U ‖ V ‖ W` de 128 bytes del stanza. Errores `IbeError` con motivo (`length`, `encoding`, `identity`, `proof`) y texto fijos, sin ningún valor del cálculo; borra sigma y los hashes derivados. Sobre `@noble/curves` 2.4.0; lleva el aviso MIT de `tlock-js`, cuya estructura sigue. Lo usa la apertura (`open.ts`) | `encrypt/ibe` de drand/kyber (`DecryptCCAonG2`), `tlock.BytesToCiphertext` y `TimeUnlock` |
| `release.ts` | Verificación local del release (§17, §51, §63 paso 10), en el orden y con los textos de `provider.Verify`:<br>1. el rango de la ronda (`ERR_DATEKEY_INVALID`);<br>2. la ronda del release antes que la firma (`ERR_ROUND_MISMATCH`);<br>3. la longitud de la firma;<br>4. la clave pinneada (`ERR_UNKNOWN_PROFILE`);<br>5. la firma: codificación canónica de un punto de G1 que no sea el infinito, y firma BLS válida de la ronda sobre `@noble/curves` 2.4.0, con el DST de RFC 9380 para G1 (`ERR_RELEASE_INVALID`).<br>Nada de noble se copia a los errores. Solo verifica el scheme de Quicknet: un perfil de otro scheme falla con `ERR_UNKNOWN_PROFILE` tras las comprobaciones de ronda, donde la referencia sí lo verificaría (decisión 3 del plan de la fase 2). También define `ReleaseSource`, con su contrato de fuentes de red y de la corrección 6, y `suppliedRelease`, el release que entrega quien llama | `provider` (`Verify`, `ReleaseSource`) |
| `open.ts` | Los pasos 9 a 18 de §63 sobre los pasos 1 a 8 de `inspectWith`, con los checks, códigos y textos de `capsule.Open`:<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 los formatos 2 y 3, L, la regla y P.<br>En el formato 3, el paso 17 lo hace `open3.ts`, y los ficheros van a `sink`; sin él, `open` rechaza con un `TypeError` justo tras el paso 2, antes de pedir nada, como `ErrSinkRequired`. `Opened` da entonces el head, los veredictos del área de seguridad y el tamaño del área.<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`) |
| `encrypt.ts`, `writer.ts` | El writer de la fase 3: `encrypt(src, opts)` escribe un `.dkc` de formato 2 y, si se pide, una `.dkk` portable (§61, §62, §62.1), en el orden y con los textos y códigos de `capsule.Encrypt`:<br>- el formato 2 siempre; L conocida de antemano (el tamaño de un `Uint8Array` o un `Blob`, o `length` con un `ReadableStream`), y una fuente que da más o menos bytes falla con los textos de Go;<br>- el relleno `reforzado` por defecto, o `bloque256`;<br>- de 1 a 16 credenciales, canónicas y no de orden bajo, un señuelo en cada hueco libre, cuyo escalar se borra al derivar su clave pública, y un orden uniforme de los 16 (`random.ts`);<br>- `SEALED_CONTROL_LEN` con la fórmula del §62.1, comprobada con el sellado real;<br>- las autocomprobaciones de la regla 11 y dos más: `OUTER_TIME_AGE` con las reglas del lector, y la cabecera de `PAYLOAD_AGE`, que `I_PAYLOAD` abre antes de escribir nada.<br>Nada se escribe hasta que todo lo anterior al contenido está comprobado. El contenido va en trozos de 64 KiB, seguido de los ceros del relleno, con presión inversa, hacia memoria (hasta `MAX_MEMORY_DKC`, 1 GiB) o hacia `output`, que se cierra solo con la cápsula completa y comprobada y se aborta ante cualquier fallo. Los errores de la fuente y de la salida se relanzan tal cual.<br>El núcleo, `writer.ts`, recibe la aleatoriedad de quien lo llama: `encrypt.ts` le da la de `crypto.getRandomValues`, y solo `testing/encrypt.ts` la fija, para reproducir los fixtures de Go | `capsule.Encrypt`, `accesskey.Encode` |
| `encrypt.ts`, `writer.ts` | Los writers. `encryptFiles(files, opts)` escribe un `.dkc` de formato 3, como `capsule.EncryptFiles`: comprueba las rutas y los textos con las reglas del lector y con los textos de Go, pone los ficheros en el orden de los bytes de sus rutas, mide L con un head de sal y hashes a cero, lee cada fichero dos veces y falla si cambió entre las dos lecturas; el head, el control y el área de seguridad se decodifican antes de escribir. Reproduce byte a byte `PRELUDE`, PUBLIC_HEADER, CONTROL_CBOR y `BODY` de los cinco fixtures que escribió `EncryptFiles`. `fileSource` hace la fuente de un `File`.<br>`encrypt(src, opts)`, el writer de la fase 3, escribe un `.dkc` de formato 2 y, si se pide, una `.dkk` portable (§61, §62, §62.1), en el orden y con los textos y códigos de `capsule.Encrypt`:<br>- el formato 2 siempre; L conocida de antemano (el tamaño de un `Uint8Array` o un `Blob`, o `length` con un `ReadableStream`), y una fuente que da más o menos bytes falla con los textos de Go;<br>- el relleno `reforzado` por defecto, o `bloque256`;<br>- de 1 a 16 credenciales, canónicas y no de orden bajo, un señuelo en cada hueco libre, cuyo escalar se borra al derivar su clave pública, y un orden uniforme de los 16 (`random.ts`);<br>- `SEALED_CONTROL_LEN` con la fórmula del §62.1, comprobada con el sellado real;<br>- las autocomprobaciones de la regla 11 y dos más: `OUTER_TIME_AGE` con las reglas del lector, y la cabecera de `PAYLOAD_AGE`, que `I_PAYLOAD` abre antes de escribir nada.<br>Nada se escribe hasta que todo lo anterior al contenido está comprobado. El contenido va en trozos de 64 KiB, seguido de los ceros del relleno, con presión inversa, hacia memoria (hasta `MAX_MEMORY_DKC`, 1 GiB) o hacia `output`, que se cierra solo con la cápsula completa y comprobada y se aborta ante cualquier fallo. Los errores de la fuente y de la salida se relanzan tal cual.<br>El núcleo, `writer.ts`, recibe la aleatoriedad de quien lo llama: `encrypt.ts` le da la de `crypto.getRandomValues`, y solo `testing/encrypt.ts` la fija, para reproducir los fixtures de Go | `capsule.Encrypt`, `accesskey.Encode` |
| `tlock.ts` | `timeRecipient`, el `Recipient` de `age-encryption` para `OUTER_TIME_AGE` (§32, §35), como `agewrap.TimeRecipient`: cifra la file key con `ibe.ts` para una ronda de un perfil pinneado y escribe el stanza `tlock <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` |
| `lengths.ts` | El tamaño de un `.dkc` de formato 2 antes de escribirlo: `sealedControlLength`, la fórmula de `SEALED_CONTROL_LEN` del §62.1 con la que el writer comprueba su sellado, y `capsuleLength`, el tamaño exacto que escribe `encrypt` para una ronda, una política, L, el relleno y las extensiones, que la página muestra antes de cifrar porque cualquiera con el fichero lo ve (§55.2). Sin noble ni `age-encryption` | `capsule.Encrypt`, que mide un borrador sellado |
| `lengths.ts` | El tamaño de un `.dkc` de formato 2 o 3 antes de escribirlo. Para el formato 3, `bodyLength` da L con `headLength`, que mide el head por los tamaños de sus elementos CBOR sin codificarlo ni cargar las tablas de Unicode, y `mtimeSeconds` y `headComment` dan la mtime y el comentario tal como el writer los guarda. Para los dos formatos: `sealedControlLength`, la fórmula de `SEALED_CONTROL_LEN` del §62.1 con la que el writer comprueba su sellado, y `capsuleLength`, el tamaño exacto que escribe `encrypt` para una ronda, una política, L, el relleno y las extensiones, que la página muestra antes de cifrar porque cualquiera con el fichero lo ve (§55.2). Sin noble ni `age-encryption` | `capsule.Encrypt`, que mide un borrador sellado |
| `padding.ts` | El relleno del formato 2 (§29.1): los códigos 1 (`bloque256`) y 2 (`reforzado`), `paddedLength`, exacta hasta L_MAX = 2⁵³ − 2⁴⁶ (`bitlen` con `BigInt` y los redondeos con `ceil`, exactos en doubles; nunca operaciones de 32 bits, `Math.clz32` ni `Math.log2`), y la longitud de `PAYLOAD_AGE` | `capsule/padding.go` |
| `digest.ts` | SHA-256 incremental con `@noble/hashes` (`sha256Hasher`, `sha256Stream`), para el `capsule_digest` de un `.dkc` que no está en memoria o que se está escribiendo (Web Crypto solo calcula el hash de buffers enteros) | |
| `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` |

@ -0,0 +1,489 @@
// Tests of encryptFiles (encrypt.ts and writer.ts, plan of format 3 in
// datekeys-ts, step 4): capsules of format 3 written here open with open to
// their files, reproduce the deterministic sections of the fixtures of the
// Go reference and their BODY byte for byte, measure what lengths.ts says
// before writing, and fail with the texts of capsule.EncryptFiles, taken
// from the reference at spec-v0.10, with their output aborted.
import { describe, expect, it } from 'vitest';
import { type AccessKey, decodeAccessKey, encodeAccessKey } from './accesskey.ts';
import { ACCESS_SLOTS } from './age.ts';
import { decrypt, decryptAll } from './agefile.ts';
import { compareBytes, concatBytes, sha256, utf8Bytes } from './bytes.ts';
import { type Instant, parseRFC3339 } from './datekey.ts';
import { encryptFiles, type EncryptOptions, type FileSource, fileSource } from './encrypt.ts';
import { errorCode } from './errors.ts';
import { ExtensionSet } from './extension.ts';
import { FORMAT_3 } from './framing.ts';
import { decodeHead, encodeHead, type Head, type HeadFile } from './head.ts';
import { TIME_AND_KEY, TIME_ONLY } from './header.ts';
import { bodyLength, capsuleLength, headComment, headLength, type HeadShape, mtimeSeconds } from './lengths.ts';
import { accessIdentity, open, type OpenOptions, payloadIdentity, timeIdentity } from './open.ts';
import { BLOQUE256, MAX_PAYLOAD_LENGTH, type Padding, REFORZADO } from './padding.ts';
import { quicknet } from './profile.ts';
import { type Release, suppliedRelease } from './release.ts';
import { MemorySink } from './sink.ts';
import { split } from './testing/capsule.ts';
import { encryptFilesWith, wordsFor } from './testing/encrypt.ts';
import { h, hx, readBytes, readJSON } from './testing/testdata.ts';
import { newX25519Identity, x25519PublicKey } from './x25519.ts';
interface FixtureRecord {
release: { round: number; signature: string };
unlock_at: string;
access_policy: 'time_only' | 'time_and_key';
prelude: string;
public_header: string;
control_cbor: string;
capsule_id: string;
payload_identity: string;
payload_length: number;
padding: Padding;
padded_length: number;
plaintext_file: string;
access_key_file?: string;
access_key_stanza?: number;
head_cbor: string;
salt: string;
comment?: string;
declared_author?: string;
content_offset: number;
files?: { path: string; size: number; start: number; end: number; sha256: string; mtime?: number }[];
}
const record = (name: string): FixtureRecord => readJSON<FixtureRecord>(`fixtures/${name}.json`);
const releaseOf = (fx: FixtureRecord): Release => ({ round: fx.release.round, signature: h(fx.release.signature) });
const R1000 = releaseOf(record('format3_single'));
const GENESIS: Instant = parseRFC3339('2023-08-23T15:09:27Z');
// Round r opens at genesis + (r - 1)·3 s.
const roundAt = (r: number): Instant => ({ seconds: GENESIS.seconds + (r - 1) * 3, nanos: 0 });
const te = new TextEncoder();
const options = (extra: Partial<EncryptOptions> = {}): EncryptOptions => ({
profile: quicknet(),
unlockAt: roundAt(1000),
policy: TIME_ONLY,
now: () => GENESIS,
...extra,
});
const opening = (r: Release, extra: Partial<OpenOptions> = {}): OpenOptions => ({ source: suppliedRelease(r), now: () => roundAt(r.round), ...extra });
// A file whose every reading gives `bytes`.
const source = (path: string, bytes: Uint8Array | string, mtime?: number): FileSource => {
const b = typeof bytes === 'string' ? te.encode(bytes) : bytes;
return { path, size: b.length, ...(mtime === undefined ? {} : { mtime }), open: () => new Blob([b as Uint8Array<ArrayBuffer>]).stream() };
};
// A file of `size` bytes whose readings give the texts in turn, the last one
// from then on.
function changing(path: string, size: number, ...readings: string[]): FileSource {
let n = 0;
return { path, size, open: () => new Blob([te.encode(readings[Math.min(n++, readings.length - 1)]!)]).stream() };
}
// A stream of the chunks given, then an error when one is given.
function chunked(chunks: readonly Uint8Array[], error?: Error): ReadableStream<Uint8Array> {
const list = [...chunks];
return new ReadableStream<Uint8Array>({
pull(c) {
const next = list.shift();
if (next !== undefined) c.enqueue(next);
else if (error !== undefined) c.error(error);
else c.close();
},
});
}
// An output that records what it receives and how it ended.
function recorder(): { stream: WritableStream<Uint8Array>; chunks: Uint8Array[]; state: { closed: boolean; aborted: unknown } } {
const chunks: Uint8Array[] = [];
const state: { closed: boolean; aborted: unknown } = { closed: false, aborted: undefined };
const stream = new WritableStream<Uint8Array>({
write: (c) => void chunks.push(c.slice()),
close: () => void (state.closed = true),
abort: (reason) => void (state.aborted = reason ?? 'aborted'),
});
return { stream, chunks, state };
}
async function failure(p: Promise<unknown>): Promise<Error> {
try {
await p;
} catch (err) {
return err as Error;
}
throw new Error('expected a failure');
}
// The plaintext of PAYLOAD_AGE of a capsule, with its I_PAYLOAD.
async function plaintextOf(dkc: Uint8Array, id: Uint8Array): Promise<Uint8Array> {
const plain = await decrypt(new Blob([split(dkc).payload as Uint8Array<ArrayBuffer>]).stream(), payloadIdentity(id), 'PAYLOAD_AGE');
return new Uint8Array(await new Response(plain).arrayBuffer());
}
// Opens a capsule of format 3 into memory: its head and its files.
async function openFiles(dkc: Uint8Array, r: Release, extra: Partial<OpenOptions> = {}): Promise<{ head: Head; files: Map<string, string> }> {
const sink = new MemorySink();
const res = await open(dkc, opening(r, { sink, ...extra }));
expect(res.error?.message).toBeUndefined();
const opened = sink.opened!;
return { head: opened.head, files: new Map(opened.head.files.map((f, i) => [f.path, hx(opened.files[i]!)])) };
}
describe('encryptFiles, the fixtures of format 3 of the Go reference', () => {
// The five that capsule.EncryptFiles wrote; the other four hold security
// areas that only a generator of test vectors writes.
const names = ['format3_single', 'format3_tree', 'format3_comment_only', 'format3_bloque256', 'format3_time_and_key_portable'];
it.each(names)('%s: reproduces PRELUDE, PUBLIC_HEADER, CONTROL_CBOR, HEAD_CBOR and BODY byte for byte, the lengths and the .dkk', async (name) => {
const fx = record(name);
const body = readBytes(`fixtures/${fx.plaintext_file}`);
// Given in reverse order: the writer puts them in the byte order of their paths.
const files = (fx.files ?? [])
.map((f) => source(f.path, body.subarray(fx.content_offset + f.start, fx.content_offset + f.end), f.mtime === undefined ? undefined : f.mtime * 1000))
.reverse();
const dkk = fx.access_key_file === undefined ? undefined : decodeAccessKey(readBytes(`fixtures/${fx.access_key_file}`));
const keyed = fx.access_policy === 'time_and_key';
const res = await encryptFilesWith(
files,
options({
unlockAt: parseRFC3339(fx.unlock_at),
policy: keyed ? TIME_AND_KEY : TIME_ONLY,
newPortableKey: dkk !== undefined,
padding: fx.padding,
...(fx.comment === undefined ? {} : { comment: fx.comment }),
...(fx.declared_author === undefined ? {} : { author: fx.declared_author }),
}),
{
capsuleId: h(fx.capsule_id),
payloadIdentity: h(fx.payload_identity),
salt: h(fx.salt),
...(dkk === undefined ? {} : { accessIdentity: dkk.material, credentialId: dkk.credentialId }),
...(keyed ? { words: wordsFor([fx.access_key_stanza!]) } : {}),
},
);
const written = split(res.dkc!);
const original = split(readBytes(`fixtures/${name}.dkc`));
expect(hx(written.prelude)).toBe(fx.prelude);
expect(hx(written.header)).toBe(fx.public_header);
expect([written.sealed.length, written.payload.length]).toEqual([original.sealed.length, original.payload.length]);
expect([res.format, res.length, res.padding, res.paddedLength]).toEqual([FORMAT_3, fx.payload_length, fx.padding, fx.padded_length]);
expect(hx(encodeHead(res.head!))).toBe(fx.head_cbor);
const r = releaseOf(fx);
const sealed = await decryptAll(written.sealed, timeIdentity(quicknet(), r.round, r), 'age');
const control = keyed ? await decryptAll(sealed, accessIdentity([dkk!.material], ACCESS_SLOTS), 'age') : sealed;
expect(hx(control)).toBe(fx.control_cbor);
// BODY, and the zeros of its padding up to P.
const plaintext = await plaintextOf(res.dkc!, h(fx.payload_identity));
expect(hx(plaintext)).toBe(hx(concatBytes(body, new Uint8Array(fx.padded_length - fx.payload_length))));
if (dkk !== undefined) {
const want: AccessKey = { ...dkk, verification: { capsuleDigest: await sha256(res.dkc!) } };
expect(hx(encodeAccessKey(res.portableKey!))).toBe(hx(encodeAccessKey(want)));
}
const opened = await openFiles(res.dkc!, r, dkk === undefined ? {} : { accessKey: res.portableKey! });
expect(opened.head).toEqual(decodeHead(h(fx.head_cbor)));
});
});
describe('encryptFiles', () => {
it('writes the files in the byte order of their paths, whatever the order given, and open gives them back', async () => {
// UTF-16 puts U+10000 before U+FFFD; UTF-8 after.
const files = [source('z.txt', 'zeta'), source('\u{10000}.txt', 'linear b'), source('carpeta/\u00f1.txt', 'e\u00f1e'), source('\ufffd.txt', 'reemplazo'), source('a', '')];
const res = await encryptFiles(files, options());
expect(res.head!.files.map((f) => f.path)).toEqual(['a', 'carpeta/\u00f1.txt', 'z.txt', '\ufffd.txt', '\u{10000}.txt']);
const { files: got } = await openFiles(res.dkc!, R1000);
for (const f of files) expect(got.get(f.path), f.path).toBe(hx(new Uint8Array(await new Response(f.open()).arrayBuffer())));
expect(res.format).toBe(FORMAT_3);
});
it('stores the mtime in seconds from 1970 to 9999, and none when it falls outside or is unknown', async () => {
const last = 253402300799;
const cases: [string, number | undefined, number | undefined][] = [
['unknown', undefined, undefined],
['before 1970', -1, undefined],
['1970', 0, 0],
['a second and a half', 1500, 1],
['the last millisecond of 9999', last * 1000 + 999, last],
['10000', (last + 1) * 1000, undefined],
];
const res = await encryptFiles(
cases.map(([name, mtime]) => source(name, name, mtime)),
options(),
);
const byPath = new Map(res.head!.files.map((f) => [f.path, f.mtime]));
for (const [name, mtime, want] of cases) {
expect(byPath.get(name), name).toBe(want);
expect(mtimeSeconds(mtime), name).toBe(want);
}
const { head } = await openFiles(res.dkc!, R1000);
expect(head.files.map((f) => f.mtime)).toEqual(res.head!.files.map((f) => f.mtime));
});
it('turns CR LF and a lone CR of the comment into LF, keeps the declared author, and writes a capsule of a comment alone', async () => {
const res = await encryptFiles([], options({ comment: 'uno\u000d\u000ados\u000dtres\u000a', author: 'Ana L\u00f3pez' }));
expect([res.head!.comment, res.head!.author, res.head!.files]).toEqual(['uno\u000ados\u000atres\u000a', 'Ana L\u00f3pez', []]);
expect(headComment('a\u000d\u000a\u000d\u000ab\u000d')).toBe('a\u000a\u000ab\u000a');
const { head } = await openFiles(res.dkc!, R1000);
expect([head.comment, head.author]).toEqual([res.head!.comment, res.head!.author]);
});
it('writes time_and_key with recipients and a portable key, each of which opens it alone', async () => {
const ids = [newX25519Identity(), newX25519Identity()];
const files = [source('carta.txt', 'para vosotros')];
const res = await encryptFiles(files, options({ policy: TIME_AND_KEY, recipients: ids.map(x25519PublicKey), newPortableKey: true, padding: BLOQUE256 }));
expect(res.padding).toBe(BLOQUE256);
for (const extra of [{ identities: [ids[0]!] }, { identities: [ids[1]!] }, { accessKey: res.portableKey! }]) {
const { files: got } = await openFiles(res.dkc!, R1000, extra);
expect(got.get('carta.txt')).toBe(hx(te.encode('para vosotros')));
}
});
it('writes the extensions of the head: a critical one the reader must know, a noncritical one it may ignore', async () => {
const x = { id: 'x.example', version: 1, data: undefined };
const res = await encryptFiles([source('a', 'x')], options({ headCritical: [x], headNoncritical: [{ id: 'y.example', version: 2, data: Uint8Array.of(1) }] }));
expect(res.head!.critical).toEqual([x]);
const unknown = await open(res.dkc!, opening(R1000, { sink: new MemorySink() }));
expect([unknown.error?.message, unknown.inspection.checks.at(-1)?.step]).toEqual(['capsule: head: extension x.example v1: ERR_EXTENSION_CRITICAL_UNKNOWN', 17]);
await openFiles(res.dkc!, R1000, { extensions: new ExtensionSet([['x.example', [1]]]) });
});
it('streams into an output, closed at the end, and reports its progress', async () => {
const big = new Uint8Array(200_000).map((_, i) => i % 251);
const out = recorder();
const progress: [number, number][] = [];
const res = await encryptFiles(
[{ path: 'big.bin', size: big.length, open: () => chunked([big.subarray(0, 150_000), new Uint8Array(0), big.subarray(150_000)]) }],
options({ output: out.stream, progress: (w, t) => progress.push([w, t]) }),
);
expect([res.dkc, out.state]).toEqual([undefined, { closed: true, aborted: undefined }]);
const dkc = concatBytes(...out.chunks);
expect([dkc.length, progress.at(-1)]).toEqual([res.size, [res.size, res.size]]);
const { files } = await openFiles(dkc, R1000);
expect(files.get('big.bin')).toBe(hx(big));
});
it('makes the source of a File or a Blob with fileSource', async () => {
const file = new File(['hola'], 'nota.txt', { lastModified: 1_790_769_600_500 });
expect(fileSource('nota.txt', file)).toMatchObject({ path: 'nota.txt', size: 4, mtime: 1_790_769_600_500 });
expect(fileSource('x', file, 5)).toMatchObject({ mtime: 5 });
expect(fileSource('y', new Blob(['ab']))).not.toHaveProperty('mtime');
const res = await encryptFiles([fileSource('docs/nota.txt', file)], options());
expect(res.head!.files[0]!.mtime).toBe(1_790_769_600);
const { files } = await openFiles(res.dkc!, R1000);
expect(files.get('docs/nota.txt')).toBe(hx(te.encode('hola')));
});
});
describe('the lengths of format 3', () => {
const shapes: [string, HeadShape][] = [
['one file', { files: [{ path: 'nota.txt', size: 5, mtime: 1_790_769_600_000 }] }],
['a comment alone', { files: [], comment: 'solo\u000d\u000aesto', author: 'Ana' }],
['files of every size class', { files: [23, 24, 255, 256, 65535, 65536, 70_000].map((size, i) => ({ path: `f${i}.bin`, size })) }],
['extensions', { files: [{ path: 'a', size: 1 }], critical: [{ id: 'x.example', version: 1, data: undefined }], noncritical: [{ id: 'y.example', version: 300, data: new Uint8Array(30) }] }],
];
it.each(shapes)('gives the exact size of the .dkc before writing it: %s', async (_, shape) => {
const files = shape.files.map((f) => source(f.path, new Uint8Array(f.size), f.mtime));
for (const [policy, extra] of [
[TIME_ONLY, {}],
[TIME_AND_KEY, { newPortableKey: true }],
] as const) {
const res = await encryptFiles(
files,
options({
policy,
...extra,
...(shape.comment === undefined ? {} : { comment: shape.comment }),
...(shape.author === undefined ? {} : { author: shape.author }),
...(shape.critical === undefined ? {} : { headCritical: shape.critical }),
...(shape.noncritical === undefined ? {} : { headNoncritical: shape.noncritical }),
}),
);
expect(bodyLength(shape)).toBe(res.length);
const size = capsuleLength({ profileId: 'datekeys:quicknet:v1', round: res.dateKey.round, policy, length: bodyLength(shape) });
expect([size, res.size]).toEqual([res.dkc!.length, res.dkc!.length]);
}
});
// A seeded generator, and the head that the writer builds for a shape.
function random(seed: number): () => number {
let x = seed >>> 0;
return () => {
x = (x * 1664525 + 1013904223) >>> 0;
return x / 2 ** 32;
};
}
function headOf(shape: HeadShape): Head {
const sorted = [...shape.files].sort((a, b) => compareBytes(utf8Bytes(a.path), utf8Bytes(b.path)));
let end = 0;
const files = sorted.map((f): HeadFile => {
const mtime = mtimeSeconds(f.mtime);
const file = { path: f.path, size: f.size, start: end, end: end + f.size, sha256: new Uint8Array(32), ...(mtime === undefined ? {} : { mtime }) };
end += f.size;
return file;
});
return {
salt: new Uint8Array(32),
comment: headComment(shape.comment ?? ''),
author: shape.author ?? '',
files,
critical: shape.critical ?? [],
noncritical: shape.noncritical ?? [],
};
}
it('measures the head as encodeHead writes it, around every boundary of the heads of CBOR', () => {
const next = random(7);
const pick = <T>(list: readonly T[]): T => list[Math.floor(next() * list.length)]!;
const lengths = [0, 1, 22, 23, 24, 25, 254, 255, 256, 257, 300];
const sizes = [0, 1, 23, 24, 255, 256, 65535, 65536, 2 ** 32 - 1, 2 ** 32, 2 ** 40];
const mtimes = [undefined, -1000, 0, 23_000, 24_000, 255_000, 256_000, 65_535_000, 65_536_000, 2 ** 32 * 1000, 253402300799_000];
const letters = ['a', 'b', '\u00f1', '\u20ac', '\u{1f600}'];
const text = (n: number): string => Array.from({ length: n }, () => pick(letters)).join('');
for (let i = 0; i < 300; i++) {
const count = pick([0, 1, 2, 5, 23, 24]);
const files = Array.from({ length: count }, (_, k) => ({
path: `${k}-${text(pick(lengths))}`,
size: pick(sizes),
...(() => {
const m = pick(mtimes);
return m === undefined ? {} : { mtime: m };
})(),
}));
const shape: HeadShape = {
files,
comment: text(pick(lengths)) + pick(['', '\u000d\u000a', '\u000d']),
author: text(pick([0, 1, 23, 24, 100])),
critical: pick([[], [{ id: text(pick([1, 23, 24])), version: pick([1, 23, 24, 256, 2 ** 32 - 1]), data: undefined }]]),
noncritical: pick([[], [{ id: 'y', version: 1, data: new Uint8Array(pick([1, 23, 24, 255, 256])) }]]),
};
expect(headLength(shape), `shape ${i}`).toBe(encodeHead(headOf(shape)).length);
}
});
});
describe('encryptFiles, invalid inputs', () => {
// The texts of capsule.EncryptFiles at spec-v0.10 for the same inputs.
const a = source('a.txt', 'hola');
const cases: [string, FileSource[], Partial<EncryptOptions>, string][] = [
['no files and no comment', [], {}, 'capsule: a format 3 capsule holds at least one file or a comment (spec §62.1 rule 14)'],
['length set', [a], { length: 4 }, 'capsule: EncryptOptions.Length is for Encrypt: EncryptFiles computes L from the files'],
['a comment with U+202E', [a], { comment: 'a\u202eb' }, 'capsule: comment: text: bidirectional control U+202E'],
['a comment of 16385 bytes', [a], { comment: 'a'.repeat(16385) }, 'capsule: comment: 16385 bytes, more than 16384'],
['an author with LF', [a], { author: 'a\u000ab' }, 'capsule: declared author: text: control U+000A in the declared author'],
['an author of 257 bytes', [a], { author: 'a'.repeat(257) }, 'capsule: declared author: 257 bytes, more than 256'],
['an author with a leading space', [a], { author: ' Ana' }, 'capsule: declared author: text: the declared author starts or ends with U+0020'],
['a path given twice', [a, source('a.txt', 'x')], {}, 'capsule: path "a.txt" given twice'],
['an empty path', [source('', 'x')], {}, 'capsule: path "": R1: 0 bytes, not 1 to 1024'],
['a path of 1025 bytes', [source('a'.repeat(1025), 'x')], {}, `capsule: path "${'a'.repeat(1025)}": R1: 1025 bytes, not 1 to 1024`],
['a negative size', [{ path: 'a', size: -1, open: a.open }], {}, 'capsule: file "a": negative size -1'],
['no open', [{ path: 'a', size: 1 } as FileSource], {}, 'capsule: file "a": Source.Open is nil'],
['files above L_MAX', [{ path: 'a', size: 2 ** 52, open: a.open }, { path: 'b', size: 2 ** 52, open: a.open }], {}, 'capsule: the files add up to more than 8936830510563328 bytes, the maximum of L'],
['one file of L_MAX bytes', [{ path: 'a', size: MAX_PAYLOAD_LENGTH, open: a.open }], {}, 'capsule: content of 8936830510563968 bytes exceeds L_MAX = 8936830510563328'],
['path ..', [source('..', 'x')], {}, 'capsule: path "..": R3: segment 1: the segment is two dots'],
['path a/', [source('a/', 'x')], {}, 'capsule: path "a/": R2: segment 2 is empty'],
['path CON.txt', [source('CON.txt', 'x')], {}, 'capsule: path "CON.txt": R6: segment 1: CON is a reserved device name'],
['path .datekeys-x', [source('.datekeys-x', 'x')], {}, 'capsule: path ".datekeys-x": R10: the first segment starts with ".datekeys-"'],
['paths A.txt and a.txt', [source('a.txt', 'x'), source('A.txt', 'y')], {}, 'capsule: paths "A.txt" and "a.txt": R7: path 2 collides with path 1 in segment 1'],
['paths a and a/b', [source('a/b', 'x'), source('a', 'y')], {}, 'capsule: paths "a" and "a/b": R7: path 2 makes a file of path 1 a folder, or the reverse, in segment 1'],
['a path with U+3000 at the end', [source('a\u00a0b', 'x'), source('c\u3000', 'y')], {}, 'capsule: path "c\\u3000": R6c: segment 1: code page 1250 maps the segment to one that breaks R5: the segment ends with U+0020'],
[
'a head above 16 MiB',
[a],
{ headNoncritical: [{ id: 'x.example', version: 1, data: new Uint8Array(16 << 20).fill(1) }] },
'capsule: the head is 16777342 bytes, more than 16777216: fewer files or shorter paths',
],
[
'a head extension in both arrays',
[a],
{ headCritical: [{ id: 'x.example', version: 1, data: undefined }], headNoncritical: [{ id: 'x.example', version: 1, data: undefined }] },
'extension x.example: both critical and noncritical: ERR_NON_CANONICAL_CBOR',
],
['an open that fails', [{ path: 'a', size: 1, open: () => { throw new Error('no such file'); } }], {}, 'capsule: file "a": no such file'],
['a read that fails', [{ path: 'a', size: 1, open: () => chunked([], new Error('disk on fire')) }], {}, 'capsule: file "a": disk on fire'],
['a first reading shorter', [changing('a', 4, 'hol')], {}, 'capsule: file "a": 3 bytes, not its size of 4'],
['a first reading longer', [changing('a', 4, 'holas')], {}, 'capsule: file "a": more than its size of 4 bytes'],
['a second reading shorter', [changing('a', 4, 'hola', 'hol')], {}, 'capsule: file "a" changed after its first reading: 3 bytes, not its size of 4'],
['a second reading longer', [changing('a', 4, 'hola', 'holas')], {}, 'capsule: file "a" changed after its first reading: more than its size of 4 bytes'],
['a second reading with another content', [changing('a', 4, 'hola', 'HOLA')], {}, 'capsule: file "a" changed after its first reading: its SHA-256 is another'],
];
it.each(cases)('%s', async (_, files, extra, text) => {
const out = recorder();
const err = await failure(encryptFiles(files, options({ ...extra, output: out.stream })));
expect(err.message).toBe(text);
expect([out.state.closed, out.state.aborted]).toEqual([false, err]);
});
it('refuses a text or a path that is not well formed, and more than 65535 implicit folders', async () => {
expect((await failure(encryptFiles([source('a', 'x')], options({ comment: 'a\ud800' })))).message).toBe('capsule: comment: not valid UTF-8');
expect((await failure(encryptFiles([source('a\ud800', 'x')], options()))).message).toMatch(/^capsule: path ".*": R1: not valid UTF-8$/);
// 2115 files of 32 segments, each of whose folders is new: 65565 folders.
const deep = Array.from({ length: 2115 }, (_, i) => source([`r${String(i).padStart(4, '0')}`, ...Array.from({ length: 30 }, (_, k) => `s${String(k + 1).padStart(2, '0')}`), 'f'].join('/'), ''));
expect((await failure(encryptFiles(deep, options()))).message).toBe('capsule: paths: R9: 65565 folders, more than 65535');
// What a file throws need not be an Error.
const odd: FileSource = {
path: 'a',
size: 1,
open: () => {
throw 'locked';
},
};
expect((await failure(encryptFiles([odd], options()))).message).toBe('capsule: file "a": locked');
});
it('refuses 65536 files before reading any', async () => {
let opened = 0;
const files = Array.from({ length: 65536 }, (_, i): FileSource => ({ path: `f${String(i).padStart(5, '0')}`, size: 0, open: () => (opened++, new Blob([]).stream()) }));
expect((await failure(encryptFiles(files, options()))).message).toBe('capsule: 65536 files, more than 65535');
expect(opened).toBe(0);
});
it('writes nothing before the second reading, and aborts the output after a failure in it', async () => {
const first = recorder();
await failure(encryptFiles([changing('a', 4, 'hol')], options({ output: first.stream })));
expect(first.chunks).toEqual([]);
const second = recorder();
const err = await failure(encryptFiles([source('a', 'x'), changing('b', 4, 'hola', 'HOLA')], options({ output: second.stream })));
expect(err.message).toBe('capsule: file "b" changed after its first reading: its SHA-256 is another');
expect([second.chunks.length > 0, second.state.closed, second.state.aborted]).toEqual([true, false, err]);
// A second reading whose stream fails in the middle of the file gives
// the error of the file, and the stream, already failed, is not
// cancelled.
let cancelled: unknown;
let n = 0;
const flaky: FileSource = {
path: 'c',
size: 8,
open: () =>
n++ === 0
? new Blob(['12345678']).stream()
: new ReadableStream<Uint8Array>({
start: (c) => c.enqueue(te.encode('1234')),
pull: (c) => c.error(new Error('gone')),
cancel: (reason) => void (cancelled = reason),
}),
};
expect((await failure(encryptFiles([flaky], options()))).message).toBe('capsule: file "c": gone');
expect(cancelled).toBeUndefined();
});
it('refuses inputs of the wrong type as the caller errors they are', async () => {
const a = source('a', 'x');
for (const [files, extra, text] of [
['nope', {}, 'encrypt: the files are an array of FileSource'],
[[null], {}, 'encrypt: file 0 is not a FileSource'],
[[{ path: 1, size: 0, open: a.open }], {}, 'encrypt: file 0: path is not a string'],
[[{ path: 'a', size: 1.5, open: a.open }], {}, 'encrypt: file 0: size is not a safe integer'],
[[{ path: 'a', size: 0, mtime: Number.NaN, open: a.open }], {}, 'encrypt: file 0: mtime is not a finite number of milliseconds'],
[[a], { comment: 5 }, 'encrypt: EncryptOptions.comment is not a string'],
[[a], { author: {} }, 'encrypt: EncryptOptions.author is not a string'],
] as const) {
const err = await failure(encryptFiles(files as unknown as FileSource[], options(extra as Partial<EncryptOptions>)));
expect([err instanceof TypeError, err.message]).toEqual([true, text]);
}
expect(await failure(encryptFiles([a], null as unknown as EncryptOptions))).toBeInstanceOf(TypeError);
expect(errorCode(await failure(encryptFiles([a], options({ padding: 3 as Padding }))))).toBe('');
expect((await failure(encryptFiles([a], options({ padding: REFORZADO, unlockAt: GENESIS })))).message).toMatch(/is not in the future$/);
});
});

@ -1,17 +1,52 @@
// The writer of phase 3: encrypt writes a .dkc of capsule format 2 and, when
// asked, a portable .dkk (spec §61, §62, §62.1), as capsule.Encrypt of the Go
// reference at spec-v0.9. Its random values all come from
// crypto.getRandomValues (§62.1 rule 5); the core of writer.ts, which takes
// them from its caller, is imported only here and by the tests. Loaded on
// demand: index.ts does not re-export it.
// The writers: encryptFiles writes a .dkc of capsule format 3, and encrypt
// one of format 2, each with, when asked, a portable .dkk (spec §61, §62,
// §62.1), as capsule.EncryptFiles and capsule.Encrypt of the Go reference at
// spec-v0.10. Their random values all come from crypto.getRandomValues
// (§62.1 rule 5); the core of writer.ts, which takes them from its caller, is
// imported only here and by the tests. Loaded on demand: index.ts does not
// re-export it.
import { cryptoWords } from './random.ts';
import { type Draws, type EncryptOptions, type Encrypted, type EncryptSource, MAX_MEMORY_DKC, writeCapsule } from './writer.ts';
import {
type Draws,
type EncryptOptions,
type Encrypted,
type EncryptSource,
type FileSource,
MAX_MEMORY_DKC,
writeCapsule,
writeFiles,
} from './writer.ts';
import { newX25519Identity } from './x25519.ts';
export type { EncryptOptions, Encrypted, EncryptSource };
export type { EncryptOptions, Encrypted, EncryptSource, FileSource };
export { MAX_MEMORY_DKC };
/**
* Writes a .dkc of format 3 holding `files`, and the comment and declared
* author of `opts`. It needs no network: the round is resolved locally, and
* tlock uses only the pinned public key.
*
* It reads each file twice, and writes nothing before the second reading.
* First it checks the paths and the texts with the rules of the reader,
* measures L with a head whose salt and SHA-256 are zero, as long as the
* final one, and hashes each file. Then it seals the control, with L, and
* streams PAYLOAD_AGE, reading each file again: a file whose size or SHA-256
* has changed makes it fail (§62.1 rule 18). The files go in the byte order
* of their paths, whatever the order given (R8), without the empty folders,
* which a path cannot name. The security area is the empty one of this
* version, in an area of 512 bytes (rule 13).
*
* Without `opts.output` the .dkc is returned in memory, up to
* MAX_MEMORY_DKC; with it, the .dkc is streamed into the output, closed only
* once the capsule is complete and checked and aborted on any failure. The
* checks, codes and texts are those of capsule.EncryptFiles; `opts.length`
* is for encrypt, and L is the length of BODY.
*/
export function encryptFiles(files: readonly FileSource[], opts: EncryptOptions): Promise<Encrypted> {
return writeFiles(files, opts, cryptoDraws());
}
/**
* Writes a .dkc of format 2 for the content of `src`, which must be exactly
* L bytes (§62.1 rule 6): the size of a Uint8Array or a Blob, or
@ -29,6 +64,16 @@ export function encrypt(src: EncryptSource, opts: EncryptOptions): Promise<Encry
return writeCapsule(src, opts, cryptoDraws());
}
/**
* The FileSource of a File or another Blob: its path in the capsule, its
* size, its modification time, File.lastModified unless given, and its
* stream, read twice.
*/
export function fileSource(path: string, blob: Blob, mtime?: number): FileSource {
const lastModified = mtime ?? (blob instanceof File ? blob.lastModified : undefined);
return { path, size: blob.size, ...(lastModified === undefined ? {} : { mtime: lastModified }), open: () => blob.stream() };
}
// Every random value of the writer from crypto.getRandomValues.
function cryptoDraws(): Draws {
const bytes = (n: number) => (): Uint8Array => crypto.getRandomValues(new Uint8Array(n));
@ -39,5 +84,6 @@ function cryptoDraws(): Draws {
dummy: newX25519Identity,
words: cryptoWords(),
credentialId: bytes(16),
salt: bytes(32),
};
}

@ -1,10 +1,12 @@
// The lengths of a .dkc of format 2 that follow from its inputs, without
// writing it (spec §62.1, informative note, and §29.1): what a page shows
// before encrypting, since the size is visible to anyone who holds the file
// (§55.2), and what the writer checks its seal against. No noble and no
// age-encryption, so that a page can load it with its first load.
// The lengths of a .dkc of format 2 or 3 that follow from its inputs,
// without writing it (spec §62.1, informative note, §29.1 and §29.2): what a
// page shows before encrypting, since the size is visible to anyone who
// holds the file (§55.2), and what the writer checks its seal against. No
// noble, no age-encryption and no Unicode tables, so that a page can load it
// with its first load.
import { ACCESS_SLOTS } from './age.ts';
import { compareBytes, utf8Bytes, utf8Length } from './bytes.ts';
import { encodeControl } from './control.ts';
import type { Extension } from './extension.ts';
import { DKC_PRELUDE_SIZE, FORMAT_2 } from './framing.ts';
@ -32,7 +34,7 @@ export interface CapsuleLengthInput {
readonly profileId: string;
readonly round: number;
readonly policy: Policy;
/** L, the length of the content. */
/** L, the length of the content, or of BODY in format 3 (bodyLength). */
readonly length: number;
/** The padding rule; reforzado when omitted, as in encrypt. */
readonly padding?: Padding;
@ -43,7 +45,8 @@ export interface CapsuleLengthInput {
}
/**
* The exact size of the .dkc that encrypt writes for these inputs: PRELUDE,
* The exact size of the .dkc that encrypt, or encryptFiles with L from
* bodyLength, writes for these inputs: PRELUDE,
* PUBLIC_HEADER, SEALED_CONTROL_LEN and the length of PAYLOAD_AGE for P =
* rule(L). The random values and the credentials do not change it: a
* time_and_key capsule always holds 16 stanzas (§39). It throws, as the
@ -76,3 +79,107 @@ export function capsuleLength(input: CapsuleLengthInput): number {
payloadAgeLength(paddedLength(input.length, padding))
);
}
// ---------------------------------------------------------------------------
// Format 3
// 9999-12-31T23:59:59Z in seconds, the last mtime of a head (MAX_MTIME of
// head.ts, which this module does not import: its rules of the paths bring
// the Unicode tables).
const LAST_MTIME = 253402300799;
// The frame of BODY and the security area that writers write (body.ts).
const FRAME_AND_AREA = 12 + 512;
/**
* The comment as the writer of format 3 stores it: CR LF, and any lone CR,
* turned into LF (spec §29.6).
*/
export function headComment(comment: string): string {
return comment.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
}
/**
* The mtime a head stores for a file modified at `ms`, milliseconds since
* 1970-01-01 UTC as File.lastModified gives it: its seconds when they fall
* from 1970-01-01 to 9999-12-31T23:59:59Z, and none otherwise, never clipped
* (spec §62.1 rule 16).
*/
export function mtimeSeconds(ms: number | undefined): number | undefined {
if (ms === undefined) return undefined;
const s = Math.floor(ms / 1000);
return s >= 0 && s <= LAST_MTIME ? s : undefined;
}
/** What the length of the head of a format 3 capsule depends on. */
export interface HeadShape {
/** Each file: its path, its size and its mtime in milliseconds, as FileSource. */
readonly files: readonly { readonly path: string; readonly size: number; readonly mtime?: number }[];
/** As EncryptOptions gives them; the comment is taken as headComment stores it. */
readonly comment?: string;
readonly author?: string;
readonly critical?: readonly Extension[];
readonly noncritical?: readonly Extension[];
}
// The bytes of the head of a CBOR item whose argument is n.
const cborHead = (n: number): number => (n < 24 ? 1 : n < 0x100 ? 2 : n < 0x10000 ? 3 : n < 0x100000000 ? 5 : 9);
// A text or a byte string of n bytes, head included.
const cborString = (n: number): number => cborHead(n) + n;
// An extension map: {0: extension_id, 1: extension_version, ? 2: data}.
const extensionLength = (e: Extension): number =>
cborHead(e.data === undefined ? 2 : 3) +
1 +
cborString(utf8Length(e.id)) +
1 +
cborHead(e.version) +
(e.data === undefined ? 0 : 1 + cborString(e.data.length));
/**
* The length of the HEAD_CBOR that the writer of format 3 writes for these
* files and texts, from the sizes of its CBOR items (spec §29.4), without
* encoding it. The files go in the byte order of their paths, which decides
* their start and end.
*/
export function headLength(h: HeadShape): number {
const comment = headComment(h.comment ?? '');
const author = h.author ?? '';
const files = h.files
.map((f) => ({ ...f, key: utf8Bytes(f.path) }))
.sort((a, b) => compareBytes(a.key, b.key));
let pairs = 3;
// Keys 0, 1 and 2: "datekeys-head", 1 and the salt of 32 bytes.
let n = 1 + cborString(13) + 1 + 1 + 1 + cborString(32);
for (const text of [comment, author]) {
if (text === '') continue;
pairs++;
n += 1 + cborString(utf8Length(text));
}
if (files.length > 0) {
pairs++;
n += 1 + cborHead(files.length);
let end = 0;
for (const f of files) {
const mtime = mtimeSeconds(f.mtime);
n += cborHead(mtime === undefined ? 5 : 6) + 1 + cborString(f.key.length) + 1 + cborHead(f.size) + 1 + cborHead(end);
end += f.size;
n += 1 + cborHead(end) + 1 + cborString(32) + (mtime === undefined ? 0 : 1 + cborHead(mtime));
}
}
for (const list of [h.critical ?? [], h.noncritical ?? []]) {
if (list.length === 0) continue;
pairs++;
n += 1 + cborHead(list.length) + list.reduce((sum, e) => sum + extensionLength(e), 0);
}
return cborHead(pairs) + n;
}
/**
* L of a format 3 capsule: the length of its BODY, the frame, the security
* area of 512 bytes, the head and the files (spec §29.2). With it,
* capsuleLength gives the size of the .dkc, since the control of format 3
* has the length of the control of format 2.
*/
export function bodyLength(h: HeadShape): number {
return FRAME_AND_AREA + headLength(h) + h.files.reduce((sum, f) => sum + f.size, 0);
}

@ -5,7 +5,7 @@
// hand the writer copies, and the writer wipes them.
import { cryptoWords, type RandomWords } from '../random.ts';
import { type Draws, type EncryptOptions, type Encrypted, type EncryptSource, writeCapsule } from '../writer.ts';
import { type Draws, type EncryptOptions, type Encrypted, type EncryptSource, type FileSource, writeCapsule, writeFiles } from '../writer.ts';
import { newX25519Identity } from '../x25519.ts';
/** The random values to fix; any other is drawn at random. */
@ -14,6 +14,8 @@ export interface FixedDraws {
readonly payloadIdentity?: Uint8Array;
readonly accessIdentity?: Uint8Array;
readonly credentialId?: Uint8Array;
/** The salt of the head of a format 3 capsule. */
readonly salt?: Uint8Array;
/** The scalars of the dummies, in the order they are drawn. */
readonly dummies?: readonly Uint8Array[];
/** The words of the permutation of the slots; see wordsFor. */
@ -51,6 +53,7 @@ export function fixedDraws(f: FixedDraws): Draws {
dummy: secret(() => dummies.shift()?.slice() ?? newX25519Identity()),
words,
credentialId: fixed(f.credentialId, bytes(16)),
salt: fixed(f.salt, bytes(32)),
};
}
@ -59,6 +62,11 @@ export function encryptWith(src: EncryptSource, opts: EncryptOptions, f: FixedDr
return writeCapsule(src, opts, fixedDraws(f));
}
/** encryptFiles with some of its random values fixed. */
export function encryptFilesWith(files: readonly FileSource[], opts: EncryptOptions, f: FixedDraws): Promise<Encrypted> {
return writeFiles(files, opts, fixedDraws(f));
}
/**
* The words that make the permutation of the writer leave credential i, the
* item at position i before it, in slot `slots[i]`, the dummies filling the

@ -18,20 +18,24 @@ import { Decrypter, Encrypter, type ReadableStreamWithSize } from 'age-encryptio
import { ACCESS_TYPE_X25519, type AccessKey } from './accesskey.ts';
import { ACCESS_SLOTS, ageStanzas, checkAccessStanzas, checkTimeStanzas, parseAgeHeader } from './age.ts';
import { decryptAll } from './agefile.ts';
import { copyBytes, equalBytes } from './bytes.ts';
import { AREA_LEN, BODY_FRAME_SIZE, bodyFrameBytes, contentLength, MAX_HEAD_LEN, parseBodyFrame } from './body.ts';
import { compareBytes, copyBytes, equalBytes, goQuote, utf8Bytes, utf8Length } from './bytes.ts';
import { decodeControl, encodeControl } from './control.ts';
import { compareInstants, type DateKey, formatRFC3339Nano, type Instant, isInstant, resolveDateKey, roundTime } from './datekey.ts';
import { sha256Hasher } from './digest.ts';
import { DateKeysError } from './errors.ts';
import type { Extension } from './extension.ts';
import { DKC_PRELUDE_SIZE, FORMAT_2, type FORMAT_3, headerBinding, MAX_SEALED_CONTROL_LEN, preludeBytes } from './framing.ts';
import { DKC_PRELUDE_SIZE, FORMAT_2, FORMAT_3, headerBinding, MAX_SEALED_CONTROL_LEN, preludeBytes } from './framing.ts';
import { checkHeadEnd, decodeWrittenHead, encodeHead, type Head, type HeadFile, MAX_FILES, SALT_SIZE } from './head.ts';
import { decodeHeader, encodeHeader, type Policy, TIME_AND_KEY, TIME_ONLY } from './header.ts';
import { accessIdentity, payloadIdentity } from './open.ts';
import { sealedControlLength } from './lengths.ts';
import { headComment, mtimeSeconds, sealedControlLength } from './lengths.ts';
import { isPadding, MAX_PAYLOAD_LENGTH, paddedLength, type Padding, payloadAgeLength, REFORZADO } from './padding.ts';
import { checkAuthor, checkComment, checkPath, checkTree, MAX_AUTHOR_LEN, MAX_COMMENT_LEN, MAX_PATH_LEN, PathRuleError } from './pathrule.ts';
import { cloneProfile, type Profile, validateProfile } from './profile.ts';
import { permute, type RandomWords } from './random.ts';
import { checkX25519Recipient, formatX25519Recipient } from './recipient.ts';
import { encodeSecurity, evaluateSecurity } from './security.ts';
import { timeRecipient } from './tlock.ts';
import { x25519PublicKey } from './x25519.ts';
@ -41,6 +45,28 @@ export const MAX_MEMORY_DKC = 1 << 30;
/** What a capsule seals: bytes in memory, a Blob such as a File, or a stream of declared length. */
export type EncryptSource = Uint8Array | Blob | ReadableStream<Uint8Array>;
/** A file that encryptFiles writes into a format 3 capsule, as capsule.Source. */
export interface FileSource {
/**
* Its path in the capsule, relative, with '/' between its segments (spec
* §29.5). It is stored as given: a path that breaks a rule is rejected,
* with a message that names the rule and the character, and never
* corrected (spec §62.1 rule 15).
*/
readonly path: string;
/** Its number of bytes, checked in each of its two readings. */
readonly size: number;
/**
* Its modification time at its source, in milliseconds since 1970-01-01
* UTC as File.lastModified gives it, when known. It is stored in seconds
* when it falls from 1970-01-01 to 9999-12-31T23:59:59Z, and omitted
* otherwise, never clipped (spec §62.1 rule 16). It proves nothing.
*/
readonly mtime?: number;
/** A reading of the file from its start. encryptFiles calls it twice. */
readonly open: () => ReadableStream<Uint8Array>;
}
/** Options of encrypt, field by field those of capsule.EncryptOptions. */
export interface EncryptOptions {
/** The pinned Provider Profile. Required. */
@ -63,6 +89,18 @@ export interface EncryptOptions {
/** The CONTROL_CBOR extensions, sealed with the control. */
readonly controlCritical?: readonly Extension[];
readonly controlNoncritical?: readonly Extension[];
/**
* The comment and the declared author of the head that encryptFiles
* writes, absent or '' when none (spec §29.4, §29.6): the comment of 1 to
* 16384 bytes, in which encryptFiles turns CR LF, and a lone CR, into LF,
* and the declared author of 1 to 256. The declared author is text of the
* creator and proves nothing (spec §55.1).
*/
readonly comment?: string;
readonly author?: string;
/** The extensions of the head that encryptFiles writes, sealed in PAYLOAD_AGE (spec §29.4, §54). */
readonly headCritical?: readonly Extension[];
readonly headNoncritical?: readonly Extension[];
/** The clock. Required, and called once. */
readonly now: () => Instant;
/**
@ -88,6 +126,11 @@ export interface Encrypted {
readonly paddedLength: number;
/** The .dkk, when newPortableKey is set: encode it with encodeAccessKey and wipe it with wipeAccessKey. */
readonly portableKey?: AccessKey;
/**
* The head that encryptFiles wrote: the files in the byte order of their
* paths, with their layout and SHA-256, and the comment as written.
*/
readonly head?: Head;
/** The size of the .dkc. */
readonly size: number;
/** The .dkc, only without an output. */
@ -108,6 +151,8 @@ export interface Draws {
readonly words: RandomWords;
/** credential_id, 16 bytes (§42). */
readonly credentialId: () => Uint8Array;
/** The salt of the head of a format 3 capsule, 32 bytes (§29.4). */
readonly salt: () => Uint8Array;
}
const CHUNK = 64 << 10;
@ -126,6 +171,163 @@ export async function writeCapsule(src: EncryptSource, opts: EncryptOptions, dra
});
}
/**
* Writes a .dkc of format 3 holding `files` and the comment and declared
* author of `opts`, with the random values of `draws`, as
* capsule.EncryptFiles. See encryptFiles.
*/
export async function writeFiles(files: readonly FileSource[], opts: EncryptOptions, draws: Draws): Promise<Encrypted> {
return guarded(opts, async (state) => {
// Step 1: the inputs, before anything is used.
if (typeof opts !== 'object' || opts === null) throw new TypeError('encrypt: options are required');
if (opts.length !== undefined) throw new Error('capsule: EncryptOptions.Length is for Encrypt: EncryptFiles computes L from the files');
checkOptions(opts);
const sources = copySources(files);
const comment = checkText(opts.comment, 'comment');
const author = checkText(opts.author, 'author');
const headCritical = copyExtensions(opts.headCritical);
const headNoncritical = copyExtensions(opts.headNoncritical);
const s = await newSealer(opts, 0);
const { head, order } = newHead(sources, headComment(comment), author, headCritical, headNoncritical);
// Step 2 of spec §61: L, with a head as long as the final one.
const measured = encodeHead(head);
if (measured.length > MAX_HEAD_LEN) throw new Error(`capsule: the head is ${measured.length} bytes, more than ${MAX_HEAD_LEN}: fewer files or shorter paths`);
selfCheckHead(measured);
const content = head.files.at(-1)?.end ?? 0;
const length = BODY_FRAME_SIZE + AREA_LEN + measured.length + content;
checkLength(length, s.code);
// Step 3: the first reading, for the SHA-256 of each file.
const sums: Uint8Array[] = [];
for (const [i, f] of head.files.entries()) sums.push(await readFile(sources[order[i]!]!, f.size));
// Step 12: the head, with a fresh salt, and SECURITY_CBOR, decoded with
// the rules of the reader; seal decodes CONTROL_CBOR.
const salt = copyBytes(drawn(draws.salt(), SALT_SIZE, 'the salt'));
const final: Head = { ...head, salt, files: head.files.map((f, i) => ({ ...f, sha256: sums[i]! })) };
const headBytes = encodeHead(final);
/* v8 ignore next -- @preserve: the salt and the SHA-256 do not change the length of the head */
if (headBytes.length !== measured.length) throw new Error(`capsule: internal error: the head is ${headBytes.length} bytes, measured ${measured.length}`);
selfCheckHead(headBytes);
const security = encodeSecurity();
const v = evaluateSecurity(security);
/* v8 ignore next 3 -- @preserve: encodeSecurity writes the empty area, F0 and S0 */
if (v.signature !== 'F0' || v.seal !== 'S0') {
throw new Error(`capsule: self-check: the reader finds the verdicts ${v.signature} and ${v.seal} in this security area`);
}
const frame = bodyFrameBytes({ areaLen: AREA_LEN, securityLen: security.length, headLen: headBytes.length });
selfCheck('capsule: self-check', () => checkHeadEnd(final, contentLength(parseBodyFrame(frame, length), length)));
// Step 16: BODY, and the second reading of each file.
const area = new Uint8Array(AREA_LEN);
area.set(security);
const res = await seal(s, FORMAT_3, length, draws, state, () => bodyContent([frame, area, headBytes], final.files, sources, order));
return { ...res, head: final };
});
}
// Copies of the sources, which must be of the types of FileSource.
function copySources(files: readonly FileSource[]): FileSource[] {
if (!Array.isArray(files)) throw new TypeError('encrypt: the files are an array of FileSource');
return files.map((f: FileSource, i) => {
if (typeof f !== 'object' || f === null) throw new TypeError(`encrypt: file ${i} is not a FileSource`);
if (typeof f.path !== 'string') throw new TypeError(`encrypt: file ${i}: path is not a string`);
if (!Number.isSafeInteger(f.size)) throw new TypeError(`encrypt: file ${i}: size is not a safe integer`);
if (f.mtime !== undefined && !Number.isFinite(f.mtime)) throw new TypeError(`encrypt: file ${i}: mtime is not a finite number of milliseconds`);
return { path: f.path, size: f.size, ...(f.mtime === undefined ? {} : { mtime: f.mtime }), open: f.open };
});
}
// A text of the head as given: a string, '' when absent.
function checkText(v: string | undefined, what: string): string {
if (v !== undefined && typeof v !== 'string') throw new TypeError(`encrypt: EncryptOptions.${what} is not a string`);
return v ?? '';
}
// newHead of the reference: the files and the texts checked with the rules
// of spec §29.4 to §29.6, in the words of a writer (§62.1 rule 15), and the
// head with the files in the byte order of their paths, their layout and
// mtime, and a zero salt and zero SHA-256; order[i] is the source of entry i.
function newHead(
sources: readonly FileSource[],
comment: string,
author: string,
critical: Extension[],
noncritical: Extension[],
): { head: Head; order: number[] } {
if (sources.length === 0 && comment === '') throw new Error('capsule: a format 3 capsule holds at least one file or a comment (spec §62.1 rule 14)');
if (sources.length > MAX_FILES) throw new Error(`capsule: ${sources.length} files, more than ${MAX_FILES}`);
checkHeadText('comment', comment, MAX_COMMENT_LEN, checkComment);
checkHeadText('declared author', author, MAX_AUTHOR_LEN, checkAuthor);
// R8: the byte order of the paths, which is not the order of JavaScript
// strings, by UTF-16 code units. The sort is stable.
const keys = sources.map((f) => utf8Bytes(f.path));
const order = sources.map((_, i) => i).sort((a, b) => compareBytes(keys[a]!, keys[b]!));
const files: HeadFile[] = [];
const paths: string[] = [];
let end = 0;
for (const [i, j] of order.entries()) {
const src = sources[j]!;
const p = src.path;
if (i > 0 && p === paths[i - 1]) throw new Error(`capsule: path ${goQuote(p)} given twice`);
if (!p.isWellFormed()) throw new Error(`capsule: path ${goQuote(p)}: R1: not valid UTF-8`);
const n = utf8Length(p);
if (n === 0 || n > MAX_PATH_LEN) throw new Error(`capsule: path ${goQuote(p)}: R1: ${n} bytes, not 1 to ${MAX_PATH_LEN}`);
if (src.size < 0) throw new Error(`capsule: file ${goQuote(p)}: negative size ${src.size}`);
if (typeof src.open !== 'function') throw new Error(`capsule: file ${goQuote(p)}: Source.Open is nil`);
if (src.size > MAX_PAYLOAD_LENGTH - end) throw new Error(`capsule: the files add up to more than ${MAX_PAYLOAD_LENGTH} bytes, the maximum of L`);
try {
checkPath(p);
} catch (err) {
throw pathFailure(err, `capsule: path ${goQuote(p)}`);
}
const mtime = mtimeSeconds(src.mtime);
files.push({ path: p, size: src.size, start: end, end: end + src.size, sha256: new Uint8Array(32), ...(mtime === undefined ? {} : { mtime }) });
paths.push(p);
end += src.size;
}
try {
checkTree(paths);
} catch (err) {
const e = pathFailure(err, 'capsule: paths');
const [later, earlier] = (err as PathRuleError).paths;
throw later > 0 ? new Error(`capsule: paths ${goQuote(paths[earlier - 1]!)} and ${goQuote(paths[later - 1]!)}: ${(err as PathRuleError).message}`) : e;
}
return { head: { salt: new Uint8Array(SALT_SIZE), comment, author, files, critical, noncritical }, order };
}
// A violation of a rule of the paths or the texts, after prefix; anything
// but a PathRuleError is a bug and propagates.
function pathFailure(err: unknown, prefix: string): Error {
/* v8 ignore next -- @preserve: the rules throw only PathRuleError */
if (!(err instanceof PathRuleError)) throw err;
return new Error(`${prefix}: ${err.message}`);
}
// checkHeadText of the reference: the comment or the declared author, when
// present: well formed, at most max bytes, and the characters of §29.6.
function checkHeadText(what: string, s: string, max: number, check: (s: string) => void): void {
if (s === '') return;
if (!s.isWellFormed()) throw new Error(`capsule: ${what}: not valid UTF-8`);
const n = utf8Length(s);
if (n > max) throw new Error(`capsule: ${what}: ${n} bytes, more than ${max}`);
try {
check(s);
} catch (err) {
throw pathFailure(err, `capsule: ${what}`);
}
}
// selfCheckHead of the reference: HEAD_CBOR decoded with the rules of the
// reader, but for the knowledge of its critical extensions, which depends on
// the reader (§62.1 rule 17). A head that the reader rejects would only be
// found after the date.
function selfCheckHead(b: Uint8Array): void {
selfCheck('capsule: self-check: the reader rejects this head', () => decodeWrittenHead(b));
}
interface WriteState {
writer?: WritableStreamDefaultWriter<Uint8Array>;
}
@ -661,6 +863,127 @@ function sourceContent(src: EncryptSource, length: number): Content {
};
}
// A reading of a file of a format 3 capsule, as readSource of the reference:
// its bytes in pieces of at most 64 KiB, which must be exactly its size, and
// their SHA-256. In the second reading, a file whose size differs has
// changed (§62.1 rule 18). The errors of the file keep their text, after its
// path; the error is their cause.
class FileReading {
readonly #source: FileSource;
readonly #size: number;
readonly #second: boolean;
readonly #reader: ReadableStreamDefaultReader<Uint8Array>;
readonly #sum = sha256Hasher();
#pending: Uint8Array = new Uint8Array(0);
#n = 0;
constructor(source: FileSource, size: number, second: boolean) {
this.#source = source;
this.#size = size;
this.#second = second;
try {
this.#reader = source.open().getReader();
} catch (err) {
throw this.#failure(err);
}
}
#failure(err: unknown): Error {
return new Error(`capsule: file ${goQuote(this.#source.path)}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
}
#mismatch(detail: string): Error {
const p = goQuote(this.#source.path);
return new Error(this.#second ? `capsule: file ${p} changed after its first reading: ${detail}` : `capsule: file ${p}: ${detail}`);
}
// The next piece of the file, or undefined at its end, once it is exactly
// its size.
async next(): Promise<Uint8Array | undefined> {
while (this.#pending.length === 0) {
let r: ReadableStreamReadResult<Uint8Array>;
try {
r = await this.#reader.read();
} catch (err) {
throw this.#failure(err);
}
if (r.done) {
if (this.#n !== this.#size) throw this.#mismatch(`${this.#n} bytes, not its size of ${this.#size}`);
return undefined;
}
if (r.value.length > this.#size - this.#n) throw this.#mismatch(`more than its size of ${this.#size} bytes`);
this.#pending = r.value;
}
const piece = this.#pending.subarray(0, CHUNK);
this.#pending = this.#pending.subarray(piece.length);
this.#n += piece.length;
this.#sum.update(piece);
return piece;
}
/** The SHA-256 of the file, once next gave its end. */
digest(): Uint8Array {
return this.#sum.digest();
}
async cancel(reason: unknown): Promise<void> {
await this.#reader.cancel(reason).catch(() => undefined);
}
}
// The first reading of a file: its SHA-256.
async function readFile(source: FileSource, size: number): Promise<Uint8Array> {
const r = new FileReading(source, size, false);
try {
while ((await r.next()) !== undefined) {
// Hashed as it is read.
}
return r.digest();
} catch (err) {
await r.cancel(err);
throw err;
}
}
// The content of format 3: the frame, the security area and the head, and
// then the files in the order of the head, each read a second time. A file
// whose SHA-256 has changed since the first reading fails (§62.1 rule 18),
// as a file whose size has.
function bodyContent(parts: readonly Uint8Array[], files: readonly HeadFile[], sources: readonly FileSource[], order: readonly number[]): Content {
let part = 0;
let offset = 0;
let file = -1;
let reading: FileReading | undefined;
return {
async next() {
for (; part < parts.length; part++, offset = 0) {
const b = parts[part]!;
if (offset < b.length) {
const piece = b.subarray(offset, offset + CHUNK);
offset += piece.length;
return piece;
}
}
for (;;) {
if (reading === undefined) {
if (++file >= files.length) return undefined;
reading = new FileReading(sources[order[file]!]!, files[file]!.size, true);
}
const piece = await reading.next();
if (piece !== undefined) return piece;
const sum = reading.digest();
reading = undefined;
if (!equalBytes(sum, files[file]!.sha256)) {
throw new Error(`capsule: file ${goQuote(files[file]!.path)} changed after its first reading: its SHA-256 is another`);
}
}
},
async cancel(reason) {
await reading?.cancel(reason);
},
};
}
// A reader of a Uint8Array in pieces of 64 KiB.
function arrayReader(b: Uint8Array): Pick<ReadableStreamDefaultReader<Uint8Array>, 'read' | 'cancel'> {
let at = 0;

Loading…
Cancel
Save

Powered by TurnKey Linux.