You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
275 lines
11 KiB
275 lines
11 KiB
// 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<string>();
|
|
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<Uint8Array>({ 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),
|
|
},
|
|
};
|
|
}
|