You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
DateKeys-App/scripts/capsule-ts-samples.mjs

285 lines
12 KiB

#!/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`,
);

Powered by TurnKey Linux.