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.
227 lines
9.7 KiB
227 lines
9.7 KiB
// 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: <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 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));
|
|
});
|
|
});
|