encrypt(src, opts) writes a .dkc of format 2 and, when asked, a portable .dkk (spec §61, §62, §62.1), in the order and with the texts and codes of capsule.Encrypt: - L known in advance: the size of a Uint8Array or a Blob, or the length declared with a ReadableStream; a source of another length fails with Go's texts; - reforzado padding by default, or bloque256; - 1 to 16 credentials, canonical and not of low order, a dummy in each slot left, whose scalar is wiped once its public key is derived, and a uniform order of the 16; - SEALED_CONTROL_LEN from the formula of §62.1, checked against the real seal; - the self-checks of rule 11, plus OUTER_TIME_AGE under the reader's rules and the header of PAYLOAD_AGE opened by I_PAYLOAD before anything is written. The content is streamed in pieces of 64 KiB, then the zeros of the padding, into memory (up to MAX_MEMORY_DKC) or an output that is closed only once the capsule is complete and checked and aborted on any failure. The core in writer.ts takes its random values from the caller: encrypt.ts passes crypto.getRandomValues, and only testing/encrypt.ts fixes them. Tests: the deterministic sections of the seven format 2 fixtures of Go byte for byte; round trips with open for both policies, 1 to 16 credentials and every padding boundary; the invalid options; streaming and failures of the source and the output; the internal errors with age-encryption replaced by a spy; a property loop (50 seeds per run, 500 by hand). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
8838c7dd05
commit
8473fdf709
@ -0,0 +1,168 @@
|
||||
// Tests of the internal errors of writer.ts, which no correct encoder or
|
||||
// version of age-encryption reaches: age-encryption is replaced by a spy that
|
||||
// fails, lengthens, retypes or cuts what it seals. Each failure gives its
|
||||
// text, writes nothing that is not aborted, and never closes the output.
|
||||
|
||||
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 { errorCode } from './errors.ts';
|
||||
import { TIME_ONLY } from './header.ts';
|
||||
import { quicknet } from './profile.ts';
|
||||
import { formatX25519Recipient } from './recipient.ts';
|
||||
import { newX25519Identity, x25519PublicKey } from './x25519.ts';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const original = Encrypter.prototype.encrypt;
|
||||
// The two overloads of the original, called on an Encrypter.
|
||||
const sealBytes = (self: Encrypter, file: Uint8Array): Promise<Uint8Array> =>
|
||||
(original as (this: Encrypter, f: Uint8Array) => Promise<Uint8Array>).call(self, file);
|
||||
const sealStream = (self: Encrypter, file: ReadableStream<Uint8Array>): Promise<ReadableStreamWithSize> =>
|
||||
(original as (this: Encrypter, f: ReadableStream<Uint8Array>) => Promise<ReadableStreamWithSize>).call(self, file);
|
||||
const options = (output: WritableStream<Uint8Array>, extra: Partial<EncryptOptions> = {}): EncryptOptions => ({
|
||||
profile: quicknet(),
|
||||
unlockAt: parseRFC3339('2023-08-23T15:59:24Z'),
|
||||
policy: TIME_ONLY,
|
||||
now: () => parseRFC3339('2023-08-23T15:09:27Z'),
|
||||
output,
|
||||
...extra,
|
||||
});
|
||||
|
||||
function recorder(): { stream: WritableStream<Uint8Array>; chunks: Uint8Array[]; state: { closed: boolean; aborted: unknown } } {
|
||||
const chunks: Uint8Array[] = [];
|
||||
const state: { closed: boolean; aborted: unknown } = { closed: false, aborted: undefined };
|
||||
const stream = new WritableStream<Uint8Array>({
|
||||
write: (c) => void chunks.push(c.slice()),
|
||||
close: () => void (state.closed = true),
|
||||
abort: (reason) => void (state.aborted = reason ?? 'aborted'),
|
||||
});
|
||||
return { stream, chunks, state };
|
||||
}
|
||||
|
||||
async function failure(p: Promise<unknown>): Promise<Error> {
|
||||
try {
|
||||
await p;
|
||||
} catch (err) {
|
||||
return err as Error;
|
||||
}
|
||||
throw new Error('expected a failure');
|
||||
}
|
||||
|
||||
// Replaces call `n` of Encrypter.encrypt (1: OUTER_TIME_AGE of a time_only
|
||||
// capsule, 2: its PAYLOAD_AGE) with `replace`, which may call the original.
|
||||
function onCall(n: number, replace: (self: Encrypter, file: Uint8Array | ReadableStream<Uint8Array>) => Promise<unknown>): void {
|
||||
let call = 0;
|
||||
vi.spyOn(Encrypter.prototype, 'encrypt').mockImplementation(async function (this: Encrypter, file: Uint8Array | ReadableStream<Uint8Array>) {
|
||||
if (++call === n) return replace(this, file);
|
||||
return file instanceof Uint8Array ? sealBytes(this, file) : sealStream(this, file);
|
||||
} as typeof Encrypter.prototype.encrypt);
|
||||
}
|
||||
|
||||
// The stream of PAYLOAD_AGE with its first piece, the age header, and then
|
||||
// `rest`: nothing, or a failure.
|
||||
async function headerOnly(self: Encrypter, file: ReadableStream<Uint8Array>, fail?: Error): Promise<ReadableStreamWithSize> {
|
||||
const real = await sealStream(self, file);
|
||||
const reader = real.getReader();
|
||||
const first = await reader.read();
|
||||
await reader.cancel();
|
||||
let sent = false;
|
||||
const cut = new ReadableStream<Uint8Array>({
|
||||
pull(c) {
|
||||
if (!sent) {
|
||||
sent = true;
|
||||
c.enqueue(first.value!);
|
||||
} else if (fail !== undefined) c.error(fail);
|
||||
else c.close();
|
||||
},
|
||||
}) as ReadableStreamWithSize;
|
||||
cut.size = (n: number) => real.size(n);
|
||||
return cut;
|
||||
}
|
||||
|
||||
describe('the internal errors of the writer', () => {
|
||||
it('reports a failure of age-encryption while sealing with a fixed text, the original as its cause', async () => {
|
||||
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)));
|
||||
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]);
|
||||
});
|
||||
|
||||
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)));
|
||||
expect(err.message).toBe('capsule: internal error: SEALED_CONTROL is 459 bytes, measured 458');
|
||||
expect([out.chunks.length, out.state.aborted]).toEqual([0, err]);
|
||||
});
|
||||
|
||||
it('refuses an OUTER_TIME_AGE that the reader would reject', async () => {
|
||||
onCall(1, (_self, file) => {
|
||||
const other = new Encrypter();
|
||||
other.addRecipient(formatX25519Recipient(x25519PublicKey(newX25519Identity())));
|
||||
return sealBytes(other, file as Uint8Array);
|
||||
});
|
||||
const out = recorder();
|
||||
const err = await failure(encrypt(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]);
|
||||
});
|
||||
|
||||
it('refuses a PAYLOAD_AGE whose length is not the one P gives, before and after writing it', async () => {
|
||||
onCall(2, async (self, file) => {
|
||||
const s = await sealStream(self, file as ReadableStream<Uint8Array>);
|
||||
s.size = () => 0;
|
||||
return s;
|
||||
});
|
||||
const out = recorder();
|
||||
const err = await failure(encrypt(new Uint8Array(1), options(out.stream)));
|
||||
expect(err.message).toBe('capsule: internal error: PAYLOAD_AGE would be 0 bytes, P = 256 gives 456');
|
||||
expect([out.chunks.length, out.state.aborted]).toEqual([0, err]);
|
||||
vi.restoreAllMocks();
|
||||
|
||||
onCall(2, (self, file) => headerOnly(self, file as ReadableStream<Uint8Array>));
|
||||
const out2 = recorder();
|
||||
const err2 = await failure(encrypt(new Uint8Array(1), options(out2.stream)));
|
||||
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]);
|
||||
});
|
||||
|
||||
it('refuses a PAYLOAD_AGE without an age header', async () => {
|
||||
onCall(2, async (self, file) => {
|
||||
const real = await sealStream(self, file as ReadableStream<Uint8Array>);
|
||||
await real.cancel();
|
||||
const empty = new ReadableStream<Uint8Array>({ start: (c) => c.close() }) as ReadableStreamWithSize;
|
||||
empty.size = (n: number) => real.size(n);
|
||||
return empty;
|
||||
});
|
||||
const out = recorder();
|
||||
const err = await failure(encrypt(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]);
|
||||
});
|
||||
|
||||
it('reports a failure of age-encryption in the STREAM of PAYLOAD_AGE with a fixed text', async () => {
|
||||
const broke = new Error('the STREAM broke');
|
||||
onCall(2, (self, file) => headerOnly(self, file as ReadableStream<Uint8Array>, broke));
|
||||
const out = recorder();
|
||||
const err = await failure(encrypt(new Uint8Array(1), options(out.stream)));
|
||||
expect([err.message, err.cause]).toEqual(['capsule: age failed to seal', broke]);
|
||||
expect([out.state.closed, out.state.aborted]).toEqual([false, err]);
|
||||
});
|
||||
|
||||
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] })));
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,222 @@
|
||||
// The property loop of the writer (plan of phase 3, section 8, point 7), as
|
||||
// FuzzEncodeImpliesDecode of the Go reference: random options, sometimes made
|
||||
// invalid on purpose. Each case either fails, exactly when it was made
|
||||
// invalid, without writing anything and with its output aborted; or writes a
|
||||
// capsule that inspect accepts, that open opens with all its credentials, in
|
||||
// which each credential opens exactly one of the 16 stanzas, whose lengths
|
||||
// follow the formulas of §62.1, and whose .dkk decodes and encodes back the
|
||||
// same. 50 seeds in every run; DATEKEYS_PROPERTY_SEEDS=500 for the run by
|
||||
// hand of each step. The seed is in the name of each case.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { decodeAccessKey, encodeAccessKey } from './accesskey.ts';
|
||||
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 { Extension } from './extension.ts';
|
||||
import { TIME_AND_KEY, TIME_ONLY } from './header.ts';
|
||||
import { inspect } from './inspect.ts';
|
||||
import { open, timeIdentity } from './open.ts';
|
||||
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 { h, hx, readJSON } from './testing/testdata.ts';
|
||||
import { sealedControlLength } from './writer.ts';
|
||||
import { newX25519Identity, unwrapX25519, x25519PublicKey } from './x25519.ts';
|
||||
|
||||
const SEEDS = Number(process.env.DATEKEYS_PROPERTY_SEEDS ?? 50);
|
||||
const releaseOf = (fixture: string): Release => {
|
||||
const r = readJSON<{ release: { round: number; signature: string } }>(`fixtures/${fixture}.json`).release;
|
||||
return { round: r.round, signature: h(r.signature) };
|
||||
};
|
||||
const RELEASES = [releaseOf('time_only'), releaseOf('empty_payload'), releaseOf('time_only_extensions')];
|
||||
const GENESIS: Instant = parseRFC3339('2023-08-23T15:09:27Z');
|
||||
const roundAt = (r: number): Instant => ({ seconds: GENESIS.seconds + (r - 1) * 3, nanos: 0 });
|
||||
|
||||
// mulberry32: the generator of the loop.
|
||||
function generator(seed: number): { int: (n: number) => number; chance: (p: number) => boolean; pick: <T>(list: readonly T[]) => T } {
|
||||
let a = seed >>> 0;
|
||||
const next = (): number => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 2 ** 32;
|
||||
};
|
||||
return {
|
||||
int: (n) => Math.floor(next() * n),
|
||||
chance: (p) => next() < p,
|
||||
pick: (list) => list[Math.floor(next() * list.length)]!,
|
||||
};
|
||||
}
|
||||
|
||||
// Extension ids of 1 to 4 bytes per character, U+FF61 and U+10000 among them,
|
||||
// whose order by bytes differs from their order by UTF-16 units.
|
||||
const ID_CHARS = ['a', 'z', '0', '.', 'é', '。', '𐀀'];
|
||||
|
||||
interface Case {
|
||||
opts: EncryptOptions;
|
||||
body: Uint8Array;
|
||||
release: Release;
|
||||
ids: Uint8Array[];
|
||||
invalid: string | undefined;
|
||||
}
|
||||
|
||||
function makeCase(seed: number): Case {
|
||||
const g = generator(seed);
|
||||
const release = g.pick(RELEASES);
|
||||
const policy = g.chance(0.04) ? 7 : g.chance(0.45) ? TIME_ONLY : TIME_AND_KEY;
|
||||
let invalid: string | undefined = policy === 7 ? 'policy' : undefined;
|
||||
const bad = (why: string): void => void (invalid ??= why);
|
||||
|
||||
let recipients: Uint8Array[] = [];
|
||||
let ids: Uint8Array[] = [];
|
||||
let portable = false;
|
||||
if (policy === TIME_AND_KEY) {
|
||||
const count = g.chance(0.1) ? g.pick([15, 16, 17]) : g.int(5);
|
||||
ids = Array.from({ length: count }, newX25519Identity);
|
||||
recipients = ids.map(x25519PublicKey);
|
||||
portable = g.chance(0.6);
|
||||
if (count + (portable ? 1 : 0) === 0) bad('no credentials');
|
||||
if (count + (portable ? 1 : 0) > ACCESS_SLOTS) bad('too many credentials');
|
||||
if (count > 0 && g.chance(0.08)) {
|
||||
const which = g.int(4);
|
||||
const i = g.int(count);
|
||||
if (which === 0) recipients[i] = new Uint8Array(31);
|
||||
else if (which === 1) recipients[i]![31]! |= 0x80;
|
||||
else if (which === 2) recipients[i] = Uint8Array.of(1, ...new Uint8Array(31));
|
||||
else recipients.push(recipients[i]!);
|
||||
bad('a bad recipient');
|
||||
if (which === 3 && count + 1 + (portable ? 1 : 0) > ACCESS_SLOTS) bad('too many credentials');
|
||||
}
|
||||
} else if (policy === TIME_ONLY && g.chance(0.05)) {
|
||||
portable = true;
|
||||
bad('time_only with a key');
|
||||
}
|
||||
|
||||
const extensions = (count: number, taken: Set<string>): Extension[] => {
|
||||
const out: Extension[] = [];
|
||||
while (out.length < count) {
|
||||
const id = Array.from({ length: 1 + g.int(6) }, () => g.pick(ID_CHARS)).join('');
|
||||
if (taken.has(id)) continue;
|
||||
taken.add(id);
|
||||
const version = g.chance(0.1) ? 2 ** 32 - 1 - g.int(3) : g.int(10);
|
||||
out.push({ id, version, data: g.chance(0.3) ? undefined : Uint8Array.from({ length: 1 + g.int(2048) }, () => g.int(256)) });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const arrays = (): [Extension[], Extension[]] => {
|
||||
const taken = new Set<string>();
|
||||
const size = (): number => (g.chance(0.05) ? 65 : g.chance(0.2) ? g.int(64) : g.int(3));
|
||||
const a = extensions(size(), taken);
|
||||
const b = extensions(size(), taken);
|
||||
if (a.length > 64 || b.length > 64) bad('more than 64 extensions');
|
||||
return [a, b];
|
||||
};
|
||||
const [critical, noncritical] = arrays();
|
||||
const [controlCritical, controlNoncritical] = arrays();
|
||||
// A critical extension is fine to write: only a reader that does not know
|
||||
// it rejects the capsule, and inspect and open below know every one.
|
||||
if (g.chance(0.04) && noncritical.length > 0) {
|
||||
noncritical[0] = { ...noncritical[0]!, version: 2 ** 32 };
|
||||
bad('an extension version above 2^32 - 1');
|
||||
}
|
||||
|
||||
const length = g.chance(0.08)
|
||||
? 1_000_000 + g.int(2_000_000)
|
||||
: g.pick([0, 1, 255, 256, 257, 8191, 8192, 8193, 65535, 65536, 65537, g.int(300_000)]);
|
||||
const body = Uint8Array.from({ length: Math.min(length, 4096) }, () => g.int(256));
|
||||
const full = new Uint8Array(length);
|
||||
for (let at = 0; at < length; at += body.length || 1) full.set(body.subarray(0, Math.min(body.length, length - at)), at);
|
||||
const padding = g.pick([undefined, 1, 2] as const);
|
||||
|
||||
return {
|
||||
opts: {
|
||||
profile: quicknet(),
|
||||
unlockAt: roundAt(release.round),
|
||||
policy,
|
||||
recipients,
|
||||
newPortableKey: portable,
|
||||
...(padding === undefined ? {} : { padding }),
|
||||
critical,
|
||||
noncritical,
|
||||
controlCritical,
|
||||
controlNoncritical,
|
||||
now: () => GENESIS,
|
||||
},
|
||||
body: full,
|
||||
release,
|
||||
ids,
|
||||
invalid,
|
||||
};
|
||||
}
|
||||
|
||||
// A registry that knows every extension, so that critical ones do not fail.
|
||||
const everything = { known: () => true, validateData: () => undefined };
|
||||
|
||||
describe('the property loop of the writer', () => {
|
||||
it.each(Array.from({ length: SEEDS }, (_, i) => [20260929 + i]))('seed %i', async (seed) => {
|
||||
const c = makeCase(seed);
|
||||
const chunks: Uint8Array[] = [];
|
||||
const state = { closed: false, aborted: undefined as unknown };
|
||||
const output = new WritableStream<Uint8Array>({
|
||||
write: (b) => void chunks.push(b.slice()),
|
||||
close: () => void (state.closed = true),
|
||||
abort: (r) => void (state.aborted = r),
|
||||
});
|
||||
let res;
|
||||
try {
|
||||
res = await encrypt(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]);
|
||||
return;
|
||||
}
|
||||
expect(c.invalid, 'an invalid case was written').toBeUndefined();
|
||||
const dkc = new Uint8Array(chunks.reduce((n, b) => n + b.length, 0));
|
||||
let at = 0;
|
||||
for (const b of chunks) {
|
||||
dkc.set(b, at);
|
||||
at += b.length;
|
||||
}
|
||||
expect([state.closed, res.size]).toEqual([true, dkc.length]);
|
||||
const P = paddedLength(c.body.length, c.opts.padding ?? REFORZADO);
|
||||
expect(res.paddedLength).toBe(P);
|
||||
|
||||
// inspect, with every extension known.
|
||||
const insp = await inspect(dkc, { extensions: everything });
|
||||
expect(insp.error?.message).toBeUndefined();
|
||||
|
||||
// The lengths of §62.1, and the slots.
|
||||
const parts = split(dkc);
|
||||
expect(parts.payload.length).toBe(payloadAgeLength(P));
|
||||
const sealed = await decryptAll(parts.sealed, timeIdentity(quicknet(), c.release.round, c.release), 'age');
|
||||
const keyed = c.opts.policy === TIME_AND_KEY;
|
||||
const credentials = [...c.ids, ...(res.portableKey === undefined ? [] : [res.portableKey.material])];
|
||||
if (keyed) {
|
||||
const stanzas = ageStanzas(sealed);
|
||||
expect(stanzas).toHaveLength(ACCESS_SLOTS);
|
||||
for (const id of credentials) expect(stanzas.filter((s) => unwrapX25519(id, s.args, s.body) !== null)).toHaveLength(1);
|
||||
} else {
|
||||
expect(parts.sealed.length).toBe(sealedControlLength(TIME_ONLY, sealed.length, c.release.round));
|
||||
}
|
||||
|
||||
// open, with every credential, gives the content back.
|
||||
const dkk = res.portableKey === undefined ? undefined : encodeAccessKey(res.portableKey);
|
||||
const opened = await open(dkc, {
|
||||
source: suppliedRelease(c.release),
|
||||
now: () => roundAt(c.release.round),
|
||||
extensions: everything,
|
||||
identities: c.ids,
|
||||
...(dkk === undefined ? {} : { accessKeyFile: dkk.slice() }),
|
||||
});
|
||||
expect(opened.error?.message).toBeUndefined();
|
||||
expect(hx(await sha256(opened.plaintext!))).toBe(hx(await sha256(c.body)));
|
||||
|
||||
// The .dkk decodes and encodes back the same.
|
||||
if (dkk !== undefined) expect(hx(encodeAccessKey(decodeAccessKey(dkk)))).toBe(hx(dkk));
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,229 @@
|
||||
// Tests of encrypt.ts and writer.ts, step 4 of the plan of phase 3:
|
||||
// streaming from every kind of source into memory or an output, the
|
||||
// capsule_digest of what is written, the failures of the source and of the
|
||||
// output, and mixes of capsules written here.
|
||||
|
||||
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 { 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 { h, hx, readJSON } from './testing/testdata.ts';
|
||||
|
||||
const R1000: Release = (() => {
|
||||
const r = readJSON<{ release: { round: number; signature: string } }>('fixtures/time_only.json').release;
|
||||
return { round: r.round, signature: h(r.signature) };
|
||||
})();
|
||||
const GENESIS: Instant = parseRFC3339('2023-08-23T15:09:27Z');
|
||||
const T1000: Instant = parseRFC3339('2023-08-23T15:59:24Z');
|
||||
const te = new TextEncoder();
|
||||
|
||||
const options = (extra: Partial<EncryptOptions> = {}): EncryptOptions => ({
|
||||
profile: quicknet(),
|
||||
unlockAt: T1000,
|
||||
policy: TIME_ONLY,
|
||||
now: () => GENESIS,
|
||||
...extra,
|
||||
});
|
||||
const opening = (extra: Partial<OpenOptions> = {}): OpenOptions => ({ source: suppliedRelease(R1000), now: () => T1000, ...extra });
|
||||
|
||||
const content = (n: number, seed = 1): Uint8Array => {
|
||||
const b = new Uint8Array(n);
|
||||
let x = seed;
|
||||
for (let i = 0; i < n; i++) {
|
||||
x = (x * 1103515245 + 12345) >>> 0;
|
||||
b[i] = x >>> 24;
|
||||
}
|
||||
return b;
|
||||
};
|
||||
|
||||
function recorder(): { stream: WritableStream<Uint8Array>; chunks: Uint8Array[]; state: { closed: boolean; aborted: unknown } } {
|
||||
const chunks: Uint8Array[] = [];
|
||||
const state: { closed: boolean; aborted: unknown } = { closed: false, aborted: undefined };
|
||||
const stream = new WritableStream<Uint8Array>({
|
||||
write: (c) => void chunks.push(c.slice()),
|
||||
close: () => void (state.closed = true),
|
||||
abort: (reason) => void (state.aborted = reason ?? 'aborted'),
|
||||
});
|
||||
return { stream, chunks, state };
|
||||
}
|
||||
|
||||
async function failure(p: Promise<unknown>): Promise<Error> {
|
||||
try {
|
||||
await p;
|
||||
} catch (err) {
|
||||
return err as Error;
|
||||
}
|
||||
throw new Error('expected a failure');
|
||||
}
|
||||
|
||||
// A stream that yields `b` in pieces of the sizes given in turn, then the
|
||||
// `extra` pieces, then ends, or fails with `fail`.
|
||||
function chunked(b: Uint8Array, sizes: readonly number[], opts: { extra?: Uint8Array[]; fail?: unknown } = {}): ReadableStream<Uint8Array> {
|
||||
const pieces: Uint8Array[] = [];
|
||||
let at = 0;
|
||||
for (let i = 0; at < b.length; i++) {
|
||||
const n = sizes[i % sizes.length]!;
|
||||
pieces.push(b.slice(at, at + n));
|
||||
at += n;
|
||||
}
|
||||
pieces.push(...(opts.extra ?? []));
|
||||
return new ReadableStream<Uint8Array>({
|
||||
pull(c) {
|
||||
const next = pieces.shift();
|
||||
if (next !== undefined) c.enqueue(next);
|
||||
else if (opts.fail !== undefined) c.error(opts.fail);
|
||||
else c.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('streaming', () => {
|
||||
it('seals a Uint8Array, a Blob and a ReadableStream of any chunking to the same content, into an output', async () => {
|
||||
for (const n of [0, 1, 65536, 200_000, 5_000_000]) {
|
||||
const body = content(n, 3);
|
||||
const sources: [string, () => Uint8Array | Blob | ReadableStream<Uint8Array>][] = [
|
||||
['Uint8Array', () => body],
|
||||
['Blob', () => new Blob([body as Uint8Array<ArrayBuffer>])],
|
||||
['irregular chunks', () => chunked(body, [1, 7, 65535, 65537, 3], { extra: [new Uint8Array(0)] })],
|
||||
['one chunk', () => chunked(body, [Math.max(1, n)])],
|
||||
];
|
||||
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 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]);
|
||||
expect(progress.at(-1), label).toEqual([res.size, res.size]);
|
||||
expect(progress.every(([w], i) => i === 0 || w > progress[i - 1]![0]), label).toBe(true);
|
||||
const r = await open(dkc, opening());
|
||||
expect(r.error, `${n}, ${label}`).toBeUndefined();
|
||||
expect(hx(await sha256(r.plaintext!)), `${n}, ${label}`).toBe(hx(await sha256(body)));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 }));
|
||||
expect(hx(res.portableKey!.verification!.capsuleDigest)).toBe(hx(await sha256(concatBytes(...out.chunks))));
|
||||
});
|
||||
|
||||
it('fails a source of another length with the texts of Go, and aborts the output', async () => {
|
||||
const body = content(100);
|
||||
for (const [label, src, text] of [
|
||||
['one byte short', chunked(body.subarray(0, 99), [10]), 'capsule: the source ended after 99 bytes, and EncryptOptions.Length is 100'],
|
||||
['one byte more', chunked(content(101), [10]), 'capsule: the source delivers more than the 100 bytes of EncryptOptions.Length'],
|
||||
['a piece that goes past L', chunked(content(128), [64]), 'capsule: the source delivers more than the 100 bytes of EncryptOptions.Length'],
|
||||
[
|
||||
'one more piece after L',
|
||||
chunked(body, [100], { extra: [new Uint8Array(0), new Uint8Array(1)] }),
|
||||
'capsule: the source delivers more than the 100 bytes of EncryptOptions.Length',
|
||||
],
|
||||
] as const) {
|
||||
const out = recorder();
|
||||
const err = await failure(encrypt(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]);
|
||||
}
|
||||
});
|
||||
|
||||
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 })));
|
||||
expect(err).toBe(boom);
|
||||
expect([out.state.closed, out.state.aborted]).toEqual([false, boom]);
|
||||
expect(out.chunks.length).toBeGreaterThan(0);
|
||||
|
||||
const full = new Error('disk full');
|
||||
let writes = 0;
|
||||
const failing = new WritableStream<Uint8Array>({ write: () => (++writes === 3 ? Promise.reject(full) : undefined) });
|
||||
expect(await failure(encrypt(content(300_000), options({ output: failing })))).toBe(full);
|
||||
|
||||
const late = new Error('no more room');
|
||||
const out2 = recorder();
|
||||
const err2 = await failure(
|
||||
encrypt(
|
||||
content(300_000),
|
||||
options({
|
||||
output: out2.stream,
|
||||
progress: (w) => {
|
||||
if (w > 100_000) throw late;
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(err2).toBe(late);
|
||||
expect([out2.state.closed, out2.state.aborted]).toEqual([false, late]);
|
||||
|
||||
// A source whose cancel fails does not change the error either.
|
||||
const endless = new ReadableStream<Uint8Array>({
|
||||
pull: (c) => c.enqueue(new Uint8Array(65536)),
|
||||
cancel: () => {
|
||||
throw new Error('cannot cancel');
|
||||
},
|
||||
});
|
||||
const refused = new WritableStream<Uint8Array>({ write: () => Promise.reject(full) });
|
||||
expect(await failure(encrypt(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 })));
|
||||
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]);
|
||||
});
|
||||
|
||||
it('writes nothing when progress refuses the total, and an abort that fails does not change the error', async () => {
|
||||
const out = recorder();
|
||||
const refused = new Error('quota');
|
||||
const err = await failure(
|
||||
encrypt(
|
||||
content(10),
|
||||
options({
|
||||
output: out.stream,
|
||||
progress: () => {
|
||||
throw refused;
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(err).toBe(refused);
|
||||
expect([out.chunks.length, out.state.closed, out.state.aborted]).toEqual([0, false, refused]);
|
||||
const stubborn = new WritableStream<Uint8Array>({ abort: () => Promise.reject(new Error('cannot abort')) });
|
||||
expect((await failure(encrypt(content(10), options({ output: stubborn, policy: 7 })))).message).toBe('capsule: unknown access policy 7');
|
||||
});
|
||||
});
|
||||
|
||||
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 frame = (prelude: Uint8Array, header: Uint8Array, sealed: Uint8Array, payload: Uint8Array): Uint8Array => {
|
||||
const out = concatBytes(prelude, header, sealed, payload);
|
||||
const v = new DataView(out.buffer);
|
||||
v.setUint32(8, header.length);
|
||||
v.setUint32(12, sealed.length);
|
||||
return out;
|
||||
};
|
||||
for (const [label, dkc, code, step] of [
|
||||
['the header of A with the rest of B', frame(a.prelude, a.header, b.sealed, b.payload), 'ERR_HEADER_BINDING', 15],
|
||||
['the control of B inside A', frame(a.prelude, a.header, b.sealed, a.payload), 'ERR_HEADER_BINDING', 15],
|
||||
['the payload of B inside A', frame(a.prelude, a.header, a.sealed, b.payload), 'ERR_INTEGRITY', 17],
|
||||
['a time_only header over the control of a time_and_key capsule', frame(a.prelude, a.header, k.sealed, k.payload), 'ERR_POLICY_STRUCTURE_MISMATCH', 12],
|
||||
] as const) {
|
||||
const r = await open(dkc, opening());
|
||||
expect([r.error?.code, r.inspection.checks.at(-1)?.step], label).toEqual([code, step]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,505 @@
|
||||
// Tests of encrypt.ts and writer.ts (plan of phase 3, section 8): capsules of
|
||||
// format 2 written here open with open to their content, reproduce the
|
||||
// deterministic sections of the fixtures of the Go reference byte for byte,
|
||||
// and fail with the texts and codes of capsule.Encrypt, without writing
|
||||
// anything and with their output aborted.
|
||||
|
||||
import { Encrypter } from 'age-encryption';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { type AccessKey, decodeAccessKey, encodeAccessKey, wipeAccessKey } from './accesskey.ts';
|
||||
import { ACCESS_SLOTS, ageStanzas } from './age.ts';
|
||||
import { decryptAll } from './agefile.ts';
|
||||
import { concatBytes, sha256 } from './bytes.ts';
|
||||
import { decodeControl } from './control.ts';
|
||||
import { type Instant, parseRFC3339 } from './datekey.ts';
|
||||
import { encrypt, type EncryptOptions, MAX_MEMORY_DKC } from './encrypt.ts';
|
||||
import { errorCode } from './errors.ts';
|
||||
import type { Extension } from './extension.ts';
|
||||
import { FORMAT_2 } from './framing.ts';
|
||||
import { TIME_AND_KEY, TIME_ONLY } from './header.ts';
|
||||
import { inspect } from './inspect.ts';
|
||||
import { accessIdentity, open, type OpenOptions, timeIdentity } from './open.ts';
|
||||
import { BLOQUE256, MAX_PAYLOAD_LENGTH, type Padding, paddedLength, payloadAgeLength, REFORZADO } from './padding.ts';
|
||||
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 { h, hx, listTestdata, readBytes, readJSON } from './testing/testdata.ts';
|
||||
import { sealedControlLength, selfCheckInner, selfCheckPayloadHeader } from './writer.ts';
|
||||
import { newX25519Identity, parseX25519Identity, unwrapX25519, x25519PublicKey } from './x25519.ts';
|
||||
|
||||
interface FixtureRecord {
|
||||
format: number;
|
||||
release: { round: number; signature: string };
|
||||
unlock_at: string;
|
||||
access_policy: 'time_only' | 'time_and_key';
|
||||
prelude: string;
|
||||
public_header: string;
|
||||
control_cbor: string;
|
||||
capsule_id: string;
|
||||
payload_identity: string;
|
||||
payload_length: number;
|
||||
padding?: Padding;
|
||||
plaintext_file: string;
|
||||
identities?: string[];
|
||||
identity_stanzas?: number[];
|
||||
access_key_file?: string;
|
||||
access_key_stanza?: number;
|
||||
header_extensions?: { critical: boolean; id: string; version: number; data?: string }[];
|
||||
control_extensions?: { critical: boolean; id: string; version: number; data?: string }[];
|
||||
}
|
||||
|
||||
const releaseOf = (fx: FixtureRecord): Release => ({ round: fx.release.round, signature: h(fx.release.signature) });
|
||||
const record = (name: string): FixtureRecord => readJSON<FixtureRecord>(`fixtures/${name}.json`);
|
||||
const R1000 = releaseOf(record('time_only'));
|
||||
const R1001 = releaseOf(record('empty_payload'));
|
||||
const R2000 = releaseOf(record('time_only_extensions'));
|
||||
const GENESIS: Instant = parseRFC3339('2023-08-23T15:09:27Z');
|
||||
// Round r opens at genesis + (r - 1)·3 s.
|
||||
const roundAt = (r: number): Instant => ({ seconds: GENESIS.seconds + (r - 1) * 3, nanos: 0 });
|
||||
const te = new TextEncoder();
|
||||
|
||||
const options = (extra: Partial<EncryptOptions> = {}): EncryptOptions => ({
|
||||
profile: quicknet(),
|
||||
unlockAt: roundAt(1000),
|
||||
policy: TIME_ONLY,
|
||||
now: () => GENESIS,
|
||||
...extra,
|
||||
});
|
||||
const opening = (r: Release, extra: Partial<OpenOptions> = {}): OpenOptions => ({ source: suppliedRelease(r), now: () => roundAt(r.round), ...extra });
|
||||
|
||||
// A deterministic content of n bytes.
|
||||
const content = (n: number, seed = 1): Uint8Array => {
|
||||
const b = new Uint8Array(n);
|
||||
let x = seed;
|
||||
for (let i = 0; i < n; i++) {
|
||||
x = (x * 1103515245 + 12345) >>> 0;
|
||||
b[i] = x >>> 24;
|
||||
}
|
||||
return b;
|
||||
};
|
||||
|
||||
// An output that records what it receives and how it ended.
|
||||
function recorder(): { stream: WritableStream<Uint8Array>; chunks: Uint8Array[]; state: { closed: boolean; aborted: unknown } } {
|
||||
const chunks: Uint8Array[] = [];
|
||||
const state: { closed: boolean; aborted: unknown } = { closed: false, aborted: undefined };
|
||||
const stream = new WritableStream<Uint8Array>({
|
||||
write: (c) => void chunks.push(c.slice()),
|
||||
close: () => void (state.closed = true),
|
||||
abort: (reason) => void (state.aborted = reason ?? 'aborted'),
|
||||
});
|
||||
return { stream, chunks, state };
|
||||
}
|
||||
|
||||
async function failure(p: Promise<unknown>): Promise<Error> {
|
||||
try {
|
||||
await p;
|
||||
} catch (err) {
|
||||
return err as Error;
|
||||
}
|
||||
throw new Error('expected a failure');
|
||||
}
|
||||
|
||||
// What SEALED_CONTROL holds under the release: CONTROL_CBOR, or INNER_ACCESS_AGE.
|
||||
async function sealedPlaintext(dkc: Uint8Array, r: Release): Promise<Uint8Array> {
|
||||
return decryptAll(split(dkc).sealed, timeIdentity(quicknet(), r.round, r), 'age');
|
||||
}
|
||||
|
||||
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());
|
||||
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);
|
||||
const r = await open(res.dkc!, opening(R1000));
|
||||
expect(r.error).toBeUndefined();
|
||||
expect(r.plaintext).toEqual(text);
|
||||
expect([r.format, r.payloadLength, r.padding, r.paddedLength]).toEqual([2, text.length, REFORZADO, 256]);
|
||||
});
|
||||
|
||||
it('writes time_and_key with 1 to 16 credentials, each of which opens it alone, and all of them together', async () => {
|
||||
const body = content(1000);
|
||||
for (const [recipients, portable] of [
|
||||
[0, true],
|
||||
[1, false],
|
||||
[3, true],
|
||||
[15, true],
|
||||
[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 label = `${recipients} recipients${portable ? ' and a portable key' : ''}`;
|
||||
expect(res.portableKey === undefined, label).toBe(!portable);
|
||||
const dkk = portable ? encodeAccessKey(res.portableKey!) : undefined;
|
||||
if (portable) {
|
||||
expect(hx(decodeAccessKey(dkk!).verification!.capsuleDigest), label).toBe(hx(await sha256(res.dkc!)));
|
||||
wipeAccessKey(res.portableKey!);
|
||||
}
|
||||
const each: Partial<OpenOptions>[] = [...ids.map((id) => ({ identities: [id] })), ...(dkk === undefined ? [] : [{ accessKeyFile: dkk.slice() }])];
|
||||
for (const creds of [...each, { identities: ids, ...(dkk === undefined ? {} : { accessKeyFile: dkk.slice() }) }]) {
|
||||
const r = await open(res.dkc!, opening(R1001, creds));
|
||||
expect(r.error, label).toBeUndefined();
|
||||
expect(r.plaintext, label).toEqual(body);
|
||||
expect(r.inspection.checks.find((c) => c.step === 12)?.detail, label).toBe('time_and_key: INNER_ACCESS_AGE with 16 X25519 stanzas');
|
||||
}
|
||||
// A stranger opens nothing.
|
||||
const stranger = await open(res.dkc!, opening(R1001, { identities: [newX25519Identity()] }));
|
||||
expect([stranger.error?.code, stranger.inspection.checks.at(-1)?.step], label).toEqual(['ERR_ACCESS_INVALID', 13]);
|
||||
}
|
||||
});
|
||||
|
||||
it('pads with both rules around every boundary, and open gives back L, the rule and P', async () => {
|
||||
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 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));
|
||||
const r = await open(res.dkc!, opening(R1000));
|
||||
expect(r.error, `${n}, ${code}`).toBeUndefined();
|
||||
expect(r.plaintext!.length).toBe(n);
|
||||
expect(hx(await sha256(r.plaintext!)), `${n}, ${code}`).toBe(hx(await sha256(body)));
|
||||
expect([r.payloadLength, r.padding, r.paddedLength], `${n}, ${code}`).toEqual([n, code, p]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves the requested instant to the first round at or after it, for rounds 1000, 1001 and 2000', async () => {
|
||||
for (const [requested, round, r] of [
|
||||
[roundAt(1000), 1000, R1000],
|
||||
[{ 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 }));
|
||||
expect([res.dateKey.round, res.unlockAt]).toEqual([round, roundAt(round)]);
|
||||
expect((await open(res.dkc!, opening(r))).plaintext).toEqual(te.encode('x'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the fixtures of the Go reference', () => {
|
||||
const names = listTestdata('fixtures', '.dkc')
|
||||
.map((p) => p.slice('fixtures/'.length, -'.dkc'.length))
|
||||
.filter((n) => record(n).format === 2);
|
||||
|
||||
it('has the seven fixtures of format 2', () => {
|
||||
expect(names).toHaveLength(7);
|
||||
});
|
||||
|
||||
const exts = (list: FixtureRecord['header_extensions'], critical: boolean): Extension[] =>
|
||||
(list ?? []).filter((e) => e.critical === critical).map((e) => ({ id: e.id, version: e.version, data: e.data === undefined ? undefined : h(e.data) }));
|
||||
|
||||
// With the values of the record, the deterministic sections come out byte
|
||||
// for byte, and each credential lands in the slot of the record.
|
||||
it.each(names)('%s: reproduces PRELUDE, PUBLIC_HEADER, header_binding and CONTROL_CBOR, the lengths, the slots and the .dkk', async (name) => {
|
||||
const fx = record(name);
|
||||
const body = readBytes(`fixtures/${fx.plaintext_file}`);
|
||||
const ids = (fx.identities ?? []).map(parseX25519Identity);
|
||||
const dkk = fx.access_key_file === undefined ? undefined : decodeAccessKey(readBytes(`fixtures/${fx.access_key_file}`));
|
||||
const slots = [...(fx.identity_stanzas ?? []), ...(fx.access_key_stanza === undefined ? [] : [fx.access_key_stanza])];
|
||||
const keyed = fx.access_policy === 'time_and_key';
|
||||
const res = await encryptWith(
|
||||
body,
|
||||
options({
|
||||
unlockAt: parseRFC3339(fx.unlock_at),
|
||||
policy: keyed ? TIME_AND_KEY : TIME_ONLY,
|
||||
recipients: ids.map(x25519PublicKey),
|
||||
newPortableKey: dkk !== undefined,
|
||||
padding: fx.padding!,
|
||||
critical: exts(fx.header_extensions, true),
|
||||
noncritical: exts(fx.header_extensions, false),
|
||||
controlCritical: exts(fx.control_extensions, true),
|
||||
controlNoncritical: exts(fx.control_extensions, false),
|
||||
}),
|
||||
{
|
||||
capsuleId: h(fx.capsule_id),
|
||||
payloadIdentity: h(fx.payload_identity),
|
||||
...(dkk === undefined ? {} : { accessIdentity: dkk.material, credentialId: dkk.credentialId }),
|
||||
...(keyed ? { words: wordsFor(slots) } : {}),
|
||||
},
|
||||
);
|
||||
const written = split(res.dkc!);
|
||||
const original = split(readBytes(`fixtures/${name}.dkc`));
|
||||
expect(hx(written.prelude)).toBe(fx.prelude);
|
||||
expect(hx(written.header)).toBe(fx.public_header);
|
||||
expect([written.sealed.length, written.payload.length]).toEqual([original.sealed.length, original.payload.length]);
|
||||
const r = releaseOf(fx);
|
||||
const sealed = await sealedPlaintext(res.dkc!, r);
|
||||
if (!keyed) {
|
||||
expect(hx(sealed)).toBe(fx.control_cbor);
|
||||
} else {
|
||||
const stanzas = ageStanzas(sealed);
|
||||
expect(stanzas).toHaveLength(ACCESS_SLOTS);
|
||||
const credentials = [...ids, ...(dkk === undefined ? [] : [dkk.material])];
|
||||
for (const [i, id] of credentials.entries()) {
|
||||
const hits = stanzas.flatMap((s, k) => (unwrapX25519(id, s.args, s.body) === null ? [] : [k]));
|
||||
expect(hits, `credential ${i}`).toEqual([slots[i]]);
|
||||
}
|
||||
expect(hx(await decryptAll(sealed, accessIdentity([credentials[0]!], ACCESS_SLOTS), 'age'))).toBe(fx.control_cbor);
|
||||
}
|
||||
if (dkk !== undefined) {
|
||||
const want: AccessKey = { ...dkk, verification: { capsuleDigest: await sha256(res.dkc!) } };
|
||||
expect(hx(encodeAccessKey(res.portableKey!))).toBe(hx(encodeAccessKey(want)));
|
||||
}
|
||||
// The .dkk of the fixture binds the capsule of the fixture; the one the
|
||||
// writer returns binds the capsule it wrote.
|
||||
const opened = await open(res.dkc!, opening(r, { identities: ids, ...(res.portableKey === undefined ? {} : { accessKey: res.portableKey }) }));
|
||||
expect(opened.error).toBeUndefined();
|
||||
expect(opened.plaintext).toEqual(body);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid options', () => {
|
||||
const p = quicknet();
|
||||
const recipient = x25519PublicKey(newX25519Identity());
|
||||
const le = (n: bigint): Uint8Array => Uint8Array.from({ length: 32 }, (_, i) => Number((n >> BigInt(8 * i)) & 0xffn));
|
||||
const P = 2n ** 255n - 19n;
|
||||
const bit255 = le(9n);
|
||||
bit255[31]! |= 0x80;
|
||||
const stream = (): ReadableStream<Uint8Array> => new ReadableStream({ start: (c) => c.close() });
|
||||
const cases: [string, unknown, Record<string, unknown>, string, string | RegExp, string][] = [
|
||||
['no profile', te.encode('x'), { profile: undefined }, 'TypeError', 'capsule: EncryptOptions.Profile is required', ''],
|
||||
['no clock', te.encode('x'), { now: undefined }, 'TypeError', 'capsule: EncryptOptions.Now is required', ''],
|
||||
['no policy', te.encode('x'), { policy: undefined }, 'TypeError', 'encrypt: EncryptOptions.policy is required', ''],
|
||||
['a malformed instant', te.encode('x'), { unlockAt: { seconds: 1.5, nanos: 0 } }, 'TypeError', /unlockAt is not an Instant/, ''],
|
||||
['a clock that is not an instant', te.encode('x'), { now: () => ({ seconds: 0, nanos: -1 }) }, 'TypeError', /now\(\) did not return an Instant/, ''],
|
||||
['an instant in the past', te.encode('x'), { now: () => roundAt(1001) }, 'Error', 'capsule: unlock time 2023-08-23T15:59:24Z is not in the future', ''],
|
||||
['an instant equal to now', te.encode('x'), { now: () => roundAt(1000) }, 'Error', 'capsule: unlock time 2023-08-23T15:59:24Z is not in the future', ''],
|
||||
['time_only with recipients', te.encode('x'), { recipients: [recipient] }, 'Error', 'capsule: time_only takes no recipients and no portable key', ''],
|
||||
['time_only with a portable key', te.encode('x'), { newPortableKey: true }, 'Error', 'capsule: time_only takes no recipients and no portable key', ''],
|
||||
['time_and_key without credentials', te.encode('x'), { policy: TIME_AND_KEY }, 'Error', 'capsule: time_and_key needs at least one recipient or a portable key', ''],
|
||||
[
|
||||
'16 recipients and a portable key',
|
||||
te.encode('x'),
|
||||
{ policy: TIME_AND_KEY, recipients: Array.from({ length: 16 }, () => x25519PublicKey(newX25519Identity())), newPortableKey: true },
|
||||
'Error',
|
||||
'capsule: time_and_key takes at most 16 credentials, recipients and portable key together; 17 given',
|
||||
'',
|
||||
],
|
||||
[
|
||||
'17 recipients',
|
||||
te.encode('x'),
|
||||
{ policy: TIME_AND_KEY, recipients: Array.from({ length: 17 }, () => x25519PublicKey(newX25519Identity())) },
|
||||
'Error',
|
||||
'capsule: time_and_key takes at most 16 credentials, recipients and portable key together; 17 given',
|
||||
'',
|
||||
],
|
||||
['a recipient of 31 bytes', te.encode('x'), { policy: TIME_AND_KEY, recipients: [new Uint8Array(31)] }, 'TypeError', 'encrypt: recipient 0 is not a 32-byte X25519 public key', ''],
|
||||
[
|
||||
'a recipient with bit 255',
|
||||
te.encode('x'),
|
||||
{ policy: TIME_AND_KEY, recipients: [recipient, bit255] },
|
||||
'Error',
|
||||
`capsule: recipient 1: agewrap: recipient ${formatX25519Recipient(bit255)} is not canonical: bit 255 is set`,
|
||||
'',
|
||||
],
|
||||
[
|
||||
'a recipient u = p',
|
||||
te.encode('x'),
|
||||
{ policy: TIME_AND_KEY, recipients: [le(P)] },
|
||||
'Error',
|
||||
`capsule: recipient 0: agewrap: recipient ${formatX25519Recipient(le(P))} is not canonical: u is not below 2^255 - 19`,
|
||||
'',
|
||||
],
|
||||
[
|
||||
'a recipient of low order',
|
||||
te.encode('x'),
|
||||
{ policy: TIME_AND_KEY, recipients: [le(1n)] },
|
||||
'Error',
|
||||
`capsule: recipient 0: agewrap: recipient ${formatX25519Recipient(le(1n))} is a point of low order: the shared secret would be zero`,
|
||||
'',
|
||||
],
|
||||
[
|
||||
'a recipient listed twice',
|
||||
te.encode('x'),
|
||||
{ policy: TIME_AND_KEY, recipients: [recipient, recipient] },
|
||||
'Error',
|
||||
`capsule: recipient ${formatX25519Recipient(recipient)} listed twice; INNER_ACCESS_AGE holds one stanza per recipient`,
|
||||
'',
|
||||
],
|
||||
['policy 7', te.encode('x'), { policy: 7 }, 'Error', 'capsule: unknown access policy 7', ''],
|
||||
['padding code 3', te.encode('x'), { padding: 3 }, 'Error', 'capsule: padding code 3 is not defined', ''],
|
||||
['padding code 0', te.encode('x'), { padding: 0 }, 'Error', 'capsule: padding code 0 is not defined', ''],
|
||||
['L = L_MAX + 1', stream(), { length: MAX_PAYLOAD_LENGTH + 1 }, 'Error', `capsule: content of ${MAX_PAYLOAD_LENGTH + 1} bytes exceeds L_MAX = ${MAX_PAYLOAD_LENGTH}`, ''],
|
||||
['a length that is not the size of a Blob', new Blob(['abc']), { length: 4 }, 'TypeError', 'encrypt: EncryptOptions.length 4 is not the 3 bytes of the source', ''],
|
||||
['a negative length', te.encode('x'), { length: -1 }, 'TypeError', /length -1 is not a non-negative safe integer/, ''],
|
||||
['a stream without a length', stream(), {}, 'TypeError', 'encrypt: a ReadableStream needs EncryptOptions.length', ''],
|
||||
['a source of another type', 'text', {}, 'TypeError', 'encrypt: the source is a Uint8Array, a Blob or a ReadableStream', ''],
|
||||
['a chain hash with a bit changed', te.encode('x'), { profile: { ...p, chainHash: p.chainHash.map((b, i) => (i === 0 ? b ^ 1 : b)) } }, 'DateKeysError', /chain/, 'ERR_PROFILE_MISMATCH'],
|
||||
[
|
||||
'a repeated header extension',
|
||||
te.encode('x'),
|
||||
{ critical: [{ id: 'a', version: 1 }, { id: 'a', version: 1 }] },
|
||||
'DateKeysError',
|
||||
/extension a/,
|
||||
'ERR_NON_CANONICAL_CBOR',
|
||||
],
|
||||
[
|
||||
'a control extension in both arrays',
|
||||
te.encode('x'),
|
||||
{ controlCritical: [{ id: 'a', version: 1 }], controlNoncritical: [{ id: 'a', version: 1 }] },
|
||||
'DateKeysError',
|
||||
/extension a: both critical and noncritical/,
|
||||
'ERR_NON_CANONICAL_CBOR',
|
||||
],
|
||||
];
|
||||
|
||||
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));
|
||||
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);
|
||||
expect(errorCode(err)).toBe(code);
|
||||
expect(out.chunks).toHaveLength(0);
|
||||
expect([out.state.closed, out.state.aborted]).toEqual([false, err]);
|
||||
});
|
||||
|
||||
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 }));
|
||||
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}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects options that are not an object, and an output that is not a WritableStream', async () => {
|
||||
expect((await failure(encrypt(te.encode('x'), undefined as unknown as EncryptOptions))).message).toBe('encrypt: options are required');
|
||||
expect((await failure(encrypt(te.encode('x'), { ...options(), output: {} as WritableStream<Uint8Array> }))).message).toMatch(/output is not a WritableStream/);
|
||||
expect((await failure(encrypt(te.encode('x'), { ...options(), progress: 3 as unknown as () => void }))).message).toMatch(/progress is not a function/);
|
||||
});
|
||||
|
||||
it('rejects a random draw of the wrong length', async () => {
|
||||
for (const [what, fixed] of [
|
||||
['capsule_id', { capsuleId: new Uint8Array(15) }],
|
||||
['I_PAYLOAD', { payloadIdentity: new Uint8Array(31) }],
|
||||
] as const) {
|
||||
expect((await failure(encryptWith(te.encode('x'), options(), fixed))).message).toBe(
|
||||
`encrypt: the random draw of ${what} is not ${what === 'capsule_id' ? 16 : 32} bytes`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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 }));
|
||||
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));
|
||||
// The same credential in two capsules: 32 distinct shares.
|
||||
const shares = new Set<string>();
|
||||
const payloadIds = new Set<string>();
|
||||
for (const c of [a, b]) {
|
||||
const inner = await sealedPlaintext(c.dkc!, R1001);
|
||||
for (const s of ageStanzas(inner)) shares.add(s.args[0]!);
|
||||
const control = decodeControl(await decryptAll(inner, accessIdentity([c.portableKey!.material], ACCESS_SLOTS), 'age'), FORMAT_2);
|
||||
payloadIds.add(hx(control.payloadIdentity));
|
||||
}
|
||||
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();
|
||||
});
|
||||
|
||||
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 }));
|
||||
let calls = 0;
|
||||
const source = { fetch: () => (calls++, Promise.resolve(R1001)) };
|
||||
const withDkk = await open(b.dkc!, { source, now: () => roundAt(1001), accessKeyFile: encodeAccessKey(a.portableKey!) });
|
||||
expect([withDkk.error?.code, withDkk.inspection.checks.at(-1)?.step, calls]).toEqual(['ERR_ACCESS_INVALID', 9, 0]);
|
||||
const withId = await open(b.dkc!, { source, now: () => roundAt(1001), identities: [a.portableKey!.material] });
|
||||
expect([withId.error?.code, withId.inspection.checks.at(-1)?.step, calls]).toEqual(['ERR_ACCESS_INVALID', 13, 1]);
|
||||
});
|
||||
|
||||
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 effective = res.unlockAt;
|
||||
const period = quicknet().period;
|
||||
expect(effective.seconds * 1e9 + effective.nanos).toBeGreaterThanOrEqual(requested.seconds * 1e9 + requested.nanos);
|
||||
expect(effective.seconds).toBeLessThan(requested.seconds + period + 1);
|
||||
let calls = 0;
|
||||
const r = await open(res.dkc!, { source: { fetch: () => (calls++, Promise.resolve(R1000)) }, now: () => now });
|
||||
expect([r.error?.code, calls]).toEqual(['ERR_RELEASE_UNAVAILABLE', 0]);
|
||||
expect((await inspect(res.dkc!)).header!.dateKey).toEqual(res.dateKey);
|
||||
});
|
||||
|
||||
it('seals a control whose length depends on neither L nor the rule, as §55.2 asks, and matches the formula', async () => {
|
||||
const lengths = new Set<number>();
|
||||
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 sealed = split(res.dkc!).sealed.length;
|
||||
expect(sealed).toBe(sealedControlLength(policy, 103, 1000));
|
||||
lengths.add(sealed);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect([...lengths].sort((x, y) => x - y)).toEqual([458, 2128]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the self-checks of the writer', () => {
|
||||
const control = te.encode('a control that is not CBOR, as far as age cares');
|
||||
const sealFor = async (recipients: Uint8Array[], plaintext = control): Promise<Uint8Array> => {
|
||||
const e = new Encrypter();
|
||||
for (const r of recipients) e.addRecipient(formatX25519Recipient(r));
|
||||
return e.encrypt(plaintext);
|
||||
};
|
||||
const others = (n: number): Uint8Array[] => Array.from({ length: n }, () => x25519PublicKey(newX25519Identity()));
|
||||
|
||||
it('checks INNER_ACCESS_AGE as selfCheckInner: 16 stanzas, the portable key opening exactly one, the control inside', async () => {
|
||||
const key = newX25519Identity();
|
||||
const mine = x25519PublicKey(key);
|
||||
await expect(selfCheckInner(await sealFor([mine, ...others(15)]), control, key)).resolves.toBeUndefined();
|
||||
await expect(selfCheckInner(await sealFor(others(16)), control, undefined)).resolves.toBeUndefined();
|
||||
const text = async (inner: Uint8Array, c = control): Promise<string> => (await failure(selfCheckInner(inner, c, key))).message;
|
||||
expect(await text(await sealFor([mine, ...others(14)]))).toBe(
|
||||
'capsule: self-check: agewrap: INNER_ACCESS_AGE has 15 stanzas, want exactly 16: ERR_POLICY_STRUCTURE_MISMATCH',
|
||||
);
|
||||
expect(await text(await sealFor([mine, mine, ...others(14)]))).toBe(
|
||||
'capsule: self-check: the portable key does not open INNER_ACCESS_AGE: capsule: age: agewrap: one identity opens 2 INNER_ACCESS_AGE stanzas, want one per recipient: ERR_POLICY_STRUCTURE_MISMATCH',
|
||||
);
|
||||
expect(await text(await sealFor(others(16)))).toBe(
|
||||
'capsule: self-check: the portable key does not open INNER_ACCESS_AGE: capsule: age: agewrap: no supplied identity is a recipient of INNER_ACCESS_AGE: ERR_ACCESS_INVALID',
|
||||
);
|
||||
expect(await text(await sealFor([mine, ...others(15)]), te.encode('another control'))).toBe('capsule: self-check: INNER_ACCESS_AGE does not hold the control');
|
||||
expect(await text(te.encode('not an age file'))).toMatch(/^capsule: self-check: INNER_ACCESS_AGE: agewrap: .*: ERR_INTEGRITY$/);
|
||||
});
|
||||
|
||||
it('checks the header of PAYLOAD_AGE as selfCheckPayload: alone, parsed, and opened by I_PAYLOAD', async () => {
|
||||
const key = newX25519Identity();
|
||||
const header = (file: Uint8Array): Uint8Array => file.subarray(0, file.length - 16 - 16 - 1);
|
||||
const file = await sealFor([x25519PublicKey(key)], new Uint8Array(1));
|
||||
await expect(selfCheckPayloadHeader(header(file), key)).resolves.toBeUndefined();
|
||||
const text = async (first: Uint8Array): Promise<string> => (await failure(selfCheckPayloadHeader(first, key))).message;
|
||||
expect(await text(header(await sealFor(others(1), new Uint8Array(1))))).toBe('capsule: self-check: I_PAYLOAD does not open the header of PAYLOAD_AGE');
|
||||
expect(await text(te.encode('garbage'))).toBe('capsule: self-check: the age header of PAYLOAD_AGE does not parse');
|
||||
expect(await text(concatBytes(header(file), new Uint8Array(1)))).toBe('capsule: internal error: the first piece of PAYLOAD_AGE is not its age header alone');
|
||||
});
|
||||
|
||||
it('wipes every secret it was handed, whether it succeeds or fails', async () => {
|
||||
for (const fails of [false, true]) {
|
||||
const handed: Uint8Array[] = [];
|
||||
const run = encryptWith(
|
||||
te.encode('x'),
|
||||
options({ policy: TIME_AND_KEY, unlockAt: roundAt(1001), newPortableKey: true, ...(fails ? { progress: () => { throw new Error('no room'); } } : {}) }),
|
||||
{ handed },
|
||||
);
|
||||
if (fails) expect((await failure(run)).message).toBe('no room');
|
||||
else await run;
|
||||
// I_ACCESS, I_PAYLOAD and 15 dummies.
|
||||
expect(handed).toHaveLength(17);
|
||||
expect(handed.every((b) => b.every((x) => x === 0))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,43 @@
|
||||
// The writer of phase 3: encrypt writes a .dkc of capsule format 2 and, when
|
||||
// asked, a portable .dkk (spec §61, §62, §62.1), as capsule.Encrypt of the Go
|
||||
// reference at spec-v0.9. Its 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.
|
||||
|
||||
import { cryptoWords } from './random.ts';
|
||||
import { type Draws, type EncryptOptions, type Encrypted, type EncryptSource, MAX_MEMORY_DKC, writeCapsule } from './writer.ts';
|
||||
import { newX25519Identity } from './x25519.ts';
|
||||
|
||||
export type { EncryptOptions, Encrypted, EncryptSource };
|
||||
export { MAX_MEMORY_DKC };
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function encrypt(src: EncryptSource, opts: EncryptOptions): Promise<Encrypted> {
|
||||
return writeCapsule(src, opts, cryptoDraws());
|
||||
}
|
||||
|
||||
// Every random value of the writer from crypto.getRandomValues.
|
||||
function cryptoDraws(): Draws {
|
||||
const bytes = (n: number) => (): Uint8Array => crypto.getRandomValues(new Uint8Array(n));
|
||||
return {
|
||||
capsuleId: bytes(16),
|
||||
payloadIdentity: newX25519Identity,
|
||||
accessIdentity: newX25519Identity,
|
||||
dummy: newX25519Identity,
|
||||
words: cryptoWords(),
|
||||
credentialId: bytes(16),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,621 @@
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// the capsule is complete and checked, and aborted on any failure, so that
|
||||
// nothing partial is ever presented as a capsule (§62.1 rule 9). The content
|
||||
// is streamed in pieces of 64 KiB, followed by the zeros of its padding.
|
||||
|
||||
import { Decrypter, Encrypter, type ReadableStreamWithSize } from 'age-encryption';
|
||||
import { ACCESS_TYPE_X25519, type AccessKey } from './accesskey.ts';
|
||||
import { ACCESS_SLOTS, ageStanzas, checkAccessStanzas, checkTimeStanzas, parseAgeHeader } from './age.ts';
|
||||
import { decryptAll } from './agefile.ts';
|
||||
import { copyBytes, equalBytes } from './bytes.ts';
|
||||
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 type { Extension } from './extension.ts';
|
||||
import { DKC_PRELUDE_SIZE, FORMAT_2, 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 { isPadding, MAX_PAYLOAD_LENGTH, paddedLength, type Padding, payloadAgeLength, REFORZADO } from './padding.ts';
|
||||
import { cloneProfile, type Profile, validateProfile } from './profile.ts';
|
||||
import { permute, type RandomWords } from './random.ts';
|
||||
import { checkX25519Recipient, formatX25519Recipient } from './recipient.ts';
|
||||
import { timeRecipient } from './tlock.ts';
|
||||
import { x25519PublicKey } from './x25519.ts';
|
||||
|
||||
/** The largest .dkc that encrypt returns in memory; a larger one needs an output stream. */
|
||||
export const MAX_MEMORY_DKC = 1 << 30;
|
||||
|
||||
/** What a capsule seals: bytes in memory, a Blob such as a File, or a stream of declared length. */
|
||||
export type EncryptSource = Uint8Array | Blob | ReadableStream<Uint8Array>;
|
||||
|
||||
/** Options of encrypt, field by field those of capsule.EncryptOptions. */
|
||||
export interface EncryptOptions {
|
||||
/** The pinned Provider Profile. Required. */
|
||||
readonly profile: Profile;
|
||||
/** The requested instant: the capsule opens at the first round at or after it (§15), and it must be after now(). */
|
||||
readonly unlockAt: Instant;
|
||||
/** TIME_ONLY or TIME_AND_KEY (§25). Required. */
|
||||
readonly policy: Policy;
|
||||
/** Raw 32-byte X25519 public keys of the known holders, for time_and_key (§37, §39). */
|
||||
readonly recipients?: readonly Uint8Array[];
|
||||
/** Generates a fresh I_ACCESS for this capsule only, returned as a .dkk (§38). */
|
||||
readonly newPortableKey?: boolean;
|
||||
/** L, the exact number of bytes of the source: its size for a Blob or a Uint8Array, required for a stream (§62.1 rule 6). */
|
||||
readonly length?: number;
|
||||
/** The padding rule; reforzado when omitted (§29.1, §62.1 rule 10). */
|
||||
readonly padding?: Padding;
|
||||
/** The PUBLIC_HEADER extensions, visible to anyone holding the .dkc. */
|
||||
readonly critical?: readonly Extension[];
|
||||
readonly noncritical?: readonly Extension[];
|
||||
/** The CONTROL_CBOR extensions, sealed with the control. */
|
||||
readonly controlCritical?: readonly Extension[];
|
||||
readonly controlNoncritical?: readonly Extension[];
|
||||
/** The clock. Required, and called once. */
|
||||
readonly now: () => Instant;
|
||||
/**
|
||||
* Where the .dkc goes instead of Encrypted.dkc. It is closed only once the
|
||||
* capsule is complete and checked, and aborted on any failure.
|
||||
*/
|
||||
readonly output?: WritableStream<Uint8Array>;
|
||||
/** Called before the first write with (0, total), then after each write; if it throws, the writing stops. */
|
||||
readonly progress?: (written: number, total: number) => void;
|
||||
}
|
||||
|
||||
/** The result of encrypt, as capsule.Result. */
|
||||
export interface Encrypted {
|
||||
readonly dateKey: DateKey;
|
||||
/** 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;
|
||||
/** L, the padding rule and P = rule(L), the length of the plaintext of PAYLOAD_AGE (§29.1). */
|
||||
readonly length: number;
|
||||
readonly padding: Padding;
|
||||
readonly paddedLength: number;
|
||||
/** The .dkk, when newPortableKey is set: encode it with encodeAccessKey and wipe it with wipeAccessKey. */
|
||||
readonly portableKey?: AccessKey;
|
||||
/** The size of the .dkc. */
|
||||
readonly size: number;
|
||||
/** The .dkc, only without an output. */
|
||||
readonly dkc?: Uint8Array;
|
||||
}
|
||||
|
||||
/** The random values of the writer, one method per value (§62.1 rule 5). */
|
||||
export interface Draws {
|
||||
/** capsule_id, 16 bytes (§21). */
|
||||
readonly capsuleId: () => Uint8Array;
|
||||
/** I_PAYLOAD, a raw X25519 identity of 32 bytes (§29). */
|
||||
readonly payloadIdentity: () => Uint8Array;
|
||||
/** I_ACCESS, a raw X25519 identity of 32 bytes (§38). */
|
||||
readonly accessIdentity: () => Uint8Array;
|
||||
/** The scalar of a dummy, 32 bytes, dropped once its public key is derived (§39). */
|
||||
readonly dummy: () => Uint8Array;
|
||||
/** The 32-bit words of the permutation of the 16 slots (§39). */
|
||||
readonly words: RandomWords;
|
||||
/** credential_id, 16 bytes (§42). */
|
||||
readonly credentialId: () => Uint8Array;
|
||||
}
|
||||
|
||||
const CHUNK = 64 << 10;
|
||||
const chunks = (n: number): number => Math.max(1, Math.ceil(n / 65536));
|
||||
|
||||
/**
|
||||
* SEALED_CONTROL_LEN from the lengths of spec §62.1 (informative note), with
|
||||
* C the length of CONTROL_CBOR: INNER_ACCESS_AGE holds 16 X25519 stanzas of
|
||||
* 98 bytes, and the tlock stanza of OUTER_TIME_AGE is 249 bytes plus the
|
||||
* digits of the round. The real seal is checked to measure exactly this.
|
||||
*/
|
||||
export function sealedControlLength(policy: Policy, controlLength: number, round: number): number {
|
||||
const n = policy === TIME_AND_KEY ? 86 + 98 * ACCESS_SLOTS + controlLength + 16 * chunks(controlLength) : controlLength;
|
||||
return 335 + String(round).length + n + 16 * chunks(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a .dkc of format 2 for the content of `src` with the random values
|
||||
* of `draws`, as capsule.Encrypt. See encrypt.
|
||||
*/
|
||||
export async function writeCapsule(src: EncryptSource, opts: EncryptOptions, draws: Draws): Promise<Encrypted> {
|
||||
const state: WriteState = {};
|
||||
try {
|
||||
return await write(src, opts, draws, 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);
|
||||
else if (output instanceof WritableStream) await output.abort(err);
|
||||
} catch {
|
||||
// An output whose abort fails does not change the error.
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
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');
|
||||
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.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);
|
||||
|
||||
// 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`);
|
||||
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);
|
||||
const controlCritical = copyExtensions(opts.controlCritical);
|
||||
const controlNoncritical = copyExtensions(opts.controlNoncritical);
|
||||
const { policy, output, progress } = opts;
|
||||
const wantsPortableKey = opts.newPortableKey === true;
|
||||
const code = opts.padding === undefined ? REFORZADO : opts.padding;
|
||||
|
||||
// Step 3: the profile.
|
||||
await validateProfile(profile);
|
||||
|
||||
// Step 4: the clock, once (§62.1 rule 2).
|
||||
const now = opts.now();
|
||||
if (!isInstant(now)) throw new TypeError('encrypt: EncryptOptions.now() did not return an Instant');
|
||||
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);
|
||||
|
||||
// Step 6: the DateKey, resolved locally (§15).
|
||||
const dateKey = resolveDateKey(profile, unlockAt);
|
||||
const unlock = roundTime(profile, dateKey.round);
|
||||
/* v8 ignore next 3 -- @preserve: resolveDateKey never resolves to a round before the instant (§15) */
|
||||
if (compareInstants(unlock, unlockAt) < 0) {
|
||||
throw new DateKeysError('ERR_ROUND_MISMATCH', `capsule: resolved round ${dateKey.round} opens before the requested time`);
|
||||
}
|
||||
|
||||
const wipe: Uint8Array[] = [];
|
||||
try {
|
||||
// Step 7: the credentials, and I_ACCESS when asked, the last of them.
|
||||
const credentials = accessRecipients(policy, recipients, wantsPortableKey);
|
||||
let portable: Uint8Array | undefined;
|
||||
if (wantsPortableKey) {
|
||||
portable = drawn(draws.accessIdentity(), 32, 'I_ACCESS');
|
||||
wipe.push(portable);
|
||||
credentials.push(x25519PublicKey(portable));
|
||||
}
|
||||
|
||||
// Step 8: capsule_id and I_PAYLOAD.
|
||||
const capsuleId = copyBytes(drawn(draws.capsuleId(), 16, 'capsule_id'));
|
||||
const payloadId = drawn(draws.payloadIdentity(), 32, 'I_PAYLOAD');
|
||||
wipe.push(payloadId);
|
||||
const payloadRecipient = x25519PublicKey(payloadId);
|
||||
|
||||
// Step 9: the 16 slots of INNER_ACCESS_AGE, a dummy in each one left,
|
||||
// whose scalar is dropped at once, in a uniformly random order (§39).
|
||||
const slots: Uint8Array[] = [];
|
||||
if (policy === TIME_AND_KEY) {
|
||||
slots.push(...credentials);
|
||||
while (slots.length < ACCESS_SLOTS) {
|
||||
const scalar = drawn(draws.dummy(), 32, 'a dummy');
|
||||
try {
|
||||
slots.push(x25519PublicKey(scalar));
|
||||
} finally {
|
||||
scalar.fill(0);
|
||||
}
|
||||
}
|
||||
permute(slots, draws.words);
|
||||
}
|
||||
|
||||
// Step 10: PUBLIC_HEADER, checked with the decoder of the reader.
|
||||
const header = encodeHeader({ capsuleId, dateKey, policy, critical, 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
|
||||
// binding, I_PAYLOAD, L or the rule (§62.1 rule 7), and from it
|
||||
// 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,
|
||||
);
|
||||
const controlLength = provisional.length;
|
||||
provisional.fill(0);
|
||||
const sealedLength = sealedControlLength(policy, controlLength, dateKey.round);
|
||||
if (sealedLength > MAX_SEALED_CONTROL_LEN) {
|
||||
throw new DateKeysError('ERR_INTEGRITY', `capsule: SEALED_CONTROL of ${sealedLength} bytes exceeds ${MAX_SEALED_CONTROL_LEN}`);
|
||||
}
|
||||
const total = DKC_PRELUDE_SIZE + header.length + sealedLength + payloadAgeLength(padded);
|
||||
if (output === undefined && total > MAX_MEMORY_DKC) {
|
||||
throw new TypeError(`encrypt: a capsule of ${total} bytes needs EncryptOptions.output; in memory the limit is ${MAX_MEMORY_DKC}`);
|
||||
}
|
||||
|
||||
// Step 12: PRELUDE and header_binding over the exact bytes (§26).
|
||||
const prelude = preludeBytes({ format: FORMAT_2, 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.
|
||||
const control = encodeControl(
|
||||
{ headerBinding: binding, payloadIdentity: payloadId, critical: controlCritical, noncritical: controlNoncritical, payloadLength: length, padding: code },
|
||||
FORMAT_2,
|
||||
);
|
||||
wipe.push(control);
|
||||
selfCheck('capsule: self-check: the reader rejects this CONTROL_CBOR', () => decodeControl(control, FORMAT_2).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
|
||||
// file draws its own file key, so the three are independent (§62).
|
||||
let plaintext = control;
|
||||
if (policy === TIME_AND_KEY) {
|
||||
const e = new Encrypter();
|
||||
for (const r of slots) e.addRecipient(formatX25519Recipient(r));
|
||||
const inner = await sealWith(() => e.encrypt(control));
|
||||
await selfCheckInner(inner, control, portable);
|
||||
plaintext = inner;
|
||||
}
|
||||
const recipient = timeRecipient(profile, dateKey.round);
|
||||
const outer = new Encrypter();
|
||||
outer.addRecipient(recipient);
|
||||
const sealed = await sealWith(() => outer.encrypt(plaintext));
|
||||
selfCheck('capsule: self-check: OUTER_TIME_AGE', () => checkTimeStanzas(ageStanzas(sealed), profile, dateKey.round));
|
||||
if (sealed.length !== sealedLength) throw new Error(`capsule: internal error: SEALED_CONTROL is ${sealed.length} bytes, measured ${sealedLength}`);
|
||||
control.fill(0);
|
||||
|
||||
// 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
|
||||
// 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 pe = new Encrypter();
|
||||
pe.addRecipient(formatX25519Recipient(payloadRecipient));
|
||||
const payload = (await sealWith(() => pe.encrypt(input))) as ReadableStreamWithSize;
|
||||
const reader = payload.getReader();
|
||||
try {
|
||||
if (payload.size(padded) !== payloadAgeLength(padded)) {
|
||||
throw new Error(`capsule: internal error: PAYLOAD_AGE would be ${payload.size(padded)} bytes, P = ${padded} gives ${payloadAgeLength(padded)}`);
|
||||
}
|
||||
const first = await readPiece(reader, source);
|
||||
await selfCheckPayloadHeader(first.done ? new Uint8Array(0) : first.value, payloadId);
|
||||
payloadId.fill(0);
|
||||
|
||||
// Step 16: the total, and the room for it.
|
||||
progress?.(0, total);
|
||||
const sink = output === undefined ? memorySink(total) : streamSink(output, state);
|
||||
|
||||
// Step 17: PRELUDE, PUBLIC_HEADER, SEALED_CONTROL and PAYLOAD_AGE, each
|
||||
// piece hashed for the capsule_digest before it is written.
|
||||
const hasher = sha256Hasher();
|
||||
let written = 0;
|
||||
const put = async (b: Uint8Array): Promise<void> => {
|
||||
hasher.update(b);
|
||||
await sink.write(b);
|
||||
written += b.length;
|
||||
progress?.(written, total);
|
||||
};
|
||||
await put(prelude);
|
||||
await put(header);
|
||||
await put(sealed);
|
||||
let payloadBytes = 0;
|
||||
for (let r = first; !r.done; r = await readPiece(reader, source)) {
|
||||
payloadBytes += r.value.length;
|
||||
await put(r.value);
|
||||
}
|
||||
|
||||
// Step 18: P bytes handed to age, and PAYLOAD_AGE of the length P gives.
|
||||
if (payloadBytes !== payloadAgeLength(padded)) {
|
||||
throw new Error(`capsule: self-check: PAYLOAD_AGE is ${payloadBytes} bytes, P = ${padded} gives ${payloadAgeLength(padded)}`);
|
||||
}
|
||||
const dkc = await sink.close();
|
||||
|
||||
// Step 19: the .dkk, with the digest of the whole .dkc (§62, step 17).
|
||||
let portableKey: AccessKey | undefined;
|
||||
if (portable !== undefined) {
|
||||
portableKey = {
|
||||
credentialId: copyBytes(drawn(draws.credentialId(), 16, 'credential_id')),
|
||||
capsuleId: copyBytes(capsuleId),
|
||||
type: ACCESS_TYPE_X25519,
|
||||
material: copyBytes(portable),
|
||||
verification: { capsuleDigest: hasher.digest() },
|
||||
critical: [],
|
||||
noncritical: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
dateKey,
|
||||
unlockAt: unlock,
|
||||
capsuleId,
|
||||
format: FORMAT_2,
|
||||
length,
|
||||
padding: code,
|
||||
paddedLength: padded,
|
||||
...(portableKey === undefined ? {} : { portableKey }),
|
||||
size: total,
|
||||
...(dkc === undefined ? {} : { dkc }),
|
||||
};
|
||||
} catch (err) {
|
||||
await reader.cancel(err).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
for (const b of wipe) b.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
// L: the size of a Uint8Array or a Blob, which a declared length must match,
|
||||
// or the declared length of a stream (§62.1 rule 6).
|
||||
function sourceLength(src: EncryptSource, length: number | undefined): number {
|
||||
if (length !== undefined && (!Number.isSafeInteger(length) || length < 0)) {
|
||||
throw new TypeError(`encrypt: EncryptOptions.length ${String(length)} is not a non-negative safe integer`);
|
||||
}
|
||||
if (src instanceof Uint8Array || src instanceof Blob) {
|
||||
const size = src instanceof Uint8Array ? src.length : src.size;
|
||||
if (length !== undefined && length !== size) throw new TypeError(`encrypt: EncryptOptions.length ${length} is not the ${size} bytes of the source`);
|
||||
return size;
|
||||
}
|
||||
if (src instanceof ReadableStream) {
|
||||
if (length === undefined) throw new TypeError('encrypt: a ReadableStream needs EncryptOptions.length');
|
||||
return length;
|
||||
}
|
||||
throw new TypeError('encrypt: the source is a Uint8Array, a Blob or a ReadableStream');
|
||||
}
|
||||
|
||||
const copyExtensions = (list: readonly Extension[] | undefined): Extension[] =>
|
||||
(list ?? []).map((e) => ({ id: e.id, version: e.version, data: e.data === undefined ? undefined : copyBytes(e.data) }));
|
||||
|
||||
// A random value of the length it must have.
|
||||
function drawn(b: Uint8Array, n: number, what: string): Uint8Array {
|
||||
if (!(b instanceof Uint8Array) || b.length !== n) throw new Error(`encrypt: the random draw of ${what} is not ${n} bytes`);
|
||||
return b;
|
||||
}
|
||||
|
||||
// The credentials of INNER_ACCESS_AGE, as accessRecipients of the Go
|
||||
// reference: from 1 to 16, X25519, canonical, not of low order, none twice
|
||||
// (§37, §39, §62.1 rule 3). I_ACCESS is added by the caller.
|
||||
function accessRecipients(policy: Policy, recipients: readonly Uint8Array[], portable: boolean): Uint8Array[] {
|
||||
if (policy === TIME_ONLY) {
|
||||
if (recipients.length !== 0 || portable) throw new Error('capsule: time_only takes no recipients and no portable key');
|
||||
return [];
|
||||
}
|
||||
if (policy !== TIME_AND_KEY) throw new Error(`capsule: unknown access policy ${policy}`);
|
||||
const n = recipients.length + (portable ? 1 : 0);
|
||||
if (n === 0) throw new Error('capsule: time_and_key needs at least one recipient or a portable key');
|
||||
if (n > ACCESS_SLOTS) {
|
||||
throw new Error(`capsule: time_and_key takes at most ${ACCESS_SLOTS} credentials, recipients and portable key together; ${n} given`);
|
||||
}
|
||||
const out: Uint8Array[] = [];
|
||||
for (const [i, r] of recipients.entries()) {
|
||||
try {
|
||||
checkX25519Recipient(r);
|
||||
} catch (err) {
|
||||
throw new Error(`capsule: recipient ${i}: ${(err as Error).message}`);
|
||||
}
|
||||
if (out.some((x) => equalBytes(x, r))) {
|
||||
throw new Error(`capsule: recipient ${formatX25519Recipient(r)} listed twice; INNER_ACCESS_AGE holds one stanza per recipient`);
|
||||
}
|
||||
out.push(r);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Runs a self-check; its failure keeps the normative code of the reader, if
|
||||
// any, after `prefix`.
|
||||
function selfCheck(prefix: string, check: () => void): void {
|
||||
try {
|
||||
check();
|
||||
} catch (err) {
|
||||
throw selfCheckError(prefix, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Every check of the reader fails with a DateKeysError; anything else is a
|
||||
// bug and propagates, as in open.ts.
|
||||
function selfCheckError(prefix: string, err: unknown): Error {
|
||||
/* v8 ignore next -- @preserve */
|
||||
if (!(err instanceof DateKeysError)) throw err;
|
||||
return err.wrap(prefix);
|
||||
}
|
||||
|
||||
// INNER_ACCESS_AGE with the rules of the reader, as selfCheckInner: 16 X25519
|
||||
// stanzas with distinct shares, and, with a portable key, I_ACCESS opens
|
||||
// exactly one of them and yields the control (§62.1 rule 11).
|
||||
export async function selfCheckInner(inner: Uint8Array, control: Uint8Array, portable: Uint8Array | undefined): Promise<void> {
|
||||
const stanzas = stanzasOf(inner);
|
||||
selfCheck('capsule: self-check', () => checkAccessStanzas(stanzas, ACCESS_SLOTS));
|
||||
if (portable === undefined) return;
|
||||
let got: Uint8Array;
|
||||
try {
|
||||
got = await decryptAll(inner, accessIdentity([portable], ACCESS_SLOTS), 'age');
|
||||
} catch (err) {
|
||||
throw selfCheckError('capsule: self-check: the portable key does not open INNER_ACCESS_AGE', err);
|
||||
}
|
||||
const same = equalBytes(got, control);
|
||||
got.fill(0);
|
||||
if (!same) throw new Error('capsule: self-check: INNER_ACCESS_AGE does not hold the control');
|
||||
}
|
||||
|
||||
// The stanzas of INNER_ACCESS_AGE, with the text of Go when its header does
|
||||
// not parse.
|
||||
function stanzasOf(inner: Uint8Array): ReturnType<typeof ageStanzas> {
|
||||
try {
|
||||
return ageStanzas(inner);
|
||||
} catch (err) {
|
||||
throw selfCheckError('capsule: self-check: INNER_ACCESS_AGE', err);
|
||||
}
|
||||
}
|
||||
|
||||
// The age header of PAYLOAD_AGE, the first piece of its stream: it is the
|
||||
// whole piece, and I_PAYLOAD opens it and verifies its MAC, as
|
||||
// selfCheckPayload with age.DecryptHeader (§62.1 rule 11). The file key it
|
||||
// yields is wiped.
|
||||
export async function selfCheckPayloadHeader(first: Uint8Array, payloadId: Uint8Array): Promise<void> {
|
||||
let length: number;
|
||||
try {
|
||||
length = parseAgeHeader(first).length;
|
||||
} catch {
|
||||
throw new Error('capsule: self-check: the age header of PAYLOAD_AGE does not parse');
|
||||
}
|
||||
if (length !== first.length) throw new Error('capsule: internal error: the first piece of PAYLOAD_AGE is not its age header alone');
|
||||
const d = new Decrypter();
|
||||
d.addIdentity(payloadIdentity(payloadId));
|
||||
let fileKey: Uint8Array;
|
||||
try {
|
||||
fileKey = await d.decryptHeader(first);
|
||||
} catch {
|
||||
throw new Error('capsule: self-check: I_PAYLOAD does not open the header of PAYLOAD_AGE');
|
||||
}
|
||||
fileKey.fill(0);
|
||||
}
|
||||
|
||||
// A failure of age-encryption while sealing: a fixed text, the original in
|
||||
// its cause (plan of phase 3, decision 9).
|
||||
async function sealWith<T>(seal: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await seal();
|
||||
} catch (err) {
|
||||
throw new Error('capsule: age failed to seal', { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
interface SourceState {
|
||||
/** The error of the source, or of its length, to rethrow as it is. */
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
// Reads a piece of PAYLOAD_AGE. A failure of the source reaches age as the
|
||||
// error of its input, and comes back here as it was.
|
||||
async function readPiece(reader: ReadableStreamDefaultReader<Uint8Array>, source: SourceState): Promise<ReadableStreamReadResult<Uint8Array>> {
|
||||
try {
|
||||
return await reader.read();
|
||||
} catch (err) {
|
||||
if ('error' in source) throw source.error;
|
||||
throw new Error('capsule: age failed to seal', { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
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();
|
||||
ended = true;
|
||||
}
|
||||
if (padding > 0) {
|
||||
const zeros = Math.min(CHUNK, padding);
|
||||
controller.enqueue(new Uint8Array(zeros));
|
||||
padding -= zeros;
|
||||
return;
|
||||
}
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
state.error = err;
|
||||
controller.error(err);
|
||||
await reader.cancel(err).catch(() => 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;
|
||||
return {
|
||||
read: async () => {
|
||||
if (at >= b.length) return { done: true, value: undefined };
|
||||
const value = b.subarray(at, at + CHUNK);
|
||||
at += value.length;
|
||||
return { done: false, value };
|
||||
},
|
||||
cancel: async () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
interface Sink {
|
||||
write(b: Uint8Array): Promise<void>;
|
||||
/** Commits what was written; the .dkc when it was held in memory. */
|
||||
close(): Promise<Uint8Array | undefined>;
|
||||
}
|
||||
|
||||
// The .dkc in memory, in one buffer of its exact size, reserved only now.
|
||||
function memorySink(total: number): Sink {
|
||||
const buf = new Uint8Array(total);
|
||||
let at = 0;
|
||||
return {
|
||||
write: async (b) => {
|
||||
buf.set(b, at);
|
||||
at += b.length;
|
||||
},
|
||||
close: async () => buf,
|
||||
};
|
||||
}
|
||||
|
||||
// The .dkc into the output; a failure of the output keeps its own error, as
|
||||
// Go returns the error of dst.
|
||||
function streamSink(output: WritableStream<Uint8Array>, state: WriteState): Sink {
|
||||
const w = output.getWriter();
|
||||
state.writer = w;
|
||||
return {
|
||||
write: (b) => w.write(b),
|
||||
close: async () => {
|
||||
await w.close();
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in new issue