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/encrypt.property.test.ts

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

Powered by TurnKey Linux.