The writers as Go: test vectors only through testing/, and the note

Fixes T9, T11 and T12 of the review of the session of 1 and 2 October:

- T9: EncryptOptions no longer has testVectors nor areaLen, with which any
  caller could write format 2, or an area of 512 bytes, which rule 13
  forbids and which tells that the capsule has no signature (§55.2). What
  only a generator of test vectors asks, as Go's TestVectors, is the
  TestVectors argument of the core of writer.ts, which only the helpers of
  testing/encrypt.ts pass: encryptVectors and encryptWith write format 2,
  and encryptFilesWith another area, with which the tests still reproduce
  byte for byte the fixtures of 512 bytes. encrypt keeps the shape of
  capsule.Encrypt: without a generator it fails with the text of Go,
  whatever the caller adds. dependencies.test.ts refuses an import of
  testing/ from anything but the tests and testing/ itself, check-build.mjs
  refuses a test or a module of testing/ in the bundle of the pages, and
  note.ts joins the modules that index.ts must not re-export.
- T12: encrypt as a generator refuses a public note and an area with the
  text of Go, "capsule: format 2 has no security area or public note: ...",
  after the head and the length, as capsule.Encrypt. The errors of the note
  carry "capsule: ", and newSealer checks the note, then the profile and the
  clock, in the order of Go. The tests compare the texts byte for byte, also
  for two faults at once.
- T11: capsuleLength takes the public note and predicts exactly the size of
  the .dkc with it: eight notes of 1 to 1024 bytes, across the boundaries of
  the heads of CBOR, with both policies and with other extensions.

The 40 texts that capsule.EncryptFiles and extension.CheckNote give at
spec-v0.11 on the same notes and options, taken with an oracle, are those
of this library; HEAD gave another one in 23 of them. npm run verify
passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
dev 6 days ago
parent 67a9037839
commit 4377a87f7f

