// Tests only: what scripts/capsule-ts-samples.mjs writes for the Go // reference and interop.test.ts checks again (plan of phase 3, decision 11 // and section 8, point 9): fixed identities, the inputs of the encoder // differential, drawn from a seed, the corpus of recipient strings and keys, // and the table of invalid options of encrypt as a generator of test // vectors, in a form the Go script reads. Everything here is deterministic. import type { AccessKey } from '../accesskey.ts'; import { ACCESS_TYPE_X25519 } from '../accesskey.ts'; import { fromHex, toHex } from '../bytes.ts'; import type { Control } from '../control.ts'; import { parseRFC3339 } from '../datekey.ts'; import { sha256Hasher } from '../digest.ts'; import type { EncryptOptions, EncryptSource } from '../encrypt.ts'; import type { Extension } from '../extension.ts'; import type { Header } from '../header.ts'; import { MAX_PAYLOAD_LENGTH, type Padding } from '../padding.ts'; import { quicknet } from '../profile.ts'; import { formatX25519Recipient } from '../recipient.ts'; const te = new TextEncoder(); /** A fixed raw X25519 identity: SHA-256 of a label. */ export function sampleIdentity(i: number): Uint8Array { const h = sha256Hasher(); h.update(te.encode(`datekeys-ts phase 3 sample identity ${i}`)); return h.digest(); } // mulberry32. function generator(seed: number): { int: (n: number) => number; bytes: (n: number) => Uint8Array; chance: (p: number) => boolean } { 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; }; const int = (n: number): number => Math.floor(next() * n); return { int, bytes: (n) => Uint8Array.from({ length: n }, () => int(256)), chance: (p) => next() < p }; } /** An extension as JSON, data in hex. */ export interface ExtensionJSON { id: string; version: number; data?: string; } const extJSON = (e: Extension): ExtensionJSON => ({ id: e.id, version: e.version, ...(e.data === undefined ? {} : { data: toHex(e.data) }) }); /** One input of the encoder differential, with its encodings by this library. */ export interface EncoderCase { header: Header; control: Control; dkk: AccessKey; } // Ids of 1 to 4 bytes per character, whose byte order differs from their // order in UTF-16 units. const ID_CHARS = ['a', 'b', 'z', '0', '-', '.', 'é', '。', '𐀀']; /** The inputs of the encoder differential: valid objects drawn from `seed`. */ export function encoderCases(seed: number, count: number): EncoderCase[] { const g = generator(seed); const extensionArrays = (): [Extension[], Extension[]] => { const taken = new Set(); const one = (): Extension[] => { const out: Extension[] = []; const n = g.chance(0.5) ? 0 : 1 + g.int(4); while (out.length < n) { const id = Array.from({ length: 1 + g.int(5) }, () => ID_CHARS[g.int(ID_CHARS.length)]!).join(''); if (taken.has(id)) continue; taken.add(id); const version = g.chance(0.2) ? 2 ** 32 - 1 - g.int(2) : g.int(1000); out.push({ id, version, data: g.chance(0.3) ? undefined : g.bytes(1 + g.int(40)) }); } return out; }; return [one(), one()]; }; const cases: EncoderCase[] = []; for (let i = 0; i < count; i++) { const [hc, hn] = extensionArrays(); const [cc, cn] = extensionArrays(); const [kc, kn] = extensionArrays(); const capsuleId = g.bytes(16); cases.push({ header: { capsuleId, dateKey: { profileId: 'datekeys:quicknet:v1', round: 1 + g.int(g.chance(0.3) ? 83_903_165_811 : 100_000) }, policy: g.int(2), critical: hc, noncritical: hn, }, control: { headerBinding: g.bytes(32), payloadIdentity: g.bytes(32), critical: cc, noncritical: cn, payloadLength: g.chance(0.1) ? MAX_PAYLOAD_LENGTH - g.int(3) : g.chance(0.5) ? g.int(1 << 20) : g.int(2 ** 31) * 2 ** 21 + g.int(2 ** 21), padding: g.chance(0.5) ? 1 : 2, }, dkk: { credentialId: g.bytes(16), capsuleId, type: ACCESS_TYPE_X25519, material: g.bytes(32), verification: g.chance(0.7) ? { capsuleDigest: g.bytes(32) } : undefined, critical: kc, noncritical: kn, }, }); } return cases; } /** An encoder case as JSON for the Go script. */ export function encoderCaseJSON(c: EncoderCase): object { return { header: { capsule_id: toHex(c.header.capsuleId), round: c.header.dateKey.round, policy: c.header.policy, critical: c.header.critical.map(extJSON), noncritical: c.header.noncritical.map(extJSON), }, control: { header_binding: toHex(c.control.headerBinding), payload_identity: toHex(c.control.payloadIdentity), payload_length: c.control.payloadLength, padding: c.control.padding, critical: c.control.critical.map(extJSON), noncritical: c.control.noncritical.map(extJSON), }, dkk: { credential_id: toHex(c.dkk.credentialId), capsule_id: toHex(c.dkk.capsuleId), material: toHex(c.dkk.material), ...(c.dkk.verification === undefined ? {} : { capsule_digest: toHex(c.dkk.verification.capsuleDigest) }), critical: c.dkk.critical.map(extJSON), noncritical: c.dkk.noncritical.map(extJSON), }, }; } const P = 2n ** 255n - 19n; const le = (n: bigint): Uint8Array => Uint8Array.from({ length: 32 }, (_, i) => Number((n >> BigInt(8 * i)) & 0xffn)); /** The raw keys of the recipient corpus: valid, non-canonical, of low order and on the twist. */ export function recipientKeys(): Uint8Array[] { const high = le(9n); high[31]! |= 0x80; return [ le(9n), sampleIdentity(900), high, le(P), le(P + 1n), le(P + 18n), le(0n), le(1n), le(325606250916557431795983626356110631294008115727848805560023387167927233504n), le(39382357235489614581723060781553021112529911719440698176882885853963445705823n), le(P - 1n), le(P - 2n), le(2n), ]; } /** The strings of the recipient corpus, as a person or another program may write them. */ export function recipientStrings(): string[] { const good = formatX25519Recipient(le(9n)); return [ good, good.toUpperCase(), good.slice(0, 12) + good.slice(12).toUpperCase(), good.slice(0, -1) + (good.endsWith('q') ? 'p' : 'q'), `${good} `, 'age1', '', 'AGE-SECRET-KEY-1GFPYYSJZGFPYYSJZGFPYYSJZGFPYYSJZGFPYYSJZGFPYYSJZGFPQ4EGAEX', 'age1pq1qqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0jqpuppd4', ...recipientKeys().map(formatX25519Recipient), ]; } /** A case of invalid options of encrypt, in the form the Go script reads. */ export interface ErrorCase { name: string; policy: number; /** Raw 32-byte recipients, hex. */ recipients?: string[]; portable?: boolean; unlock_at: string; now: string; /** L, of a source of that many zero bytes. */ length: number; padding?: number; critical?: ExtensionJSON[]; noncritical?: ExtensionJSON[]; control_critical?: ExtensionJSON[]; control_noncritical?: ExtensionJSON[]; /** Flips the lowest bit of the chain hash of the profile. */ chain_hash_flip?: boolean; } /** The invalid options that have an equivalent in Go, whose texts must match. */ export function errorCases(): ErrorCase[] { const at = '2023-08-23T15:59:24Z'; const before = '2023-08-23T15:09:27Z'; // 32 fixed bytes with bit 255 cleared: a canonical key, and not of low // order, with overwhelming probability. Half of them are points of the // twist, which both implementations accept (§37, MAY). const pub = (i: number): string => { const b = sampleIdentity(2000 + i); b[31]! &= 0x7f; return toHex(b); }; const high = le(9n); high[31]! |= 0x80; const base = { policy: 0, unlock_at: at, now: before, length: 1 }; return [ { ...base, name: 'an instant in the past', now: '2023-08-23T15:59:27Z' }, { ...base, name: 'an instant equal to now', now: at }, { ...base, name: 'time_only with recipients', recipients: [pub(0)] }, { ...base, name: 'time_only with a portable key', portable: true }, { ...base, name: 'time_and_key without credentials', policy: 1 }, { ...base, name: '16 recipients and a portable key', policy: 1, recipients: Array.from({ length: 16 }, (_, i) => pub(i)), portable: true }, { ...base, name: '17 recipients', policy: 1, recipients: Array.from({ length: 17 }, (_, i) => pub(i)) }, { ...base, name: 'a recipient with bit 255', policy: 1, recipients: [pub(0), toHex(high)] }, { ...base, name: 'a recipient u = p', policy: 1, recipients: [toHex(le(P))] }, { ...base, name: 'a recipient of low order', policy: 1, recipients: [toHex(le(1n))] }, { ...base, name: 'a recipient listed twice', policy: 1, recipients: [pub(3), pub(3)] }, { ...base, name: 'policy 7', policy: 7 }, { ...base, name: 'padding code 3', padding: 3 }, { ...base, name: 'L = L_MAX + 1', length: MAX_PAYLOAD_LENGTH + 1 }, { ...base, name: 'a chain hash with a bit changed', chain_hash_flip: true }, { ...base, name: 'a repeated header extension', critical: [{ id: 'a', version: 1 }, { id: 'a', version: 1 }] }, { ...base, name: 'a control extension in both arrays', control_critical: [{ id: 'a', version: 1 }], control_noncritical: [{ id: 'a', version: 1 }] }, { ...base, name: 'a header extension of version 2^32', noncritical: [{ id: 'a', version: 2 ** 32 }] }, { ...base, name: 'an empty extension_id in the control', control_noncritical: [{ id: '', version: 1 }] }, { ...base, name: '65 critical header extensions', critical: Array.from({ length: 65 }, (_, i) => ({ id: `e${String(i).padStart(2, '0')}`, version: 1 })) }, { ...base, name: 'an extension with empty data', noncritical: [{ id: 'a', version: 1, data: '' }] }, ]; } /** The source and the options of encrypt that a case of the table stands for. */ export function errorCaseInput(c: ErrorCase): { src: EncryptSource; opts: EncryptOptions } { const p = quicknet(); const profile = c.chain_hash_flip === true ? { ...p, chainHash: p.chainHash.map((b, i) => (i === 0 ? b ^ 1 : b)) } : p; const ext = (list: ExtensionJSON[] | undefined): Extension[] => (list ?? []).map((e) => ({ id: e.id, version: e.version, data: e.data === undefined ? undefined : fromHex(e.data) })); // A case of more than one byte fails before its source is read: a stream // of declared length stands for it. const src: EncryptSource = c.length <= 1 ? new Uint8Array(c.length) : new ReadableStream({ pull: (ctl) => ctl.close() }); return { src, opts: { profile, unlockAt: parseRFC3339(c.unlock_at), now: () => parseRFC3339(c.now), policy: c.policy, recipients: (c.recipients ?? []).map(fromHex), newPortableKey: c.portable === true, ...(c.length > 1 ? { length: c.length } : {}), ...(c.padding === undefined ? {} : { padding: c.padding as Padding }), critical: ext(c.critical), noncritical: ext(c.noncritical), controlCritical: ext(c.control_critical), controlNoncritical: ext(c.control_noncritical), }, }; }