#!/usr/bin/env node // Writes, as JSON, what scripts/capsule-go-verdicts.go checks with the Go // reference (plan of phase 3, decision 11 and section 8, point 9): // // - samples: .dkc of format 2 written by encryptVectors of // src/lib/dkc/testing/encrypt.ts, as a generator of test vectors, the only // writer of format 2, and of format 3 written by encryptFiles as any // caller writes them, with the area of 32 KiB and, in two of them, a // public note; for rounds 1000, 1001 and 2000, with now at the genesis and // fixed identities, each with the credentials that open it and the // SHA-256 of its content, generated from its length, or in format 3 the // head written and the SHA-256 of each file; // - mixes: capsules spliced from two capsules of one round; // - encoders: every input of the encoder differential, drawn from a seed // (src/lib/dkc/testing/interop.ts), with its encodings by this library; // - recipients: strings for age.ParseX25519Recipient and // agewrap.CheckX25519Recipient; // - errors: the invalid options of encrypt as a generator of test vectors // that Go also rejects, with the text of this library; // - note_errors: the public notes that encryptFiles refuses, with the text // of this library. // // The capsules are random (age draws its file keys and shares), so the output // is generated once and frozen with the verdicts of Go in // src/lib/dkc/testing/capsule-vectors.json. Node runs the TypeScript sources // directly (type stripping, Node 22.6+): // // node scripts/capsule-ts-samples.mjs > ts-samples.json import { encodeAccessKey, marshalAccessKeyBody } from '../src/lib/dkc/accesskey.ts'; import { concatBytes, sha256, toHex } from '../src/lib/dkc/bytes.ts'; import { encodeControl } from '../src/lib/dkc/control.ts'; import { parseRFC3339 } from '../src/lib/dkc/datekey.ts'; import { encryptFiles } from '../src/lib/dkc/encrypt.ts'; import { FORMAT_2 } from '../src/lib/dkc/framing.ts'; import { encodeHead } from '../src/lib/dkc/head.ts'; import { encodeHeader, TIME_AND_KEY, TIME_ONLY } from '../src/lib/dkc/header.ts'; import { quicknet } from '../src/lib/dkc/profile.ts'; import { encryptVectors } from '../src/lib/dkc/testing/encrypt.ts'; import { encoderCaseJSON, encoderCases, errorCaseInput, errorCases, noteErrorCases, noteErrorInput, recipientStrings, sampleIdentity, } from '../src/lib/dkc/testing/interop.ts'; import { x25519PublicKey } from '../src/lib/dkc/x25519.ts'; const GENESIS = parseRFC3339('2023-08-23T15:09:27Z'); const roundAt = (r) => ({ seconds: GENESIS.seconds + (r - 1) * 3, nanos: 0 }); const ENCODER_SEED = 20260929; const ENCODER_COUNT = 500; // A deterministic content of n bytes, as in the tests of the writer. function content(n, seed = 1) { const b = new Uint8Array(n); let x = seed; for (let i = 0; i < n; i++) { x = (x * 1103515245 + 12345) >>> 0; b[i] = x >>> 24; } return b; } const base = (extra) => ({ profile: quicknet(), now: () => GENESIS, policy: TIME_ONLY, unlockAt: roundAt(1000), ...extra }); // Format 2, which only a generator of test vectors writes (spec §62.1 rule 1). async function sample(name, body, opts, identities = [], dkkExtensions = [], known = []) { const res = await encryptVectors(body, opts); let dkk; if (res.portableKey !== undefined) dkk = encodeAccessKey({ ...res.portableKey, noncritical: dkkExtensions }); return { name, round: res.dateKey.round, policy: opts.policy === TIME_AND_KEY ? 'time_and_key' : 'time_only', format: res.format, length: res.length, padding: res.padding, padded_length: res.paddedLength, content_sha256: toHex(await sha256(body)), identities: identities.map(toHex), ...(dkk === undefined ? {} : { dkk: toHex(dkk) }), known, dkc: toHex(res.dkc), }; } const samples = []; for (const n of [0, 46, 65536, 78000]) { for (const padding of [1, 2]) { samples.push(await sample(`time_only, ${n} bytes, padding ${padding}`, content(n, n + padding), base({ padding }))); } } const ids = (from, n) => Array.from({ length: n }, (_, i) => sampleIdentity(from + i)); const three = ids(10, 3); const sixteen = ids(20, 16); samples.push(await sample('time_and_key, a portable key', content(41), base({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), newPortableKey: true }))); samples.push( await sample( 'time_and_key, three recipients and a portable key', content(41), base({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), recipients: three.map(x25519PublicKey), newPortableKey: true }), three, ), ); samples.push( await sample('time_and_key, sixteen recipients', content(41), base({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), recipients: sixteen.map(x25519PublicKey) }), sixteen), ); const ext = (id, version, data) => ({ id, version, data: data === undefined ? undefined : new TextEncoder().encode(data) }); samples.push( await sample( 'extensions in PUBLIC_HEADER, CONTROL_CBOR and the .dkk', content(1000), base({ policy: TIME_AND_KEY, unlockAt: roundAt(2000), newPortableKey: true, critical: [ext('org.example.header-critical', 1)], noncritical: [ext('org.example.header', 7, 'public data'), ext('。', 2), ext('𐀀', 3, 'x')], controlCritical: [ext('org.example.control-critical', 4294967295, 'sealed')], controlNoncritical: [ext('org.example.control', 1, 'sealed data')], }), [], [ext('org.example.dkk', 2, 'dkk data')], [ { id: 'org.example.header-critical', version: 1 }, { id: 'org.example.control-critical', version: 4294967295 }, ], ), ); samples.push(await sample('an instant one nanosecond after round 1000', content(10), base({ unlockAt: { seconds: roundAt(1000).seconds, nanos: 1 } }))); // Format 3, written by encryptFiles: the files given as paths, contents and // mtimes in milliseconds. async function filesSample(name, files, opts, identities = [], known = []) { const sources = files.map(([path, bytes, mtime]) => ({ path, size: bytes.length, ...(mtime === undefined ? {} : { mtime }), open: () => new Blob([bytes]).stream() })); const res = await encryptFiles(sources, opts); return { name, round: res.dateKey.round, policy: opts.policy === TIME_AND_KEY ? 'time_and_key' : 'time_only', format: res.format, length: res.length, padding: res.padding, padded_length: res.paddedLength, head_cbor: toHex(encodeHead(res.head)), files: res.head.files.map((f) => ({ path: f.path, size: f.size, sha256: toHex(f.sha256), ...(f.mtime === undefined ? {} : { mtime: f.mtime }) })), identities: identities.map(toHex), ...(res.portableKey === undefined ? {} : { dkk: toHex(encodeAccessKey(res.portableKey)) }), known, ...(opts.publicNote === undefined ? {} : { public_note: opts.publicNote }), dkc: toHex(res.dkc), }; } samples.push(await filesSample('format 3: one file with its mtime', [['nota.txt', content(46), 1_790_769_600_000]], base())); samples.push( await filesSample( 'format 3: a tree of files, one over two STREAM chunks, and paths out of ASCII', [ ['fotos/2025/playa.jpg', content(80_000, 7), 1_790_683_200_000], ['fotos/2025/atardecer.jpg', content(3000, 8)], ['carta.txt', new TextEncoder().encode('Para abrir en familia.\n'), 1_790_769_600_000], ['docs/vacío.txt', new Uint8Array(0)], ['Ñandú/nota 🙂.txt', content(10, 9)], ['\uFFFD.txt', content(3, 10)], ['\u{10000}.txt', content(4, 11)], ], base({ comment: 'Para ti,\r\ncon cariño.', author: 'Ana López' }), ), ); samples.push(await filesSample('format 3: a comment and a declared author, and no file', [], base({ comment: 'Solo unas líneas.\n\tCon un tabulador.', author: 'Ana' }))); samples.push(await filesSample('format 3: bloque256', [['datos.bin', content(20_000, 12)]], base({ padding: 1 }))); samples.push( await filesSample( 'format 3: time_and_key, three recipients and a portable key', [['carta.txt', content(41, 13)]], base({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), recipients: three.map(x25519PublicKey), newPortableKey: true }), three, ), ); samples.push( await filesSample( 'format 3: extensions of the head', [['a.txt', content(5, 14)]], base({ unlockAt: roundAt(2000), headCritical: [ext('org.example.head-critical', 1)], headNoncritical: [ext('org.example.head', 3, 'head data')], }), [], [{ id: 'org.example.head-critical', version: 1 }], ), ); // The public note of spec v0.11 §24.1, in PUBLIC_HEADER, which Go reads with // Header.PublicNote: one out of ASCII, and one of the 1024 bytes of the most, // beside a noncritical extension of the header, in time_and_key. samples.push(await filesSample('format 3: a public note', [['carta.txt', content(500, 15)]], base({ publicNote: 'Cartas del viaje a Lisboa · 2026, para abrir en familia' }))); samples.push( await filesSample( 'format 3: time_and_key, a portable key and a public note of 1024 bytes', [['fotos/playa.jpg', content(3000, 16), 1_790_683_200_000]], base({ policy: TIME_AND_KEY, unlockAt: roundAt(2000), newPortableKey: true, noncritical: [ext('org.example.header', 1, 'public data')], publicNote: 'ñ'.repeat(512) }), ), ); // Mixes of two capsules of round 1000 (section 8, point 8). const parts = (dkc) => { const v = new DataView(dkc.buffer, dkc.byteOffset); const hl = v.getUint32(8); const sl = v.getUint32(12); return { prelude: dkc.slice(0, 16), header: dkc.slice(16, 16 + hl), sealed: dkc.slice(16 + hl, 16 + hl + sl), payload: dkc.slice(16 + hl + sl) }; }; const frame = (prelude, header, sealed, payload) => { const out = concatBytes(prelude, header, sealed, payload); const v = new DataView(out.buffer); v.setUint32(8, header.length); v.setUint32(12, sealed.length); return out; }; const a = parts((await encryptVectors(new TextEncoder().encode('A'), base())).dkc); const b = parts((await encryptVectors(new TextEncoder().encode('B'), base())).dkc); const k = parts((await encryptVectors(new TextEncoder().encode('K'), base({ policy: TIME_AND_KEY, newPortableKey: true }))).dkc); const mixes = [ ['the header of A with the rest of B', frame(a.prelude, a.header, b.sealed, b.payload)], ['the control of B inside A', frame(a.prelude, a.header, b.sealed, a.payload)], ['the payload of B inside A', frame(a.prelude, a.header, a.sealed, b.payload)], ['a time_only header over the control of a time_and_key capsule', frame(a.prelude, a.header, k.sealed, k.payload)], ].map(([name, dkc]) => ({ name, round: 1000, dkc: toHex(dkc) })); // The encoder differential. const cases = encoderCases(ENCODER_SEED, ENCODER_COUNT); const encodings = []; const encoders = cases.map((c) => { const header = encodeHeader(c.header); const control = encodeControl(c.control, FORMAT_2); const body = marshalAccessKeyBody(c.dkk); encodings.push(header, control, body); return { ...encoderCaseJSON(c), header_cbor: toHex(header), control_cbor: toHex(control), dkk_body: toHex(body) }; }); // The invalid options, with the text of this library. const errors = []; for (const c of errorCases()) { const { src, opts } = errorCaseInput(c); let text = 'ok'; try { await encryptVectors(src, opts); } catch (err) { text = err.message; } errors.push({ ...c, ts: text }); } // The public notes that encryptFiles refuses, with the text of this library. const noteErrors = []; for (const c of noteErrorCases()) { const { files, opts } = noteErrorInput(c); let text = 'ok'; try { await encryptFiles(files, opts); } catch (err) { text = err.message; } noteErrors.push({ ...c, ts: text }); } process.stdout.write( `${JSON.stringify( { generator: 'scripts/capsule-ts-samples.mjs', samples, mixes, encoders: { seed: ENCODER_SEED, count: ENCODER_COUNT, sha256: toHex(await sha256(concatBytes(...encodings))), cases: encoders }, recipients: recipientStrings(), errors, note_errors: noteErrors, }, null, 1, )}\n`, );