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