Format 3, step 2: the codec of BODY, the security area and the head

body.ts, security.ts and head.ts port format3.go of the Go reference at
spec-v0.10:

- body.ts: the frame of BODY (spec 29.2), AREA_LEN, SECURITY_LEN and
  HEAD_LEN with their limits, and the zeros of the security area, all
  ERR_INTEGRITY.
- security.ts: the empty SECURITY_CBOR that writers write, and the
  verdicts X, F0, F1, S0, S1 and S2 of spec 29.7 with the Spanish lines
  of the official SDK. The evaluation never fails and carries no code.
- head.ts: HEAD_CBOR in the layers of spec 69.1: R1 and R8 in the CDDL,
  R8 by UTF-8 bytes and not by the UTF-16 order of JavaScript strings;
  then the comment, the declared author, the paths with pathrule.ts, the
  layout of the files, R7 and R9, all ERR_HEAD_INVALID; and the critical
  extensions of the new extension object "head". decodeWrittenHead
  leaves the extensions to the caller, for the self-check of the writer.

ERR_HEAD_INVALID becomes the 19th normative code, with its gloss.

Tests: the cases of format3_test.go and a few more, with the error
texts and the verdicts of the reference byte for byte, taken from it at
spec-v0.10 with a scratch program. Coverage 100 %.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
dev 1 week ago
parent 36096a9c79
commit d9a9cf5083

@ -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']);
});
});

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

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

@ -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];

@ -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';

@ -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']);
});
});

@ -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<number>();
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<number>();
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`);
}

@ -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('');
});
});

@ -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<number>();
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<number>();
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<T>(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',
};
}

@ -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.';
}
}

@ -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 },

Loading…
Cancel
Save

Powered by TurnKey Linux.