diff --git a/src/lib/dkc/body.test.ts b/src/lib/dkc/body.test.ts new file mode 100644 index 0000000..a49aa14 --- /dev/null +++ b/src/lib/dkc/body.test.ts @@ -0,0 +1,67 @@ +// Tests of body.ts: the frame of BODY at its limits and past them, and the +// zeros of the security area (TestBodyFrame of the Go reference), with the +// error texts of the reference at spec-v0.10. + +import { describe, expect, it } from 'vitest'; +import { AREA_LEN, bodyFrameBytes, checkArea, contentLength, parseBodyFrame } from './body.ts'; +import { errorCode } from './errors.ts'; +import { encodeSecurity } from './security.ts'; + +const frame = (areaLen: number, securityLen: number, headLen: number): Uint8Array => bodyFrameBytes({ areaLen, securityLen, headLen }); + +// The code of the error that fn throws, '' for one without a code, and +// its message. +function caught(fn: () => unknown): [string, string] { + try { + fn(); + } catch (err) { + return [errorCode(err), (err as Error).message]; + } + throw new Error('no error'); +} + +describe('parseBodyFrame', () => { + it.each([ + [512, 22, 53, 577, 0], + [512, 512, 1, 525, 0], + [65536, 1, 2 ** 24, 12 + 65536 + 2 ** 24, 0], + [1024, 1024, 100, 2 ** 40, 1099511626640], + ])('reads AREA_LEN %i, SECURITY_LEN %i and HEAD_LEN %i in a BODY of %i bytes', (areaLen, securityLen, headLen, l, c) => { + const f = parseBodyFrame(frame(areaLen, securityLen, headLen), l); + expect(f).toEqual({ areaLen, securityLen, headLen }); + expect(contentLength(f, l)).toBe(c); + }); + + it.each([ + ['L shorter than the frame', 512, 22, 53, 11, 'L = 11 is shorter than the frame of 12 bytes'], + ['AREA_LEN 0', 0, 22, 53, 1000, 'AREA_LEN 0 is not a multiple of 512 from 512 to 65536'], + ['AREA_LEN 511', 511, 22, 53, 1000, 'AREA_LEN 511 is not a multiple of 512 from 512 to 65536'], + ['AREA_LEN 513', 513, 22, 53, 1000, 'AREA_LEN 513 is not a multiple of 512 from 512 to 65536'], + ['AREA_LEN 66048', 66048, 22, 53, 100000, 'AREA_LEN 66048 is not a multiple of 512 from 512 to 65536'], + ['SECURITY_LEN 0', 512, 0, 53, 1000, 'SECURITY_LEN 0 is not from 1 to AREA_LEN = 512'], + ['SECURITY_LEN above AREA_LEN', 512, 513, 53, 1000, 'SECURITY_LEN 513 is not from 1 to AREA_LEN = 512'], + ['HEAD_LEN 0', 512, 22, 0, 1000, 'HEAD_LEN 0 is not from 1 to 16777216'], + ['HEAD_LEN 2^24 + 1', 512, 22, 2 ** 24 + 1, 2 ** 30, 'HEAD_LEN 16777217 is not from 1 to 16777216'], + ['the frame, the area and the head above L', 512, 22, 53, 576, 'the frame, the area of 512 bytes and the head of 53 bytes exceed L = 576'], + ])('rejects %s', (_, areaLen, securityLen, headLen, l, detail) => { + expect(caught(() => parseBodyFrame(frame(areaLen, securityLen, headLen), l))).toEqual([ + 'ERR_INTEGRITY', + `capsule: BODY: ${detail}: ERR_INTEGRITY`, + ]); + }); + + it('writes the frame big-endian, and refuses a frame that is not 12 bytes with no code, as the reference', () => { + expect(frame(0x01020304, 0x05060708, 0x090a0b0c)).toEqual(Uint8Array.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)); + expect(caught(() => parseBodyFrame(new Uint8Array(11), 100))).toEqual(['', 'capsule: BODY: frame of 11 bytes, want 12']); + }); +}); + +describe('checkArea', () => { + it('requires zeros after SECURITY_CBOR up to AREA_LEN', () => { + const area = new Uint8Array(AREA_LEN); + area.set(encodeSecurity()); + expect(() => checkArea(area, 22)).not.toThrow(); + area[511] = 1; + expect(caught(() => checkArea(area, 22))).toEqual(['ERR_INTEGRITY', 'capsule: BODY: byte 511 of the security area is not zero: ERR_INTEGRITY']); + }); +}); diff --git a/src/lib/dkc/body.ts b/src/lib/dkc/body.ts new file mode 100644 index 0000000..bdf2e9d --- /dev/null +++ b/src/lib/dkc/body.ts @@ -0,0 +1,86 @@ +// The frame of BODY in a format 3 capsule (spec §29.2), as BodyFrame, +// ParseBodyFrame and CheckArea of the Go package capsule at spec-v0.10 +// (format3.go). The plaintext of PAYLOAD_AGE is BODY followed by its +// padding, and BODY is +// +// AREA_LEN || SECURITY_LEN || HEAD_LEN || SECURITY_CBOR, then zeros up to +// AREA_LEN bytes || HEAD_CBOR || CONTENT +// +// with the three lengths as unsigned 32-bit big-endian integers. Every +// violation of the frame is ERR_INTEGRITY. Internal: index.ts does not +// re-export it. + +import { readUint32BE, writeUint32BE } from './bytes.ts'; +import { DateKeysError } from './errors.ts'; + +/** The size of the frame: AREA_LEN, SECURITY_LEN and HEAD_LEN. */ +export const BODY_FRAME_SIZE = 12; +/** The unit of AREA_LEN, and its maximum. */ +export const AREA_UNIT = 512; +export const MAX_AREA_LEN = 128 * AREA_UNIT; +/** + * The size of the security area that writers of this version write, always, + * whatever the capsule holds (spec §29.2, §62.1 rule 13). + */ +export const AREA_LEN = 512; +/** The maximum of HEAD_LEN, 16 MiB. */ +export const MAX_HEAD_LEN = 16 << 20; + +/** The frame of BODY (spec §29.2). */ +export interface BodyFrame { + readonly areaLen: number; + readonly securityLen: number; + readonly headLen: number; +} + +/** The 12 bytes of the frame. */ +export function bodyFrameBytes(f: BodyFrame): Uint8Array { + const b = new Uint8Array(BODY_FRAME_SIZE); + writeUint32BE(b, 0, f.areaLen); + writeUint32BE(b, 4, f.securityLen); + writeUint32BE(b, 8, f.headLen); + return b; +} + +/** + * C, the length of CONTENT in a BODY of length l whose frame is f. + * parseBodyFrame has checked that it is not negative. + */ +export function contentLength(f: BodyFrame, l: number): number { + return l - BODY_FRAME_SIZE - f.areaLen - f.headLen; +} + +const integrity = (detail: string): DateKeysError => new DateKeysError('ERR_INTEGRITY', `capsule: BODY: ${detail}`); + +/** + * Decodes the frame of BODY from its first 12 bytes and checks it against L, + * the length of BODY (spec §29.2, §63 step 17.2). The plaintext of + * PAYLOAD_AGE is at least 256 bytes, so the 12 bytes exist even when L is + * shorter than the frame. + */ +export function parseBodyFrame(b: Uint8Array, l: number): BodyFrame { + if (b.length !== BODY_FRAME_SIZE) throw new Error(`capsule: BODY: frame of ${b.length} bytes, want ${BODY_FRAME_SIZE}`); + if (l < BODY_FRAME_SIZE) throw integrity(`L = ${l} is shorter than the frame of ${BODY_FRAME_SIZE} bytes`); + const f: BodyFrame = { areaLen: readUint32BE(b, 0), securityLen: readUint32BE(b, 4), headLen: readUint32BE(b, 8) }; + if (f.areaLen < AREA_UNIT || f.areaLen > MAX_AREA_LEN || f.areaLen % AREA_UNIT !== 0) { + throw integrity(`AREA_LEN ${f.areaLen} is not a multiple of ${AREA_UNIT} from ${AREA_UNIT} to ${MAX_AREA_LEN}`); + } + if (f.securityLen < 1 || f.securityLen > f.areaLen) { + throw integrity(`SECURITY_LEN ${f.securityLen} is not from 1 to AREA_LEN = ${f.areaLen}`); + } + if (f.headLen < 1 || f.headLen > MAX_HEAD_LEN) throw integrity(`HEAD_LEN ${f.headLen} is not from 1 to ${MAX_HEAD_LEN}`); + if (BODY_FRAME_SIZE + f.areaLen + f.headLen > l) { + throw integrity(`the frame, the area of ${f.areaLen} bytes and the head of ${f.headLen} bytes exceed L = ${l}`); + } + return f; +} + +/** + * Checks that the bytes of the security area after SECURITY_CBOR, its first + * securityLen bytes, are zero (spec §29.2). + */ +export function checkArea(area: Uint8Array, securityLen: number): void { + for (let i = securityLen; i < area.length; i++) { + if (area[i] !== 0) throw integrity(`byte ${i} of the security area is not zero`); + } +} diff --git a/src/lib/dkc/bytes.test.ts b/src/lib/dkc/bytes.test.ts index 90ac42d..659a5be 100644 --- a/src/lib/dkc/bytes.test.ts +++ b/src/lib/dkc/bytes.test.ts @@ -125,7 +125,7 @@ describe('bytes', () => { describe('errors', () => { it('lists the eighteen codes of spec §69', () => { - expect(ERROR_CODES).toHaveLength(18); + expect(ERROR_CODES).toHaveLength(19); expect(isErrorCode('ERR_INTEGRITY')).toBe(true); expect(isErrorCode('ERR_NOPE')).toBe(false); expect(isErrorCode(1)).toBe(false); diff --git a/src/lib/dkc/errors.ts b/src/lib/dkc/errors.ts index 2309131..f43a076 100644 --- a/src/lib/dkc/errors.ts +++ b/src/lib/dkc/errors.ts @@ -25,6 +25,7 @@ export const ERROR_CODES = [ 'ERR_INTEGRITY', 'ERR_EXTENSION_CRITICAL_UNKNOWN', 'ERR_EXTENSION_DATA_INVALID', + 'ERR_HEAD_INVALID', ] as const; export type ErrorCode = (typeof ERROR_CODES)[number]; diff --git a/src/lib/dkc/extension.ts b/src/lib/dkc/extension.ts index 5aa89a3..437eb52 100644 --- a/src/lib/dkc/extension.ts +++ b/src/lib/dkc/extension.ts @@ -241,7 +241,7 @@ function sortedIds(exts: readonly Extension[]): { id: string; utf8: Uint8Array } // Registries /** An object that carries extension arrays, by its name in the spec (§54). */ -export type ExtensionObject = 'PUBLIC_HEADER' | 'CONTROL_CBOR' | '.dkk'; +export type ExtensionObject = 'PUBLIC_HEADER' | 'CONTROL_CBOR' | '.dkk' | 'head'; /** One of the two extension arrays of an object. */ export type ExtensionArray = 'critical_extensions' | 'noncritical_extensions'; diff --git a/src/lib/dkc/head.test.ts b/src/lib/dkc/head.test.ts new file mode 100644 index 0000000..28a4cbe --- /dev/null +++ b/src/lib/dkc/head.test.ts @@ -0,0 +1,163 @@ +// Tests of head.ts: the encoding of heads byte for byte, and their decoding +// in the layers of spec §69.1 on the cases of TestDecodeHeadLayers of the Go +// reference and a few more, with the error texts of the reference at +// spec-v0.10. The vectors of testdata/vectors/head_schema.json run in +// vectors.test.ts. + +import { describe, expect, it } from 'vitest'; +import { errorCode } from './errors.ts'; +import { ExtensionSet } from './extension.ts'; +import { checkHeadEnd, decodeHead, decodeWrittenHead, encodeHead, type Head, type HeadFile } from './head.ts'; +import { MAX_PAYLOAD_LENGTH } from './padding.ts'; +import { arr, b, bn, ext, map, t, u } from './testing/cborhex.ts'; +import { h, hx } from './testing/testdata.ts'; + +// The code of the error that fn throws, '' for one without a code, and +// its message. +function caught(fn: () => unknown): [string, string] { + try { + fn(); + } catch (err) { + return [errorCode(err), (err as Error).message]; + } + throw new Error('no error'); +} + +const zeros = (n: number): Uint8Array => new Uint8Array(n); + +// A head as the tests of the reference build it: keys 0 and 1 and a salt of +// zeros, then the pairs given, in ascending order of their keys. +const head = (...pairs: [number, string][]): Uint8Array => h(map([0, t('datekeys-head')], [1, u(1)], [2, bn(32)], ...pairs)); +const file = (path: string, size = 0, start = 0, end = 0): string => map([0, t(path)], [1, u(size)], [2, u(start)], [3, u(end)], [4, bn(32)]); +const withKeys = (...pairs: [number, string][]): string => map([0, t('a')], [1, u(0)], [2, u(0)], [3, u(0)], [4, bn(32)], ...pairs); + +const SAMPLE: Head = { + salt: Uint8Array.from({ length: 32 }, (_, i) => (i === 0 ? 1 : 0)), + comment: 'Para ti \u2764\ufe0f', + author: 'Ana López', + files: [ + { path: 'fotos/playa.jpg', size: 10, start: 0, end: 10, sha256: Uint8Array.from({ length: 32 }, (_, i) => (i === 0 ? 2 : 0)), mtime: 1759190400 }, + { path: 'nota.txt', size: 5, start: 10, end: 15, sha256: zeros(32) }, + ], + critical: [], + noncritical: [], +}; +const EMPTY: Head = { salt: zeros(32), comment: '', author: '', files: [], critical: [], noncritical: [] }; + +describe('encodeHead', () => { + it('writes the head as the reference does, byte for byte', () => { + expect(hx(encodeHead(SAMPLE))).toBe( + 'a6006d646174656b6579732d6865616401010258200100000000000000000000000000000000000000000000000000000000000000036e5061726120746920e29da4efb88f046a416e61204cc3b370657a0582a6006f666f746f732f706c6179612e6a7067010a0200030a0458200200000000000000000000000000000000000000000000000000000000000000051a68db1d80a500686e6f74612e7478740105020a030f0458200000000000000000000000000000000000000000000000000000000000000000', + ); + const nota: HeadFile = { path: 'nota.txt', size: 1000, start: 0, end: 1000, sha256: zeros(32), mtime: 1759190400 }; + expect(encodeHead(EMPTY).length).toBe(53); + expect(encodeHead({ ...EMPTY, comment: 'x' }).length).toBe(56); + expect(encodeHead({ ...EMPTY, files: [nota] }).length).toBe(117); + expect(encodeHead({ ...EMPTY, files: [{ path: 'a', size: 0, start: 0, end: 0, sha256: zeros(32) }] }).length).toBe(100); + const extensions = { ...EMPTY, critical: [{ id: 'x.example', version: 1, data: undefined }], noncritical: [{ id: 'y.example', version: 2, data: undefined }] }; + expect(hx(encodeHead(extensions))).toBe( + 'a5006d646174656b6579732d68656164010102582000000000000000000000000000000000000000000000000000000000000000000681a20069782e6578616d706c6501010781a20069792e6578616d706c650102', + ); + }); + + it('refuses more than 65535 files, an extension in both arrays, and a salt or a SHA-256 that is not 32 bytes', () => { + const many = Array.from({ length: 65536 }, (_, i): HeadFile => ({ path: `f${String(i).padStart(5, '0')}`, size: 0, start: 0, end: 0, sha256: zeros(32) })); + expect(caught(() => encodeHead({ ...EMPTY, files: many }))).toEqual(['', 'capsule: head: 65536 files, more than 65535']); + const x = { id: 'x.example', version: 1, data: undefined }; + expect(caught(() => encodeHead({ ...EMPTY, critical: [x], noncritical: [x] }))).toEqual([ + 'ERR_NON_CANONICAL_CBOR', + 'extension x.example: both critical and noncritical: ERR_NON_CANONICAL_CBOR', + ]); + expect(() => encodeHead({ ...EMPTY, salt: zeros(31) })).toThrow(RangeError); + expect(() => encodeHead({ ...EMPTY, files: [{ path: 'a', size: 0, start: 0, end: 0, sha256: zeros(31) }] })).toThrow(RangeError); + }); +}); + +describe('decodeHead', () => { + it('reads back what encodeHead writes', () => { + expect(decodeHead(encodeHead(SAMPLE))).toEqual(SAMPLE); + expect(decodeHead(encodeHead(EMPTY))).toEqual(EMPTY); + }); + + // An array of 65536 files, whose head is written by hand: arr() would + // take them as 65536 arguments. + const many = '9a00010000' + Array.from({ length: 65536 }, (_, i) => file('a' + String.fromCharCode(97 + (i % 26)) + 'x'.repeat(Math.floor(i / 26) % 3))).join(''); + + it.each([ + // Layer 2. + ['another type tag', h(map([0, t('datekeys-control')], [1, u(1)], [2, bn(32)])), 'ERR_NON_CANONICAL_CBOR', 'codec: type "datekeys-control", want "datekeys-head"'], + ['version 2', h(map([0, t('datekeys-head')], [1, u(2)], [2, bn(32)])), 'ERR_UNSUPPORTED_VERSION', 'codec: datekeys-head schema version 2, want 1'], + ['version 2 and ..', h(map([0, t('datekeys-head')], [1, u(2)], [2, bn(32)], [5, arr(file('..'))])), 'ERR_UNSUPPORTED_VERSION', 'codec: datekeys-head schema version 2, want 1'], + // Layer 3. + ['no salt', h(map([0, t('datekeys-head')], [1, u(1)])), 'ERR_NON_CANONICAL_CBOR', 'key 2 is missing'], + ['a salt of 31 bytes', h(map([0, t('datekeys-head')], [1, u(1)], [2, bn(31)])), 'ERR_NON_CANONICAL_CBOR', 'key 2: codec: offset 21: a byte string of 31 bytes outside 32..32'], + ['an empty comment', head([3, t('')]), 'ERR_NON_CANONICAL_CBOR', 'key 3: empty comment'], + ['a comment of 16385 bytes', head([3, t('a'.repeat(16385))]), 'ERR_NON_CANONICAL_CBOR', 'key 3: codec: offset 57: a text string of 16385 bytes outside 0..16384'], + ['a comment that is not UTF-8', head([3, '62c328']), 'ERR_NON_CANONICAL_CBOR', 'key 3: codec: offset 54: text string is not valid UTF-8'], + ['an author of 257 bytes', head([4, t('a'.repeat(257))]), 'ERR_NON_CANONICAL_CBOR', 'key 4: codec: offset 57: a text string of 257 bytes outside 0..256'], + ['an empty author', head([4, t('')]), 'ERR_NON_CANONICAL_CBOR', 'key 4: empty declared author'], + ['an empty array of files', head([5, arr()]), 'ERR_NON_CANONICAL_CBOR', 'key 5: empty array of files'], + ['65536 files', head([5, many]), 'ERR_NON_CANONICAL_CBOR', 'key 5: codec: offset 59: array of 65536 items, at most 65535'], + ['R1: an empty path', head([5, arr(file(''))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 1: key 0: R1: the path is empty'], + ['R1: a path of 1025 bytes', head([5, arr(file('a'.repeat(1025)))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 1: key 0: codec: offset 60: a text string of 1025 bytes outside 0..1024'], + ['R8: b before a', head([5, arr(file('b'), file('a'))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 2: R8: the path is not after the path of file 1 in byte order'], + ['R8: a repeated path', head([5, arr(file('a'), file('a'))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 2: R8: the path is not after the path of file 1 in byte order'], + ['R8 before R3: b/.. and a', head([5, arr(file('b/..'), file('a'))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 2: R8: the path is not after the path of file 1 in byte order'], + // UTF-16 puts U+10000 (D800 DC00) before U+FFFD; UTF-8 puts it after. + ['R8 in bytes: U+10000 before U+FFFD', head([5, arr(file('\u{10000}'), file('\ufffd'))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 2: R8: the path is not after the path of file 1 in byte order'], + ['a size above L_MAX', head([5, arr(file('a', MAX_PAYLOAD_LENGTH + 1))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 1: key 1: codec: offset 69: unsigned integer 8936830510563329 above 8936830510563328'], + ['an mtime after 9999', head([5, arr(withKeys([5, u(253402300800)]))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 1: key 5: codec: offset 110: unsigned integer 253402300800 above 253402300799'], + ['a file with key 6', head([5, arr(withKeys([6, u(0)]))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 1: key 6: key 6 is not defined'], + ['a file without its SHA-256', head([5, arr(map([0, t('a')], [1, u(0)], [2, u(0)], [3, u(0)]))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 1: key 4 is missing'], + ['a file with a SHA-256 of 31 bytes', head([5, arr(map([0, t('a')], [1, u(0)], [2, u(0)], [3, u(0)], [4, bn(31)]))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 1: key 4: codec: offset 68: a byte string of 31 bytes outside 32..32'], + ['a file that is not a map', head([5, arr(u(1))]), 'ERR_NON_CANONICAL_CBOR', 'key 5: file 1: codec: offset 55: an unsigned integer where a map was expected'], + ['an unknown key 8', head([8, u(1)]), 'ERR_NON_CANONICAL_CBOR', 'key 8 is not defined'], + ['a byte more', h(hx(head()) + '00'), 'ERR_NON_CANONICAL_CBOR', 'codec: offset 53: 1 trailing bytes'], + ['an extension in both arrays', head([6, arr(ext('x.example'))], [7, arr(ext('x.example'))]), 'ERR_NON_CANONICAL_CBOR', 'extension x.example: both critical and noncritical'], + ['an empty critical array', head([6, arr()]), 'ERR_NON_CANONICAL_CBOR', 'key 6: extension: empty array; an absent array omits its key'], + // Layer 4, in key order. + ['a comment with U+202E', head([3, t('a\u202eb')]), 'ERR_HEAD_INVALID', 'comment: text: bidirectional control U+202E'], + ['a comment with the tag U+E0041', head([3, t('a\u{e0041}')]), 'ERR_HEAD_INVALID', 'comment: text: invisible U+E0041'], + ['an author with LF', head([4, t('a\u000ab')]), 'ERR_HEAD_INVALID', 'declared author: text: control U+000A in the declared author'], + ['R3: ..', head([5, arr(file('..'))]), 'ERR_HEAD_INVALID', 'file 1: R3: segment 1: the segment is two dots'], + ['R2: /a', head([5, arr(file('/a'))]), 'ERR_HEAD_INVALID', 'file 1: R2: segment 1 is empty'], + ['R4b: a and VS16', head([5, arr(file('a\ufe0f'))]), 'ERR_HEAD_INVALID', 'file 1: R4b: segment 1: U+FE0F is not part of an emoji variation sequence'], + ['R6: CON.txt', head([5, arr(file('CON.txt'))]), 'ERR_HEAD_INVALID', 'file 1: R6: segment 1: CON is a reserved device name'], + ['R10: .datekeys-x', head([5, arr(file('.datekeys-x'))]), 'ERR_HEAD_INVALID', 'file 1: R10: the first segment starts with ".datekeys-"'], + ['layout: a first start that is not 0', head([5, arr(file('a', 1, 1, 2))]), 'ERR_HEAD_INVALID', 'file 1: start 1 is not 0, the end of the file before'], + ['layout: end minus start is not size', head([5, arr(file('a', 2, 0, 1))]), 'ERR_HEAD_INVALID', 'file 1: from start 0 to end 1 is not the size 2'], + ['layout: end before start', head([5, arr(file('a', 0, 5, 4))]), 'ERR_HEAD_INVALID', 'file 1: start 5 is not 0, the end of the file before'], + ['layout: a gap', head([5, arr(file('a', 1, 0, 1), file('b', 1, 2, 3))]), 'ERR_HEAD_INVALID', 'file 2: start 2 is not 1, the end of the file before'], + ['R7: A.txt and a.txt', head([5, arr(file('A.txt'), file('a.txt'))]), 'ERR_HEAD_INVALID', 'R7: path 2 collides with path 1 in segment 1'], + ['a comment and a path that break', head([3, t('a\u202e')], [5, arr(file('..'))]), 'ERR_HEAD_INVALID', 'comment: text: bidirectional control U+202E'], + ['an author and a path that break', head([4, t(' a')], [5, arr(file('..'))]), 'ERR_HEAD_INVALID', 'declared author: text: the declared author starts or ends with U+0020'], + ['a path that breaks, then an unknown critical extension', head([5, arr(file('..'))], [6, arr(ext('x.example'))]), 'ERR_HEAD_INVALID', 'file 1: R3: segment 1: the segment is two dots'], + ['an unknown critical extension', head([6, arr(ext('x.example'))]), 'ERR_EXTENSION_CRITICAL_UNKNOWN', 'extension x.example v1'], + ])('%s', (_, input, code, detail) => { + expect(caught(() => decodeHead(input))).toEqual([code, `capsule: head: ${detail}: ${code}`]); + }); + + it('refuses a head above 16 MiB before reading it', () => { + expect(caught(() => decodeHead(new Uint8Array(16777275)))).toEqual(['ERR_INTEGRITY', 'capsule: head: 16777275 bytes, more than 16777216: ERR_INTEGRITY']); + }); + + it('takes paths in the order of their UTF-8 bytes, the last mtime, a known critical extension and an unknown noncritical one', () => { + const paths = decodeHead(head([5, arr(file('\ufffd'), file('\u{10000}'))])).files.map((f) => f.path); + expect(paths).toEqual(['\ufffd', '\u{10000}']); + expect(decodeHead(head([5, arr(withKeys([5, u(253402300799)]))])).files[0]!.mtime).toBe(253402300799); + const critical = head([6, arr(ext('x.example'))]); + expect(decodeHead(critical, new ExtensionSet([['x.example', [1]]])).critical).toEqual([{ id: 'x.example', version: 1, data: undefined }]); + expect(decodeWrittenHead(critical).critical).toHaveLength(1); + expect(decodeHead(head([7, arr(ext('x.example'))])).noncritical).toHaveLength(1); + }); +}); + +describe('checkHeadEnd', () => { + it('requires the files to end at C, or C to be 0 without files', () => { + const sample = decodeHead(encodeHead(SAMPLE)); + expect(() => checkHeadEnd(sample, 15)).not.toThrow(); + expect(caught(() => checkHeadEnd(sample, 16))).toEqual(['ERR_INTEGRITY', 'capsule: BODY: the files end at byte 15 of a content of 16 bytes: ERR_INTEGRITY']); + expect(() => checkHeadEnd(EMPTY, 0)).not.toThrow(); + expect(caught(() => checkHeadEnd(EMPTY, 1))).toEqual(['ERR_INTEGRITY', 'capsule: BODY: the files end at byte 0 of a content of 1 bytes: ERR_INTEGRITY']); + }); +}); diff --git a/src/lib/dkc/head.ts b/src/lib/dkc/head.ts new file mode 100644 index 0000000..3818f8a --- /dev/null +++ b/src/lib/dkc/head.ts @@ -0,0 +1,324 @@ +// The head of a format 3 capsule (spec §29.4 to §29.6), as EncodeHead, +// DecodeHead and CheckHeadEnd of the Go package capsule at spec-v0.10 +// (format3.go). HEAD_CBOR is the map +// +// {0: "datekeys-head", 1: 1, 2: salt, ? 3: comment, ? 4: declared author, +// ? 5: [+ file], ? 6: critical extensions, ? 7: noncritical extensions} +// +// and each file is {0: path, 1: size, 2: start, 3: end, 4: SHA-256, +// ? 5: mtime}. It is decoded in the layers of spec §69.1: the limit of §57 +// (layer 1, ERR_INTEGRITY); the type tag and the version (layer 2); the CDDL +// with R1 and R8 (layer 3, ERR_NON_CANONICAL_CBOR); and then, in key order, +// the comment and the declared author (§29.6), the files with R2 to R6c, +// R10 and their layout, and R7 and R9 over the tree (§29.5), all +// ERR_HEAD_INVALID, and the critical extensions (layer 4). +// +// Internal: index.ts does not re-export it, since it brings the tables of +// pathrule.ts. + +import { compareBytes, utf8Length } from './bytes.ts'; +import { checkSchema, type Decoder, Encoder, unmarshal } from './cbor.ts'; +import { MAX_HEAD_LEN } from './body.ts'; +import { DateKeysError, withContext } from './errors.ts'; +import { canonicalExtensions, checkCritical, checkDisjoint, decodeArray, type Extension, type ExtensionRegistry } from './extension.ts'; +import { MAX_PAYLOAD_LENGTH } from './padding.ts'; +import { checkAuthor, checkComment, checkPath, checkTree, MAX_AUTHOR_LEN, MAX_COMMENT_LEN, MAX_PATH_LEN, PathRuleError } from './pathrule.ts'; +import { encodeExtensionArrays, fieldOf, presence, requireKeys } from './schema.ts'; + +export const HEAD_TYPE_TAG = 'datekeys-head'; +export const HEAD_VERSION = 1; +/** The size of the salt of the head (spec §29.4). */ +export const SALT_SIZE = 32; +/** The number of files of a head, at most, fixed with format 3 (spec §29.4). */ +export const MAX_FILES = 65535; +/** 9999-12-31T23:59:59Z in seconds since 1970-01-01 UTC, the last mtime. */ +export const MAX_MTIME = 253402300799; +const SHA256_SIZE = 32; + +/** An entry of the head: a file of CONTENT. */ +export interface HeadFile { + readonly path: string; + readonly size: number; + readonly start: number; + readonly end: number; + /** 32 bytes. */ + readonly sha256: Uint8Array; + /** + * The modification time of the file at its source, in seconds since + * 1970-01-01 UTC, when known. It is informative: it proves nothing. + */ + readonly mtime?: number; +} + +/** The head of a format 3 capsule (spec §29.4). */ +export interface Head { + /** 32 bytes. */ + readonly salt: Uint8Array; + /** + * The comment and the declared author, '' when absent. The declared author + * is text of the creator and proves nothing (spec §55.1). + */ + readonly comment: string; + readonly author: string; + /** The files, in strictly ascending byte order of their paths. */ + readonly files: readonly HeadFile[]; + /** Keys 6 and 7. */ + readonly critical: readonly Extension[]; + readonly noncritical: readonly Extension[]; +} + +// HEAD_CBOR as it is encoded. +interface HeadWire { + salt: Uint8Array; + comment: string; + author: string; + files: readonly HeadFile[]; + critical: Extension[] | undefined; + noncritical: Extension[] | undefined; +} + +const nonCanonical = (detail: string): DateKeysError => new DateKeysError('ERR_NON_CANONICAL_CBOR', detail); + +// Reads HEAD_CBOR with the rules of the third layer: the CDDL, R1 and R8. +function decodeWire(d: Decoder): HeadWire { + const w: HeadWire = { salt: new Uint8Array(SALT_SIZE), comment: '', author: '', files: [], critical: undefined, noncritical: undefined }; + const pairs = d.map(8); + const seen = new Set(); + for (let i = 0; i < pairs; i++) { + const k = d.key(); + const field = fieldOf(k); + switch (k) { + case 0: + field(() => d.text(utf8Length(HEAD_TYPE_TAG))); + break; + case 1: + field(() => d.uint(HEAD_VERSION)); + break; + case 2: + w.salt = field(() => d.bstr(SALT_SIZE, SALT_SIZE)); + break; + case 3: + w.comment = field(() => decodeText(d, MAX_COMMENT_LEN, 'comment')); + break; + case 4: + w.author = field(() => decodeText(d, MAX_AUTHOR_LEN, 'declared author')); + break; + case 5: + w.files = field(() => decodeFiles(d)); + break; + case 6: + w.critical = field(() => decodeArray(d)); + break; + case 7: + w.noncritical = field(() => decodeArray(d)); + break; + default: + throw nonCanonical(`key ${k} is not defined`); + } + seen.add(Number(k)); + } + requireKeys(seen, 3); + d.endMap(); + return w; +} + +// Reads a text of 1 to max bytes. +function decodeText(d: Decoder, max: number, what: string): string { + const s = d.text(max); + if (s === '') throw nonCanonical(`empty ${what}`); + return s; +} + +// Reads the array of files: 1 to MAX_FILES, each path of 1 to MAX_PATH_LEN +// bytes (R1), in strictly ascending order of their UTF-8 bytes (R8), which +// is not the order of JavaScript strings, by UTF-16 code units. +function decodeFiles(d: Decoder): HeadFile[] { + const n = d.array(MAX_FILES); + if (n === 0) throw nonCanonical('empty array of files'); + const files: HeadFile[] = []; + let previous: Uint8Array | undefined; + for (let i = 0; i < n; i++) { + const { file, path } = withContext(`file ${i + 1}`, () => decodeFile(d)); + if (previous !== undefined && compareBytes(path, previous) <= 0) { + throw nonCanonical(`file ${i + 1}: R8: the path is not after the path of file ${i} in byte order`); + } + previous = path; + files.push(file); + } + return files; +} + +// Reads a file: keys 0 to 4 required, and 5 optional. It returns the UTF-8 +// bytes of its path too, for R8. +function decodeFile(d: Decoder): { file: HeadFile; path: Uint8Array } { + const pairs = d.map(6); + const seen = new Set(); + let path: { text: string; utf8: Uint8Array } = { text: '', utf8: new Uint8Array(0) }; + let size = 0; + let start = 0; + let end = 0; + let sha256: Uint8Array = new Uint8Array(SHA256_SIZE); + let mtime: number | undefined; + for (let i = 0; i < pairs; i++) { + const k = d.key(); + fieldOf(k)(() => { + switch (k) { + case 0: + path = d.textUtf8(MAX_PATH_LEN); + if (path.text === '') throw nonCanonical('R1: the path is empty'); + break; + case 1: + size = d.uint(MAX_PAYLOAD_LENGTH); + break; + case 2: + start = d.uint(MAX_PAYLOAD_LENGTH); + break; + case 3: + end = d.uint(MAX_PAYLOAD_LENGTH); + break; + case 4: + sha256 = d.bstr(SHA256_SIZE, SHA256_SIZE); + break; + case 5: + mtime = d.uint(MAX_MTIME); + break; + default: + throw nonCanonical(`key ${k} is not defined`); + } + }); + seen.add(Number(k)); + } + requireKeys(seen, 5); + d.endMap(); + const file: HeadFile = { path: path.text, size, start, end, sha256, ...(mtime === undefined ? {} : { mtime }) }; + return { file, path: path.utf8 }; +} + +function encodeWire(e: Encoder, w: HeadWire): void { + const optional = (w.comment === '' ? 0 : 1) + (w.author === '' ? 0 : 1) + (w.files.length === 0 ? 0 : 1); + e.map(3 + optional + presence(w.critical) + presence(w.noncritical)); + e.uint(0); + e.text(HEAD_TYPE_TAG); + e.uint(1); + e.uint(HEAD_VERSION); + e.uint(2); + e.bstr(w.salt); + if (w.comment !== '') { + e.uint(3); + e.text(w.comment); + } + if (w.author !== '') { + e.uint(4); + e.text(w.author); + } + if (w.files.length > 0) { + e.uint(5); + e.array(w.files.length); + for (const f of w.files) encodeFile(e, f); + } + encodeExtensionArrays(e, 6, w.critical, w.noncritical); +} + +function encodeFile(e: Encoder, f: HeadFile): void { + e.map(f.mtime === undefined ? 5 : 6); + e.uint(0); + e.text(f.path); + e.uint(1); + e.uint(f.size); + e.uint(2); + e.uint(f.start); + e.uint(3); + e.uint(f.end); + e.uint(4); + e.bstr(f.sha256); + if (f.mtime !== undefined) { + e.uint(5); + e.uint(f.mtime); + } +} + +/** + * The Deterministic CBOR bytes of HEAD_CBOR for h. The files must already + * be in the byte order of their paths. The writer checks the result with + * decodeWrittenHead, the rules of the reader (spec §62.1 rule 17). + */ +export function encodeHead(h: Head): Uint8Array { + if (h.salt.length !== SALT_SIZE) throw new RangeError(`capsule: head: salt of ${h.salt.length} bytes, want ${SALT_SIZE}`); + h.files.forEach((f, i) => { + if (f.sha256.length !== SHA256_SIZE) throw new RangeError(`capsule: head: file ${i + 1}: SHA-256 of ${f.sha256.length} bytes, want ${SHA256_SIZE}`); + }); + const critical = canonicalExtensions(h.critical); + const noncritical = canonicalExtensions(h.noncritical); + checkDisjoint(h.critical, h.noncritical); + if (h.files.length > MAX_FILES) throw new Error(`capsule: head: ${h.files.length} files, more than ${MAX_FILES}`); + const e = new Encoder(); + encodeWire(e, { salt: h.salt, comment: h.comment, author: h.author, files: h.files, critical, noncritical }); + return e.out(); +} + +/** + * Validates and decodes HEAD_CBOR with the layers of spec §69.1 (spec + * §29.4, §63 step 17.4): the type tag and the version (layer 2), the CDDL + * with R1 and R8 (layer 3), and then, in key order, the comment and the + * declared author (§29.6), the files with R2 to R6c, R10 and their layout, + * R7 and R9 over the tree (§29.5), all ERR_HEAD_INVALID, and the critical + * extensions with reg (layer 4). + */ +export function decodeHead(b: Uint8Array, reg?: ExtensionRegistry): Head { + const h = decodeWrittenHead(b); + withContext('capsule: head', () => checkCritical(h.critical, reg, 'head')); + return h; +} + +/** + * decodeHead but for the critical extensions, whose knowledge depends on the + * reader: the self-check of the writer decodes with it, as it decodes the + * control with decodeControl (spec §62.1 rule 17). + */ +export function decodeWrittenHead(b: Uint8Array): Head { + if (b.length > MAX_HEAD_LEN) throw new DateKeysError('ERR_INTEGRITY', `capsule: head: ${b.length} bytes, more than ${MAX_HEAD_LEN}`); + const w = withContext('capsule: head', () => { + checkSchema(b, HEAD_TYPE_TAG, HEAD_VERSION); + const w = unmarshal(b, decodeWire, encodeWire); + checkDisjoint(w.critical ?? [], w.noncritical ?? []); + return w; + }); + const h: Head = { salt: w.salt, comment: w.comment, author: w.author, files: w.files, critical: w.critical ?? [], noncritical: w.noncritical ?? [] }; + checkHeadFields(h); + return h; +} + +// The rules of the fourth layer with a code of their own, ERR_HEAD_INVALID: +// keys 3, 4 and 5, in that order. +function checkHeadFields(h: Head): void { + const invalid = (what: string, detail: string): DateKeysError => new DateKeysError('ERR_HEAD_INVALID', `capsule: head: ${what}${detail}`); + const rule = (what: string, check: () => void): void => { + try { + check(); + } catch (err) { + /* v8 ignore next -- @preserve: the rules throw only PathRuleError */ + if (!(err instanceof PathRuleError)) throw err; + throw invalid(what, err.message); + } + }; + if (h.comment !== '') rule('comment: ', () => checkComment(h.comment)); + if (h.author !== '') rule('declared author: ', () => checkAuthor(h.author)); + let end = 0; + h.files.forEach((f, i) => { + const what = `file ${i + 1}: `; + rule(what, () => checkPath(f.path)); + if (f.start !== end) throw invalid(what, `start ${f.start} is not ${end}, the end of the file before`); + if (f.end < f.start || f.end - f.start !== f.size) throw invalid(what, `from start ${f.start} to end ${f.end} is not the size ${f.size}`); + end = f.end; + }); + rule('', () => checkTree(h.files.map((f) => f.path))); +} + +/** + * Checks that the files fill CONTENT, of c bytes: the last one ends at C, or + * C is 0 without files (spec §29.4, §63 step 17.5). + */ +export function checkHeadEnd(h: Head, c: number): void { + const end = h.files.length > 0 ? h.files[h.files.length - 1]!.end : 0; + if (end !== c) throw new DateKeysError('ERR_INTEGRITY', `capsule: BODY: the files end at byte ${end} of a content of ${c} bytes`); +} diff --git a/src/lib/dkc/security.test.ts b/src/lib/dkc/security.test.ts new file mode 100644 index 0000000..c5686d8 --- /dev/null +++ b/src/lib/dkc/security.test.ts @@ -0,0 +1,85 @@ +// Tests of security.ts: the empty area that writers write, the verdicts of +// the signature and of the seal on the cases of TestSecurityVerdicts of the +// Go reference and a few more, and the Spanish lines of spec §29.7, all as +// the reference gives them at spec-v0.10. + +import { describe, expect, it } from 'vitest'; +import { h, hx } from './testing/testdata.ts'; +import { arr, b, bn, map, t, u } from './testing/cborhex.ts'; +import { encodeSecurity, evaluateSecurity, type Verdict, verdictLines, verdictText } from './security.ts'; + +const EMPTY = 'a20071646174656b6579732d73656375726974790101'; +// An author-signature of alg 1 with a key of 32 zero bytes and a signature +// of 64: 105 bytes, as Go's EncodeAuthorSignature(1, ...) writes it. +const SIGNATURE = map([0, u(1)], [1, bn(32)], [2, bn(64)]); +const SEAL = map([0, u(1)], [1, b('010203')]); + +// SECURITY_CBOR with keys 2 and 3 when given, as hex. +function security(signature?: string, seal?: string): Uint8Array { + const pairs: [number, string][] = [ + [0, t('datekeys-security')], + [1, u(1)], + ]; + if (signature !== undefined) pairs.push([2, b(signature)]); + if (seal !== undefined) pairs.push([3, b(seal)]); + return h(map(...pairs)); +} + +describe('encodeSecurity', () => { + it('writes the empty area of 22 bytes, which yields F0 and S0', () => { + expect(hx(encodeSecurity())).toBe(EMPTY); + expect(SIGNATURE.length / 2).toBe(105); + expect(security(SIGNATURE).length).toBe(130); + }); +}); + +describe('evaluateSecurity', () => { + it.each([ + ['empty', security(), 'F0', 'S0'], + ['a signature of alg 1', security(SIGNATURE), 'F1', 'S0'], + ['a seal of seal_type 1', security(undefined, SEAL), 'F0', 'S1'], + ['both', security(SIGNATURE, SEAL), 'F1', 'S1'], + ['alg 0', security(map([0, u(0)], [1, b('')], [2, b('')])), 'F1', 'S0'], + ['a signature that is no map', security('01'), 'F1', 'S0'], + // The first row that holds decides: a seal with an unknown key and an + // unknown seal_type breaks its schema, S2, before its type is read. + ['a seal with an unknown key', security(undefined, 'a300070141000200'), 'F0', 'S2'], + ['seal_type 0', security(undefined, map([0, u(0)], [1, b('01')])), 'F0', 'S2'], + ['a seal that is not CBOR', security(SIGNATURE, 'ff'), 'F1', 'S2'], + ['a seal with seal_type 2^32', security(undefined, map([0, u(2 ** 32)], [1, b('')])), 'F0', 'S2'], + ['a seal with a trailing byte', security(undefined, SEAL + '00'), 'F0', 'S2'], + ['a seal without its token', security(undefined, map([0, u(1)])), 'F0', 'S2'], + ['a seal with key 2 in place of its token', security(undefined, map([0, u(1)], [2, b('')])), 'F0', 'S2'], + ['a seal with an empty token', security(undefined, map([0, u(7)], [1, b('')])), 'F0', 'S1'], + ['version 2', h(map([0, t('datekeys-security')], [1, u(2)])), 'X', 'X'], + ['another type tag', h(map([0, t('datekeys-head')], [1, u(1)])), 'X', 'X'], + ['an unknown key 4', h(map([0, t('datekeys-security')], [1, u(1)], [4, b('01')])), 'X', 'X'], + ['key 2 not a byte string', h(map([0, t('datekeys-security')], [1, u(1)], [2, u(1)])), 'X', 'X'], + ['an empty key 2', h(map([0, t('datekeys-security')], [1, u(1)], [2, b('')])), 'X', 'X'], + ['a key 2 of 65537 bytes', h(map([0, t('datekeys-security')], [1, u(1)], [2, bn(65537, 1)])), 'X', 'X'], + ['a byte more', h(EMPTY + '00'), 'X', 'X'], + ['not CBOR', new TextEncoder().encode('security'), 'X', 'X'], + ['no key 1', h(map([0, t('datekeys-security')])), 'X', 'X'], + ['an array', h(arr(t('datekeys-security'), u(1))), 'X', 'X'], + ])('%s: %s and %s', (_, input, signature, seal) => { + expect(evaluateSecurity(input)).toEqual({ signature, seal }); + }); +}); + +describe('verdictLines', () => { + const F1 = 'No se ha comprobado ninguna firma: trátala como no firmada.'; + const S1 = 'Lleva un sello de tiempo que esta versión no sabe comprobar: aquí no prueba nada.'; + const S2 = 'El sello de tiempo es ilegible: no prueba nada.'; + + it('shows X alone, and otherwise the signature and then the seal unless it is S0', () => { + const lines = (signature: Verdict, seal: Verdict): string[] => verdictLines({ signature, seal }); + expect(lines('X', 'X')).toEqual(['No se han podido comprobar la firma ni el sello: trátala como no firmada y sin fecha probada.']); + expect(lines('F0', 'S0')).toEqual(['Sin firma de autor.']); + expect(lines('F0', 'S1')).toEqual(['Sin firma de autor.', S1]); + expect(lines('F0', 'S2')).toEqual(['Sin firma de autor.', S2]); + expect(lines('F1', 'S0')).toEqual([F1]); + expect(lines('F1', 'S1')).toEqual([F1, S1]); + expect(lines('F1', 'S2')).toEqual([F1, S2]); + expect(verdictText('S0')).toBe(''); + }); +}); diff --git a/src/lib/dkc/security.ts b/src/lib/dkc/security.ts new file mode 100644 index 0000000..f34e4f3 --- /dev/null +++ b/src/lib/dkc/security.ts @@ -0,0 +1,204 @@ +// The security area of a format 3 capsule (spec §29.3, §29.7), as +// EncodeSecurity, EvaluateSecurity and the verdicts of the Go package +// capsule at spec-v0.10 (format3.go). SECURITY_CBOR is the map +// {0: "datekeys-security", 1: 1, ? 2: author-signature, ? 3: seal}, whose +// keys 2 and 3 hold CBOR encoded apart. This version implements no alg and +// no seal_type, and writes the area empty. Its evaluation never fails: the +// security area never decides the opening, and its verdicts carry no error +// code. Internal: index.ts does not re-export it. + +import { utf8Length } from './bytes.ts'; +import { type Decoder, Encoder, peek, unmarshal } from './cbor.ts'; +import { DateKeysError } from './errors.ts'; +import { fieldOf, requireKeys } from './schema.ts'; + +export const SECURITY_TYPE_TAG = 'datekeys-security'; +export const SECURITY_VERSION = 1; +// The bound of the byte strings of keys 2 and 3, and that of alg and +// seal_type. +const MAX_SECURITY_ITEM = 65536; +const MAX_ALG = 2 ** 32 - 1; + +/** + * The verdict on the signature or on the seal of the security area (spec + * §29.7), which never prevents opening: + * - X: the area fails its layer 2 or 3; it stands for both; + * - F0: no signature (no key 2); + * - F1: a signature that is not checked: it does not decode, breaks its + * schema or has an alg this reader does not implement; + * - S0: no seal (no key 3); nothing is shown about the date; + * - S1: a seal_type this reader does not implement; + * - S2: a seal that does not decode or breaks its schema. + */ +export type Verdict = 'X' | 'F0' | 'F1' | 'S0' | 'S1' | 'S2'; + +/** The verdicts of the security area of a format 3 capsule. */ +export interface Verdicts { + readonly signature: Verdict; + readonly seal: Verdict; +} + +/** The text of a verdict that the official SDK shows, in Spanish (spec §29.7), and '' for S0, which shows nothing. */ +export function verdictText(v: Verdict): string { + switch (v) { + case 'X': + return 'No se han podido comprobar la firma ni el sello: trátala como no firmada y sin fecha probada.'; + case 'F0': + return 'Sin firma de autor.'; + case 'F1': + return 'No se ha comprobado ninguna firma: trátala como no firmada.'; + case 'S1': + return 'Lleva un sello de tiempo que esta versión no sabe comprobar: aquí no prueba nada.'; + case 'S2': + return 'El sello de tiempo es ilegible: no prueba nada.'; + case 'S0': + return ''; + } +} + +/** The verdicts as the official SDK shows them, in order: X alone, or the signature and then the seal, when it shows something. */ +export function verdictLines(v: Verdicts): string[] { + if (v.signature === 'X') return [verdictText('X')]; + const seal = verdictText(v.seal); + return seal === '' ? [verdictText(v.signature)] : [verdictText(v.signature), seal]; +} + +// The outer map of SECURITY_CBOR: keys 2 and 3, undefined when absent. +interface SecurityWire { + signature: Uint8Array | undefined; + seal: Uint8Array | undefined; +} + +function decodeWire(d: Decoder): SecurityWire { + const w: SecurityWire = { signature: undefined, seal: undefined }; + const pairs = d.map(4); + const seen = new Set(); + for (let i = 0; i < pairs; i++) { + const k = d.key(); + const field = fieldOf(k); + switch (k) { + case 0: + field(() => d.text(utf8Length(SECURITY_TYPE_TAG))); + break; + case 1: + field(() => d.uint(SECURITY_VERSION)); + break; + case 2: + w.signature = field(() => d.bstr(1, MAX_SECURITY_ITEM)); + break; + case 3: + w.seal = field(() => d.bstr(1, MAX_SECURITY_ITEM)); + break; + default: + throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `key ${k} is not defined`); + } + seen.add(Number(k)); + } + requireKeys(seen, 2); + d.endMap(); + return w; +} + +function encodeWire(e: Encoder, w: SecurityWire): void { + e.map(2 + (w.signature === undefined ? 0 : 1) + (w.seal === undefined ? 0 : 1)); + e.uint(0); + e.text(SECURITY_TYPE_TAG); + e.uint(1); + e.uint(SECURITY_VERSION); + if (w.signature !== undefined) { + e.uint(2); + e.bstr(w.signature); + } + if (w.seal !== undefined) { + e.uint(3); + e.bstr(w.seal); + } +} + +/** SECURITY_CBOR as a writer of this version writes it: empty, {0: "datekeys-security", 1: 1}, 22 bytes (spec §29.3). */ +export function encodeSecurity(): Uint8Array { + const e = new Encoder(); + encodeWire(e, { signature: undefined, seal: undefined }); + return e.out(); +} + +// The content of key 3: {0: seal_type, 1: token}. +interface Seal { + sealType: number; + token: Uint8Array; +} + +function decodeSeal(d: Decoder): Seal { + const s: Seal = { sealType: 0, token: new Uint8Array(0) }; + const pairs = d.map(2); + const seen = new Set(); + for (let i = 0; i < pairs; i++) { + const k = d.key(); + fieldOf(k)(() => { + switch (k) { + case 0: + s.sealType = decodeAlg(d); + break; + case 1: + s.token = d.bstr(0, MAX_SECURITY_ITEM); + break; + default: + throw new DateKeysError('ERR_NON_CANONICAL_CBOR', `key ${k} is not defined`); + } + }); + seen.add(Number(k)); + } + requireKeys(seen, 2); + d.endMap(); + return s; +} + +function encodeSeal(e: Encoder, s: Seal): void { + e.map(2); + e.uint(0); + e.uint(s.sealType); + e.uint(1); + e.bstr(s.token); +} + +// Reads alg or seal_type, from 1 to 2^32 - 1. +function decodeAlg(d: Decoder): number { + const v = d.uint(MAX_ALG); + if (v === 0) throw new DateKeysError('ERR_NON_CANONICAL_CBOR', '0 is not defined'); + return v; +} + +// Runs a decoding whose failure is a verdict, not an error: its result, or +// undefined when it fails. Anything but a DateKeysError is a bug and +// propagates. +function attempt(decode: () => T): T | undefined { + try { + return decode(); + } catch (err) { + /* v8 ignore next -- @preserve: the decoders throw only DateKeysError */ + if (!(err instanceof DateKeysError)) throw err; + return undefined; + } +} + +/** + * Reads SECURITY_CBOR and returns its verdicts (spec §29.3, §29.7). It never + * fails: the security area never decides the opening. For the signature and + * for the seal apart, the first row of the table of §29.7 that holds + * decides: alg and seal_type are read only from content that decodes and + * meets its schema. + */ +export function evaluateSecurity(b: Uint8Array): Verdicts { + const w = attempt(() => { + const h = peek(b); + return h.typeTag === SECURITY_TYPE_TAG && h.version === SECURITY_VERSION ? unmarshal(b, decodeWire, encodeWire) : undefined; + }); + if (w === undefined) return { signature: 'X', seal: 'X' }; + const { signature, seal } = w; + return { + // A content that does not decode and an alg this version does not + // implement give the same verdict, and this version implements none. + signature: signature === undefined ? 'F0' : 'F1', + seal: seal === undefined ? 'S0' : attempt(() => unmarshal(seal, decodeSeal, encodeSeal)) === undefined ? 'S2' : 'S1', + }; +} diff --git a/src/lib/inspector/format.ts b/src/lib/inspector/format.ts index ee4cadc..9adb0d7 100644 --- a/src/lib/inspector/format.ts +++ b/src/lib/inspector/format.ts @@ -154,6 +154,8 @@ export function errorGloss(code: ErrorCode): string { return 'La cápsula o la clave .dkk exige una extensión crítica que este lector no conoce, así que se rechaza.'; case 'ERR_EXTENSION_DATA_INVALID': return 'Una extensión conocida trae datos que no siguen su esquema registrado.'; + case 'ERR_HEAD_INVALID': + return 'El head de una cápsula de formato 3 está bien codificado, pero uno de sus campos incumple sus reglas: una ruta, el comentario, el autor declarado o la maquetación de los ficheros.'; } } diff --git a/vitest.config.ts b/vitest.config.ts index 4e8db77..7074d51 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -50,8 +50,12 @@ export default defineConfig({ 'src/lib/inspector/create-input.ts': { 100: true }, 'src/lib/inspector/creator.ts': { 100: true }, // Format 3 (plan of format 3 in datekeys-ts): the rules of the paths - // and texts of the head, and the ZIP in which the page delivers files. + // and texts of the head, the codec of BODY, of the security area and of + // the head, and the ZIP in which the page delivers files. 'src/lib/dkc/pathrule.ts': { 100: true }, + 'src/lib/dkc/body.ts': { 100: true }, + 'src/lib/dkc/security.ts': { 100: true }, + 'src/lib/dkc/head.ts': { 100: true }, 'src/lib/inspector/crc32.ts': { 100: true }, 'src/lib/inspector/zip.ts': { 100: true }, 'src/lib/dkc/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 },