@ -26,6 +26,9 @@
// nested copy of a package other than the noble copy under
// @noble/post-quantum (plan of phase 2, section 3 and decision 5), as
// .svelte-kit/output/client-modules.json records it (vite.config.ts);
// - the client bundle holds a test, or a module of src/lib/dkc/testing/,
// whose helpers write what only a generator of test vectors may write
// (format 2, another security area than 32 KiB);
// - a page loads noble, @scure/base or age-encryption with the page instead
// of on demand, or a page of ON_DEMAND cannot load its code on demand: the
// opening on /inspect (plan of phase 2, section 9) and the writer on
@ -250,6 +253,8 @@ for (const f of files) {
const MODULES = join(ROOT, '.svelte-kit', 'output', 'client-modules.json');
const FORBIDDEN_PACKAGES = [/^tlock-js$/, /^drand-client$/, /^@babel\//];
// The modules that only the tests load: the tests and the helpers of testing/.
const TEST_ONLY = /\/src\/(?:lib\/dkc\/testing\/|.*\.test\.ts$)/;
const chunks = existsSync(MODULES) ? JSON.parse(readFileSync(MODULES, 'utf8')).chunks : {};
if (!existsSync(MODULES)) fail(`${rel(MODULES)} is missing: vite.config.ts writes it during "npm run build"`);
@ -268,6 +273,7 @@ const packages = new Set();
for (const [file, chunk] of Object.entries(chunks)) {
if (!existsSync(join(BUILD, file))) fail(`client chunk ${file} is not in the site`);
for (const id of Object.keys(chunk.modules)) {
if (TEST_ONLY.test(id)) fail(`${file} bundles ${id}, which only the tests may load`);
const pkg = packageOf(id);
if (pkg === null) continue;
if (FORBIDDEN_PACKAGES.some((re) => re.test(pkg.name))) fail(`${file} bundles ${pkg.name}: ${id}`);

@ -18,7 +18,12 @@
// - a file of src/ other than author.ts, cms.ts, digest.ts, ed25519strict.ts, ibe.ts,
// release.ts, x25519.ts and the tests names @noble/, or a noble import is not a subpath of @noble/curves,
// @noble/hashes or @noble/ciphers, the root copies that those files
// resolve to.
// resolve to;
// - a file of src/ other than the tests and src/lib/dkc/testing/ itself
// imports a module of testing/, whose helpers write what only a generator
// of test vectors may write (format 2, another security area): no page and
// no module of the library may reach them. check-build.mjs checks the
// bundle of the pages as well.
//
// Each check is also run on bad inputs, so that a guard that no longer
// catches anything fails too.
@ -63,6 +68,8 @@ const AGE_IMPORTERS = ['src/lib/dkc/agefile.ts', 'src/lib/dkc/open.ts', 'src/lib
// crypto.getRandomValues, and the test helpers of testing/.
const WRITER_IMPORTERS = ['src/lib/dkc/encrypt.ts'];
const WRITER_IMPORTER_DIRS = ['src/lib/dkc/testing/'];
// The helpers of the tests, which only the tests and the helpers themselves import.
const TESTING_DIR = 'src/lib/dkc/testing/';
// The modules that index.ts must not re-export: the opening and the writer,
// which would bring noble or age-encryption into the first load of a page,
// and the internals of both; and the head of format 3 and the rules of its
@ -78,6 +85,7 @@ const NOT_IN_INDEX = [
'encrypt.ts',
'head.ts',
'ibe.ts',
'note.ts',
'open.ts',
'open3.ts',
'pathrule-tables.ts',
@ -165,6 +173,7 @@ function importProblems(files: Source[]): string[] {
if (!test && reaches(name, s, 'writer.ts') && !WRITER_IMPORTERS.includes(name) && !WRITER_IMPORTER_DIRS.some((d) => name.startsWith(d))) {
problems.push(`${name} imports the core of the writer`);
}
if (!test && !name.startsWith(TESTING_DIR) && /(?:^|\/)testing\//.test(s)) problems.push(`${name} imports ${s}, a helper of the tests`);
if (name === 'src/lib/dkc/index.ts' && NOT_IN_INDEX.some((m) => s === `./${m}`)) problems.push(`index.ts re-exports ${s}`);
if (FORBIDDEN.includes(packageOf(s))) problems.push(`${name}: ${s} is forbidden`);
else if (s.includes('node_modules')) problems.push(`${name}: ${s} reaches into node_modules`);
@ -228,6 +237,12 @@ describe('runtime dependencies', () => {
expect(AGE_IMPORTERS.filter((f) => f !== 'src/lib/dkc/writer.ts').every((f) => sources().some((s) => s.name === f))).toBe(true);
});
it('only the tests and testing/ itself import the helpers of testing/, which write what only a generator of test vectors may write', () => {
// The same check as above, whose problems include this rule.
expect(importProblems(sources())).toEqual([]);
expect(sources().some((s) => s.name.startsWith(TESTING_DIR) && s.name.endsWith('/encrypt.ts'))).toBe(true);
});
it('the import check rejects each forbidden import', () => {
const noble = "import { bls12_381 } from '@noble/curves/bls12-381.js';";
const chacha = "import { chacha20poly1305 } from '@noble/ciphers/chacha.js';";
@ -241,6 +256,9 @@ describe('runtime dependencies', () => {
{ name: 'src/lib/dkc/testing/encrypt.ts', text: "import { seal } from '../writer.ts';" },
{ name: 'src/lib/dkc/writer.test.ts', text: "import { seal } from './writer.ts';\nimport { Decrypter } from 'age-encryption';" },
{ name: 'src/lib/dkc/index.ts', text: "export * from './padding.ts';" },
{ name: 'src/lib/dkc/encrypt.test.ts', text: "import { encryptVectors } from './testing/encrypt.ts';" },
{ name: 'src/lib/inspector/opener.test.ts', text: "import { encryptVectors } from '../dkc/testing/encrypt.ts';" },
{ name: 'src/lib/dkc/testing/interop.ts', text: "import { sampleIdentity } from './testdata.ts';" },
]),
).toEqual([]);
const bad: [label: string, name: string, text: string][] = [
@ -260,6 +278,11 @@ describe('runtime dependencies', () => {
['the writer in index.ts', 'src/lib/dkc/index.ts', "export * from './encrypt.ts';"],
['the opening in index.ts', 'src/lib/dkc/index.ts', "export { open } from './open.ts';"],
['recipient.ts in index.ts', 'src/lib/dkc/index.ts', "export * from './recipient.ts';"],
['note.ts in index.ts', 'src/lib/dkc/index.ts', "export * from './note.ts';"],
['a helper of the tests in index.ts', 'src/lib/dkc/index.ts', "export * from './testing/encrypt.ts';"],
['a helper of the tests from the library', 'src/lib/dkc/encrypt.ts', "import { encryptWith } from './testing/encrypt.ts';"],
['a helper of the tests from a page', 'src/lib/inspector/creator.ts', "const t = await import('../dkc/testing/encrypt.ts');"],
['a helper of the tests from a route', 'src/routes/create/+page.svelte', "import { fixedDraws } from '$lib/dkc/testing/encrypt.ts';"],
];
for (const [label, name, text] of bad) {
expect(importProblems([{ name, text }]), label).not.toEqual([]);

@ -153,9 +153,6 @@ describe('encryptFiles, the fixtures of format 3 of the Go reference', () => {
policy: keyed ? TIME_AND_KEY : TIME_ONLY,
newPortableKey: dkk !== undefined,
padding: fx.padding,
// The fixtures of format 3 were written by a writer of v0.10, with the area of 512 bytes, which only a generator of test vectors writes now.
testVectors: true,
areaLen: AREA_UNIT,
...(fx.comment === undefined ? {} : { comment: fx.comment }),
...(fx.declared_author === undefined ? {} : { author: fx.declared_author }),
}),
@ -166,6 +163,9 @@ describe('encryptFiles, the fixtures of format 3 of the Go reference', () => {
...(dkk === undefined ? {} : { accessIdentity: dkk.material, credentialId: dkk.credentialId }),
...(keyed ? { words: wordsFor([fx.access_key_stanza!]) } : {}),
},
// The fixtures of format 3 were written by a writer of v0.10, with the area of 512 bytes, which only a generator
// of test vectors writes now.
{ areaLen: AREA_UNIT },
);
const written = split(res.dkc!);
const original = split(readBytes(`fixtures/${name}.dkc`));
@ -203,16 +203,24 @@ describe('encryptFiles, the writer of spec v0.11', () => {
expect(AREA_LEN).toBe(32768);
expect(res.length).toBeGreaterThan(AREA_LEN);
// The area of the writers of v0.10 is for a generator of test vectors, and only in multiples of its unit.
const old = await encryptFiles([source('a', 'x')], options({ testVectors: true, areaLen: AREA_UNIT }));
const old = await encryptFilesWith([source('a', 'x')], options(), {}, { areaLen: AREA_UNIT });
const oldOpened = await open(old.dkc!, opening(R1000, { sink: new MemorySink() }));
expect([oldOpened.error, oldOpened.areaLen]).toEqual([undefined, AREA_UNIT]);
for (const [name, extra] of [
['512 without testVectors', { areaLen: AREA_UNIT }],
['an area that is not a multiple of 512', { testVectors: true, areaLen: 1000 }],
['an area above 64 KiB', { testVectors: true, areaLen: 66_048 }],
// A generator that asks for no area writes the common one.
const generated = await encryptFilesWith([source('a', 'x')], options(), {}, {});
expect((await open(generated.dkc!, opening(R1000, { sink: new MemorySink() }))).areaLen).toBe(AREA_LEN);
for (const [name, areaLen] of [
['an area that is not a multiple of 512', 1000],
['an area above 64 KiB', 66_048],
['an area below 512', 0],
['an area that is not an integer', 512.5],
] as const) {
expect((await failure(encryptFiles([source('a', 'x')], options({ ...extra })))).message, name).toMatch(/the security area is 32768 bytes/);
const err = await failure(encryptFilesWith([source('a', 'x')], options(), {}, { areaLen }));
expect(err.message, name).toBe('capsule: the security area is 32768 bytes: another size is for a generator of test vectors, a multiple of 512 up to 65536 (spec §62.1 rule 13)');
}
// No option of encryptFiles asks for another area: what a caller adds is nothing, and the area is 32 KiB.
const asked = await encryptFiles([source('a', 'x')], { ...options(), testVectors: true, areaLen: AREA_UNIT } as EncryptOptions);
expect((await open(asked.dkc!, opening(R1000, { sink: new MemorySink() }))).areaLen).toBe(AREA_LEN);
});
it('writes the public note in PUBLIC_HEADER, which anyone reads and header_binding ties to the control', async () => {
@ -226,13 +234,50 @@ describe('encryptFiles, the writer of spec v0.11', () => {
changed.set(new TextEncoder().encode('Lisbon'), at);
const bad = await open(changed, opening(R1000, { sink: new MemorySink() }));
expect(bad.error?.code).toBe('ERR_HEADER_BINDING');
// A note that breaks the rules of text is refused before anything is written.
for (const text of ['a\tb', 'a\nb', ' a', 'a ', 'x'.repeat(1025)]) {
expect((await failure(encryptFiles([source('a', 'x')], options({ publicNote: text })))).message, JSON.stringify(text.slice(0, 8))).toMatch(/public note/);
}
// With no note, or an empty one, there is none.
const none = await encryptFiles([source('a', 'x')], options({ publicNote: '' }));
expect(none.dkc).toBeDefined();
const noneOpened = await open(none.dkc!, opening(R1000, { sink: new MemorySink() }));
expect(noneOpened.inspection.header!.noncritical).toEqual([]);
});
// A note that breaks the rules of §24.1 is refused before anything is written, never corrected, with the texts that
// capsule.EncryptFiles gives at spec-v0.11: those of extension.CheckNote after "capsule: ".
const rules = (detail: string): string => `capsule: a public note that breaks the rules of text: text: ${detail}: ERR_EXTENSION_DATA_INVALID`;
it.each([
['a tab', 'a\tb', rules('control U+0009 in the declared author')],
['a line feed', 'a\nb', rules('control U+000A in the declared author')],
['a carriage return', 'a\rb', rules('control U+000D')],
['a space at the start', ' a', rules('the declared author starts or ends with U+0020')],
['a space at the end', 'a ', rules('the declared author starts or ends with U+0020')],
['1025 bytes', 'x'.repeat(1025), 'capsule: a public note of 1025 bytes, not 1 to 1024: ERR_EXTENSION_DATA_INVALID'],
['a right-to-left override', 'a\u{202e}b', rules('bidirectional control U+202E')],
['a zero-width space', 'a\u{200b}b', rules('invisible U+200B')],
['a byte order mark at the start', '\u{feff}abc', rules('byte order mark U+FEFF')],
['a lone surrogate', `a${String.fromCharCode(0xd800)}b`, 'capsule: a public note that is not valid UTF-8: ERR_EXTENSION_DATA_INVALID'],
])('refuses a public note with %s, with the text of Go, and aborts the output', async (_name, note, text) => {
const out = recorder();
const err = await failure(encryptFiles([source('a', 'x')], options({ publicNote: note, output: out.stream })));
expect([err.message, errorCode(err), out.chunks.length, out.state.aborted]).toEqual([text, 'ERR_EXTENSION_DATA_INVALID', 0, err]);
});
// capsule.newSealer checks the public note first, then the profile and the clock, and EncryptFiles checks Length
// before newSealer and the files after it: the texts that Go gives for two faults at once.
it('checks the public note in the order of Go', async () => {
const tab = rules('control U+0009 in the declared author');
const bad = { publicNote: 'a\tb' };
const cases: [string, FileSource[], EncryptOptions, string][] = [
['a bad note and no profile', [source('a', 'x')], { ...options(bad), profile: undefined } as unknown as EncryptOptions, tab],
['a bad note and no clock', [source('a', 'x')], { ...options(bad), now: undefined } as unknown as EncryptOptions, tab],
['a bad note and an instant in the past', [source('a', 'x')], options({ ...bad, unlockAt: GENESIS }), tab],
['a bad note and policy 7', [source('a', 'x')], options({ ...bad, policy: 7 }), tab],
['a bad note and padding code 3', [source('a', 'x')], options({ ...bad, padding: 3 as Padding }), tab],
['a bad note and a bad declared author', [source('a', 'x')], options({ ...bad, author: ' x' }), tab],
['a bad note and no files', [], options(bad), tab],
['a bad note and Length', [source('a', 'x')], options({ ...bad, length: 5 }), 'capsule: EncryptOptions.Length is for Encrypt: EncryptFiles computes L from the files'],
['no profile and no clock', [source('a', 'x')], { ...options(), profile: undefined, now: undefined } as unknown as EncryptOptions, 'capsule: EncryptOptions.Profile is required'],
['no clock and an instant in the past', [source('a', 'x')], { ...options({ unlockAt: GENESIS }), now: undefined } as unknown as EncryptOptions, 'capsule: EncryptOptions.Now is required'],
];
for (const [name, files, opts, text] of cases) expect((await failure(encryptFiles(files, opts))).message, name).toBe(text);
});
});
@ -366,6 +411,27 @@ describe('the lengths of format 3', () => {
}
});
// Spec v0.11 §24.1: the public note is an extension of PUBLIC_HEADER, which capsuleLength measures with the others,
// around the boundaries of the heads of CBOR and up to its 1024 bytes.
it('gives the exact size of the .dkc with a public note', async () => {
const notes = ['x', 'a'.repeat(23), 'a'.repeat(24), 'b'.repeat(255), 'c'.repeat(256), 'Cartas del viaje a Lisboa · 2026', 'ñ'.repeat(512), '\u{1f600}'.repeat(256)];
for (const [i, publicNote] of notes.entries()) {
const shape: HeadShape = { files: [{ path: 'nota.txt', size: 1000 * i, mtime: 1_790_769_600_000 }] };
const noncritical = i % 2 === 0 ? [] : [{ id: 'a.example', version: 2, data: Uint8Array.of(1, 2) }];
for (const [policy, extra] of [
[TIME_ONLY, {}],
[TIME_AND_KEY, { newPortableKey: true }],
] as const) {
const files = shape.files.map((f) => source(f.path, new Uint8Array(f.size), f.mtime));
const res = await encryptFiles(files, options({ policy, ...extra, noncritical, publicNote }));
const input = { profileId: 'datekeys:quicknet:v1', round: res.dateKey.round, policy, length: bodyLength(shape), noncritical };
expect([capsuleLength({ ...input, publicNote }), res.size], `note ${i}, policy ${policy}`).toEqual([res.dkc!.length, res.dkc!.length]);
// Without the note, the size falls short: the note is public, and the .dkc carries it as it is.
expect(capsuleLength(input)).toBeLessThan(res.dkc!.length - new TextEncoder().encode(publicNote).length);
}
}
});
// A seeded generator, and the head that the writer builds for a shape.
function random(seed: number): () => number {
let x = seed >>> 0;
@ -539,6 +605,7 @@ describe('encryptFiles, invalid inputs', () => {
[[{ 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'],
[[a], { publicNote: 5 }, 'encrypt: EncryptOptions.publicNote 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]);

@ -7,11 +7,12 @@ import { Encrypter, type ReadableStreamWithSize } from 'age-encryption';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { concatBytes } from './bytes.ts';
import { parseRFC3339 } from './datekey.ts';
import { encrypt, type EncryptOptions } from './encrypt.ts';
import type { EncryptOptions } from './encrypt.ts';
import { errorCode } from './errors.ts';
import { TIME_ONLY } from './header.ts';
import { quicknet } from './profile.ts';
import { formatX25519Recipient } from './recipient.ts';
import { encryptVectors } from './testing/encrypt.ts';
import { newX25519Identity, x25519PublicKey } from './x25519.ts';
afterEach(() => {
@ -30,7 +31,6 @@ const options = (output: WritableStream<Uint8Array>, extra: Partial<EncryptOptio
policy: TIME_ONLY,
now: () => parseRFC3339('2023-08-23T15:09:27Z'),
output,
testVectors: true,
...extra,
});
@ -90,7 +90,7 @@ describe('the internal errors of the writer', () => {
const gone = new Error('Web Crypto is gone');
onCall(1, () => Promise.reject(gone));
const out = recorder();
const err = await failure(encrypt(new Uint8Array(1), options(out.stream)));
const err = await failure(encryptVectors(new Uint8Array(1), options(out.stream)));
expect([err.message, err.cause, errorCode(err)]).toEqual(['capsule: age failed to seal', gone, '']);
expect([out.chunks.length, out.state.closed, out.state.aborted]).toEqual([0, false, err]);
});
@ -98,7 +98,7 @@ describe('the internal errors of the writer', () => {
it('refuses a SEALED_CONTROL that does not measure what the formula gives', async () => {
onCall(1, async (self, file) => concatBytes(await sealBytes(self, file as Uint8Array), new Uint8Array(1)));
const out = recorder();
const err = await failure(encrypt(new Uint8Array(1), options(out.stream)));
const err = await failure(encryptVectors(new Uint8Array(1), options(out.stream)));
expect(err.message).toBe('capsule: internal error: SEALED_CONTROL is 459 bytes, measured 458');
expect([out.chunks.length, out.state.aborted]).toEqual([0, err]);
});
@ -110,7 +110,7 @@ describe('the internal errors of the writer', () => {
return sealBytes(other, file as Uint8Array);
});
const out = recorder();
const err = await failure(encrypt(new Uint8Array(1), options(out.stream)));
const err = await failure(encryptVectors(new Uint8Array(1), options(out.stream)));
expect(err.message).toMatch(/^capsule: self-check: OUTER_TIME_AGE: agewrap: /);
expect(errorCode(err)).not.toBe('');
expect([out.chunks.length, out.state.aborted]).toEqual([0, err]);
@ -123,14 +123,14 @@ describe('the internal errors of the writer', () => {
return s;
});
const out = recorder();
const err = await failure(encrypt(new Uint8Array(1), options(out.stream)));
const err = await failure(encryptVectors(new Uint8Array(1), options(out.stream)));
expect(err.message).toBe('capsule: internal error: PAYLOAD_AGE would be 0 bytes, P = 256 gives 456');
expect([out.chunks.length, out.state.aborted]).toEqual([0, err]);
vi.restoreAllMocks();
onCall(2, (self, file) => headerOnly(self, file as ReadableStream<Uint8Array>));
const out2 = recorder();
const err2 = await failure(encrypt(new Uint8Array(1), options(out2.stream)));
const err2 = await failure(encryptVectors(new Uint8Array(1), options(out2.stream)));
expect(err2.message).toBe('capsule: self-check: PAYLOAD_AGE is 168 bytes, P = 256 gives 456');
expect([out2.state.closed, out2.state.aborted]).toEqual([false, err2]);
});
@ -144,7 +144,7 @@ describe('the internal errors of the writer', () => {
return empty;
});
const out = recorder();
const err = await failure(encrypt(new Uint8Array(1), options(out.stream)));
const err = await failure(encryptVectors(new Uint8Array(1), options(out.stream)));
expect(err.message).toBe('capsule: self-check: the age header of PAYLOAD_AGE does not parse');
expect([out.chunks.length, out.state.aborted]).toEqual([0, err]);
});
@ -153,7 +153,7 @@ describe('the internal errors of the writer', () => {
const broke = new Error('the STREAM broke');
onCall(2, (self, file) => headerOnly(self, file as ReadableStream<Uint8Array>, broke));
const out = recorder();
const err = await failure(encrypt(new Uint8Array(1), options(out.stream)));
const err = await failure(encryptVectors(new Uint8Array(1), options(out.stream)));
expect([err.message, err.cause]).toEqual(['capsule: age failed to seal', broke]);
expect([out.state.closed, out.state.aborted]).toEqual([false, err]);
});
@ -161,7 +161,7 @@ describe('the internal errors of the writer', () => {
it('refuses a SEALED_CONTROL above 64 MiB before sealing anything (§57)', async () => {
const out = recorder();
const big = { id: 'org.example.big', version: 1, data: new Uint8Array(64 << 20) };
const err = await failure(encrypt(new Uint8Array(1), options(out.stream, { controlNoncritical: [big] })));
const err = await failure(encryptVectors(new Uint8Array(1), options(out.stream, { controlNoncritical: [big] })));
expect(err.message).toMatch(/^capsule: SEALED_CONTROL of \d+ bytes exceeds 67108864: ERR_INTEGRITY$/);
expect(errorCode(err)).toBe('ERR_INTEGRITY');
expect([out.chunks.length, out.state.aborted]).toEqual([0, err]);

@ -15,7 +15,7 @@ import { ACCESS_SLOTS, ageStanzas } from './age.ts';
import { decryptAll } from './agefile.ts';
import { sha256 } from './bytes.ts';
import { type Instant, parseRFC3339 } from './datekey.ts';
import { encrypt, type EncryptOptions } from './encrypt.ts';
import type { EncryptOptions } from './encrypt.ts';
import type { Extension } from './extension.ts';
import { TIME_AND_KEY, TIME_ONLY } from './header.ts';
import { inspect } from './inspect.ts';
@ -25,6 +25,7 @@ import { paddedLength, payloadAgeLength, REFORZADO } from './padding.ts';
import { quicknet } from './profile.ts';
import { type Release, suppliedRelease } from './release.ts';
import { split } from './testing/capsule.ts';
import { encryptVectors } from './testing/encrypt.ts';
import { h, hx, readJSON } from './testing/testdata.ts';
import { newX25519Identity, unwrapX25519, x25519PublicKey } from './x25519.ts';
@ -147,7 +148,6 @@ function makeCase(seed: number): Case {
controlCritical,
controlNoncritical,
now: () => GENESIS,
testVectors: true,
},
body: full,
release,
@ -171,7 +171,7 @@ describe('the property loop of the writer', () => {
});
let res;
try {
res = await encrypt(c.body, { ...c.opts, output });
res = await encryptVectors(c.body, { ...c.opts, output });
} catch (err) {
expect(c.invalid, `unexpected failure: ${(err as Error).message}`).toBeDefined();
expect([chunks.length, state.closed, state.aborted]).toEqual([0, false, err]);

@ -6,13 +6,14 @@
import { describe, expect, it } from 'vitest';
import { concatBytes, sha256 } from './bytes.ts';
import { type Instant, parseRFC3339 } from './datekey.ts';
import { encrypt, type EncryptOptions } from './encrypt.ts';
import type { EncryptOptions } from './encrypt.ts';
import { errorCode } from './errors.ts';
import { TIME_AND_KEY, TIME_ONLY } from './header.ts';
import { open, type OpenOptions } from './open.ts';
import { quicknet } from './profile.ts';
import { type Release, suppliedRelease } from './release.ts';
import { split } from './testing/capsule.ts';
import { encryptVectors } from './testing/encrypt.ts';
import { h, hx, readJSON } from './testing/testdata.ts';
const R1000: Release = (() => {
@ -28,7 +29,6 @@ const options = (extra: Partial<EncryptOptions> = {}): EncryptOptions => ({
unlockAt: T1000,
policy: TIME_ONLY,
now: () => GENESIS,
testVectors: true,
...extra,
});
const opening = (extra: Partial<OpenOptions> = {}): OpenOptions => ({ source: suppliedRelease(R1000), now: () => T1000, ...extra });
@ -97,7 +97,7 @@ describe('streaming', () => {
for (const [label, src] of sources) {
const out = recorder();
const progress: [number, number][] = [];
const res = await encrypt(src(), options({ length: n, output: out.stream, progress: (w, t) => void progress.push([w, t]) }));
const res = await encryptVectors(src(), options({ length: n, output: out.stream, progress: (w, t) => void progress.push([w, t]) }));
const dkc = concatBytes(...out.chunks);
expect([res.dkc, res.size, out.state.closed, out.state.aborted], `${n}, ${label}`).toEqual([undefined, dkc.length, true, undefined]);
expect(progress[0], label).toEqual([0, res.size]);
@ -112,7 +112,7 @@ describe('streaming', () => {
it('digests exactly what it writes into the capsule_digest of the .dkk', async () => {
const out = recorder();
const res = await encrypt(chunked(content(100_000), [4096]), options({ length: 100_000, output: out.stream, policy: TIME_AND_KEY, newPortableKey: true }));
const res = await encryptVectors(chunked(content(100_000), [4096]), options({ length: 100_000, output: out.stream, policy: TIME_AND_KEY, newPortableKey: true }));
expect(hx(res.portableKey!.verification!.capsuleDigest)).toBe(hx(await sha256(concatBytes(...out.chunks))));
});
@ -129,7 +129,7 @@ describe('streaming', () => {
],
] as const) {
const out = recorder();
const err = await failure(encrypt(src, options({ length: 100, output: out.stream })));
const err = await failure(encryptVectors(src, options({ length: 100, output: out.stream })));
expect([err.message, errorCode(err)], label).toEqual([text, '']);
expect([out.state.closed, out.state.aborted], label).toEqual([false, err]);
}
@ -138,7 +138,7 @@ describe('streaming', () => {
it('rethrows the error of the source and of the output as it is, and aborts the output', async () => {
const boom = new Error('disk read failed');
const out = recorder();
const err = await failure(encrypt(chunked(content(300_000), [65536], { fail: boom }), options({ length: 400_000, output: out.stream })));
const err = await failure(encryptVectors(chunked(content(300_000), [65536], { fail: boom }), options({ length: 400_000, output: out.stream })));
expect(err).toBe(boom);
expect([out.state.closed, out.state.aborted]).toEqual([false, boom]);
expect(out.chunks.length).toBeGreaterThan(0);
@ -146,12 +146,12 @@ describe('streaming', () => {
const full = new Error('disk full');
let writes = 0;
const failing = new WritableStream<Uint8Array>({ write: () => (++writes === 3 ? Promise.reject(full) : undefined) });
expect(await failure(encrypt(content(300_000), options({ output: failing })))).toBe(full);
expect(await failure(encryptVectors(content(300_000), options({ output: failing })))).toBe(full);
const late = new Error('no more room');
const out2 = recorder();
const err2 = await failure(
encrypt(
encryptVectors(
content(300_000),
options({
output: out2.stream,
@ -172,13 +172,13 @@ describe('streaming', () => {
},
});
const refused = new WritableStream<Uint8Array>({ write: () => Promise.reject(full) });
expect(await failure(encrypt(endless, options({ length: 1 << 20, output: refused })))).toBe(full);
expect(await failure(encryptVectors(endless, options({ length: 1 << 20, output: refused })))).toBe(full);
// A stream that another reader holds fails as the caller's own error.
const locked = chunked(content(10), [10]);
locked.getReader();
const out3 = recorder();
const err3 = await failure(encrypt(locked, options({ length: 10, output: out3.stream })));
const err3 = await failure(encryptVectors(locked, options({ length: 10, output: out3.stream })));
expect(err3.name).toBe('TypeError');
expect(err3.message).not.toBe('capsule: age failed to seal');
expect([out3.chunks.length, out3.state.aborted]).toEqual([0, err3]);
@ -188,7 +188,7 @@ describe('streaming', () => {
const out = recorder();
const refused = new Error('quota');
const err = await failure(
encrypt(
encryptVectors(
content(10),
options({
output: out.stream,
@ -201,15 +201,15 @@ describe('streaming', () => {
expect(err).toBe(refused);
expect([out.chunks.length, out.state.closed, out.state.aborted]).toEqual([0, false, refused]);
const stubborn = new WritableStream<Uint8Array>({ abort: () => Promise.reject(new Error('cannot abort')) });
expect((await failure(encrypt(content(10), options({ output: stubborn, policy: 7 })))).message).toBe('capsule: unknown access policy 7');
expect((await failure(encryptVectors(content(10), options({ output: stubborn, policy: 7 })))).message).toBe('capsule: unknown access policy 7');
});
});
describe('mixes of capsules written here', () => {
it('gives the code and the step of the reader for each mix of two capsules of one round', async () => {
const a = split((await encrypt(te.encode('A'), options())).dkc!);
const b = split((await encrypt(te.encode('B'), options())).dkc!);
const k = split((await encrypt(te.encode('K'), options({ policy: TIME_AND_KEY, newPortableKey: true }))).dkc!);
const a = split((await encryptVectors(te.encode('A'), options())).dkc!);
const b = split((await encryptVectors(te.encode('B'), options())).dkc!);
const k = split((await encryptVectors(te.encode('K'), options({ policy: TIME_AND_KEY, newPortableKey: true }))).dkc!);
const frame = (prelude: Uint8Array, header: Uint8Array, sealed: Uint8Array, payload: Uint8Array): Uint8Array => {
const out = concatBytes(prelude, header, sealed, payload);
const v = new DataView(out.buffer);

@ -24,7 +24,7 @@ import { quicknet } from './profile.ts';
import { formatX25519Recipient } from './recipient.ts';
import { type Release, suppliedRelease } from './release.ts';
import { split } from './testing/capsule.ts';
import { encryptWith, wordsFor } from './testing/encrypt.ts';
import { encryptVectors, encryptWith, wordsFor } from './testing/encrypt.ts';
import { h, hx, listTestdata, readBytes, readJSON } from './testing/testdata.ts';
import { sealedControlLength } from './lengths.ts';
import { selfCheckInner, selfCheckPayloadHeader } from './writer.ts';
@ -67,7 +67,6 @@ const options = (extra: Partial<EncryptOptions> = {}): EncryptOptions => ({
unlockAt: roundAt(1000),
policy: TIME_ONLY,
now: () => GENESIS,
testVectors: true,
...extra,
});
const opening = (r: Release, extra: Partial<OpenOptions> = {}): OpenOptions => ({ source: suppliedRelease(r), now: () => roundAt(r.round), ...extra });
@ -112,7 +111,7 @@ async function sealedPlaintext(dkc: Uint8Array, r: Release): Promise<Uint8Array>
describe('encrypt', () => {
it('writes a time_only capsule of format 2 that open opens to its content', async () => {
const text = te.encode('una carta para el futuro');
const res = await encrypt(text, options());
const res = await encryptVectors(text, options());
expect([res.format, res.length, res.padding, res.paddedLength]).toEqual([2, text.length, REFORZADO, 256]);
expect([res.dateKey.round, res.unlockAt, res.portableKey, res.size]).toEqual([1000, roundAt(1000), undefined, res.dkc!.length]);
expect(res.dkc![4]).toBe(FORMAT_2);
@ -132,7 +131,7 @@ describe('encrypt', () => {
[16, false],
] as const) {
const ids = Array.from({ length: recipients }, newX25519Identity);
const res = await encrypt(body, options({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), recipients: ids.map(x25519PublicKey), newPortableKey: portable }));
const res = await encryptVectors(body, options({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), recipients: ids.map(x25519PublicKey), newPortableKey: portable }));
const label = `${recipients} recipients${portable ? ' and a portable key' : ''}`;
expect(res.portableKey === undefined, label).toBe(!portable);
const dkk = portable ? encodeAccessKey(res.portableKey!) : undefined;
@ -157,7 +156,7 @@ describe('encrypt', () => {
for (const n of [0, 1, 255, 256, 257, 8192, 8193, 65535, 65536, 65537, 78000, 320000, 5000000]) {
for (const code of [BLOQUE256, REFORZADO] as const) {
const body = content(n, n + code);
const res = await encrypt(body, options({ padding: code }));
const res = await encryptVectors(body, options({ padding: code }));
const p = paddedLength(n, code);
expect([res.length, res.padding, res.paddedLength], `${n}, ${code}`).toEqual([n, code, p]);
expect(split(res.dkc!).payload.length, `${n}, ${code}`).toBe(payloadAgeLength(p));
@ -176,7 +175,7 @@ describe('encrypt', () => {
[{ seconds: roundAt(1000).seconds, nanos: 1 }, 1001, R1001],
[{ seconds: roundAt(2000).seconds - 1, nanos: 999_999_999 }, 2000, R2000],
] as const) {
const res = await encrypt(te.encode('x'), options({ unlockAt: requested }));
const res = await encryptVectors(te.encode('x'), options({ unlockAt: requested }));
expect([res.dateKey.round, res.unlockAt]).toEqual([round, roundAt(round)]);
expect((await open(res.dkc!, opening(r))).plaintext).toEqual(te.encode('x'));
}
@ -362,7 +361,7 @@ describe('invalid options', () => {
it.each(cases)('%s: fails with the text and code of Go, writes nothing and aborts the output', async (_label, src, extra, type, message, code) => {
const out = recorder();
const err = await failure(encrypt(src as Uint8Array, { ...options(), output: out.stream, ...extra } as EncryptOptions));
const err = await failure(encryptVectors(src as Uint8Array, { ...options(), output: out.stream, ...extra } as EncryptOptions));
expect(err.name === 'Error' && errorCode(err) !== '' ? 'DateKeysError' : err.name).toBe(type);
if (typeof message === 'string') expect(err.message).toBe(code === '' ? message : `${message}: ${code}`);
else expect(err.message).toMatch(message);
@ -372,39 +371,59 @@ describe('invalid options', () => {
});
it('keeps in memory a capsule of at most MAX_MEMORY_DKC bytes, and needs an output above', async () => {
const err = await failure(encrypt(stream(), { ...options(), length: MAX_MEMORY_DKC }));
const err = await failure(encryptVectors(stream(), { ...options(), length: MAX_MEMORY_DKC }));
expect([err.name, err.message]).toEqual([
'TypeError',
`encrypt: a capsule of ${16 + 121 + 458 + payloadAgeLength(paddedLength(MAX_MEMORY_DKC, REFORZADO))} bytes needs EncryptOptions.output; in memory the limit is ${MAX_MEMORY_DKC}`,
]);
});
// Spec §62.1 rule 1, §70: only a generator of test vectors writes format
// 2, and format 2 has no head; the texts of capsule.Encrypt.
it('writes format 2 only for a generator of test vectors, and with no head', async () => {
// Spec §62.1 rule 1, §70: only a generator of test vectors writes format 2, which has no head, no security area and
// no public note; the texts of capsule.Encrypt, in its order: the generator, the head, the length, the area and the
// note, and then the options of every writer.
it('writes format 2 only for a generator of test vectors, and with no head, no area and no note', async () => {
const generator = 'capsule: Encrypt writes format 2, which only a generator of test vectors may write (spec §62.1 rule 1): EncryptFiles writes format 3';
const head = 'capsule: format 2 has no head: Comment, Author and the head extensions are for EncryptFiles';
const { testVectors: _, ...plain } = options();
for (const [label, opts, text] of [
['no testVectors', plain, generator],
['testVectors false', { ...plain, testVectors: false }, generator],
['a comment', options({ comment: 'x' }), head],
['a declared author', options({ author: 'x' }), head],
['critical head extensions', options({ headCritical: [] }), head],
['noncritical head extensions', options({ headNoncritical: [] }), head],
const area = 'capsule: format 2 has no security area or public note: AuthorKey, CMSSigner, Sealer, LargeArea and PublicNote are for EncryptFiles';
// The encrypt of encrypt.ts is no generator: no option asks for format 2, and a testVectors of the caller is nothing.
for (const [label, opts] of [
['the options of the tests', options()],
['testVectors', { ...options(), testVectors: true } as EncryptOptions],
['a comment and a public note', options({ comment: 'x', publicNote: 'Hola' })],
] as const) {
const out = recorder();
const err = await failure(encrypt(content(10), { ...opts, output: out.stream }));
expect([err.message, out.chunks.length, out.state.aborted], label).toEqual([generator, 0, err]);
}
const noProfile = { ...options({ publicNote: 'Hola' }), profile: undefined } as unknown as EncryptOptions;
for (const [label, opts, vectors, text] of [
['a comment', options({ comment: 'x' }), {}, head],
['a declared author', options({ author: 'x' }), {}, head],
['critical head extensions', options({ headCritical: [] }), {}, head],
['noncritical head extensions', options({ headNoncritical: [] }), {}, head],
['a comment and a public note', options({ comment: 'x', publicNote: 'Hola' }), {}, head],
['a public note', options({ publicNote: 'Hola' }), {}, area],
['a public note that breaks its rules', options({ publicNote: 'a\tb' }), {}, area],
['a public note and no profile', noProfile, {}, area],
['a security area of 512 bytes', options(), { areaLen: 512 }, area],
['a security area of 32 KiB', options(), { areaLen: 32768 }, area],
] as const) {
const out = recorder();
const err = await failure(encryptVectors(content(10), { ...opts, output: out.stream }, vectors));
expect([err.message, out.chunks.length, out.state.aborted], label).toEqual([text, 0, err]);
}
// An empty comment or author is none.
expect((await encrypt(content(10), options({ comment: '', author: '' }))).format).toBe(FORMAT_2);
// The length of the source comes before the note, as Length < 0 does in Go.
expect((await failure(encryptVectors(content(10), options({ length: -1, publicNote: 'Hola' })))).message).toBe(
'encrypt: EncryptOptions.length -1 is not a non-negative safe integer',
);
// An empty comment, author or note is none.
expect((await encryptVectors(content(10), options({ comment: '', author: '', publicNote: '' }))).format).toBe(FORMAT_2);
});
it('rejects options that are not an object, and an output that is not a WritableStream', async () => {
expect((await failure(encrypt(te.encode('x'), undefined as unknown as EncryptOptions))).message).toBe('encrypt: options are required');
expect((await failure(encrypt(te.encode('x'), { ...options(), output: {} as WritableStream<Uint8Array> }))).message).toMatch(/output is not a WritableStream/);
expect((await failure(encrypt(te.encode('x'), { ...options(), progress: 3 as unknown as () => void }))).message).toMatch(/progress is not a function/);
expect((await failure(encryptVectors(te.encode('x'), undefined as unknown as EncryptOptions))).message).toBe('encrypt: options are required');
expect((await failure(encryptVectors(te.encode('x'), { ...options(), output: {} as WritableStream<Uint8Array> }))).message).toMatch(/output is not a WritableStream/);
expect((await failure(encryptVectors(te.encode('x'), { ...options(), progress: 3 as unknown as () => void }))).message).toMatch(/progress is not a function/);
});
it('rejects a random draw of the wrong length', async () => {
@ -422,8 +441,8 @@ describe('invalid options', () => {
describe('properties of the Go reference', () => {
it('never reuses a portable key, a capsule_id, an I_PAYLOAD or a dummy', async () => {
const one = x25519PublicKey(newX25519Identity());
const a = await encrypt(te.encode('x'), options({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), recipients: [one], newPortableKey: true }));
const b = await encrypt(te.encode('x'), options({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), recipients: [one], newPortableKey: true }));
const a = await encryptVectors(te.encode('x'), options({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), recipients: [one], newPortableKey: true }));
const b = await encryptVectors(te.encode('x'), options({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), recipients: [one], newPortableKey: true }));
expect(hx(a.portableKey!.material)).not.toBe(hx(b.portableKey!.material));
expect(hx(a.portableKey!.credentialId)).not.toBe(hx(b.portableKey!.credentialId));
expect(hx(a.capsuleId)).not.toBe(hx(b.capsuleId));
@ -439,12 +458,12 @@ describe('properties of the Go reference', () => {
expect(shares.size).toBe(32);
expect(payloadIds.size).toBe(2);
// Without a portable key, no key comes back.
expect((await encrypt(te.encode('x'), options({ policy: TIME_AND_KEY, recipients: [one] }))).portableKey).toBeUndefined();
expect((await encryptVectors(te.encode('x'), options({ policy: TIME_AND_KEY, recipients: [one] }))).portableKey).toBeUndefined();
});
it('opens a .dkk only on its capsule, before any request', async () => {
const a = await encrypt(te.encode('a'), options({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), newPortableKey: true }));
const b = await encrypt(te.encode('b'), options({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), newPortableKey: true }));
const a = await encryptVectors(te.encode('a'), options({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), newPortableKey: true }));
const b = await encryptVectors(te.encode('b'), options({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), newPortableKey: true }));
let calls = 0;
const source = { fetch: () => (calls++, Promise.resolve(R1001)) };
const withDkk = await open(b.dkc!, { source, now: () => roundAt(1001), accessKeyFile: encodeAccessKey(a.portableKey!) });
@ -456,7 +475,7 @@ describe('properties of the Go reference', () => {
it('seals for the first round at or after now + 1 h, which nobody can open yet', async () => {
const now = { seconds: roundAt(5000).seconds + 1, nanos: 7 };
const requested = { seconds: now.seconds + 3600, nanos: now.nanos };
const res = await encrypt(te.encode('pronto'), options({ unlockAt: requested, now: () => now }));
const res = await encryptVectors(te.encode('pronto'), options({ unlockAt: requested, now: () => now }));
const effective = res.unlockAt;
const period = quicknet().period;
expect(effective.seconds * 1e9 + effective.nanos).toBeGreaterThanOrEqual(requested.seconds * 1e9 + requested.nanos);
@ -472,7 +491,7 @@ describe('properties of the Go reference', () => {
for (const n of [0, 1, 300, 70000]) {
for (const code of [BLOQUE256, REFORZADO] as const) {
for (const policy of [TIME_ONLY, TIME_AND_KEY]) {
const res = await encrypt(content(n), options({ padding: code, policy, ...(policy === TIME_AND_KEY ? { newPortableKey: true } : {}) }));
const res = await encryptVectors(content(n), options({ padding: code, policy, ...(policy === TIME_AND_KEY ? { newPortableKey: true } : {}) }));
const sealed = split(res.dkc!).sealed.length;
expect(sealed).toBe(sealedControlLength(policy, 103, 1000));
lengths.add(sealed);

@ -1,10 +1,11 @@
// 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.
// The writers: encryptFiles writes a .dkc of capsule format 3, with, when
// asked, a portable .dkk (spec §61, §62, §62.1), as capsule.EncryptFiles of
// the Go reference at spec-v0.11; encrypt, as capsule.Encrypt, writes format
// 2 only for a generator of test vectors, which these options cannot ask
// for. 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 helpers of the tests. Loaded on demand: index.ts does
// not re-export it.
import { cryptoWords } from './random.ts';
import {
@ -35,30 +36,25 @@ export { MAX_MEMORY_DKC };
* 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).
* library, which does not sign, in an area of 32 KiB, whatever the capsule
* holds (rule 13); `opts.publicNote` goes in PUBLIC_HEADER (§24.1).
*
* 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.
* checks, codes and texts are those of capsule.EncryptFiles, in its order;
* `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
* `opts.length` for a stream. It needs no network: the round is resolved
* locally, and tlock uses only the pinned public key.
*
* Without `opts.output` the .dkc is returned in memory, up to
* MAX_MEMORY_DKC. With it, the .dkc is streamed into the output, which is
* closed only once the capsule is complete and checked, and aborted on any
* failure; the content is read in pieces of 64 KiB and never held whole.
* The checks, codes and texts are those of capsule.Encrypt; the errors of
* the source and of the output are rethrown as they are.
* capsule.Encrypt, which writes a .dkc of format 2 only for a generator of
* test vectors (§62.1 rule 1, §70): no option of a caller asks for it, so it
* fails with the text of Go, and its output, if any, is aborted. Capsules
* are written with encryptFiles. The tests write format 2 with the helpers of
* testing/encrypt.ts, which pass the core what only they may ask.
*/
export function encrypt(src: EncryptSource, opts: EncryptOptions): Promise<Encrypted> {
return writeCapsule(src, opts, cryptoDraws());

@ -14,7 +14,6 @@ import { marshalAccessKeyBody } from './accesskey.ts';
import { concatBytes, fromHex, sha256, toHex } from './bytes.ts';
import { encodeControl } from './control.ts';
import type { Instant } from './datekey.ts';
import { encrypt } from './encrypt.ts';
import { errorCode } from './errors.ts';
import { ExtensionSet } from './extension.ts';
import { FORMAT_2, FORMAT_3 } from './framing.ts';
@ -25,6 +24,7 @@ import { open, type OpenOptions } from './open.ts';
import { checkX25519Recipient, parseX25519Recipient } from './recipient.ts';
import { suppliedRelease } from './release.ts';
import { MemorySink } from './sink.ts';
import { encryptVectors } from './testing/encrypt.ts';
import { encoderCases, errorCaseInput, errorCases, recipientStrings, type ErrorCase } from './testing/interop.ts';
// A file of a format 3 capsule, as the Go script records it.
@ -249,7 +249,7 @@ describe('the invalid options against capsule.Encrypt', () => {
for (const c of V.errors) {
it(`rejects ${c.name} with the text of Go`, async () => {
const { src, opts } = errorCaseInput(c);
const err = await failure(encrypt(src, opts));
const err = await failure(encryptVectors(src, opts));
expect([err.message, c.ts]).toEqual([c.go, c.go]);
});
}

@ -6,12 +6,12 @@
import { describe, expect, it } from 'vitest';
import { decodeControl } from './control.ts';
import { parseRFC3339 } from './datekey.ts';
import { encrypt } from './encrypt.ts';
import { FORMAT_2 } from './framing.ts';
import { decodeHeader, TIME_AND_KEY, TIME_ONLY } from './header.ts';
import { capsuleLength, sealedControlLength } from './lengths.ts';
import { BLOQUE256, MAX_PAYLOAD_LENGTH, REFORZADO } from './padding.ts';
import { QUICKNET_ID, quicknet } from './profile.ts';
import { encryptVectors } from './testing/encrypt.ts';
import { h, listTestdata, readBytes, readJSON } from './testing/testdata.ts';
import { newX25519Identity, x25519PublicKey } from './x25519.ts';
@ -68,12 +68,11 @@ describe('capsuleLength', () => {
];
for (const c of cases) {
const keyed = c.policy === TIME_AND_KEY;
const res = await encrypt(new Uint8Array(c.length), {
const res = await encryptVectors(new Uint8Array(c.length), {
profile: quicknet(),
unlockAt: roundAt(c.round),
policy: c.policy,
now: () => ({ seconds: GENESIS.seconds - 3600, nanos: 0 }),
testVectors: true,
...(keyed ? { recipients: [x25519PublicKey(newX25519Identity())], newPortableKey: true } : {}),
});
expect(res.dkc!.length).toBe(res.size);
@ -89,6 +88,19 @@ describe('capsuleLength', () => {
expect(capsuleLength({ ...at, length: 70_000, padding: REFORZADO }) - capsuleLength({ ...at, length: 70_000, padding: BLOQUE256 })).toBe(1536);
});
// Spec v0.11 §24.1: encryptFiles adds the public note to the noncritical extensions of PUBLIC_HEADER, as the
// extension datekeys.note of version 1; an empty note is none. encrypt.files.test.ts compares the size with what
// encryptFiles writes.
it('counts the public note as the extension that the writer adds, and an empty note as none', () => {
const at = { profileId: QUICKNET_ID, round: 1000, policy: TIME_ONLY, length: 100 };
expect(capsuleLength({ ...at, publicNote: '' })).toBe(capsuleLength(at));
const note = 'Cartas del viaje a Lisboa';
const extension = { id: 'datekeys.note', version: 1, data: new TextEncoder().encode(note) };
expect(capsuleLength({ ...at, publicNote: note })).toBe(capsuleLength({ ...at, noncritical: [extension] }));
const other = { id: 'z.example', version: 1, data: undefined };
expect(capsuleLength({ ...at, noncritical: [other], publicNote: note })).toBe(capsuleLength({ ...at, noncritical: [extension, other] }));
});
it('throws, as the encoders do, for an invalid DateKey, policy or L', () => {
const at = { profileId: QUICKNET_ID, round: 1000, policy: TIME_ONLY, length: 1 };
expect(() => capsuleLength({ ...at, round: 0 })).toThrow('capsule: invalid DateKey');

@ -41,10 +41,20 @@ export interface CapsuleLengthInput {
readonly padding?: Padding;
readonly critical?: readonly Extension[];
readonly noncritical?: readonly Extension[];
/**
* The public note that encryptFiles adds to the noncritical extensions of
* PUBLIC_HEADER (spec v0.11, §24.1); absent or '' for none. Only its length
* counts here: the writer checks its rules.
*/
readonly publicNote?: string;
readonly controlCritical?: readonly Extension[];
readonly controlNoncritical?: readonly Extension[];
}
// The extension_id of the public note (NOTE_ID of note.ts, which this module
// does not import: its rules of text bring the Unicode tables).
const NOTE_ID = 'datekeys.note';
/**
* The exact size of the .dkc that encrypt, or encryptFiles with L from
* bodyLength, writes for these inputs: PRELUDE,
@ -55,12 +65,14 @@ export interface CapsuleLengthInput {
*/
export function capsuleLength(input: CapsuleLengthInput): number {
const padding = input.padding ?? REFORZADO;
const noncritical = [...(input.noncritical ?? [])];
if ((input.publicNote ?? '') !== '') noncritical.push({ id: NOTE_ID, version: 1, data: utf8Bytes(input.publicNote!) });
const header = encodeHeader({
capsuleId: new Uint8Array(CAPSULE_ID_SIZE),
dateKey: { profileId: input.profileId, round: input.round },
policy: input.policy,
critical: input.critical ?? [],
noncritical: input.noncritical ?? [],
noncritical,
});
const control = encodeControl(
{

@ -1,11 +1,15 @@
// Tests only: the writer with its random values fixed by name (plan of phase
// 3, decision 4), to reproduce the deterministic sections of the fixtures of
// the Go reference and to reach the checks of the writer. A value that is
// not given is drawn from crypto.getRandomValues, as encrypt does. The draws
// hand the writer copies, and the writer wipes them.
// Tests only: the writer as a generator of test vectors (spec §62.1 rules 1
// and 13, §70), the only one that writes format 2 or another security area
// than the 32 KiB of AREA_LEN, which the options of encrypt.ts cannot ask
// for; and with its random values fixed by name (plan of phase 3, decision
// 4), to reproduce the deterministic sections of the fixtures of the Go
// reference and to reach the checks of the writer. A value that is not given
// is drawn from crypto.getRandomValues, as encrypt does. The draws hand the
// writer copies, and the writer wipes them. index.ts does not re-export this
// module, and a guard keeps every module of testing/ out of the pages.
import { cryptoWords, type RandomWords } from '../random.ts';
import { type Draws, type EncryptOptions, type Encrypted, type EncryptSource, type FileSource, writeCapsule, writeFiles } from '../writer.ts';
import { type Draws, type EncryptOptions, type Encrypted, type EncryptSource, type FileSource, type TestVectors, writeCapsule, writeFiles } from '../writer.ts';
import { newX25519Identity } from '../x25519.ts';
/** The random values to fix; any other is drawn at random. */
@ -58,16 +62,27 @@ export function fixedDraws(f: FixedDraws): Draws {
}
/**
* encrypt with some of its random values fixed, as a generator of test
* vectors, which may write format 2: testVectors is set unless given.
* encrypt as a generator of test vectors, the only writer of format 2, with
* every random value drawn as encrypt draws it: what the tests of format 2
* and scripts/capsule-ts-samples.mjs write with. `vectors` reaches the
* writer as it is, so that a test can ask for what format 2 refuses.
*/
export function encryptWith(src: EncryptSource, opts: EncryptOptions, f: FixedDraws): Promise<Encrypted> {
return writeCapsule(src, { testVectors: true, ...opts }, fixedDraws(f));
export function encryptVectors(src: EncryptSource, opts: EncryptOptions, vectors: TestVectors = {}): Promise<Encrypted> {
return writeCapsule(src, opts, fixedDraws({}), vectors);
}
/** 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));
/** encryptVectors with some of its random values fixed. */
export function encryptWith(src: EncryptSource, opts: EncryptOptions, f: FixedDraws, vectors: TestVectors = {}): Promise<Encrypted> {
return writeCapsule(src, opts, fixedDraws(f), vectors);
}
/**
* encryptFiles with some of its random values fixed and, given `vectors`, as
* a generator of test vectors: with another security area, such as the 512
* bytes of the fixtures of format 3 that the writers of v0.10 wrote.
*/
export function encryptFilesWith(files: readonly FileSource[], opts: EncryptOptions, f: FixedDraws, vectors?: TestVectors): Promise<Encrypted> {
return writeFiles(files, opts, fixedDraws(f), vectors);
}
/**

@ -2,8 +2,8 @@
// reference and interop.test.ts checks again (plan of phase 3, decision 11
// and section 8, point 9): fixed identities, the inputs of the encoder
// differential, drawn from a seed, the corpus of recipient strings and keys,
// and the table of invalid options of encrypt in a form the Go script reads.
// Everything here is deterministic.
// and the table of invalid options of encrypt as a generator of test
// vectors, in a form the Go script reads. Everything here is deterministic.
import type { AccessKey } from '../accesskey.ts';
import { ACCESS_TYPE_X25519 } from '../accesskey.ts';
@ -269,7 +269,6 @@ export function errorCaseInput(c: ErrorCase): { src: EncryptSource; opts: Encryp
noncritical: ext(c.noncritical),
controlCritical: ext(c.control_critical),
controlNoncritical: ext(c.control_noncritical),
testVectors: true,
},
};
}

@ -1,12 +1,14 @@
// The core of the writer of phase 3: a .dkc of capsule format 2 and, when
// asked, a portable .dkk (spec §61, §62, §62.1), in the order and with the
// texts of capsule.Encrypt of the Go reference at spec-v0.10, split as it
// is: newSealer checks the options that do not depend on the content, and
// seal writes a capsule of a format around a content given in pieces. Its
// random values come from the caller (plan of phase 3, decision 4):
// encrypt.ts passes those of crypto.getRandomValues, and only the tests of
// testing/ fix them. Internal: only encrypt.ts and testing/ import it, which
// a guard checks.
// The core of the writers: a .dkc of capsule format 3, or of format 2 for a
// generator of test vectors, and, when asked, a portable .dkk (spec §61, §62,
// §62.1), in the order and with the texts of capsule.EncryptFiles and
// capsule.Encrypt of the Go reference at spec-v0.11, split as they are:
// newSealer checks the options that do not depend on the content, and seal
// writes a capsule of a format around a content given in pieces. Its random
// values come from the caller (plan of phase 3, decision 4): encrypt.ts
// passes those of crypto.getRandomValues, and only the helpers of testing/
// fix them and ask for what only a generator of test vectors writes
// (TestVectors). Internal: only encrypt.ts and testing/ import it, which a
// guard checks.
//
// Nothing is written before every check that can precede the content has
// passed, the header of PAYLOAD_AGE included. The output is closed only once
@ -24,7 +26,7 @@ import { compareBytes, copyBytes, equalBytes, goQuote, toHex, utf8Bytes, utf8Len
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 { DateKeysError, withContext } from './errors.ts';
import type { Extension } from './extension.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';
@ -114,18 +116,11 @@ export interface EncryptOptions {
* The public note of the capsule (spec v0.11, §24.1): the extension
* datekeys.note in PUBLIC_HEADER, a line of text that anyone who holds the
* .dkc reads before the date and that nobody can check. It must pass
* checkNote. Absent or '' for none. The writer SHOULD warn the person that
* it is public and that, with the date, it can identify someone.
* checkNote. Absent or '' for none; encryptFiles only, since format 2 has
* no note. The writer SHOULD warn the person that it is public and that,
* with the date, it can identify someone.
*/
readonly publicNote?: string;
/**
* Lets encrypt write format 2, and encryptFiles another area than the 32 KiB
* of AREA_LEN, which only a generator of test vectors may write (spec §62.1
* rules 1 and 13, §70).
*/
readonly testVectors?: boolean;
/** The size of the security area, with testVectors only: 512 reproduces the fixtures of the writers of v0.10. */
readonly areaLen?: number;
/** The clock. Required, and called once. */
readonly now: () => Instant;
/**
@ -180,25 +175,47 @@ export interface Draws {
readonly salt: () => Uint8Array;
}
/**
* What only a generator of test vectors asks of the writers, as
* capsule.EncryptOptions.TestVectors (spec §62.1 rules 1 and 13, §70): format
* 2, and another security area than the 32 KiB of AREA_LEN. Only the helpers
* of testing/ pass it, and encrypt.ts never does: no caller of the library
* writes format 2, or an area that would tell that the capsule has no
* signature (spec §55.2).
*/
export interface TestVectors {
/**
* The size of the security area of writeFiles, a multiple of AREA_UNIT up
* to MAX_AREA_LEN: 512 reproduces the fixtures of the writers of v0.10.
* Format 2 has no area, and takes none.
*/
readonly areaLen?: number;
}
const CHUNK = 64 << 10;
/**
* Writes a .dkc of format 2 for the content of `src` with the random values
* of `draws`, as capsule.Encrypt. See encrypt.
* of `draws`, as capsule.Encrypt: only for a generator of test vectors, the
* one that passes `vectors`. See encrypt.
*/
export async function writeCapsule(src: EncryptSource, opts: EncryptOptions, draws: Draws): Promise<Encrypted> {
export async function writeCapsule(src: EncryptSource, opts: EncryptOptions, draws: Draws, vectors?: TestVectors): Promise<Encrypted> {
return guarded(opts, async (state) => {
// Step 1: the inputs, before anything is used. Format 2 first: only a
// generator of test vectors writes it, and it has no head.
// Step 1: the inputs, before anything is used, in the order of
// capsule.Encrypt. Format 2 first: only a generator of test vectors
// writes it, and it has no head, no security area and no public note.
if (typeof opts !== 'object' || opts === null) throw new TypeError('encrypt: options are required');
if (opts.testVectors !== true) {
if (vectors === undefined) {
throw new Error('capsule: Encrypt writes format 2, which only a generator of test vectors may write (spec §62.1 rule 1): EncryptFiles writes format 3');
}
if ((opts.comment ?? '') !== '' || (opts.author ?? '') !== '' || opts.headCritical !== undefined || opts.headNoncritical !== undefined) {
throw new Error('capsule: format 2 has no head: Comment, Author and the head extensions are for EncryptFiles');
}
checkOptions(opts);
checkTypes(opts);
const length = sourceLength(src, opts.length);
if ((opts.publicNote ?? '') !== '' || vectors.areaLen !== undefined) {
throw new Error('capsule: format 2 has no security area or public note: AuthorKey, CMSSigner, Sealer, LargeArea and PublicNote are for EncryptFiles');
}
const s = await newSealer(opts, length);
return seal(s, FORMAT_2, length, draws, state, () => sourceContent(src, length));
});
@ -207,17 +224,19 @@ 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.
* capsule.EncryptFiles, with the area that `vectors` asks for, when given.
* See encryptFiles.
*/
export async function writeFiles(files: readonly FileSource[], opts: EncryptOptions, draws: Draws): Promise<Encrypted> {
export async function writeFiles(files: readonly FileSource[], opts: EncryptOptions, draws: Draws, vectors?: TestVectors): Promise<Encrypted> {
return guarded(opts, async (state) => {
// Step 1: the inputs, before anything is used.
// Step 1: the inputs, before anything is used, in the order of
// capsule.EncryptFiles.
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);
checkTypes(opts);
// Spec §62.1 rule 13: the area is 32 KiB, and only a generator of test vectors writes another.
const areaLen = opts.areaLen ?? AREA_LEN;
if (areaLen !== AREA_LEN && (opts.testVectors !== true || !Number.isInteger(areaLen) || areaLen < AREA_UNIT || areaLen > MAX_AREA_LEN || areaLen % AREA_UNIT !== 0)) {
const areaLen = vectors?.areaLen ?? AREA_LEN;
if (!Number.isInteger(areaLen) || areaLen < AREA_UNIT || areaLen > MAX_AREA_LEN || areaLen % AREA_UNIT !== 0) {
throw new Error(`capsule: the security area is ${AREA_LEN} bytes: another size is for a generator of test vectors, a multiple of ${AREA_UNIT} up to ${MAX_AREA_LEN} (spec §62.1 rule 13)`);
}
const sources = copySources(files);
@ -424,13 +443,14 @@ async function guarded(opts: EncryptOptions, write: (state: WriteState) => Promi
}
}
// The options that every writer takes, checked as types before anything is
// used; each writer has checked first that they are an object.
function checkOptions(opts: EncryptOptions): void {
if (opts.profile === undefined || opts.profile === null) throw new TypeError('capsule: EncryptOptions.Profile is required');
if (typeof opts.now !== 'function') throw new TypeError('capsule: EncryptOptions.Now is required');
// The types of the options that every writer takes and that Go's types
// enforce, checked before anything is used; each writer has checked first
// that they are an object. The profile and the clock, which Go requires
// with its own texts, are checked by newSealer, in the order of Go.
function checkTypes(opts: EncryptOptions): void {
if (typeof opts.policy !== 'number') throw new TypeError('encrypt: EncryptOptions.policy is required');
if (!isInstant(opts.unlockAt)) throw new TypeError('encrypt: EncryptOptions.unlockAt is not an Instant');
if (opts.publicNote !== undefined && typeof opts.publicNote !== 'string') throw new TypeError('encrypt: EncryptOptions.publicNote is not a string');
if (opts.output !== undefined && !(opts.output instanceof WritableStream)) throw new TypeError('encrypt: EncryptOptions.output is not a WritableStream');
if (opts.progress !== undefined && typeof opts.progress !== 'function') throw new TypeError('encrypt: EncryptOptions.progress is not a function');
}
@ -458,28 +478,35 @@ interface Sealer {
readonly unlock: Instant;
}
// newSealer of the reference: copies of the inputs, then the profile, the
// clock, the padding rule with length, a first L, checked against L_MAX, the
// DateKey and the credentials (§15, §62.1 rules 2, 3 and 8).
// newSealer of the reference: copies of the inputs, then, in the order of Go,
// the public note, the presence of the profile and of the clock, the
// profile, the clock, the padding rule with length, a first L, checked
// against L_MAX, the DateKey and the credentials (§15, §24.1, §62.1 rules 2,
// 3 and 8).
async function newSealer(opts: EncryptOptions, length: number): Promise<Sealer> {
// Step 2: copies, since the caller could change its inputs during an await.
// Step 2: copies, since the caller could change its inputs during an await,
// each checked as a type as it is copied.
const recipients = (opts.recipients ?? []).map((r, i) => {
if (!(r instanceof Uint8Array) || r.length !== 32) throw new TypeError(`encrypt: recipient ${i} is not a 32-byte X25519 public key`);
return copyBytes(r);
});
const profile = cloneProfile(opts.profile);
const unlockAt: Instant = { seconds: opts.unlockAt.seconds, nanos: opts.unlockAt.nanos };
const critical = copyExtensions(opts.critical);
const noncritical = copyExtensions(opts.noncritical);
if (opts.publicNote !== undefined && opts.publicNote !== '') noncritical.push(newNote(opts.publicNote));
const controlCritical = copyExtensions(opts.controlCritical);
const controlNoncritical = copyExtensions(opts.controlNoncritical);
const { policy, output, progress } = opts;
const portable = opts.newPortableKey === true;
const words = (opts.words ?? []).map((w, i) => {
if (typeof w !== 'string') throw new TypeError(`encrypt: word ${i} is not a string`);
return w;
});
// The public note, among the noncritical extensions of PUBLIC_HEADER, with
// the texts of Go after "capsule: ".
if ((opts.publicNote ?? '') !== '') noncritical.push(withContext('capsule', () => newNote(opts.publicNote!)));
if (opts.profile === undefined || opts.profile === null) throw new TypeError('capsule: EncryptOptions.Profile is required');
if (typeof opts.now !== 'function') throw new TypeError('capsule: EncryptOptions.Now is required');
const profile = cloneProfile(opts.profile);
const unlockAt: Instant = { seconds: opts.unlockAt.seconds, nanos: opts.unlockAt.nanos };
const { policy, output, progress } = opts;
const portable = opts.newPortableKey === true;
const code = opts.padding === undefined ? REFORZADO : opts.padding;
// Step 3: the profile.

@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { encrypt } from '../dkc/encrypt.ts';
import { equalBytes, fromHex, type Instant, quicknet, roundTime, sha256, TIME_ONLY, toHex } from '../dkc/index.ts';
import { encryptVectors } from '../dkc/testing/encrypt.ts';
import { listTestdata, readBytes, readJSON } from '../dkc/testing/testdata.ts';
import { memoryFile } from '../dkc/testing/zip.ts';
import { openCapsule, type OpenRequest, parseIdentities, PREVIEW_BYTES, systemClock } from './opener.ts';
@ -141,7 +141,7 @@ describe('openCapsule', () => {
const body = Uint8Array.from({ length: PREVIEW_BYTES + 1000 }, (_, i) => 0x61 + (i % 26));
const round = f.record.release.round;
const p = quicknet();
const sealed = await encrypt(body, { profile: p, unlockAt: roundTime(p, round), policy: TIME_ONLY, now: () => ({ seconds: 0, nanos: 0 }), testVectors: true });
const sealed = await encryptVectors(body, { profile: p, unlockAt: roundTime(p, round), policy: TIME_ONLY, now: () => ({ seconds: 0, nanos: 0 }) });
const big = { ...f.request, capsule: sealed.dkc! };
for (const r of [await openCapsule(big), await openCapsule({ ...big, capsule: blob(sealed.dkc!), output: memoryTemp() })]) {
if (!r.ok) throw new Error(r.problem);

Loading…
Cancel
Save

Powered by TurnKey Linux.