From 3daa1f770c901d7a0e3d305e8a3ebceac1eeceab Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 30 Sep 2026 22:57:18 +0200 Subject: [PATCH] Format 3, step 4: split the writer as the reference splits it writer.ts follows the sealer of capsule/encrypt.go at spec-v0.10, so that the writer of format 3 can share everything but its content: - newSealer: the copies of the options, the profile, the clock, the padding rule with a first L, the DateKey and the credentials, in the order and with the texts they had; - seal: steps 7 to 19 for a format and a content of L bytes given in pieces, which plaintextStream follows with the zeros of the padding; - sourceContent: the content of format 2, with the texts of the source that ends early or delivers more than L. No behaviour changes: the 175 tests of the writer pass unchanged, and writer.ts stays covered at 100 %. Co-Authored-By: Claude Opus 5.5 --- src/lib/dkc/writer.ts | 239 +++++++++++++++++++++++++++++------------- 1 file changed, 169 insertions(+), 70 deletions(-) diff --git a/src/lib/dkc/writer.ts b/src/lib/dkc/writer.ts index 2b1ac08..b1f0b85 100644 --- a/src/lib/dkc/writer.ts +++ b/src/lib/dkc/writer.ts @@ -1,9 +1,12 @@ // 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.9. 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. +// 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. // // 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 @@ -21,7 +24,7 @@ import { compareInstants, type DateKey, formatRFC3339Nano, type Instant, isInsta import { sha256Hasher } from './digest.ts'; import { DateKeysError } from './errors.ts'; import type { Extension } from './extension.ts'; -import { DKC_PRELUDE_SIZE, FORMAT_2, headerBinding, MAX_SEALED_CONTROL_LEN, preludeBytes } from './framing.ts'; +import { DKC_PRELUDE_SIZE, FORMAT_2, type FORMAT_3, headerBinding, MAX_SEALED_CONTROL_LEN, preludeBytes } from './framing.ts'; import { decodeHeader, encodeHeader, type Policy, TIME_AND_KEY, TIME_ONLY } from './header.ts'; import { accessIdentity, payloadIdentity } from './open.ts'; import { sealedControlLength } from './lengths.ts'; @@ -77,8 +80,8 @@ export interface Encrypted { /** The effective unlock time: the time of the round, never before the requested instant. */ readonly unlockAt: Instant; readonly capsuleId: Uint8Array; - /** The format written, always 2. */ - readonly format: typeof FORMAT_2; + /** The format written: 2 by encrypt, 3 by encryptFiles. */ + readonly format: typeof FORMAT_2 | typeof FORMAT_3; /** L, the padding rule and P = rule(L), the length of the plaintext of PAYLOAD_AGE (§29.1). */ readonly length: number; readonly padding: Padding; @@ -114,12 +117,26 @@ const CHUNK = 64 << 10; * of `draws`, as capsule.Encrypt. See encrypt. */ export async function writeCapsule(src: EncryptSource, opts: EncryptOptions, draws: Draws): Promise { + return guarded(opts, async (state) => { + // Step 1: the inputs, before anything is used. + checkOptions(opts); + const length = sourceLength(src, opts.length); + const s = await newSealer(opts, length); + return seal(s, FORMAT_2, length, draws, state, () => sourceContent(src, length)); + }); +} + +interface WriteState { + writer?: WritableStreamDefaultWriter; +} + +// Runs a writer. Nothing partial is presented as a capsule: the output is +// aborted after any failure, even a TypeError of the options (§62.1 rule 9). +async function guarded(opts: EncryptOptions, write: (state: WriteState) => Promise): Promise { const state: WriteState = {}; try { - return await write(src, opts, draws, state); + return await write(state); } catch (err) { - // Nothing partial is presented as a capsule: the output is aborted - // after any failure, even a TypeError of the options (§62.1 rule 9). const output = (opts as Partial | null | undefined)?.output; try { if (state.writer !== undefined) await state.writer.abort(err); @@ -131,12 +148,9 @@ export async function writeCapsule(src: EncryptSource, opts: EncryptOptions, dra } } -interface WriteState { - writer?: WritableStreamDefaultWriter; -} - -async function write(src: EncryptSource, opts: EncryptOptions, draws: Draws, state: WriteState): Promise { - // Step 1: the inputs, before anything is used. +// The options that every writer takes, checked as types before anything is +// used. +function checkOptions(opts: EncryptOptions): void { if (typeof opts !== 'object' || opts === null) throw new TypeError('encrypt: options are required'); 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'); @@ -144,8 +158,33 @@ async function write(src: EncryptSource, opts: EncryptOptions, draws: Draws, sta if (!isInstant(opts.unlockAt)) throw new TypeError('encrypt: EncryptOptions.unlockAt is not an Instant'); 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'); - const length = sourceLength(src, opts.length); +} +// What the writers of both formats share, as the sealer of the reference: +// the options that do not depend on the content, copied and checked, and the +// DateKey resolved locally. +interface Sealer { + readonly profile: Profile; + readonly policy: Policy; + /** The recipients, checked; I_ACCESS is drawn when the capsule is sealed. */ + readonly credentials: readonly Uint8Array[]; + readonly portable: boolean; + readonly code: Padding; + readonly critical: Extension[]; + readonly noncritical: Extension[]; + readonly controlCritical: Extension[]; + readonly controlNoncritical: Extension[]; + readonly output: WritableStream | undefined; + readonly progress: ((written: number, total: number) => void) | undefined; + readonly dateKey: DateKey; + /** The time of the round, never before the requested instant. */ + 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). +async function newSealer(opts: EncryptOptions, length: number): Promise { // Step 2: copies, since the caller could change its inputs during an await. 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`); @@ -158,7 +197,7 @@ async function write(src: EncryptSource, opts: EncryptOptions, draws: Draws, sta const controlCritical = copyExtensions(opts.controlCritical); const controlNoncritical = copyExtensions(opts.controlNoncritical); const { policy, output, progress } = opts; - const wantsPortableKey = opts.newPortableKey === true; + const portable = opts.newPortableKey === true; const code = opts.padding === undefined ? REFORZADO : opts.padding; // Step 3: the profile. @@ -170,9 +209,7 @@ async function write(src: EncryptSource, opts: EncryptOptions, draws: Draws, sta if (compareInstants(unlockAt, now) <= 0) throw new Error(`capsule: unlock time ${formatRFC3339Nano(unlockAt)} is not in the future`); // Step 5: the padding rule and P = rule(L), with the texts of PaddedLength. - if (!isPadding(code)) throw new Error(`capsule: padding code ${String(code)} is not defined`); - if (length > MAX_PAYLOAD_LENGTH) throw new Error(`capsule: content of ${length} bytes exceeds L_MAX = ${MAX_PAYLOAD_LENGTH}`); - const padded = paddedLength(length, code); + checkLength(length, code); // Step 6: the DateKey, resolved locally (§15). const dateKey = resolveDateKey(profile, unlockAt); @@ -182,12 +219,36 @@ async function write(src: EncryptSource, opts: EncryptOptions, draws: Draws, sta throw new DateKeysError('ERR_ROUND_MISMATCH', `capsule: resolved round ${dateKey.round} opens before the requested time`); } + // Step 7: the credentials. + const credentials = accessRecipients(policy, recipients, portable); + return { profile, policy, credentials, portable, code, critical, noncritical, controlCritical, controlNoncritical, output, progress, dateKey, unlock }; +} + +// The padding rule and L, as PaddedLength checks them. +function checkLength(length: number, code: Padding): void { + if (!isPadding(code)) throw new Error(`capsule: padding code ${String(code)} is not defined`); + if (length > MAX_PAYLOAD_LENGTH) throw new Error(`capsule: content of ${length} bytes exceeds L_MAX = ${MAX_PAYLOAD_LENGTH}`); +} + +// The write of the sealer of the reference: a capsule of `format` whose +// content, `length` bytes that `content` gives, goes into the plaintext of +// PAYLOAD_AGE, followed by the zeros of its padding up to P (§29.1). +async function seal( + s: Sealer, + format: typeof FORMAT_2 | typeof FORMAT_3, + length: number, + draws: Draws, + state: WriteState, + content: () => Content, +): Promise { + const { profile, policy, code, dateKey, output, progress } = s; + const padded = paddedLength(length, code); const wipe: Uint8Array[] = []; try { - // Step 7: the credentials, and I_ACCESS when asked, the last of them. - const credentials = accessRecipients(policy, recipients, wantsPortableKey); + // Step 7: I_ACCESS when asked, the last of the credentials. + const credentials = [...s.credentials]; let portable: Uint8Array | undefined; - if (wantsPortableKey) { + if (s.portable) { portable = drawn(draws.accessIdentity(), 32, 'I_ACCESS'); wipe.push(portable); credentials.push(x25519PublicKey(portable)); @@ -216,7 +277,7 @@ async function write(src: EncryptSource, opts: EncryptOptions, draws: Draws, sta } // Step 10: PUBLIC_HEADER, checked with the decoder of the reader. - const header = encodeHeader({ capsuleId, dateKey, policy, critical, noncritical }); + const header = encodeHeader({ capsuleId, dateKey, policy, critical: s.critical, noncritical: s.noncritical }); selfCheck('capsule: self-check: the reader rejects this PUBLIC_HEADER', () => decodeHeader(header)); // Step 11: the length of CONTROL_CBOR, which does not depend on the @@ -224,8 +285,15 @@ async function write(src: EncryptSource, opts: EncryptOptions, draws: Draws, sta // SEALED_CONTROL_LEN. The provisional control holds L and the control // extensions, hidden until the date (§55.2): it is wiped at once. const provisional = encodeControl( - { headerBinding: new Uint8Array(32), payloadIdentity: new Uint8Array(32), critical: controlCritical, noncritical: controlNoncritical, payloadLength: length, padding: code }, - FORMAT_2, + { + headerBinding: new Uint8Array(32), + payloadIdentity: new Uint8Array(32), + critical: s.controlCritical, + noncritical: s.controlNoncritical, + payloadLength: length, + padding: code, + }, + format, ); const controlLength = provisional.length; provisional.fill(0); @@ -239,17 +307,18 @@ async function write(src: EncryptSource, opts: EncryptOptions, draws: Draws, sta } // Step 12: PRELUDE and header_binding over the exact bytes (§26). - const prelude = preludeBytes({ format: FORMAT_2, publicHeaderLen: header.length, sealedControlLen: sealedLength }); + const prelude = preludeBytes({ format, publicHeaderLen: header.length, sealedControlLen: sealedLength }); const binding = await headerBinding(prelude, header); - // Step 13: CONTROL_CBOR, schema version 2, checked with the decoder of - // the reader; the copy of I_PAYLOAD it decodes is wiped at once. + // Step 13: CONTROL_CBOR, of the schema version of the format, checked + // with the decoder of the reader; the copy of I_PAYLOAD it decodes is + // wiped at once. const control = encodeControl( - { headerBinding: binding, payloadIdentity: payloadId, critical: controlCritical, noncritical: controlNoncritical, payloadLength: length, padding: code }, - FORMAT_2, + { headerBinding: binding, payloadIdentity: payloadId, critical: s.controlCritical, noncritical: s.controlNoncritical, payloadLength: length, padding: code }, + format, ); wipe.push(control); - selfCheck('capsule: self-check: the reader rejects this CONTROL_CBOR', () => decodeControl(control, FORMAT_2).payloadIdentity.fill(0)); + selfCheck('capsule: self-check: the reader rejects this CONTROL_CBOR', () => decodeControl(control, format).payloadIdentity.fill(0)); // Step 14: SEALED_CONTROL. INNER_ACCESS_AGE for the 16 slots in their // order, then OUTER_TIME_AGE with the tlock recipient alone: each age @@ -273,10 +342,10 @@ async function write(src: EncryptSource, opts: EncryptOptions, draws: Draws, sta // Step 15: PAYLOAD_AGE for R_PAYLOAD, over the content and its padding. // Its first piece is its age header alone, which I_PAYLOAD must open // before anything is written; then I_PAYLOAD is wiped. - // The source is read through contentStream, built first: a source that + // The content is read through plaintextStream, built first: a source that // cannot be read, such as a locked stream, fails as the caller's error. const source: SourceState = {}; - const input = contentStream(src, length, padded, source); + const input = plaintextStream(content(), length, padded, source); const pe = new Encrypter(); pe.addRecipient(formatX25519Recipient(payloadRecipient)); const payload = (await sealWith(() => pe.encrypt(input))) as ReadableStreamWithSize; @@ -333,9 +402,9 @@ async function write(src: EncryptSource, opts: EncryptOptions, draws: Draws, sta } return { dateKey, - unlockAt: unlock, + unlockAt: s.unlock, capsuleId, - format: FORMAT_2, + format, length, padding: code, paddedLength: padded, @@ -503,45 +572,33 @@ async function readPiece(reader: ReadableStreamDefaultReader, source } } -// The plaintext of PAYLOAD_AGE: exactly L bytes of the source, in pieces of -// at most 64 KiB, then the P - L zeros of the padding in fresh pieces -// (§29.1). A source that ends before L, or delivers more than L, fails with -// the texts of writeContent of the Go reference; empty chunks count for -// nothing. The bytes are counted for every source, since the view of a -// resizable or transferred ArrayBuffer can shrink during an await. -function contentStream(src: EncryptSource, length: number, padded: number, state: SourceState): ReadableStream { - const reader = src instanceof Uint8Array ? arrayReader(src) : (src instanceof Blob ? src.stream() : src).getReader(); +// The content of a capsule as a writer gives it: pieces of at most 64 KiB, +// exactly L bytes in all. A failure of next is the error of its source, +// which the writer rethrows as it is. +interface Content { + next(): Promise; + cancel(reason: unknown): Promise; +} + +// The plaintext of PAYLOAD_AGE: the L bytes of the content, then the P - L +// zeros of the padding in fresh pieces (§29.1). +function plaintextStream(content: Content, length: number, padded: number, state: SourceState): ReadableStream { let read = 0; - let pending: Uint8Array = new Uint8Array(0); let ended = false; let padding = padded - length; - const more = (): Error => new Error(`capsule: the source delivers more than the ${length} bytes of EncryptOptions.Length`); - const next = async (): Promise => { - for (;;) { - const r = await reader.read(); - if (r.done) return undefined; - if (r.value.length > 0) return r.value; - } - }; return new ReadableStream({ async pull(controller) { try { - if (read < length) { - if (pending.length === 0) { - const chunk = await next(); - if (chunk === undefined) throw new Error(`capsule: the source ended after ${read} bytes, and EncryptOptions.Length is ${length}`); - pending = chunk; - } - const take = Math.min(CHUNK, pending.length); - if (read + take > length) throw more(); - controller.enqueue(pending.subarray(0, take)); - pending = pending.subarray(take); - read += take; - return; - } if (!ended) { - if (pending.length > 0 || (await next()) !== undefined) throw more(); + const piece = await content.next(); + if (piece !== undefined) { + read += piece.length; + controller.enqueue(piece); + return; + } ended = true; + /* v8 ignore next -- @preserve: every content counts its bytes against L */ + if (read !== length) throw new Error(`capsule: internal error: ${read} bytes of content, L = ${length}`); } if (padding > 0) { const zeros = Math.min(CHUNK, padding); @@ -553,15 +610,57 @@ function contentStream(src: EncryptSource, length: number, padded: number, state } catch (err) { state.error = err; controller.error(err); - await reader.cancel(err).catch(() => undefined); + await content.cancel(err); } }, async cancel(reason) { - await reader.cancel(reason).catch(() => undefined); + await content.cancel(reason); }, }); } +// The content of format 2: exactly L bytes of the source, in pieces of at +// most 64 KiB. A source that ends before L, or delivers more than L, fails +// with the texts of writeContent of the Go reference; empty chunks count for +// nothing. The bytes are counted for every source, since the view of a +// resizable or transferred ArrayBuffer can shrink during an await. +function sourceContent(src: EncryptSource, length: number): Content { + const reader = src instanceof Uint8Array ? arrayReader(src) : (src instanceof Blob ? src.stream() : src).getReader(); + let read = 0; + let pending: Uint8Array = new Uint8Array(0); + const more = (): Error => new Error(`capsule: the source delivers more than the ${length} bytes of EncryptOptions.Length`); + const chunk = async (): Promise => { + for (;;) { + const r = await reader.read(); + if (r.done) return undefined; + if (r.value.length > 0) return r.value; + } + }; + return { + async next() { + if (read < length) { + if (pending.length === 0) { + const c = await chunk(); + if (c === undefined) throw new Error(`capsule: the source ended after ${read} bytes, and EncryptOptions.Length is ${length}`); + pending = c; + } + const take = Math.min(CHUNK, pending.length); + if (read + take > length) throw more(); + const piece = pending.subarray(0, take); + pending = pending.subarray(take); + read += take; + return piece; + } + // The end, which plaintextStream asks for once: nothing may follow L. + if (pending.length > 0 || (await chunk()) !== undefined) throw more(); + return undefined; + }, + async cancel(reason) { + await reader.cancel(reason).catch(() => undefined); + }, + }; +} + // A reader of a Uint8Array in pieces of 64 KiB. function arrayReader(b: Uint8Array): Pick, 'read' | 'cancel'> { let at = 0;