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.
DateKeys-App/src/lib/dkc/testing/interop.ts

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),
},
};
}

Powered by TurnKey Linux.