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.
DateKeys-App/src/lib/inspector/create-input.ts

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

Powered by TurnKey Linux.