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.
285 lines
12 KiB
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`,
|
|
);
|