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 <noreply@anthropic.com>
main
dev 1 week ago
parent b176ad2f17
commit 3daa1f770c

@ -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<Encrypted> {
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<Uint8Array>;
}
// 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<Encrypted>): Promise<Encrypted> {
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<EncryptOptions> | 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<Uint8Array>;
}
async function write(src: EncryptSource, opts: EncryptOptions, draws: Draws, state: WriteState): Promise<Encrypted> {
// 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<Uint8Array> | 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<Sealer> {
// 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<Encrypted> {
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<Uint8Array>, 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<Uint8Array> {
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<Uint8Array | undefined>;
cancel(reason: unknown): Promise<void>;
}
// 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<Uint8Array> {
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<Uint8Array | undefined> => {
for (;;) {
const r = await reader.read();
if (r.done) return undefined;
if (r.value.length > 0) return r.value;
}
};
return new ReadableStream<Uint8Array>({
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<Uint8Array | undefined> => {
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<ReadableStreamDefaultReader<Uint8Array>, 'read' | 'cancel'> {
let at = 0;

Loading…
Cancel
Save

Powered by TurnKey Linux.