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.
250 lines
11 KiB
250 lines
11 KiB
// The form of the create page (plan of phase 3, section 9, as decided in
|
|
// step 6, and the design of format 3), without DOM, clock or writer: what
|
|
// the person asked for, checked field by field in the order of the form,
|
|
// and everything the page shows before encrypting. Its imports carry no
|
|
// noble, no age-encryption and no Unicode tables, so the page loads it with
|
|
// its first load and checks the recipients as they are typed; the rules of
|
|
// the paths and the texts come on demand, with create-check.ts.
|
|
|
|
import { ACCESS_SLOTS } from '../dkc/age.ts';
|
|
import { compactDateKey, type DateKey, type Instant, isLongHorizon, resolveDateKey, roundTime } from '../dkc/datekey.ts';
|
|
import { type Policy, TIME_AND_KEY } from '../dkc/header.ts';
|
|
import { MAX_HEAD_LEN } from '../dkc/body.ts';
|
|
import { bodyLengthOf, capsuleLength, headComment, headLengthOf, type MeasuredFiles, measureFiles } from '../dkc/lengths.ts';
|
|
import { MAX_PAYLOAD_LENGTH, paddedLength, REFORZADO } from '../dkc/padding.ts';
|
|
import { quicknet } from '../dkc/profile.ts';
|
|
import { parseRecipientList, type RecipientLineProblem, RecipientListError } from '../dkc/recipient.ts';
|
|
import { MAX_CAPSULE_FILES } from './create-files.ts';
|
|
import { formatByteCount, formatInteger, safeFileName } from './format.ts';
|
|
import { localToEpochMs } from './localtime.ts';
|
|
|
|
/** An effective unlock time closer than this gets a notice: the capsule opens almost at once. */
|
|
export const SOON_MS = 3600_000;
|
|
|
|
/** The inputs of the form. */
|
|
export type CreateField = 'files' | 'comment' | 'author' | 'date' | 'time' | 'zone' | 'recipients' | 'portable';
|
|
|
|
/** A file of the capsule as encryptFiles takes it: its path, its size and its mtime in milliseconds (File.lastModified). */
|
|
export interface PlannedFile {
|
|
readonly path: string;
|
|
readonly size: number;
|
|
readonly mtime?: number;
|
|
}
|
|
|
|
/** The files of the capsule, measured once (chooseFiles): the page plans again at every change of the form. */
|
|
export interface ChosenFiles {
|
|
readonly list: readonly PlannedFile[];
|
|
readonly measured: MeasuredFiles;
|
|
}
|
|
|
|
/** The files of the capsule, in the order of the list of the page, measured. */
|
|
export function chooseFiles(list: readonly PlannedFile[]): ChosenFiles {
|
|
return { list, measured: measureFiles(list) };
|
|
}
|
|
|
|
/** What the person entered. */
|
|
export interface CreateInput {
|
|
/** The files that the capsule holds, none or many. */
|
|
readonly files: ChosenFiles;
|
|
/** The comment and the declared author of the head, '' when none; the page checks their characters (create-check.ts). */
|
|
readonly comment: string;
|
|
readonly author: string;
|
|
/** The values of <input type="date"> and <input type="time">. */
|
|
readonly date: string;
|
|
readonly time: string;
|
|
/** The zone of the date and time, an IANA name or UTC. */
|
|
readonly timeZone: string;
|
|
/** TIME_ONLY or TIME_AND_KEY. */
|
|
readonly policy: Policy;
|
|
/** For time_and_key: the recipients, age1… one per line, and whether to generate a portable .dkk. */
|
|
readonly recipients: string;
|
|
readonly portable: boolean;
|
|
}
|
|
|
|
/** Everything the page shows before encrypting, and what encrypt takes. */
|
|
export interface CapsulePlan {
|
|
/** The requested instant, the one the person picked. */
|
|
readonly requested: Instant;
|
|
readonly requestedMs: number;
|
|
/** The zone repeats the local time, and the later of its two instants was taken. */
|
|
readonly ambiguous: boolean;
|
|
readonly dateKey: DateKey;
|
|
/** The DateKey as the dk1_ string (§17). */
|
|
readonly dk1: string;
|
|
/** The effective unlock time: the time of the round, at or after the requested instant (§15). */
|
|
readonly effective: Instant;
|
|
readonly effectiveMs: number;
|
|
readonly policy: Policy;
|
|
/** The raw X25519 public keys of the recipients. */
|
|
readonly recipients: readonly Uint8Array[];
|
|
readonly portable: boolean;
|
|
/** The files of the capsule, in the order of the list, and the texts of its head. */
|
|
readonly files: readonly PlannedFile[];
|
|
readonly comment: string;
|
|
readonly author: string;
|
|
/**
|
|
* L, the length of BODY: the frame, the security area, the head and the
|
|
* files (§29.2), and P = reforzado(L), the rule the page always uses.
|
|
*/
|
|
readonly length: number;
|
|
readonly paddedLength: number;
|
|
/** The size of the .dkc. */
|
|
readonly size: number;
|
|
/** The effective time is more than 365 days away: the warnings of §53 and §50. */
|
|
readonly longHorizon: boolean;
|
|
/** The effective time is less than SOON_MS away. */
|
|
readonly soon: boolean;
|
|
/** The names offered for the files. */
|
|
readonly names: { readonly dkc: string; readonly dkk: string };
|
|
}
|
|
|
|
export type Planned =
|
|
| { readonly ok: true; readonly plan: CapsulePlan }
|
|
| { readonly ok: false; readonly field: CreateField; readonly problem: string };
|
|
|
|
const LINE_PROBLEMS: Readonly<Record<RecipientLineProblem, string>> = {
|
|
malformed: 'no es un destinatario de age (age1…).',
|
|
identity: 'es una identidad secreta (AGE-SECRET-KEY-1…), no un destinatario: bórrala de aquí y no la compartas.',
|
|
'not canonical': 'no es una clave X25519 canónica: nadie podría abrir su parte de la cápsula.',
|
|
'low order': 'es una clave X25519 de orden bajo, que no protege nada.',
|
|
duplicate: 'repite un destinatario de una línea anterior.',
|
|
};
|
|
|
|
/**
|
|
* The recipients of the text of the form, or the problem of its first bad
|
|
* line, by number and never by content (recipient.ts).
|
|
*/
|
|
export function readRecipients(text: string): { ok: true; keys: Uint8Array[] } | { ok: false; problem: string } {
|
|
try {
|
|
return { ok: true, keys: parseRecipientList(text) };
|
|
} catch (err) {
|
|
const e = err as RecipientListError;
|
|
return { ok: false, problem: `La línea ${e.line} ${LINE_PROBLEMS[e.problem]}` };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The capsule that the form asks for at `nowMs`, or the first problem, in
|
|
* the order of the form. The requested instant must be after `nowMs` (§62.1,
|
|
* rule 2), and the page checks it again with the clock when encrypting.
|
|
*/
|
|
export function planCapsule(input: CreateInput, nowMs: number): Planned {
|
|
const fail = (field: CreateField, problem: string): Planned => ({ ok: false, field, problem });
|
|
const { list, measured } = input.files;
|
|
const texts = { comment: input.comment, author: input.author };
|
|
if (measured.count === 0 && input.comment === '') return fail('files', 'Elige al menos un fichero, o escribe un mensaje en el comentario (§62.1).');
|
|
if (measured.count > MAX_CAPSULE_FILES) {
|
|
return fail('files', `Hay ${formatInteger(measured.count)} ficheros marcados, y una cápsula guarda como mucho ${formatInteger(MAX_CAPSULE_FILES)}.`);
|
|
}
|
|
const head = headLengthOf(measured, texts);
|
|
if (head > MAX_HEAD_LEN) {
|
|
return fail(
|
|
'files',
|
|
`Las rutas y los datos de los ficheros ocupan ${formatByteCount(head)} en la cabecera cifrada, más de ${formatByteCount(MAX_HEAD_LEN)}: marca menos ficheros o acorta sus rutas (§29.4).`,
|
|
);
|
|
}
|
|
// BODY holds the files with its frame, security area and head (§29.2).
|
|
const length = bodyLengthOf(measured, texts);
|
|
if (length > MAX_PAYLOAD_LENGTH) {
|
|
return fail('files', `Los ficheros ocupan más de ${formatByteCount(MAX_PAYLOAD_LENGTH - (length - measured.content))}, el máximo de una cápsula (§29.1).`);
|
|
}
|
|
if (input.date === '') return fail('date', 'Elige el día de apertura.');
|
|
if (input.time === '') return fail('time', 'Elige la hora de apertura.');
|
|
const local = localToEpochMs(input.date, input.time, input.timeZone);
|
|
if (!local.ok) {
|
|
if (local.reason === 'zone') return fail('zone', 'Este navegador no conoce esa zona horaria.');
|
|
if (local.reason === 'nonexistent') {
|
|
return fail('time', `Esa hora no existe en ${input.timeZone}: ese día los relojes se adelantan y se la saltan. Elige otra.`);
|
|
}
|
|
return fail('date', 'La fecha o la hora no son válidas.');
|
|
}
|
|
const requestedMs = local.epochMs;
|
|
if (requestedMs <= nowMs) return fail('date', 'Esa fecha ya ha pasado según el reloj de este dispositivo.');
|
|
const seconds = Math.floor(requestedMs / 1000);
|
|
const requested: Instant = { seconds, nanos: (requestedMs - seconds * 1000) * 1e6 };
|
|
const p = quicknet();
|
|
// After the clock of the device and before Quicknet began: that clock is
|
|
// behind.
|
|
if (seconds < p.genesisTime) {
|
|
return fail(
|
|
'date',
|
|
'Esa fecha es anterior al comienzo de Quicknet, la red de drand que usa DateKeys, el 23 de agosto de 2023 a las 15:09:27 UTC: ninguna ronda la abre. Si para ti es una fecha futura, el reloj de este dispositivo va atrasado.',
|
|
);
|
|
}
|
|
let dateKey: DateKey;
|
|
try {
|
|
dateKey = resolveDateKey(p, requested);
|
|
} catch {
|
|
return fail('date', 'La fecha más lejana posible es el 31 de diciembre de 9999 a las 23:59:57 UTC, la última ronda de Quicknet.');
|
|
}
|
|
|
|
const policy = input.policy;
|
|
let recipients: Uint8Array[] = [];
|
|
let portable = false;
|
|
if (policy === TIME_AND_KEY) {
|
|
const read = readRecipients(input.recipients);
|
|
if (!read.ok) return fail('recipients', read.problem);
|
|
recipients = read.keys;
|
|
portable = input.portable;
|
|
if (recipients.length === 0 && !portable) {
|
|
return fail('portable', 'Sin destinatarios, la clave portable es la única credencial de la cápsula: déjala marcada o añade un destinatario.');
|
|
}
|
|
if (recipients.length + (portable ? 1 : 0) > ACCESS_SLOTS) {
|
|
return fail(
|
|
'recipients',
|
|
`Una cápsula admite como mucho ${ACCESS_SLOTS} credenciales: ${ACCESS_SLOTS - 1} destinatarios con la clave portable, o ${ACCESS_SLOTS} sin ella. Hay ${formatInteger(recipients.length)} destinatarios.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const effective = roundTime(p, dateKey.round);
|
|
const effectiveMs = effective.seconds * 1000;
|
|
const now: Instant = { seconds: Math.floor(nowMs / 1000), nanos: (nowMs % 1000) * 1e6 };
|
|
return {
|
|
ok: true,
|
|
plan: {
|
|
requested,
|
|
requestedMs,
|
|
ambiguous: local.ambiguous,
|
|
dateKey,
|
|
dk1: compactDateKey(dateKey),
|
|
effective,
|
|
effectiveMs,
|
|
policy,
|
|
recipients,
|
|
portable,
|
|
files: list,
|
|
comment: headComment(input.comment),
|
|
author: input.author,
|
|
length,
|
|
paddedLength: paddedLength(length, REFORZADO),
|
|
size: capsuleLength({ profileId: dateKey.profileId, round: dateKey.round, policy, length }),
|
|
longHorizon: isLongHorizon(effective, now),
|
|
soon: effectiveMs - nowMs < SOON_MS,
|
|
names: defaultFileNames(effectiveMs),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The names offered for the files of a capsule that opens at `effectiveMs`:
|
|
* capsula-<date and time of opening, UTC>.dkc and .dkk. The date is already
|
|
* public in the DateKey, and says nothing of when the capsule was made.
|
|
*/
|
|
export function defaultFileNames(effectiveMs: number): { dkc: string; dkk: string } {
|
|
const stamp = new Date(effectiveMs).toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '');
|
|
return { dkc: `capsula-${stamp}.dkc`, dkk: `capsula-${stamp}.dkk` };
|
|
}
|
|
|
|
/**
|
|
* The name to save a file under, from what the person wrote: its characters
|
|
* that are not printable replaced (safeFileName), no directory separators,
|
|
* and the extension `ext` added when it lacks it. An empty name takes
|
|
* `fallback`.
|
|
*/
|
|
export function downloadName(name: string, ext: string, fallback: string): string {
|
|
const base = safeFileName(name.trim()).replace(/[/\\]/g, '_');
|
|
if (base === '') return fallback;
|
|
return base.toLowerCase().endsWith(ext) ? base : `${base}${ext}`;
|
|
}
|