// 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, whose size capsuleLength gives before // writing, 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 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 { capsuleLength, sealedControlLength } from './lengths.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 { encryptVectors } from './testing/encrypt.ts'; import { h, hx, readJSON } from './testing/testdata.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: (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): 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(); 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({ write: (b) => void chunks.push(b.slice()), close: () => void (state.closed = true), abort: (r) => void (state.aborted = r), }); let res; try { res = await encryptVectors(c.body, { ...c.opts, output }); } catch (err) { expect(c.invalid, `unexpected failure: ${(err as Error).message}`).toBeDefined(); expect([chunks.length, state.closed, state.aborted]).toEqual([0, false, err]); 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]); // The size that lengths.ts gives before writing. expect(capsuleLength({ ...c.opts, profileId: res.dateKey.profileId, round: res.dateKey.round, length: c.body.length })).toBe(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)); }); });