You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
325 lines
12 KiB
325 lines
12 KiB
// 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`);
|
|
}
|