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.
217 lines
8.7 KiB
217 lines
8.7 KiB
// The lengths of a .dkc of format 2 or 3 that follow from its inputs,
|
|
// without writing it (spec §62.1, informative note, §29.1 and §29.2): what a
|
|
// page shows before encrypting, since the size is visible to anyone who
|
|
// holds the file (§55.2), and what the writer checks its seal against. No
|
|
// noble, no age-encryption and no Unicode tables, so that a page can load it
|
|
// with its first load.
|
|
|
|
import { ACCESS_SLOTS } from './age.ts';
|
|
import { AREA_LEN } from './body.ts';
|
|
import { compareBytes, utf8Bytes, utf8Length } from './bytes.ts';
|
|
import { encodeControl } from './control.ts';
|
|
import type { Extension } from './extension.ts';
|
|
import { DKC_PRELUDE_SIZE, FORMAT_2 } from './framing.ts';
|
|
import { CAPSULE_ID_SIZE, encodeHeader, type Policy, TIME_AND_KEY } from './header.ts';
|
|
import { paddedLength, type Padding, payloadAgeLength, REFORZADO } from './padding.ts';
|
|
|
|
// The chunks of an age STREAM of n bytes: at least one, even when empty.
|
|
const chunks = (n: number): number => Math.max(1, Math.ceil(n / 65536));
|
|
|
|
/**
|
|
* SEALED_CONTROL_LEN from the lengths of spec §62.1 (informative note), with
|
|
* C the length of CONTROL_CBOR: INNER_ACCESS_AGE holds 16 X25519 stanzas of
|
|
* 98 bytes, and the tlock stanza of OUTER_TIME_AGE is 249 bytes plus the
|
|
* digits of the round. The writer checks that the real seal measures exactly
|
|
* this.
|
|
*/
|
|
export function sealedControlLength(policy: Policy, controlLength: number, round: number): number {
|
|
const n = policy === TIME_AND_KEY ? 86 + 98 * ACCESS_SLOTS + controlLength + 16 * chunks(controlLength) : controlLength;
|
|
return 335 + String(round).length + n + 16 * chunks(n);
|
|
}
|
|
|
|
/** What the size of a .dkc depends on: every input of encrypt but the content and the credentials. */
|
|
export interface CapsuleLengthInput {
|
|
/** The profile_id of the DateKey, datekeys:quicknet:v1 for Quicknet. */
|
|
readonly profileId: string;
|
|
readonly round: number;
|
|
readonly policy: Policy;
|
|
/** L, the length of the content, or of BODY in format 3 (bodyLength). */
|
|
readonly length: number;
|
|
/** The padding rule; reforzado when omitted, as in encrypt. */
|
|
readonly padding?: Padding;
|
|
readonly critical?: readonly Extension[];
|
|
readonly noncritical?: readonly Extension[];
|
|
readonly controlCritical?: readonly Extension[];
|
|
readonly controlNoncritical?: readonly Extension[];
|
|
}
|
|
|
|
/**
|
|
* The exact size of the .dkc that encrypt, or encryptFiles with L from
|
|
* bodyLength, writes for these inputs: PRELUDE,
|
|
* PUBLIC_HEADER, SEALED_CONTROL_LEN and the length of PAYLOAD_AGE for P =
|
|
* rule(L). The random values and the credentials do not change it: a
|
|
* time_and_key capsule always holds 16 stanzas (§39). It throws, as the
|
|
* encoders do, for an invalid DateKey, policy, extension or L.
|
|
*/
|
|
export function capsuleLength(input: CapsuleLengthInput): number {
|
|
const padding = input.padding ?? REFORZADO;
|
|
const header = encodeHeader({
|
|
capsuleId: new Uint8Array(CAPSULE_ID_SIZE),
|
|
dateKey: { profileId: input.profileId, round: input.round },
|
|
policy: input.policy,
|
|
critical: input.critical ?? [],
|
|
noncritical: input.noncritical ?? [],
|
|
});
|
|
const control = encodeControl(
|
|
{
|
|
headerBinding: new Uint8Array(32),
|
|
payloadIdentity: new Uint8Array(32),
|
|
payloadLength: input.length,
|
|
padding,
|
|
critical: input.controlCritical ?? [],
|
|
noncritical: input.controlNoncritical ?? [],
|
|
},
|
|
FORMAT_2,
|
|
);
|
|
return (
|
|
DKC_PRELUDE_SIZE +
|
|
header.length +
|
|
sealedControlLength(input.policy, control.length, input.round) +
|
|
payloadAgeLength(paddedLength(input.length, padding))
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Format 3
|
|
|
|
// 9999-12-31T23:59:59Z in seconds, the last mtime of a head (MAX_MTIME of
|
|
// head.ts, which this module does not import: its rules of the paths bring
|
|
// the Unicode tables).
|
|
const LAST_MTIME = 253402300799;
|
|
// The frame of BODY and the security area that writers write, of 32 KiB (body.ts, spec v0.11 §29.2).
|
|
const FRAME_AND_AREA = 12 + AREA_LEN;
|
|
|
|
/**
|
|
* The comment as the writer of format 3 stores it: CR LF, and any lone CR,
|
|
* turned into LF (spec §29.6).
|
|
*/
|
|
export function headComment(comment: string): string {
|
|
return comment.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
|
}
|
|
|
|
/**
|
|
* The mtime a head stores for a file modified at `ms`, milliseconds since
|
|
* 1970-01-01 UTC as File.lastModified gives it: its seconds when they fall
|
|
* from 1970-01-01 to 9999-12-31T23:59:59Z, and none otherwise, never clipped
|
|
* (spec §62.1 rule 16).
|
|
*/
|
|
export function mtimeSeconds(ms: number | undefined): number | undefined {
|
|
if (ms === undefined) return undefined;
|
|
const s = Math.floor(ms / 1000);
|
|
return s >= 0 && s <= LAST_MTIME ? s : undefined;
|
|
}
|
|
|
|
/** What the length of the head of a format 3 capsule depends on. */
|
|
export interface HeadShape {
|
|
/** Each file: its path, its size and its mtime in milliseconds, as FileSource. */
|
|
readonly files: readonly { readonly path: string; readonly size: number; readonly mtime?: number }[];
|
|
/** As EncryptOptions gives them; the comment is taken as headComment stores it. */
|
|
readonly comment?: string;
|
|
readonly author?: string;
|
|
readonly critical?: readonly Extension[];
|
|
readonly noncritical?: readonly Extension[];
|
|
}
|
|
|
|
// The bytes of the head of a CBOR item whose argument is n.
|
|
const cborHead = (n: number): number => (n < 24 ? 1 : n < 0x100 ? 2 : n < 0x10000 ? 3 : n < 0x100000000 ? 5 : 9);
|
|
// A text or a byte string of n bytes, head included.
|
|
const cborString = (n: number): number => cborHead(n) + n;
|
|
|
|
// An extension map: {0: extension_id, 1: extension_version, ? 2: data}.
|
|
const extensionLength = (e: Extension): number =>
|
|
cborHead(e.data === undefined ? 2 : 3) +
|
|
1 +
|
|
cborString(utf8Length(e.id)) +
|
|
1 +
|
|
cborHead(e.version) +
|
|
(e.data === undefined ? 0 : 1 + cborString(e.data.length));
|
|
|
|
/**
|
|
* The files of a head measured once, so that a page can give the length of
|
|
* BODY for other texts without sorting the files again: see headLengthOf.
|
|
*/
|
|
export interface MeasuredFiles {
|
|
/** The number of files. */
|
|
readonly count: number;
|
|
/** The bytes that key 5, the array of the files, takes in HEAD_CBOR; 0 without files. */
|
|
readonly head: number;
|
|
/** C, the bytes of the files together. */
|
|
readonly content: number;
|
|
}
|
|
|
|
/**
|
|
* The files of a head measured as the writer of format 3 lays them out, in
|
|
* the byte order of their paths, which decides their start and end.
|
|
*/
|
|
export function measureFiles(files: HeadShape['files']): MeasuredFiles {
|
|
const sorted = files.map((f) => ({ ...f, key: utf8Bytes(f.path) })).sort((a, b) => compareBytes(a.key, b.key));
|
|
if (sorted.length === 0) return { count: 0, head: 0, content: 0 };
|
|
let n = 1 + cborHead(sorted.length);
|
|
let end = 0;
|
|
for (const f of sorted) {
|
|
const mtime = mtimeSeconds(f.mtime);
|
|
n += cborHead(mtime === undefined ? 5 : 6) + 1 + cborString(f.key.length) + 1 + cborHead(f.size) + 1 + cborHead(end);
|
|
end += f.size;
|
|
n += 1 + cborHead(end) + 1 + cborString(32) + (mtime === undefined ? 0 : 1 + cborHead(mtime));
|
|
}
|
|
return { count: sorted.length, head: n, content: end };
|
|
}
|
|
|
|
/**
|
|
* The length of the HEAD_CBOR that the writer of format 3 writes for these
|
|
* files and texts, from the sizes of its CBOR items (spec §29.4), without
|
|
* encoding it.
|
|
*/
|
|
export function headLength(h: HeadShape): number {
|
|
return headLengthOf(measureFiles(h.files), h);
|
|
}
|
|
|
|
/** headLength for files already measured, and the texts and extensions of `h`. */
|
|
export function headLengthOf(files: MeasuredFiles, h: Omit<HeadShape, 'files'>): number {
|
|
const comment = headComment(h.comment ?? '');
|
|
const author = h.author ?? '';
|
|
let pairs = 3;
|
|
// Keys 0, 1 and 2: "datekeys-head", 1 and the salt of 32 bytes.
|
|
let n = 1 + cborString(13) + 1 + 1 + 1 + cborString(32);
|
|
for (const text of [comment, author]) {
|
|
if (text === '') continue;
|
|
pairs++;
|
|
n += 1 + cborString(utf8Length(text));
|
|
}
|
|
if (files.count > 0) {
|
|
pairs++;
|
|
n += files.head;
|
|
}
|
|
for (const list of [h.critical ?? [], h.noncritical ?? []]) {
|
|
if (list.length === 0) continue;
|
|
pairs++;
|
|
n += 1 + cborHead(list.length) + list.reduce((sum, e) => sum + extensionLength(e), 0);
|
|
}
|
|
return cborHead(pairs) + n;
|
|
}
|
|
|
|
/**
|
|
* L of a format 3 capsule: the length of its BODY, the frame, the security
|
|
* area of 32 KiB, the head and the files (spec §29.2). With it,
|
|
* capsuleLength gives the size of the .dkc, since the control of format 3
|
|
* has the length of the control of format 2.
|
|
*/
|
|
export function bodyLength(h: HeadShape): number {
|
|
return bodyLengthOf(measureFiles(h.files), h);
|
|
}
|
|
|
|
/** bodyLength for files already measured, and the texts and extensions of `h`. */
|
|
export function bodyLengthOf(files: MeasuredFiles, h: Omit<HeadShape, 'files'>): number {
|
|
return FRAME_AND_AREA + headLengthOf(files, h) + files.content;
|
|
}
|