From 4377a87f7ff230ffbe7ebcface2a57e1d1352edf Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 2 Oct 2026 03:35:34 +0200 Subject: [PATCH] The writers as Go: test vectors only through testing/, and the note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/check-build.mjs | 6 ++ src/lib/dependencies.test.ts | 25 +++++- src/lib/dkc/encrypt.files.test.ts | 95 +++++++++++++++++---- src/lib/dkc/encrypt.internal.test.ts | 20 ++--- src/lib/dkc/encrypt.property.test.ts | 6 +- src/lib/dkc/encrypt.stream.test.ts | 30 +++---- src/lib/dkc/encrypt.test.ts | 81 +++++++++++------- src/lib/dkc/encrypt.ts | 38 ++++----- src/lib/dkc/interop.test.ts | 4 +- src/lib/dkc/lengths.test.ts | 18 +++- src/lib/dkc/lengths.ts | 14 +++- src/lib/dkc/testing/encrypt.ts | 41 ++++++--- src/lib/dkc/testing/interop.ts | 5 +- src/lib/dkc/writer.ts | 119 ++++++++++++++++----------- src/lib/inspector/opener.test.ts | 4 +- 15 files changed, 341 insertions(+), 165 deletions(-) diff --git a/scripts/check-build.mjs b/scripts/check-build.mjs index c3c6fe4..8422de2 100644 --- a/scripts/check-build.mjs +++ b/scripts/check-build.mjs @@ -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}`); diff --git a/src/lib/dependencies.test.ts b/src/lib/dependencies.test.ts index c95c9b2..9496501 100644 --- a/src/lib/dependencies.test.ts +++ b/src/lib/dependencies.test.ts @@ -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([]); diff --git a/src/lib/dkc/encrypt.files.test.ts b/src/lib/dkc/encrypt.files.test.ts index 885afcf..15d5a18 100644 --- a/src/lib/dkc/encrypt.files.test.ts +++ b/src/lib/dkc/encrypt.files.test.ts @@ -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))); expect([err instanceof TypeError, err.message]).toEqual([true, text]); diff --git a/src/lib/dkc/encrypt.internal.test.ts b/src/lib/dkc/encrypt.internal.test.ts index c28eba7..b6d6ea5 100644 --- a/src/lib/dkc/encrypt.internal.test.ts +++ b/src/lib/dkc/encrypt.internal.test.ts @@ -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, extra: Partial 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)); 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, 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]); diff --git a/src/lib/dkc/encrypt.property.test.ts b/src/lib/dkc/encrypt.property.test.ts index 07b9806..100ad94 100644 --- a/src/lib/dkc/encrypt.property.test.ts +++ b/src/lib/dkc/encrypt.property.test.ts @@ -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]); diff --git a/src/lib/dkc/encrypt.stream.test.ts b/src/lib/dkc/encrypt.stream.test.ts index 7a3cc5d..8611d81 100644 --- a/src/lib/dkc/encrypt.stream.test.ts +++ b/src/lib/dkc/encrypt.stream.test.ts @@ -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 => ({ unlockAt: T1000, policy: TIME_ONLY, now: () => GENESIS, - testVectors: true, ...extra, }); const opening = (extra: Partial = {}): 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({ 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({ 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({ 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); diff --git a/src/lib/dkc/encrypt.test.ts b/src/lib/dkc/encrypt.test.ts index 6229247..9306604 100644 --- a/src/lib/dkc/encrypt.test.ts +++ b/src/lib/dkc/encrypt.test.ts @@ -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 => ({ unlockAt: roundAt(1000), policy: TIME_ONLY, now: () => GENESIS, - testVectors: true, ...extra, }); const opening = (r: Release, extra: Partial = {}): OpenOptions => ({ source: suppliedRelease(r), now: () => roundAt(r.round), ...extra }); @@ -112,7 +111,7 @@ async function sealedPlaintext(dkc: Uint8Array, r: Release): Promise 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 }))).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 }))).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); diff --git a/src/lib/dkc/encrypt.ts b/src/lib/dkc/encrypt.ts index 9f3c7a5..aac8fc3 100644 --- a/src/lib/dkc/encrypt.ts +++ b/src/lib/dkc/encrypt.ts @@ -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 { 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 { return writeCapsule(src, opts, cryptoDraws()); diff --git a/src/lib/dkc/interop.test.ts b/src/lib/dkc/interop.test.ts index 2227bf3..a8b8a8b 100644 --- a/src/lib/dkc/interop.test.ts +++ b/src/lib/dkc/interop.test.ts @@ -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]); }); } diff --git a/src/lib/dkc/lengths.test.ts b/src/lib/dkc/lengths.test.ts index 62b70a0..013b4da 100644 --- a/src/lib/dkc/lengths.test.ts +++ b/src/lib/dkc/lengths.test.ts @@ -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'); diff --git a/src/lib/dkc/lengths.ts b/src/lib/dkc/lengths.ts index 9d28fb1..990f8e2 100644 --- a/src/lib/dkc/lengths.ts +++ b/src/lib/dkc/lengths.ts @@ -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( { diff --git a/src/lib/dkc/testing/encrypt.ts b/src/lib/dkc/testing/encrypt.ts index 71def6f..ee9ab26 100644 --- a/src/lib/dkc/testing/encrypt.ts +++ b/src/lib/dkc/testing/encrypt.ts @@ -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 { - return writeCapsule(src, { testVectors: true, ...opts }, fixedDraws(f)); +export function encryptVectors(src: EncryptSource, opts: EncryptOptions, vectors: TestVectors = {}): Promise { + 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 { - 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 { + 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 { + return writeFiles(files, opts, fixedDraws(f), vectors); } /** diff --git a/src/lib/dkc/testing/interop.ts b/src/lib/dkc/testing/interop.ts index 6c2fae7..ffc4a33 100644 --- a/src/lib/dkc/testing/interop.ts +++ b/src/lib/dkc/testing/interop.ts @@ -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, }, }; } diff --git a/src/lib/dkc/writer.ts b/src/lib/dkc/writer.ts index 35c8528..95a06c6 100644 --- a/src/lib/dkc/writer.ts +++ b/src/lib/dkc/writer.ts @@ -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 { +export async function writeCapsule(src: EncryptSource, opts: EncryptOptions, draws: Draws, vectors?: TestVectors): Promise { 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 { +export async function writeFiles(files: readonly FileSource[], opts: EncryptOptions, draws: Draws, vectors?: TestVectors): Promise { 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 { - // 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. diff --git a/src/lib/inspector/opener.test.ts b/src/lib/inspector/opener.test.ts index 2a1565a..45e7aad 100644 --- a/src/lib/inspector/opener.test.ts +++ b/src/lib/inspector/opener.test.ts @@ -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);