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

281 lines
15 KiB

import { afterEach, describe, expect, it, vi } from 'vitest';
import {
type AccessKey,
decodeAccessKey,
decodeAccessKeyBody,
encodeAccessKey,
marshalAccessKeyBody,
wipeAccessKey,
} from './accesskey.ts';
import { sha256 } from './bytes.ts';
import { Decoder } from './cbor.ts';
import { MAX_DKK_BODY_LEN } from './framing.ts';
import { arr, b, bn, ext, map, t, u } from './testing/cborhex.ts';
import { expectCode, h, hx, readBytes, readJSON } from './testing/testdata.ts';
const NC = 'ERR_NON_CANONICAL_CBOR';
interface DkkJSON {
file: string;
sha256: string;
credential_id: string;
capsule_id: string;
access_type: string;
access_material: string;
capsule_digest?: string;
capsule: string;
extensions?: { critical: boolean; id: string; version: number; data?: string }[];
}
function body(o: { type?: string; material?: string; cred?: string; ver?: string; crit?: string; non?: string; version?: string; extra?: [number, string][] } = {}): Uint8Array {
const entries: [number, string][] = [
[0, t('datekeys-access-key')],
[1, o.version ?? u(1)],
[2, o.cred ?? bn(16, 1)],
[3, bn(16, 2)],
[4, o.type ?? t('x25519')],
[5, o.material ?? bn(32, 3)],
];
if (o.ver) entries.push([6, o.ver]);
if (o.crit) entries.push([7, o.crit]);
if (o.non) entries.push([8, o.non]);
entries.push(...(o.extra ?? []));
return h(map(...entries));
}
function framed(bodyBytes: Uint8Array): Uint8Array {
const out = new Uint8Array(12 + bodyBytes.length);
out.set([0x44, 0x4b, 0x4b, 0x31, 1]);
new DataView(out.buffer).setUint32(8, bodyBytes.length);
out.set(bodyBytes, 12);
return out;
}
describe('.dkk fixtures', () => {
for (const name of ['time_and_key_portable.dkk', 'time_and_key_recipients.dkk', 'time_and_key_portable_extension.dkk']) {
it(`decodes ${name} and re-encodes it byte for byte`, async () => {
const fx = readJSON<DkkJSON>(`fixtures/${name}.json`);
const raw = readBytes(`fixtures/${name}`);
expect(hx(await sha256(raw))).toBe(fx.sha256);
const k = decodeAccessKey(raw);
expect(hx(k.credentialId)).toBe(fx.credential_id);
expect(hx(k.capsuleId)).toBe(fx.capsule_id);
expect(k.type).toBe(fx.access_type);
expect(hx(k.material)).toBe(fx.access_material);
expect(k.verification && hx(k.verification.capsuleDigest)).toBe(fx.capsule_digest);
// capsule_digest is the SHA-256 of the exact .dkc bytes.
expect(hx(await sha256(readBytes(`fixtures/${fx.capsule}`)))).toBe(fx.capsule_digest);
const exts = [...k.critical.map((e) => ({ ...e, critical: true })), ...k.noncritical.map((e) => ({ ...e, critical: false }))];
expect(exts.map((e) => ({ critical: e.critical, id: e.id, version: e.version, data: e.data && hx(e.data) }))).toEqual(
(fx.extensions ?? []).map((e) => ({ critical: e.critical, id: e.id, version: e.version, data: e.data })),
);
expect(hx(encodeAccessKey(k))).toBe(hx(raw));
wipeAccessKey(k);
expect(k.material.every((x) => x === 0)).toBe(true);
});
}
});
describe('DKK1 framing', () => {
const ok = readBytes('fixtures/time_and_key_portable.dkk');
const with_ = (i: number, v: number): Uint8Array => {
const c = ok.slice();
c[i] = v;
return c;
};
it('checks magic, prelude, limits and length in order', () => {
expectCode(() => decodeAccessKey(new Uint8Array(0)), 'ERR_INVALID_MAGIC', /^accesskey: ERR_INVALID_MAGIC$/);
expectCode(() => decodeAccessKey(ok.subarray(0, 3)), 'ERR_INVALID_MAGIC');
expectCode(() => decodeAccessKey(with_(3, 0x32)), 'ERR_INVALID_MAGIC');
expectCode(() => decodeAccessKey(ok.subarray(0, 11)), 'ERR_INTEGRITY', /truncated prelude/);
expectCode(() => decodeAccessKey(with_(4, 2)), 'ERR_UNSUPPORTED_VERSION', /framing version 2/);
expectCode(() => decodeAccessKey(with_(5, 0x80)), 'ERR_INVALID_FLAGS', /flags 0x80, reserved 0x000/);
expectCode(() => decodeAccessKey(with_(6, 0xab)), 'ERR_INVALID_FLAGS', /flags 0x0, reserved 0xab00/);
expectCode(() => decodeAccessKey(with_(7, 1)), 'ERR_INVALID_FLAGS', /reserved 0x001/);
const big = ok.slice();
new DataView(big.buffer).setUint32(8, MAX_DKK_BODY_LEN + 1);
expectCode(() => decodeAccessKey(big), 'ERR_INTEGRITY', /^accesskey: BODY_LEN 16777217 outside 1..16777216: ERR_INTEGRITY$/);
// Spec §40, §57: no empty frame holds a valid body, so BODY_LEN 0 is a
// framing error, after FLAGS and RESERVED.
const empty = ok.slice(0, 12);
new DataView(empty.buffer).setUint32(8, 0);
expectCode(() => decodeAccessKey(empty), 'ERR_INTEGRITY', /^accesskey: BODY_LEN 0 outside 1..16777216: ERR_INTEGRITY$/);
empty[5] = 1;
expectCode(() => decodeAccessKey(empty), 'ERR_INVALID_FLAGS', /^accesskey: flags 0x1, reserved 0x000: ERR_INVALID_FLAGS$/);
expectCode(() => decodeAccessKey(ok.subarray(0, ok.length - 1)), 'ERR_INTEGRITY', /truncated body/);
expectCode(() => decodeAccessKey(new Uint8Array([...ok, 0])), 'ERR_INTEGRITY', /data after BODY_CBOR/);
});
});
describe('BODY_CBOR', () => {
it('accepts a minimal body and the optional fields', () => {
const k = decodeAccessKey(framed(body()));
expect(k.verification).toBeUndefined();
expect(decodeAccessKeyBody(body({ ver: map([0, bn(32, 9)]) })).verification?.capsuleDigest).toEqual(new Uint8Array(32).fill(9));
expect(decodeAccessKeyBody(body({ crit: arr(ext('a')), non: arr(ext('b', 2, b('01'))) })).noncritical).toEqual([
{ id: 'b', version: 2, data: h('01') },
]);
});
it('checks the layers of spec §69.1 in order: schema head, CDDL, then access_type and access_material', () => {
expectCode(() => decodeAccessKeyBody(new Uint8Array(MAX_DKK_BODY_LEN + 1)), 'ERR_INTEGRITY');
const cases: [string, Uint8Array, string, RegExp][] = [
['schema version 2', body({ version: u(2) }), 'ERR_UNSUPPORTED_VERSION', /^accesskey: codec: datekeys-access-key schema version 2, want 1: ERR_UNSUPPORTED_VERSION$/],
['unknown key 9', body({ extra: [[9, u(0)]] }), NC, /^accesskey: key 9 is not defined: ERR/],
['credential_id of 15 bytes', body({ cred: bn(15) }), NC, /^accesskey: key 2: codec: offset 26: a byte string of 15 bytes outside 16\.\.16: ERR/],
['credential_id of 15 bytes, access_type y', body({ cred: bn(15), type: t('y') }), NC, /key 2: codec: offset 26/],
['access_type as a byte string', body({ type: b('783235353139') }), NC, /^accesskey: key 4: codec: offset 61: a byte string where a text string was expected: ERR/],
['empty verification_metadata', body({ ver: map() }), NC, /^accesskey: key 6: empty verification_metadata; an absent one omits key 6: ERR/],
['empty verification_metadata, access_type y', body({ ver: map(), type: t('y') }), NC, /empty verification_metadata/],
['empty capsule_digest', body({ ver: map([0, b('')]) }), NC, /^accesskey: key 6: capsule_digest: codec: offset 107: a byte string of 0 bytes outside 32\.\.32: ERR/],
['capsule_digest of 31 bytes', body({ ver: map([0, bn(31)]) }), NC, /capsule_digest: codec: offset 108: a byte string of 31 bytes outside 32\.\.32/],
['verification_metadata key 1', body({ ver: map([1, bn(32)]) }), NC, /^accesskey: key 6: verification_metadata key 1 is not defined: ERR/],
['verification_metadata of two keys', body({ ver: map([0, bn(32)], [1, u(0)]) }), NC, /key 6: codec: offset 105: map of 2 entries, at most 1/],
['null verification_metadata', body({ ver: 'f6' }), NC, /key 6: codec: offset 104: a float or simple value \(initial byte 0xf6\) is outside the CBOR profile/],
['out of order, access_type y', body({ type: t('y'), non: arr(ext('b'), ext('a')) }), NC, /^accesskey: key 8: extension a: array is not in canonical order: ERR/],
['empty extension_id', body({ crit: arr(ext('')) }), NC, /^accesskey: key 7: extension: invalid extension_id "": ERR/],
['id in both arrays', body({ crit: arr(ext('a')), non: arr(ext('a')) }), NC, /^accesskey: extension a: both critical and noncritical: ERR/],
['access_type X25519', body({ type: t('X25519') }), 'ERR_ACCESS_INVALID', /^accesskey: access_type "X25519" is not supported by V1: ERR_ACCESS_INVALID$/],
['access_material of 31 bytes', body({ material: bn(31) }), 'ERR_ACCESS_INVALID', /^accesskey: x25519 access_material is 31 bytes, want 32: ERR_ACCESS_INVALID$/],
// access_type (key 4) before access_material (key 5).
['access_type y, material of 31 bytes', body({ type: t('y'), material: bn(31) }), 'ERR_ACCESS_INVALID', /access_type "y" is not supported/],
];
for (const [name, bytes, code, msg] of cases) expectCode(() => decodeAccessKeyBody(bytes), code, msg, name);
});
});
describe('access_material wiping (the reference clears it on every path)', () => {
afterEach(() => {
vi.restoreAllMocks();
});
// Every byte string the decoder hands out, to find the copies of the material.
function spyBstr(): Uint8Array[] {
const out: Uint8Array[] = [];
const orig = Decoder.prototype.bstr;
vi.spyOn(Decoder.prototype, 'bstr').mockImplementation(function (this: Decoder, min: number, max: number) {
const b = orig.call(this, min, max);
out.push(b);
return b;
});
return out;
}
const materials = (copies: Uint8Array[]): Uint8Array[] => copies.filter((c) => c.length === 32 && c.every((x) => x === 3 || x === 0));
it('wipes the material when decoding fails after key 5', () => {
for (const bad of [
body({ crit: arr() }),
body({ non: arr(ext('a', 1, b(''))) }),
body({ ver: map([0, b('')]) }),
body({ extra: [[9, u(0)]] }),
]) {
const copies = spyBstr();
expectCode(() => decodeAccessKeyBody(bad), NC);
const m = materials(copies);
expect(m).toHaveLength(1);
expect(m[0]!.every((x) => x === 0)).toBe(true);
vi.restoreAllMocks();
}
});
it('wipes the material when unmarshal rejects the decoded value', () => {
const copies = spyBstr();
vi.spyOn(Decoder.prototype, 'done').mockImplementation(() => {
throw new Error('rejected');
});
expect(() => decodeAccessKeyBody(body())).toThrow('rejected');
const m = materials(copies);
expect(m).toHaveLength(1);
expect(m[0]!.every((x) => x === 0)).toBe(true);
});
it('wipes its own copy after a later check fails, and after success', () => {
for (const input of [body({ cred: bn(15) }), body({ type: t('y') }), body()]) {
const copies = spyBstr();
try {
wipeAccessKey(decodeAccessKeyBody(input));
} catch {
// The failures are tested above; only the wiping matters here.
}
expect(materials(copies).every((c) => c.every((x) => x === 0))).toBe(true);
vi.restoreAllMocks();
}
});
});
describe('BODY_CBOR size limit', () => {
const key = (n: number): AccessKey => ({
credentialId: new Uint8Array(16).fill(1),
capsuleId: new Uint8Array(16).fill(2),
type: 'x25519',
material: new Uint8Array(32).fill(3),
verification: undefined,
critical: [],
noncritical: [{ id: 'a', version: 1, data: new Uint8Array(n).fill(4) }],
});
it('accepts a body of exactly MAX_DKK_BODY_LEN bytes and rejects one more', () => {
const n = 100_000 + MAX_DKK_BODY_LEN - marshalAccessKeyBody(key(100_000)).length;
const exact = marshalAccessKeyBody(key(n));
expect(exact.length).toBe(MAX_DKK_BODY_LEN);
expect(decodeAccessKeyBody(exact).noncritical[0]!.data!.length).toBe(n);
expect(decodeAccessKey(framed(exact)).noncritical[0]!.data!.length).toBe(n);
expectCode(() => marshalAccessKeyBody(key(n + 1)), 'ERR_INTEGRITY', /BODY_CBOR of 16777217 bytes exceeds 16777216/);
expectCode(() => decodeAccessKeyBody(new Uint8Array(MAX_DKK_BODY_LEN)), NC, /offset 0: an unsigned integer where a map was expected/);
expectCode(() => decodeAccessKeyBody(new Uint8Array(MAX_DKK_BODY_LEN + 1)), 'ERR_INTEGRITY', /BODY_CBOR of 16777217 bytes exceeds/);
expectCode(() => decodeAccessKey(framed(new Uint8Array(MAX_DKK_BODY_LEN))), NC, /offset 0: an unsigned integer where a map was expected/);
expectCode(() => decodeAccessKey(framed(new Uint8Array(MAX_DKK_BODY_LEN + 1))), 'ERR_INTEGRITY', /BODY_LEN 16777217 outside 1..16777216/);
});
});
describe('marshalAccessKeyBody', () => {
const key: AccessKey = {
credentialId: new Uint8Array(16).fill(1),
capsuleId: new Uint8Array(16).fill(2),
type: 'x25519',
material: new Uint8Array(32).fill(3),
verification: { capsuleDigest: new Uint8Array(32).fill(4) },
critical: [],
noncritical: [{ id: 'z', version: 1, data: h('00') }],
};
it('writes what the reader accepts', () => {
const k = decodeAccessKeyBody(marshalAccessKeyBody(key));
expect(k).toEqual(key);
expect(hx(marshalAccessKeyBody({ ...key, verification: undefined, noncritical: [] }))).toBe(hx(body()));
});
it('rejects invalid keys', () => {
expectCode(() => marshalAccessKeyBody({ ...key, type: 'y' }), 'ERR_ACCESS_INVALID');
expectCode(() => marshalAccessKeyBody({ ...key, material: new Uint8Array(31) }), 'ERR_ACCESS_INVALID');
expectCode(() => marshalAccessKeyBody({ ...key, capsuleId: new Uint8Array(15) }), NC);
expectCode(() => marshalAccessKeyBody({ ...key, verification: { capsuleDigest: new Uint8Array(0) } }), NC, /capsule_digest must be 32 bytes/);
expectCode(() => marshalAccessKeyBody({ ...key, critical: [{ id: 'z', version: 1, data: undefined }] }), NC, /both critical/);
expectCode(() => marshalAccessKeyBody({ ...key, critical: [{ id: 'y', version: -1, data: undefined }] }), NC);
const big = [{ id: 'a', version: 1, data: new Uint8Array(MAX_DKK_BODY_LEN) }];
expectCode(() => marshalAccessKeyBody({ ...key, noncritical: big }), 'ERR_INTEGRITY', /exceeds 16777216/);
});
// Spec §72, extension.CheckWrite in Go's MarshalBody: datekeys.capsule goes only in the noncritical array of a
// .dkk, with data, and datekeys.note never in a .dkk. The texts of Go on the branch v0.12.
it('writes the extensions of the specification only where §72 registers them', () => {
const capsule = { id: 'datekeys.capsule', version: 1, data: h('a0') };
const misplaced = (id: string, arr: string): string =>
`accesskey: extension: ${id} version 1 is not registered for ${arr} of .dkk: an encoder must not write it there (spec §72)`;
expect(() => marshalAccessKeyBody({ ...key, noncritical: [{ id: 'datekeys.note', version: 1, data: h('41') }] })).toThrow(
new Error(misplaced('datekeys.note', 'noncritical_extensions')),
);
expect(() => marshalAccessKeyBody({ ...key, critical: [capsule], noncritical: [] })).toThrow(new Error(misplaced('datekeys.capsule', 'critical_extensions')));
expectCode(
() => marshalAccessKeyBody({ ...key, noncritical: [{ ...capsule, data: undefined }] }),
'ERR_EXTENSION_DATA_INVALID',
/^accesskey: extension: datekeys\.capsule version 1: datekeys\.capsule without data: ERR_EXTENSION_DATA_INVALID$/,
);
// Its data is the locator's, which this library does not check: one byte is enough here.
expect(decodeAccessKeyBody(marshalAccessKeyBody({ ...key, noncritical: [capsule] })).noncritical).toEqual([capsule]);
// Another version is the application's own.
expect(() => marshalAccessKeyBody({ ...key, noncritical: [{ id: 'datekeys.note', version: 2, data: h('41') }] })).not.toThrow();
});
});

Powered by TurnKey Linux.