After steps 1 to 8, a valid capsule whose date has passed on the device clock can be opened in the page: steps 9 to 18 of spec section 63 with open, loaded on demand with a dynamic import (opener.ts), so noble and age-encryption stay out of the first load of every page. - The release is supplied directly by the person (spec 63, step 10): drand's JSON answer or the bare signature, pasted after opening the drand URL the page links to, or the release in the record of an official fixture. The page never fetches it and reads only its round and signature (spec 11, 13). The CSP is unchanged. - time_and_key credentials: a .dkk (readAccessKey reads at most 12 bytes + 16 MiB + 1) or age identities, one per line. - The plaintext of the person's own file goes to a temporary OPFS file (tempfile.ts), committed only after step 18 (spec 56), offered for download and deleted on request, with another capsule, on pagehide and, if left over, on the next visit. One directory and one Web Lock per tab keep other tabs' clean-up away from files in use. Without OPFS, or when the browser refuses it, capsules up to 64 MiB open in memory. An opening in progress stops when another capsule is loaded. - opening.ts builds the page model of steps 9 to 18 as the reference records them; fixtures show their plaintext and compare its SHA-256 with their record. - licenses.txt: the notices of tlock-js (ibe.ts) and age (bech32.ts), the license of every package in the client bundle, Vite's and rolldown's runtime code, and the site's own license. check-build now fails if a notice is missing, or if a page loads noble, @scure/base or age-encryption with its first load. - The home page no longer says that the page never asks for keys. Checked in the browser on the production build: the time_only, time_and_key_portable (with its .dkk) and time_and_key_recipients (with a pasted identity) fixtures open with the SHA-256 of their records; a tampered signature fails at step 10 and a tampered STREAM chunk at step 17, with no download and no file left; an own file opens to OPFS, downloads without a CSP violation and is deleted with its lock; a left over directory goes on the next visit; no request leaves the origin. An adversarial review (four dimensions, each finding checked by a refuter) confirmed 15 findings, all fixed here. 2611 tests; coverage 100 % of the new modules, now a threshold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
5f1b36945d
commit
48d6704b4b
@ -0,0 +1,637 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// The "abrir" action of the inspector (plan of phase 2, section 9): steps 9
|
||||||
|
// to 18 of spec §63 on a capsule that passed steps 1 to 8, with the release
|
||||||
|
// the person supplies directly, pasted from drand or taken from the record
|
||||||
|
// of an official fixture (decision 4: the page never fetches it), and the
|
||||||
|
// credentials that time_and_key asks for. The opening code, with noble and
|
||||||
|
// age-encryption, is imported on demand, so the page's first load does not
|
||||||
|
// carry it.
|
||||||
|
//
|
||||||
|
// The plaintext of a fixture is opened in memory and shown. The plaintext
|
||||||
|
// of the person's own file goes to a private temporary file of the browser
|
||||||
|
// (OPFS, tempfile.ts), committed only after step 18 (spec §56), offered for
|
||||||
|
// download and deleted on request, when another capsule is opened or
|
||||||
|
// loaded, and when the page is left; in memory, up to MEMORY_LIMIT, when
|
||||||
|
// the browser has no such file or refuses it. An opening in progress stops
|
||||||
|
// when the panel is destroyed, and its file is removed.
|
||||||
|
import { onDestroy, tick } from 'svelte';
|
||||||
|
import { toHex } from '$lib/dkc/index.ts';
|
||||||
|
import type { Fixture } from '$lib/inspector/fixtures.ts';
|
||||||
|
import { errorGloss, escapeInvisible, formatByteCount, formatInteger, printableText } from '$lib/inspector/format.ts';
|
||||||
|
import { buildOpenReport, type OpenReport, plaintextFileName } from '$lib/inspector/opening.ts';
|
||||||
|
import { drandReleaseURL, parseReleaseText, releaseText } from '$lib/inspector/release-input.ts';
|
||||||
|
import type { Report } from '$lib/inspector/report.ts';
|
||||||
|
import { browserPlatform, cancellable, createTempFile, freeSpace, type TempFile } from '$lib/inspector/tempfile.ts';
|
||||||
|
import ExtensionList from './ExtensionList.svelte';
|
||||||
|
import StepList from './StepList.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
report,
|
||||||
|
capsule,
|
||||||
|
fixture,
|
||||||
|
nowMs,
|
||||||
|
}: {
|
||||||
|
/** The inspection of the capsule: valid, with its round and profile. */
|
||||||
|
report: Report;
|
||||||
|
/** What was inspected: the bytes of a fixture or the person's file. */
|
||||||
|
capsule: Uint8Array | Blob;
|
||||||
|
/** The official fixture, when it is one. */
|
||||||
|
fixture: Fixture | undefined;
|
||||||
|
/** When the report was made. */
|
||||||
|
nowMs: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
/** The largest plaintext opened in memory when the browser has no OPFS. */
|
||||||
|
const MEMORY_LIMIT = 64 << 20;
|
||||||
|
/** Printable plaintext up to this many characters is shown whole. */
|
||||||
|
const SHOWN_TEXT = 100_000;
|
||||||
|
|
||||||
|
interface Result {
|
||||||
|
readonly report: OpenReport;
|
||||||
|
readonly ms: number;
|
||||||
|
/** The plaintext offered for download: a temporary file, or memory. */
|
||||||
|
readonly download?: { readonly url: string; readonly name: string; readonly size: number; readonly temporary: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read once: the panel is made again for each inspected capsule.
|
||||||
|
const initial = (): { round: number; release: string } => ({
|
||||||
|
round: report.capsule!.round,
|
||||||
|
release: fixture?.release === undefined ? '' : releaseText(fixture.release.round, toHex(fixture.release.signature)),
|
||||||
|
});
|
||||||
|
const { round, release: initialRelease } = initial();
|
||||||
|
|
||||||
|
/** The input a problem is about. */
|
||||||
|
type ProblemField = 'release' | 'identities' | 'accessKey';
|
||||||
|
const FIELD_IDS: Readonly<Record<ProblemField, string>> = {
|
||||||
|
release: 'release-input',
|
||||||
|
identities: 'ids-input',
|
||||||
|
accessKey: 'dkk-input',
|
||||||
|
};
|
||||||
|
|
||||||
|
let releaseInput = $state(initialRelease);
|
||||||
|
let identities = $state('');
|
||||||
|
let accessKey: File | undefined = $state();
|
||||||
|
let problem: string | undefined = $state();
|
||||||
|
let problemField: ProblemField | undefined = $state();
|
||||||
|
let busy = $state(false);
|
||||||
|
let result: Result | undefined = $state();
|
||||||
|
let deleted = $state(false);
|
||||||
|
let announcement = $state('');
|
||||||
|
|
||||||
|
const timeAndKey = $derived(report.capsule?.accessPolicy === 'time_and_key');
|
||||||
|
const due = $derived(report.unlock?.epochMs !== undefined && report.unlock.epochMs <= nowMs);
|
||||||
|
const drandURL = $derived(drandReleaseURL(report.profile!.chainHash, round));
|
||||||
|
const payloadLength = $derived(report.prelude?.payloadLength ?? 0);
|
||||||
|
|
||||||
|
// The temporary file and the object URL of the last opening.
|
||||||
|
let temp: TempFile | undefined;
|
||||||
|
let objectURL: string | undefined;
|
||||||
|
// The temporary file of the opening in progress, until it becomes `temp`
|
||||||
|
// or is removed: discard() removes it too, so a panel destroyed or a page
|
||||||
|
// left in the middle of an opening leaves nothing behind.
|
||||||
|
let pending: TempFile | undefined;
|
||||||
|
// Only the latest opening may show its result; destroying the panel
|
||||||
|
// invalidates the one in progress, which then stops writing.
|
||||||
|
let openId = 0;
|
||||||
|
|
||||||
|
async function discard(): Promise<void> {
|
||||||
|
if (objectURL !== undefined) URL.revokeObjectURL(objectURL);
|
||||||
|
objectURL = undefined;
|
||||||
|
const files = [temp, pending];
|
||||||
|
temp = undefined;
|
||||||
|
pending = undefined;
|
||||||
|
await Promise.all(files.map((f) => f?.remove()));
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
openId++;
|
||||||
|
void discard();
|
||||||
|
});
|
||||||
|
|
||||||
|
// pagehide also fires when the page goes into the back/forward cache: if
|
||||||
|
// it comes back, it must not offer what was deleted here.
|
||||||
|
function leave(): void {
|
||||||
|
if (result?.download !== undefined) deleted = true;
|
||||||
|
void discard();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNow(): Promise<void> {
|
||||||
|
await discard();
|
||||||
|
deleted = true;
|
||||||
|
announcement = 'Fichero temporal borrado.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAccessKey(event: Event & { currentTarget: HTMLInputElement }): void {
|
||||||
|
accessKey = event.currentTarget.files?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(event: SubmitEvent): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
problem = undefined;
|
||||||
|
problemField = undefined;
|
||||||
|
const parsed = parseReleaseText(releaseInput, round);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
await fail(parsed.problem, 'release');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = ++openId;
|
||||||
|
const stale = (): boolean => id !== openId;
|
||||||
|
busy = true;
|
||||||
|
result = undefined;
|
||||||
|
deleted = false;
|
||||||
|
announcement = 'Abriendo la cápsula.';
|
||||||
|
await discard();
|
||||||
|
// This opening's temporary file, until it becomes `temp`: the finally
|
||||||
|
// block removes it on every other path.
|
||||||
|
let t: TempFile | undefined;
|
||||||
|
try {
|
||||||
|
const opener = await import('$lib/inspector/opener.ts');
|
||||||
|
if (stale()) return;
|
||||||
|
if (fixture === undefined) {
|
||||||
|
const platform = browserPlatform();
|
||||||
|
let free: number | undefined;
|
||||||
|
if (platform !== undefined) {
|
||||||
|
try {
|
||||||
|
free = await freeSpace(platform);
|
||||||
|
if (free === undefined || free >= payloadLength) t = pending = await createTempFile(platform);
|
||||||
|
} catch {
|
||||||
|
// The OPFS is there but the browser refuses it (a private window,
|
||||||
|
// site data blocked): the capsule opens in memory instead.
|
||||||
|
}
|
||||||
|
if (stale()) return;
|
||||||
|
}
|
||||||
|
if (t === undefined && payloadLength > MEMORY_LIMIT) {
|
||||||
|
await fail(
|
||||||
|
free !== undefined && free < payloadLength
|
||||||
|
? `El navegador deja ${formatByteCount(free)} libres para esta página y el contenido cifrado ocupa ${formatByteCount(payloadLength)}, más de los ${formatByteCount(MEMORY_LIMIT)} que la página abre en memoria. Libera espacio o usa la CLI (datekeys decrypt).`
|
||||||
|
: `Este navegador no deja a la página un fichero temporal privado (OPFS) y el contenido cifrado ocupa ${formatByteCount(payloadLength)}, más de los ${formatByteCount(MEMORY_LIMIT)} que la página abre en memoria. Usa otro navegador o la CLI (datekeys decrypt).`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out = t;
|
||||||
|
const attempt = await opener.openCapsule({
|
||||||
|
capsule,
|
||||||
|
release: parsed.release,
|
||||||
|
...(timeAndKey ? { identities } : {}),
|
||||||
|
...(timeAndKey && accessKey !== undefined ? { accessKey } : {}),
|
||||||
|
// A stale opening stops writing, so open aborts the file and stops.
|
||||||
|
...(out === undefined ? {} : { output: { writable: cancellable(out.writable, stale), file: () => out.file(), remove: () => out.remove() } }),
|
||||||
|
});
|
||||||
|
if (stale()) {
|
||||||
|
if (attempt.ok) attempt.plaintext?.fill(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!attempt.ok) {
|
||||||
|
await fail(attempt.problem, attempt.field);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const opened = attempt.opened.error === undefined;
|
||||||
|
let text: string | undefined;
|
||||||
|
if (opened && fixture !== undefined && attempt.plaintext !== undefined) {
|
||||||
|
text = printableText(attempt.plaintext);
|
||||||
|
}
|
||||||
|
const r = buildOpenReport(
|
||||||
|
attempt.opened,
|
||||||
|
attempt.digest === undefined
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
...attempt.digest,
|
||||||
|
...(fixture?.plaintextSHA256 === undefined ? {} : { expectedSHA256: fixture.plaintextSHA256 }),
|
||||||
|
...(text === undefined ? {} : { text }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let download: Result['download'];
|
||||||
|
if (opened && fixture === undefined) {
|
||||||
|
const name = plaintextFileName(report.fileName);
|
||||||
|
if (t !== undefined) {
|
||||||
|
const file = await t.file();
|
||||||
|
if (stale()) return;
|
||||||
|
objectURL = URL.createObjectURL(file);
|
||||||
|
temp = t;
|
||||||
|
pending = undefined;
|
||||||
|
t = undefined;
|
||||||
|
download = { url: objectURL, name, size: file.size, temporary: true };
|
||||||
|
} else {
|
||||||
|
const blob = new Blob([attempt.plaintext! as Uint8Array<ArrayBuffer>]);
|
||||||
|
objectURL = URL.createObjectURL(blob);
|
||||||
|
download = { url: objectURL, name, size: blob.size, temporary: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attempt.plaintext?.fill(0);
|
||||||
|
result = download === undefined ? { report: r, ms: attempt.ms } : { report: r, ms: attempt.ms, download };
|
||||||
|
announcement = r.opened
|
||||||
|
? 'Cápsula abierta: pasos 9 a 18 superados.'
|
||||||
|
: `Apertura rechazada en el paso ${r.failure!.step}, ${r.failure!.code}.`;
|
||||||
|
await tick();
|
||||||
|
document.getElementById('open-result-title')?.focus();
|
||||||
|
} catch (err) {
|
||||||
|
if (!stale()) await fail(`No se pudo abrir: ${escapeInvisible(err instanceof Error ? err.message : String(err))}`);
|
||||||
|
} finally {
|
||||||
|
// A file that did not become `temp`: a failure, or a stale opening.
|
||||||
|
if (t !== undefined) {
|
||||||
|
if (pending === t) pending = undefined;
|
||||||
|
await t.remove();
|
||||||
|
}
|
||||||
|
if (!stale()) busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shows why the opening could not run, announces it and moves the focus to
|
||||||
|
// the field at fault, or to the message: the submit button that had it is
|
||||||
|
// disabled while the opening runs.
|
||||||
|
async function fail(message: string, field?: ProblemField): Promise<void> {
|
||||||
|
problem = message;
|
||||||
|
problemField = field;
|
||||||
|
announcement = '';
|
||||||
|
await tick();
|
||||||
|
announcement = message;
|
||||||
|
document.getElementById(field === undefined ? 'open-problem' : FIELD_IDS[field])?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function seconds(ms: number): string {
|
||||||
|
return new Intl.NumberFormat('es-ES', { maximumFractionDigits: 2 }).format(ms / 1000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onpagehide={leave} />
|
||||||
|
|
||||||
|
<section class="open" aria-labelledby="open-title">
|
||||||
|
<h3 id="open-title">Abrir la cápsula</h3>
|
||||||
|
|
||||||
|
{#if !due}
|
||||||
|
<p class="prose">
|
||||||
|
La fecha de apertura todavía no ha llegado según el reloj de este dispositivo. Hasta entonces drand no publica la
|
||||||
|
firma de la ronda {round} y nadie puede abrir la cápsula, tampoco esta página (paso 9, ERR_RELEASE_UNAVAILABLE).
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<form class="form" onsubmit={submit} novalidate>
|
||||||
|
{#if timeAndKey}
|
||||||
|
<fieldset>
|
||||||
|
<legend>Credencial de acceso</legend>
|
||||||
|
<p class="hint">
|
||||||
|
La política time_and_key pide, además de la firma de la ronda, una clave .dkk de esta cápsula o la identidad
|
||||||
|
X25519 de uno de sus destinatarios. No salen de este navegador.
|
||||||
|
</p>
|
||||||
|
<div class="field">
|
||||||
|
<label for="dkk-input">Clave .dkk</label>
|
||||||
|
<input
|
||||||
|
id="dkk-input"
|
||||||
|
type="file"
|
||||||
|
accept=".dkk"
|
||||||
|
onchange={onAccessKey}
|
||||||
|
aria-invalid={problemField === 'accessKey' ? 'true' : undefined}
|
||||||
|
aria-describedby={problemField === 'accessKey' ? 'open-problem' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="ids-input">Identidades X25519 de age</label>
|
||||||
|
<textarea
|
||||||
|
id="ids-input"
|
||||||
|
rows="2"
|
||||||
|
bind:value={identities}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="AGE-SECRET-KEY-1…"
|
||||||
|
aria-invalid={problemField === 'identities' ? 'true' : undefined}
|
||||||
|
aria-describedby={problemField === 'identities' ? 'open-problem ids-hint' : 'ids-hint'}
|
||||||
|
></textarea>
|
||||||
|
<p id="ids-hint" class="hint">Una por línea, como en un fichero de identidades de age.</p>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="release-input">Firma de la ronda {round}, el release que publica drand</label>
|
||||||
|
<textarea
|
||||||
|
id="release-input"
|
||||||
|
rows="3"
|
||||||
|
bind:value={releaseInput}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
aria-invalid={problemField === 'release' ? 'true' : undefined}
|
||||||
|
aria-describedby={problemField === 'release' ? 'open-problem release-hint' : 'release-hint'}
|
||||||
|
></textarea>
|
||||||
|
<p id="release-hint" class="hint">
|
||||||
|
{#if fixture?.release !== undefined}
|
||||||
|
Viene del registro del fixture: es la que publicó drand para la ronda {round}, como muestra
|
||||||
|
<a href={drandURL} target="_blank" rel="noopener noreferrer">su página en drand</a>. Cámbiala para ver cómo la
|
||||||
|
rechaza el paso 10.
|
||||||
|
{:else}
|
||||||
|
Abre <a href={drandURL} target="_blank" rel="noopener noreferrer">la firma de la ronda {round} en drand</a> en
|
||||||
|
otra pestaña, copia todo lo que muestra y pégalo aquí; vale también la firma sola, en hexadecimal. La página no
|
||||||
|
se conecta a drand: la abres tú.
|
||||||
|
{/if}
|
||||||
|
Solo se leen la ronda y la firma, que se verifican aquí con la clave pública del perfil fijado (§51).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if problem}
|
||||||
|
<p id="open-problem" class="problem" tabindex="-1">{problem}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="button" type="submit" disabled={busy}>{busy ? 'Abriendo…' : 'Abrir la cápsula'}</button>
|
||||||
|
{#if fixture === undefined}
|
||||||
|
<p class="hint">
|
||||||
|
El texto descifrado se escribe en un fichero temporal privado de este navegador y solo se ofrece si age lo
|
||||||
|
autentica entero (§56). Se borra cuando lo pides, al abrir o cargar otra cápsula y al salir de la página; sin
|
||||||
|
ese fichero, se abre en la memoria de la página hasta 64 MiB.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="visually-hidden" role="status">{announcement}</p>
|
||||||
|
|
||||||
|
{#if result}
|
||||||
|
{@const r = result.report}
|
||||||
|
<div class={['verdict', r.opened ? 'valid' : 'invalid']}>
|
||||||
|
<h4 id="open-result-title" class="state-word" tabindex="-1">
|
||||||
|
{r.opened ? 'Abierta' : r.failure ? `Rechazada en el paso ${r.failure.step}` : 'Rechazada'}
|
||||||
|
</h4>
|
||||||
|
{#if r.opened}
|
||||||
|
<p>
|
||||||
|
Supera los pasos 9 a 18 en {seconds(result.ms)} s. age ha autenticado el texto entero con la clave de
|
||||||
|
PAYLOAD_AGE, pero eso no prueba quién lo escribió, ni que sea el original si otros abrieron la cápsula antes
|
||||||
|
(§55.1).
|
||||||
|
</p>
|
||||||
|
{:else if r.failure}
|
||||||
|
<p class="code">{r.failure.code}</p>
|
||||||
|
<p>{errorGloss(r.failure.code)}</p>
|
||||||
|
{#if r.steps.length === 0}
|
||||||
|
<p class="detail">{escapeInvisible(r.failure.message)}</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if r.steps.length > 0}
|
||||||
|
<section class="block" aria-labelledby="open-steps-title">
|
||||||
|
<h3 id="open-steps-title">Pasos 9 a 18</h3>
|
||||||
|
<StepList steps={r.steps} />
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if r.release}
|
||||||
|
<section class="block" aria-labelledby="release-title">
|
||||||
|
<h3 id="release-title">Release verificado</h3>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Ronda</dt>
|
||||||
|
<dd class="mono">{r.release.round}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Firma</dt>
|
||||||
|
<dd class="mono">{r.release.signature}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if r.plaintext}
|
||||||
|
{@const p = r.plaintext}
|
||||||
|
<section class="block" aria-labelledby="plaintext-title">
|
||||||
|
<h3 id="plaintext-title">Texto en claro</h3>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Tamaño</dt>
|
||||||
|
<dd>{formatByteCount(p.length)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>SHA-256</dt>
|
||||||
|
<dd class="mono">{p.sha256}</dd>
|
||||||
|
</div>
|
||||||
|
{#if p.expectedSHA256 !== undefined}
|
||||||
|
<div>
|
||||||
|
<dt>Registro del fixture</dt>
|
||||||
|
<dd class={p.expectedSHA256 === p.sha256 ? 'match' : 'mismatch'}>
|
||||||
|
{p.expectedSHA256 === p.sha256 ? 'coincide' : 'no coincide'}: <span class="mono">{p.expectedSHA256}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
{#if p.text !== undefined}
|
||||||
|
{#if p.text === ''}
|
||||||
|
<p class="muted">El texto en claro está vacío.</p>
|
||||||
|
{:else}
|
||||||
|
<pre class="plaintext">{p.text.length > SHOWN_TEXT ? `${p.text.slice(0, SHOWN_TEXT)}\n…` : p.text}</pre>
|
||||||
|
{#if p.text.length > SHOWN_TEXT}
|
||||||
|
<p class="muted">Se muestran los primeros {formatInteger(SHOWN_TEXT)} caracteres.</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{:else if fixture !== undefined}
|
||||||
|
<p class="muted">No es texto UTF-8 imprimible, así que no se muestra.</p>
|
||||||
|
{/if}
|
||||||
|
{#if result.download}
|
||||||
|
{@const d = result.download}
|
||||||
|
{#if deleted}
|
||||||
|
<p class="muted">
|
||||||
|
{d.temporary
|
||||||
|
? 'Fichero temporal borrado. Para descargarlo otra vez, abre de nuevo la cápsula.'
|
||||||
|
: 'El texto descifrado ya no está en la página. Para descargarlo otra vez, abre de nuevo la cápsula.'}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="actions">
|
||||||
|
<a class="button" href={d.url} download={d.name}>Descargar {d.name}</a>
|
||||||
|
{#if d.temporary}
|
||||||
|
<button class="button quiet" type="button" onclick={deleteNow}>Borrar el fichero temporal</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
{#if d.temporary}
|
||||||
|
Está en un fichero temporal privado de este navegador ({formatByteCount(d.size)}). Se borra cuando lo pides, al
|
||||||
|
abrir o cargar otra cápsula y al salir de la página.
|
||||||
|
{:else}
|
||||||
|
Está en la memoria de esta página ({formatByteCount(d.size)}), porque el navegador no le deja un fichero
|
||||||
|
temporal privado. Se libera al abrir o cargar otra cápsula y al salir de la página.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if r.controlExtensions}
|
||||||
|
<section class="block" aria-labelledby="control-ext-title">
|
||||||
|
<h3 id="control-ext-title">Extensiones de CONTROL_CBOR</h3>
|
||||||
|
<ExtensionList extensions={r.controlExtensions} object="CONTROL_CBOR" />
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.open {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
/* minmax(0, 1fr) and min-width 0: a fieldset is as wide as its content
|
||||||
|
by default, which widens the page on a phone. */
|
||||||
|
.form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 1.1rem;
|
||||||
|
max-width: var(--measure);
|
||||||
|
padding: 1.1rem 1.25rem 1.25rem;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--paper-2);
|
||||||
|
}
|
||||||
|
fieldset {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.75rem 1rem 1rem;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
legend {
|
||||||
|
padding-inline: 0.35rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border: 1px solid var(--rule-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
resize: vertical;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
input[type='file'] {
|
||||||
|
max-width: 100%;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--t-small);
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: var(--t-small);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
max-width: var(--measure);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
/* The download link quotes the file name, which may have no break points;
|
||||||
|
break-word would not let the flex item shrink below it on a phone. */
|
||||||
|
.actions a.button {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.button[disabled] {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
.problem {
|
||||||
|
border-left: 4px solid var(--fail);
|
||||||
|
background: var(--fail-bg);
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verdict {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1.1rem 1.25rem 1.25rem;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-top: 6px solid var(--state);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--paper-2);
|
||||||
|
max-width: var(--measure);
|
||||||
|
}
|
||||||
|
.verdict.valid {
|
||||||
|
--state: var(--pass);
|
||||||
|
}
|
||||||
|
.verdict.invalid {
|
||||||
|
--state: var(--fail);
|
||||||
|
}
|
||||||
|
.state-word {
|
||||||
|
font-size: var(--t-h2);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--state);
|
||||||
|
}
|
||||||
|
.code,
|
||||||
|
.detail {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-weight: 600;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.detail {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
dl {
|
||||||
|
display: grid;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
dl > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.1rem 1rem;
|
||||||
|
padding-block: 0.55rem;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
@media (min-width: 560px) {
|
||||||
|
dl > div {
|
||||||
|
grid-template-columns: 11rem minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dt {
|
||||||
|
font-size: var(--t-small);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
dd.mono,
|
||||||
|
dd .mono {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
dd.match {
|
||||||
|
color: var(--pass);
|
||||||
|
}
|
||||||
|
dd.mismatch {
|
||||||
|
color: var(--fail);
|
||||||
|
}
|
||||||
|
.plaintext {
|
||||||
|
max-height: 24rem;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: var(--paper-2);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,162 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { equalBytes, fromHex, type Instant } from '../dkc/index.ts';
|
||||||
|
import { listTestdata, readBytes, readJSON } from '../dkc/testing/testdata.ts';
|
||||||
|
import { openCapsule, type OpenRequest, parseIdentities, systemClock } from './opener.ts';
|
||||||
|
import type { TempFile } from './tempfile.ts';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Record {
|
||||||
|
release: { round: number; signature: string };
|
||||||
|
access_policy: string;
|
||||||
|
access_key_file?: string;
|
||||||
|
identities?: string[];
|
||||||
|
plaintext_file: string;
|
||||||
|
plaintext_sha256: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const later: () => Instant = () => ({ seconds: 2_000_000_000, nanos: 0 });
|
||||||
|
const blob = (b: Uint8Array): Blob => new Blob([b as Uint8Array<ArrayBuffer>]);
|
||||||
|
|
||||||
|
function fixture(name: string): { dkc: Uint8Array; record: Record; request: OpenRequest } {
|
||||||
|
const record = readJSON<Record>(`fixtures/${name}.json`);
|
||||||
|
const dkc = readBytes(`fixtures/${name}.dkc`);
|
||||||
|
return {
|
||||||
|
dkc,
|
||||||
|
record,
|
||||||
|
request: { capsule: dkc, release: { round: record.release.round, signature: fromHex(record.release.signature) }, now: later },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A temporary file in memory: its content is the writes, once closed.
|
||||||
|
function memoryTemp(): TempFile & { removed: boolean } {
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let content: Uint8Array | undefined;
|
||||||
|
const t = {
|
||||||
|
removed: false,
|
||||||
|
writable: new WritableStream<Uint8Array>({
|
||||||
|
write: (c) => void chunks.push(c.slice()),
|
||||||
|
close: () => void (content = new Uint8Array(chunks.flatMap((c) => [...c]))),
|
||||||
|
}),
|
||||||
|
file: async () => new File([(content ?? new Uint8Array(0)) as Uint8Array<ArrayBuffer>], 'plaintext'),
|
||||||
|
remove: async () => void (t.removed = true),
|
||||||
|
};
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
const capsules = listTestdata('fixtures', '.dkc').map((p) => p.slice('fixtures/'.length, -'.dkc'.length));
|
||||||
|
|
||||||
|
describe('openCapsule', () => {
|
||||||
|
it('opens every official fixture into memory, with the SHA-256 of its record', async () => {
|
||||||
|
expect(capsules.length).toBeGreaterThanOrEqual(5);
|
||||||
|
for (const name of capsules) {
|
||||||
|
const f = fixture(name);
|
||||||
|
const credentials: Partial<OpenRequest> =
|
||||||
|
f.record.access_policy === 'time_and_key' ? { accessKey: blob(readBytes(`fixtures/${f.record.access_key_file!}`)) } : {};
|
||||||
|
const r = await openCapsule({ ...f.request, ...credentials });
|
||||||
|
if (!r.ok) throw new Error(r.problem);
|
||||||
|
expect(r.opened.error, name).toBeUndefined();
|
||||||
|
const plaintext = readBytes(`fixtures/${f.record.plaintext_file}`);
|
||||||
|
expect(equalBytes(r.plaintext!, plaintext), name).toBe(true);
|
||||||
|
expect(r.digest).toEqual({ length: plaintext.length, sha256: f.record.plaintext_sha256 });
|
||||||
|
expect(r.ms).toBeGreaterThanOrEqual(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens time_and_key with the identities of its recipients, one per line', async () => {
|
||||||
|
const f = fixture('time_and_key_recipients');
|
||||||
|
for (const text of [
|
||||||
|
f.record.identities![0]!,
|
||||||
|
`# age identity file\n\n ${f.record.identities![1]!} \r\n`,
|
||||||
|
f.record.identities!.join('\n'),
|
||||||
|
]) {
|
||||||
|
const r = await openCapsule({ ...f.request, identities: text });
|
||||||
|
expect(r.ok && r.opened.error).toBeUndefined();
|
||||||
|
expect(r.ok && r.digest?.sha256).toBe(f.record.plaintext_sha256);
|
||||||
|
}
|
||||||
|
// Without any, the protocol reports it at step 9.
|
||||||
|
const none = await openCapsule(f.request);
|
||||||
|
expect(none.ok && none.opened.error?.code).toBe('ERR_ACCESS_REQUIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes to a temporary file, and reads the SHA-256 back from it', async () => {
|
||||||
|
const f = fixture('time_only');
|
||||||
|
const t = memoryTemp();
|
||||||
|
const r = await openCapsule({ ...f.request, capsule: blob(f.dkc), output: t });
|
||||||
|
if (!r.ok) throw new Error(r.problem);
|
||||||
|
expect(r.opened.error).toBeUndefined();
|
||||||
|
expect(r.plaintext).toBeUndefined();
|
||||||
|
expect(r.digest).toEqual({ length: 78000, sha256: f.record.plaintext_sha256 });
|
||||||
|
expect(await (await t.file()).text()).toBe(new TextDecoder().decode(readBytes('fixtures/time_only.plaintext')));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the failure of the protocol, with no digest', async () => {
|
||||||
|
const f = fixture('time_only');
|
||||||
|
const r = await openCapsule({ ...f.request, release: { round: 1000, signature: new Uint8Array(48) } });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
expect(r.opened.error?.code).toBe('ERR_RELEASE_INVALID');
|
||||||
|
expect(r.digest).toBeUndefined();
|
||||||
|
expect(r.plaintext).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not run open when an identity line is not an identity, and never quotes it', async () => {
|
||||||
|
const f = fixture('time_and_key_recipients');
|
||||||
|
const secret = 'AGE-SECRET-KEY-1NOTAKEY';
|
||||||
|
const r = await openCapsule({ ...f.request, identities: `${f.record.identities![0]!}\n\n${secret}` });
|
||||||
|
expect(r).toEqual({ ok: false, problem: 'La línea 3 no es una identidad X25519 de age (AGE-SECRET-KEY-1…).', field: 'identities' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a .dkk that cannot be read', async () => {
|
||||||
|
const f = fixture('time_and_key_portable');
|
||||||
|
const unreadable = { slice: () => ({ arrayBuffer: () => Promise.reject(new Error('NotReadableError')) }) } as unknown as Blob;
|
||||||
|
expect(await openCapsule({ ...f.request, accessKey: unreadable })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
problem: 'No se pudo leer la .dkk: NotReadableError',
|
||||||
|
field: 'accessKey',
|
||||||
|
});
|
||||||
|
const odd = { slice: () => ({ arrayBuffer: () => Promise.reject('gone') }) } as unknown as Blob;
|
||||||
|
expect(await openCapsule({ ...f.request, accessKey: odd })).toEqual({ ok: false, problem: 'No se pudo leer la .dkk: gone', field: 'accessKey' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the system clock by default', async () => {
|
||||||
|
const f = fixture('time_only');
|
||||||
|
const { now: _, ...request } = f.request;
|
||||||
|
const r = await openCapsule(request);
|
||||||
|
expect(r.ok && r.opened.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseIdentities', () => {
|
||||||
|
it('reads an age identity file: one per line, blank lines and comments ignored', () => {
|
||||||
|
const ids = readJSON<Record>('fixtures/time_and_key_recipients.json').identities!;
|
||||||
|
const r = parseIdentities(`# two recipients\n${ids[0]!}\r\n\n \n${ids[1]!}\n# end`);
|
||||||
|
expect(r.ok && r.ids.length).toBe(2);
|
||||||
|
expect(parseIdentities('')).toEqual({ ok: true, ids: [] });
|
||||||
|
expect(parseIdentities('# nothing\n')).toEqual({ ok: true, ids: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses lowercase, a recipient and anything else, by line number', () => {
|
||||||
|
const id = readJSON<Record>('fixtures/time_and_key_recipients.json').identities![0]!;
|
||||||
|
for (const [text, line] of [
|
||||||
|
[id.toLowerCase(), 1],
|
||||||
|
[`${id}\nage1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq`, 2],
|
||||||
|
[`\n\n${id.slice(0, -1)}`, 3],
|
||||||
|
] as const) {
|
||||||
|
const r = parseIdentities(text);
|
||||||
|
expect(r).toEqual({ ok: false, problem: `La línea ${line} no es una identidad X25519 de age (AGE-SECRET-KEY-1…).` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('systemClock', () => {
|
||||||
|
it('splits the milliseconds of the system clock into seconds and nanoseconds', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(1_692_806_364_250);
|
||||||
|
expect(systemClock()).toEqual({ seconds: 1_692_806_364, nanos: 250_000_000 });
|
||||||
|
vi.setSystemTime(0);
|
||||||
|
expect(systemClock()).toEqual({ seconds: 0, nanos: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
// The opening, which the inspector page loads on demand with a dynamic
|
||||||
|
// import: it carries open.ts and with it noble and age-encryption, which the
|
||||||
|
// first load of the page does not need (plan of phase 2, section 9). The page
|
||||||
|
// builds what it shows from the result with opening.ts, which imports no
|
||||||
|
// noble. No release is fetched: the caller supplies it directly (spec §63
|
||||||
|
// step 10), pasted or from the record of a fixture.
|
||||||
|
|
||||||
|
import { type Instant, readAccessKey, sha256, toHex } from '../dkc/index.ts';
|
||||||
|
import { sha256Stream } from '../dkc/digest.ts';
|
||||||
|
import { open, type Opened } from '../dkc/open.ts';
|
||||||
|
import { suppliedRelease } from '../dkc/release.ts';
|
||||||
|
import { parseX25519Identity } from '../dkc/x25519.ts';
|
||||||
|
import type { SuppliedRelease } from './release-input.ts';
|
||||||
|
import type { TempFile } from './tempfile.ts';
|
||||||
|
|
||||||
|
export interface OpenRequest {
|
||||||
|
/** The capsule: the bytes of a fixture, or the person's file. */
|
||||||
|
readonly capsule: Uint8Array | Blob;
|
||||||
|
/** The release the person supplied, verified at step 10. */
|
||||||
|
readonly release: SuppliedRelease;
|
||||||
|
/** age X25519 identities (AGE-SECRET-KEY-1…), one per line, for time_and_key. */
|
||||||
|
readonly identities?: string;
|
||||||
|
/** A .dkk file, for time_and_key. */
|
||||||
|
readonly accessKey?: Blob;
|
||||||
|
/** Where the plaintext goes; memory when omitted. */
|
||||||
|
readonly output?: TempFile;
|
||||||
|
/** The clock; the system clock when omitted. */
|
||||||
|
readonly now?: () => Instant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpenAttempt =
|
||||||
|
| {
|
||||||
|
/** An input could not be used, so open did not run. */
|
||||||
|
readonly ok: false;
|
||||||
|
readonly problem: string;
|
||||||
|
/** The input at fault. */
|
||||||
|
readonly field: 'identities' | 'accessKey';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly ok: true;
|
||||||
|
readonly opened: Opened;
|
||||||
|
/** The plaintext, when the capsule opened into memory. */
|
||||||
|
readonly plaintext?: Uint8Array;
|
||||||
|
/** Length and SHA-256 of the plaintext, when the capsule opened. */
|
||||||
|
readonly digest?: { readonly length: number; readonly sha256: string };
|
||||||
|
/** How long open took, in milliseconds. */
|
||||||
|
readonly ms: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The system clock as an Instant. */
|
||||||
|
export function systemClock(): Instant {
|
||||||
|
const ms = Date.now();
|
||||||
|
const seconds = Math.floor(ms / 1000);
|
||||||
|
return { seconds, nanos: (ms - seconds * 1000) * 1e6 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the identities of a text in the form of an age identity file: one
|
||||||
|
* per line, blank lines and lines starting with # ignored. A line that is
|
||||||
|
* not an X25519 identity is reported by its number, never by its content.
|
||||||
|
*/
|
||||||
|
export function parseIdentities(text: string): { ok: true; ids: Uint8Array[] } | { ok: false; problem: string } {
|
||||||
|
const ids: Uint8Array[] = [];
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
for (const [i, raw] of lines.entries()) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (line === '' || line.startsWith('#')) continue;
|
||||||
|
try {
|
||||||
|
ids.push(parseX25519Identity(line));
|
||||||
|
} catch {
|
||||||
|
for (const id of ids) id.fill(0);
|
||||||
|
return { ok: false, problem: `La línea ${i + 1} no es una identidad X25519 de age (AGE-SECRET-KEY-1…).` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, ids };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs steps 1 to 18 on the capsule with what the person supplied. */
|
||||||
|
export async function openCapsule(req: OpenRequest): Promise<OpenAttempt> {
|
||||||
|
const parsed = parseIdentities(req.identities ?? '');
|
||||||
|
if (!parsed.ok) return { ...parsed, field: 'identities' };
|
||||||
|
const ids = parsed.ids;
|
||||||
|
try {
|
||||||
|
let accessKeyFile: Uint8Array | undefined;
|
||||||
|
if (req.accessKey !== undefined) {
|
||||||
|
try {
|
||||||
|
accessKeyFile = await readAccessKey(req.accessKey);
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, problem: `No se pudo leer la .dkk: ${err instanceof Error ? err.message : String(err)}`, field: 'accessKey' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const start = performance.now();
|
||||||
|
const opened = await open(req.capsule, {
|
||||||
|
source: suppliedRelease(req.release),
|
||||||
|
now: req.now ?? systemClock,
|
||||||
|
identities: ids,
|
||||||
|
...(accessKeyFile === undefined ? {} : { accessKeyFile }),
|
||||||
|
...(req.output === undefined ? {} : { output: req.output.writable }),
|
||||||
|
});
|
||||||
|
const ms = performance.now() - start;
|
||||||
|
if (opened.error !== undefined) return { ok: true, opened, ms };
|
||||||
|
if (req.output !== undefined) {
|
||||||
|
const file = await req.output.file();
|
||||||
|
return { ok: true, opened, digest: { length: file.size, sha256: toHex(await sha256Stream(file.stream())) }, ms };
|
||||||
|
}
|
||||||
|
const plaintext = opened.plaintext!;
|
||||||
|
return { ok: true, opened, plaintext, digest: { length: plaintext.length, sha256: toHex(await sha256(plaintext)) }, ms };
|
||||||
|
} finally {
|
||||||
|
for (const id of ids) id.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,144 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { fromHex, type Instant, toHex } from '../dkc/index.ts';
|
||||||
|
import { open, type OpenOptions } from '../dkc/open.ts';
|
||||||
|
import { suppliedRelease } from '../dkc/release.ts';
|
||||||
|
import { readBytes, readJSON } from '../dkc/testing/testdata.ts';
|
||||||
|
import { OPEN_STEPS_TIME_AND_KEY, OPEN_STEPS_TIME_ONLY, openStepGloss } from './format.ts';
|
||||||
|
import { buildOpenReport, plaintextFileName } from './opening.ts';
|
||||||
|
|
||||||
|
interface Record {
|
||||||
|
release: { round: number; signature: string };
|
||||||
|
stages: { step: number; name: string; ok: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const later: () => Instant = () => ({ seconds: 2_000_000_000, nanos: 0 });
|
||||||
|
|
||||||
|
function fixture(name: string): { dkc: Uint8Array; record: Record; options: OpenOptions } {
|
||||||
|
const record = readJSON<Record>(`fixtures/${name}.json`);
|
||||||
|
const release = { round: record.release.round, signature: fromHex(record.release.signature) };
|
||||||
|
return { dkc: readBytes(`fixtures/${name}.dkc`), record, options: { source: suppliedRelease(release), now: later } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (r: ReturnType<typeof buildOpenReport>): [number, string, string, string?][] =>
|
||||||
|
r.steps.map((s) => (s.code === undefined ? [s.step, s.name, s.state] : [s.step, s.name, s.state, s.code]));
|
||||||
|
|
||||||
|
describe('buildOpenReport', () => {
|
||||||
|
it('lists the checks of steps 9 to 18 as the reference records them', async () => {
|
||||||
|
for (const name of ['time_only', 'time_only_extensions', 'empty_payload']) {
|
||||||
|
const f = fixture(name);
|
||||||
|
const r = buildOpenReport(await open(f.dkc, f.options), { length: 1, sha256: 'ab' });
|
||||||
|
expect(r.opened, name).toBe(true);
|
||||||
|
expect(r.failure).toBeUndefined();
|
||||||
|
expect(r.steps.map((s) => [s.step, s.name, s.state]), name).toEqual(
|
||||||
|
f.record.stages.filter((s) => s.step >= 9).map((s) => [s.step, s.name, 'ok']),
|
||||||
|
);
|
||||||
|
expect(r.steps.map((s) => [s.step, s.name])).toEqual(OPEN_STEPS_TIME_ONLY);
|
||||||
|
for (const s of r.steps) expect(s.gloss).toBe(openStepGloss(s.name));
|
||||||
|
expect(r.release).toEqual({ round: f.record.release.round, signature: f.record.release.signature });
|
||||||
|
expect(r.plaintext).toEqual({ length: 1, sha256: 'ab' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists the checks of a time_and_key capsule, credentials first', async () => {
|
||||||
|
const f = fixture('time_and_key_portable');
|
||||||
|
const opened = await open(f.dkc, { ...f.options, accessKeyFile: readBytes('fixtures/time_and_key_portable.dkk') });
|
||||||
|
const r = buildOpenReport(opened);
|
||||||
|
expect(r.opened).toBe(true);
|
||||||
|
expect(r.steps.map((s) => [s.step, s.name])).toEqual(OPEN_STEPS_TIME_AND_KEY);
|
||||||
|
expect(r.steps.map((s) => [s.step, s.name])).toEqual(f.record.stages.filter((s) => s.step >= 9).map((s) => [s.step, s.name]));
|
||||||
|
expect(r.plaintext).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the extensions of CONTROL_CBOR once it is decoded', async () => {
|
||||||
|
const plain = fixture('time_only');
|
||||||
|
expect(buildOpenReport(await open(plain.dkc, plain.options)).controlExtensions).toEqual([]);
|
||||||
|
const f = fixture('time_only_extensions');
|
||||||
|
const opened = await open(f.dkc, f.options);
|
||||||
|
const ext = buildOpenReport(opened).controlExtensions!;
|
||||||
|
expect(ext).toHaveLength(1);
|
||||||
|
expect(ext[0]).toMatchObject({ critical: false, known: false });
|
||||||
|
// Critical ones come first; the page opens without a registry, so it only
|
||||||
|
// meets them in a result made by an application that knows them.
|
||||||
|
const critical = { id: 'org.example.critical', version: 1, data: undefined };
|
||||||
|
const both = buildOpenReport({ ...opened, controlCritical: [critical] }).controlExtensions!;
|
||||||
|
expect(both.map((e) => [e.id, e.critical])).toEqual([
|
||||||
|
['org.example.critical', true],
|
||||||
|
[ext[0]!.id, false],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the steps after a failure as not run', async () => {
|
||||||
|
const f = fixture('time_and_key_portable');
|
||||||
|
const r = buildOpenReport(await open(f.dkc, f.options), { length: 1, sha256: 'ab' });
|
||||||
|
expect(r.opened).toBe(false);
|
||||||
|
expect(r.failure).toMatchObject({ step: 9, code: 'ERR_ACCESS_REQUIRED' });
|
||||||
|
expect(rows(r)).toEqual([
|
||||||
|
[9, 'access credential', 'failed', 'ERR_ACCESS_REQUIRED'],
|
||||||
|
...OPEN_STEPS_TIME_AND_KEY.slice(1).map(([step, name]) => [step, name, 'not-run']),
|
||||||
|
]);
|
||||||
|
expect(r.steps[0]!.detail).toBe('capsule: time_and_key capsule and no identity or .dkk supplied: ERR_ACCESS_REQUIRED');
|
||||||
|
// No plaintext for a capsule that did not open, whatever is passed.
|
||||||
|
expect(r.plaintext).toBeUndefined();
|
||||||
|
expect(r.release).toBeUndefined();
|
||||||
|
expect(r.controlExtensions).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a release of another round at step 10, and a clock before the round at step 9', async () => {
|
||||||
|
const f = fixture('time_only');
|
||||||
|
const other = fixture('empty_payload').record.release;
|
||||||
|
const wrong = await open(f.dkc, { ...f.options, source: suppliedRelease({ round: other.round, signature: fromHex(other.signature) }) });
|
||||||
|
expect(rows(buildOpenReport(wrong)).slice(0, 3)).toEqual([
|
||||||
|
[9, 'release', 'ok'],
|
||||||
|
[10, 'release verification', 'failed', 'ERR_ROUND_MISMATCH'],
|
||||||
|
[11, 'open sealed control', 'not-run'],
|
||||||
|
]);
|
||||||
|
const bad = new Uint8Array(48);
|
||||||
|
bad[0] = 0xc0;
|
||||||
|
const invalid = await open(f.dkc, { ...f.options, source: suppliedRelease({ round: 1000, signature: bad }) });
|
||||||
|
expect(buildOpenReport(invalid).failure).toMatchObject({ step: 10, code: 'ERR_RELEASE_INVALID' });
|
||||||
|
const early = await open(f.dkc, { ...f.options, now: () => ({ seconds: 0, nanos: 0 }) });
|
||||||
|
const r = buildOpenReport(early);
|
||||||
|
expect(rows(r)[0]).toEqual([9, 'release', 'failed', 'ERR_RELEASE_UNAVAILABLE']);
|
||||||
|
expect(r.steps).toHaveLength(OPEN_STEPS_TIME_ONLY.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows a failure of step 17 with step 18', async () => {
|
||||||
|
const f = fixture('time_only');
|
||||||
|
// The last byte of the last STREAM chunk: authentication fails at step 17.
|
||||||
|
const dkc = f.dkc.slice();
|
||||||
|
dkc[dkc.length - 1]! ^= 1;
|
||||||
|
const r = buildOpenReport(await open(dkc, f.options));
|
||||||
|
expect(r.failure).toMatchObject({ step: 17, code: 'ERR_INTEGRITY' });
|
||||||
|
expect(rows(r).slice(-3)).toEqual([
|
||||||
|
[16, 'payload identity', 'ok'],
|
||||||
|
[17, 'open payload', 'failed', 'ERR_INTEGRITY'],
|
||||||
|
[18, 'commit', 'not-run'],
|
||||||
|
]);
|
||||||
|
expect(r.steps.at(-2)!.gloss).toBe(openStepGloss('open payload'));
|
||||||
|
// The release was verified before the failure.
|
||||||
|
expect(r.release?.round).toBe(1000);
|
||||||
|
expect(toHex(fromHex(r.release!.signature))).toBe(f.record.release.signature);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no steps when open stopped before step 9', async () => {
|
||||||
|
const f = fixture('time_only');
|
||||||
|
const dkc = f.dkc.slice();
|
||||||
|
dkc[0] = 0x58;
|
||||||
|
const r = buildOpenReport(await open(dkc, f.options));
|
||||||
|
expect(r.steps).toEqual([]);
|
||||||
|
expect(r.failure).toMatchObject({ step: 1, code: 'ERR_INVALID_MAGIC' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('plaintextFileName', () => {
|
||||||
|
it('drops .dkc, as age drops .age', () => {
|
||||||
|
expect(plaintextFileName('informe.pdf.dkc')).toBe('informe.pdf');
|
||||||
|
expect(plaintextFileName('carta.DKC')).toBe('carta');
|
||||||
|
expect(plaintextFileName('capsule')).toBe('capsule.descifrado');
|
||||||
|
expect(plaintextFileName('.dkc')).toBe('.dkc.descifrado');
|
||||||
|
expect(plaintextFileName('capsule.dkk')).toBe('capsule.dkk.descifrado');
|
||||||
|
// A right-to-left override cannot disguise the extension of what is saved.
|
||||||
|
expect(plaintextFileName('factura\u202efdp.exe.dkc')).toBe('factura_fdp.exe');
|
||||||
|
expect(plaintextFileName('a\u200bb\u0000.dkc')).toBe('a_b_');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
// The page model of one opening: steps 9 to 18 of spec §63 as open records
|
||||||
|
// them, the verified release, the extensions of CONTROL_CBOR and the facts of
|
||||||
|
// the plaintext, with no DOM and no clock. open.ts is imported for its types
|
||||||
|
// only, so this module stays out of the chunk that carries noble.
|
||||||
|
|
||||||
|
import type { Opened } from '../dkc/open.ts';
|
||||||
|
import { type ErrorCode, TIME_AND_KEY, toHex } from '../dkc/index.ts';
|
||||||
|
import { OPEN_STEPS_TIME_AND_KEY, OPEN_STEPS_TIME_ONLY, openStepGloss, safeFileName } from './format.ts';
|
||||||
|
import { type ExtensionRow, extensionRow, type StepRow } from './report.ts';
|
||||||
|
|
||||||
|
/** What the page knows of the plaintext of a capsule that opened. */
|
||||||
|
export interface PlaintextFacts {
|
||||||
|
readonly length: number;
|
||||||
|
/** SHA-256 of the plaintext, in hexadecimal. */
|
||||||
|
readonly sha256: string;
|
||||||
|
/** The SHA-256 that the record of an official fixture states, if any. */
|
||||||
|
readonly expectedSHA256?: string;
|
||||||
|
/** The plaintext as text, when the page shows it and it is printable. */
|
||||||
|
readonly text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenReport {
|
||||||
|
/** The capsule opened: every step up to 18 passed. */
|
||||||
|
readonly opened: boolean;
|
||||||
|
readonly failure?: { readonly step: number; readonly code: ErrorCode; readonly message: string };
|
||||||
|
/**
|
||||||
|
* The checks of steps 9 to 18 as open recorded them, followed by the ones
|
||||||
|
* of the policy that did not run; empty when open stopped before step 9.
|
||||||
|
*/
|
||||||
|
readonly steps: readonly StepRow[];
|
||||||
|
/** The release, once verified at step 10. */
|
||||||
|
readonly release?: { readonly round: number; readonly signature: string };
|
||||||
|
/** The extensions of CONTROL_CBOR, once it is decoded (step 14). */
|
||||||
|
readonly controlExtensions?: readonly ExtensionRow[];
|
||||||
|
readonly plaintext?: PlaintextFacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOpenReport(o: Opened, plaintext?: PlaintextFacts): OpenReport {
|
||||||
|
const checks = o.inspection.checks.filter((c) => c.step >= 9);
|
||||||
|
const out: { -readonly [K in keyof OpenReport]: OpenReport[K] } = {
|
||||||
|
opened: o.error === undefined,
|
||||||
|
steps: checks.length === 0 ? [] : openSteps(o, checks),
|
||||||
|
};
|
||||||
|
if (o.error !== undefined) {
|
||||||
|
const last = o.inspection.checks.at(-1)!;
|
||||||
|
out.failure = { step: last.step, code: o.error.code, message: o.error.message };
|
||||||
|
}
|
||||||
|
if (o.release !== undefined) out.release = { round: o.release.round, signature: toHex(o.release.signature) };
|
||||||
|
if (checks.some((c) => c.step === 14 && c.ok)) {
|
||||||
|
out.controlExtensions = [
|
||||||
|
...o.controlCritical.map((e) => extensionRow(e, true, o.unusableControlExtensions, undefined)),
|
||||||
|
...o.controlNoncritical.map((e) => extensionRow(e, false, o.unusableControlExtensions, undefined)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (plaintext !== undefined && out.opened) out.plaintext = plaintext;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name offered for the plaintext of the capsule `name`: without its
|
||||||
|
* .dkc, as age names the output of report.pdf.age report.pdf, or with
|
||||||
|
* ".descifrado" appended when nothing would be left; with every character
|
||||||
|
* that is not printable replaced (safeFileName).
|
||||||
|
*/
|
||||||
|
export function plaintextFileName(name: string): string {
|
||||||
|
const stem = /\.dkc$/i.test(name) ? name.slice(0, -'.dkc'.length) : '';
|
||||||
|
return safeFileName(stem === '' ? `${name}.descifrado` : stem);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Check = Opened['inspection']['checks'][number];
|
||||||
|
|
||||||
|
// The recorded checks, then the ones of the policy that come after the last
|
||||||
|
// of them and did not run.
|
||||||
|
function openSteps(o: Opened, checks: readonly Check[]): StepRow[] {
|
||||||
|
// open records a detail with every check of steps 9 to 18, and only its
|
||||||
|
// last check fails, with the error of the opening.
|
||||||
|
const rows = checks.map(
|
||||||
|
(c): StepRow =>
|
||||||
|
c.ok
|
||||||
|
? { step: c.step, name: c.name, gloss: openStepGloss(c.name), state: 'ok', detail: c.detail! }
|
||||||
|
: { step: c.step, name: c.name, gloss: openStepGloss(c.name), state: 'failed', detail: c.detail!, code: o.error!.code },
|
||||||
|
);
|
||||||
|
const expected = o.inspection.header?.policy === TIME_AND_KEY ? OPEN_STEPS_TIME_AND_KEY : OPEN_STEPS_TIME_ONLY;
|
||||||
|
const last = checks.at(-1)!;
|
||||||
|
const at = expected.findIndex(([step, name]) => step === last.step && name === last.name);
|
||||||
|
// A check that is not in the list, the failure of step 17, is followed by
|
||||||
|
// the steps after its number.
|
||||||
|
for (const [i, [step, name]] of expected.entries()) {
|
||||||
|
if (at >= 0 ? i > at : step > last.step) rows.push({ step, name, gloss: openStepGloss(name), state: 'not-run' });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { toHex } from '../dkc/index.ts';
|
||||||
|
import { readJSON } from '../dkc/testing/testdata.ts';
|
||||||
|
import { drandReleaseURL, MAX_RELEASE_TEXT, parseReleaseText, releaseText } from './release-input.ts';
|
||||||
|
|
||||||
|
// What drand's HTTP API answers for round 1000 of Quicknet, fetched on
|
||||||
|
// 28-09-2026: the release of the time_only fixture.
|
||||||
|
const DRAND_1000 =
|
||||||
|
'{"round":1000,"randomness":"fe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd","signature":"b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39"}';
|
||||||
|
const SIG_1000 =
|
||||||
|
'b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39';
|
||||||
|
|
||||||
|
describe('parseReleaseText', () => {
|
||||||
|
it("reads drand's answer, and only its round and signature", () => {
|
||||||
|
const r = parseReleaseText(`\n ${DRAND_1000}\n`, 1000);
|
||||||
|
expect(r.ok && r.form).toBe('json');
|
||||||
|
expect(r.ok && r.release.round).toBe(1000);
|
||||||
|
expect(r.ok && toHex(r.release.signature)).toBe(SIG_1000);
|
||||||
|
// The same signature as the record of the fixture.
|
||||||
|
const record = readJSON<{ release: { signature: string } }>('fixtures/time_only.json');
|
||||||
|
expect(record.release.signature).toBe(SIG_1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores every other field, a public key included (spec §11, §13)', () => {
|
||||||
|
const r = parseReleaseText(JSON.stringify({ public_key: 'ff'.repeat(96), round: 5, signature: 'AB', period: 3 }), 1000);
|
||||||
|
expect(r).toEqual({ ok: true, release: { round: 5, signature: new Uint8Array([0xab]) }, form: 'json' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a round other than the capsule one, for step 10 to reject', () => {
|
||||||
|
const r = parseReleaseText(DRAND_1000, 1001);
|
||||||
|
expect(r.ok && r.release.round).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes a signature alone as the release of the round of the capsule', () => {
|
||||||
|
expect(parseReleaseText(` ${SIG_1000.toUpperCase()} `, 1000)).toEqual({
|
||||||
|
ok: true,
|
||||||
|
release: { round: 1000, signature: Uint8Array.from(Buffer.from(SIG_1000, 'hex')) },
|
||||||
|
form: 'hex',
|
||||||
|
});
|
||||||
|
// Any length: step 10 checks it.
|
||||||
|
expect(parseReleaseText('00ff', 7)).toEqual({ ok: true, release: { round: 7, signature: new Uint8Array([0, 0xff]) }, form: 'hex' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('explains what cannot be a release', () => {
|
||||||
|
const problem = (s: string): string => {
|
||||||
|
const r = parseReleaseText(s, 1000);
|
||||||
|
if (r.ok) throw new Error(`accepted ${s}`);
|
||||||
|
return r.problem;
|
||||||
|
};
|
||||||
|
expect(problem('')).toMatch(/^Pega/);
|
||||||
|
expect(problem(' \n')).toMatch(/^Pega/);
|
||||||
|
expect(problem('x'.repeat(MAX_RELEASE_TEXT + 1))).toContain(`${MAX_RELEASE_TEXT + 1} caracteres`);
|
||||||
|
expect(problem('abc')).toMatch(/hexadecimal/);
|
||||||
|
expect(problem('0x' + SIG_1000)).toMatch(/hexadecimal/);
|
||||||
|
expect(problem('ab cd')).toMatch(/hexadecimal/);
|
||||||
|
expect(problem('{"round": 1000,')).toMatch(/no es JSON válido/);
|
||||||
|
expect(problem('{} extra')).toMatch(/no es JSON válido/);
|
||||||
|
expect(problem('{"round": 1000}')).toMatch(/signature/);
|
||||||
|
expect(problem('{"signature": "ab"}')).toMatch(/round/);
|
||||||
|
expect(problem('{"round": "1000", "signature": "ab"}')).toMatch(/round/);
|
||||||
|
expect(problem('{"round": 1.5, "signature": "ab"}')).toMatch(/round/);
|
||||||
|
expect(problem('{"round": -1, "signature": "ab"}')).toMatch(/round/);
|
||||||
|
expect(problem('{"round": 9007199254740993, "signature": "ab"}')).toMatch(/round/);
|
||||||
|
expect(problem('{"round": 1000, "signature": ""}')).toMatch(/signature/);
|
||||||
|
expect(problem('{"round": 1000, "signature": "abc"}')).toMatch(/signature/);
|
||||||
|
expect(problem('{"round": 1000, "signature": "zz"}')).toMatch(/signature/);
|
||||||
|
expect(problem('{"round": 1000, "signature": 12}')).toMatch(/signature/);
|
||||||
|
// An inherited property is not a field.
|
||||||
|
expect(problem('{"__proto__": {"round": 1000, "signature": "ab"}}')).toMatch(/round/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads as JSON only an object', () => {
|
||||||
|
// JSON that is not an object is not hexadecimal either.
|
||||||
|
for (const s of ['[1000]', 'null', '"b44679"', 'true']) {
|
||||||
|
expect(parseReleaseText(s, 1000), s).toMatchObject({ ok: false, problem: expect.stringMatching(/hexadecimal/) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('drandReleaseURL and releaseText', () => {
|
||||||
|
it("links to drand's API for the round, on the pinned chain", () => {
|
||||||
|
const chain = '52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971';
|
||||||
|
expect(drandReleaseURL(chain, 1000)).toBe(`https://api.drand.sh/${chain}/public/1000`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a release as drand writes its round and signature', () => {
|
||||||
|
expect(releaseText(1000, SIG_1000)).toBe(`{"round":1000,"signature":"${SIG_1000}"}`);
|
||||||
|
const r = parseReleaseText(releaseText(1000, SIG_1000), 1000);
|
||||||
|
expect(r.ok && toHex(r.release.signature)).toBe(SIG_1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
// The release that the person opening a capsule supplies directly (spec §63
|
||||||
|
// step 10): pasted from drand's HTTP API, or taken from the record of an
|
||||||
|
// official fixture. The page never fetches it: it links to the drand URL of
|
||||||
|
// the round, which the person opens themselves, and verifies what comes back
|
||||||
|
// at step 10 like any release the caller supplies (plan of phase 2,
|
||||||
|
// decision 4, confirmed by the author on 28-09-2026).
|
||||||
|
//
|
||||||
|
// Of what is pasted only the round and the signature are read. drand's
|
||||||
|
// answer also carries `randomness`, and other drand endpoints carry a public
|
||||||
|
// key, a period or a chain hash: none of them is read, because the root of
|
||||||
|
// trust is the pinned profile and never a remote input (spec §11, §13).
|
||||||
|
//
|
||||||
|
// No noble here: this module is part of the page's initial bundle.
|
||||||
|
|
||||||
|
import { fromHex } from '../dkc/index.ts';
|
||||||
|
|
||||||
|
/** A release as the caller supplies it, before any verification. */
|
||||||
|
export interface SuppliedRelease {
|
||||||
|
readonly round: number;
|
||||||
|
readonly signature: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The longest text read as a release: drand's answer is about 230 characters. */
|
||||||
|
export const MAX_RELEASE_TEXT = 4096;
|
||||||
|
|
||||||
|
/** What the person pasted, read as a release, or why it cannot be. */
|
||||||
|
export type ReleaseInput =
|
||||||
|
| { readonly ok: true; readonly release: SuppliedRelease; readonly form: 'json' | 'hex' }
|
||||||
|
| { readonly ok: false; readonly problem: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the text pasted as the release of `round`: drand's JSON answer,
|
||||||
|
* `{"round": …, "signature": "…"}`, or a signature alone in hexadecimal,
|
||||||
|
* which is then taken as the release of `round`. Nothing is verified here:
|
||||||
|
* a round other than `round` or a signature that is not a valid one goes to
|
||||||
|
* step 10, which reports it with its normative code.
|
||||||
|
*/
|
||||||
|
export function parseReleaseText(text: string, round: number): ReleaseInput {
|
||||||
|
const s = text.trim();
|
||||||
|
if (s === '') return { ok: false, problem: 'Pega la respuesta de drand o la firma de la ronda.' };
|
||||||
|
if (s.length > MAX_RELEASE_TEXT) {
|
||||||
|
return { ok: false, problem: `El texto pegado tiene ${s.length} caracteres; la respuesta de drand tiene unos 230.` };
|
||||||
|
}
|
||||||
|
if (s.startsWith('{')) return fromJSON(s);
|
||||||
|
if (!/^[0-9a-fA-F]+$/.test(s) || s.length % 2 !== 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
problem: 'No es la respuesta de drand (un objeto JSON) ni una firma en hexadecimal (un número par de cifras 0-9 y a-f).',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, release: { round, signature: fromHex(s) }, form: 'hex' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromJSON(s: string): ReleaseInput {
|
||||||
|
// JSON text that starts with { and parses is an object.
|
||||||
|
let v: object;
|
||||||
|
try {
|
||||||
|
v = JSON.parse(s) as object;
|
||||||
|
} catch {
|
||||||
|
return { ok: false, problem: 'Empieza por { pero no es JSON válido. Copia la respuesta de drand entera.' };
|
||||||
|
}
|
||||||
|
const round: unknown = Object.hasOwn(v, 'round') ? (v as { round: unknown }).round : undefined;
|
||||||
|
const signature: unknown = Object.hasOwn(v, 'signature') ? (v as { signature: unknown }).signature : undefined;
|
||||||
|
if (typeof round !== 'number' || !Number.isSafeInteger(round) || round < 0) {
|
||||||
|
return { ok: false, problem: 'El campo round falta o no es un número entero de ronda.' };
|
||||||
|
}
|
||||||
|
if (typeof signature !== 'string' || !/^[0-9a-fA-F]*$/.test(signature) || signature.length % 2 !== 0 || signature === '') {
|
||||||
|
return { ok: false, problem: 'El campo signature falta o no es hexadecimal.' };
|
||||||
|
}
|
||||||
|
return { ok: true, release: { round, signature: fromHex(signature) }, form: 'json' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The drand HTTP API URL of the release of `round` on the network of the
|
||||||
|
* chain hash `chainHash` (lowercase hexadecimal), for the person to open:
|
||||||
|
* https://api.drand.sh/<chain hash>/public/<round>. The page links to it and
|
||||||
|
* never fetches it.
|
||||||
|
*/
|
||||||
|
export function drandReleaseURL(chainHash: string, round: number): string {
|
||||||
|
return `https://api.drand.sh/${chainHash}/public/${round}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The JSON text of a release, as drand writes its round and signature. */
|
||||||
|
export function releaseText(round: number, signatureHex: string): string {
|
||||||
|
return JSON.stringify({ round, signature: signatureHex });
|
||||||
|
}
|
||||||
@ -0,0 +1,308 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
browserPlatform,
|
||||||
|
cancellable,
|
||||||
|
createTempFile,
|
||||||
|
freeSpace,
|
||||||
|
removeStaleTempFiles,
|
||||||
|
STALE_MS,
|
||||||
|
TEMP_FILE,
|
||||||
|
TEMP_ROOT,
|
||||||
|
type TempDirectory,
|
||||||
|
type TempFileHandle,
|
||||||
|
type TempLocks,
|
||||||
|
type TempPlatform,
|
||||||
|
} from './tempfile.ts';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const notFound = (): DOMException => new DOMException('not found', 'NotFoundError');
|
||||||
|
|
||||||
|
// An OPFS in memory. A writable keeps its writes apart until close, and drops
|
||||||
|
// them on abort, as createWritable's swap file does.
|
||||||
|
class FakeFile implements TempFileHandle {
|
||||||
|
content = new Uint8Array(0);
|
||||||
|
failWritable = false;
|
||||||
|
lastModified: number;
|
||||||
|
constructor(lastModified: number) {
|
||||||
|
this.lastModified = lastModified;
|
||||||
|
}
|
||||||
|
async createWritable(): Promise<WritableStream<Uint8Array>> {
|
||||||
|
if (this.failWritable) throw new Error('no writable');
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
return new WritableStream<Uint8Array>({
|
||||||
|
write: (c) => void chunks.push(c.slice()),
|
||||||
|
close: () => {
|
||||||
|
this.content = new Uint8Array(chunks.flatMap((c) => [...c]));
|
||||||
|
},
|
||||||
|
abort: () => void (chunks.length = 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async getFile(): Promise<File> {
|
||||||
|
return new File([this.content as Uint8Array<ArrayBuffer>], TEMP_FILE, { lastModified: this.lastModified });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDir implements TempDirectory {
|
||||||
|
readonly entries = new Map<string, FakeDir | FakeFile>();
|
||||||
|
failRemove = false;
|
||||||
|
private readonly time: () => number;
|
||||||
|
constructor(time: () => number) {
|
||||||
|
this.time = time;
|
||||||
|
}
|
||||||
|
async getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<FakeDir> {
|
||||||
|
let e = this.entries.get(name);
|
||||||
|
if (e === undefined) {
|
||||||
|
if (options?.create !== true) throw notFound();
|
||||||
|
e = new FakeDir(this.time);
|
||||||
|
this.entries.set(name, e);
|
||||||
|
}
|
||||||
|
if (!(e instanceof FakeDir)) throw new DOMException('a file', 'TypeMismatchError');
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
async getFileHandle(name: string, options?: { create?: boolean }): Promise<FakeFile> {
|
||||||
|
let e = this.entries.get(name);
|
||||||
|
if (e === undefined) {
|
||||||
|
if (options?.create !== true) throw notFound();
|
||||||
|
e = new FakeFile(this.time());
|
||||||
|
this.entries.set(name, e);
|
||||||
|
}
|
||||||
|
if (!(e instanceof FakeFile)) throw new DOMException('a directory', 'TypeMismatchError');
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
async removeEntry(name: string, options?: { recursive?: boolean }): Promise<void> {
|
||||||
|
const e = this.entries.get(name);
|
||||||
|
if (this.failRemove) throw new DOMException('busy', 'NoModificationAllowedError');
|
||||||
|
if (e === undefined) throw notFound();
|
||||||
|
if (e instanceof FakeDir && e.entries.size > 0 && options?.recursive !== true) throw new DOMException('not empty', 'InvalidModificationError');
|
||||||
|
this.entries.delete(name);
|
||||||
|
}
|
||||||
|
async *keys(): AsyncIterable<string> {
|
||||||
|
yield* [...this.entries.keys()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web Locks in memory: a held lock makes ifAvailable requests get null.
|
||||||
|
class FakeLocks implements TempLocks {
|
||||||
|
readonly held = new Set<string>();
|
||||||
|
async request(name: string, options: { ifAvailable?: boolean }, callback: (lock: object | null) => Promise<unknown>): Promise<unknown> {
|
||||||
|
if (this.held.has(name)) {
|
||||||
|
if (options.ifAvailable === true) return callback(null);
|
||||||
|
throw new Error(`the test would wait for ${name}`);
|
||||||
|
}
|
||||||
|
this.held.add(name);
|
||||||
|
try {
|
||||||
|
return await callback({});
|
||||||
|
} finally {
|
||||||
|
this.held.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function platform(opts: { locks?: boolean; now?: number } = {}): { pf: TempPlatform; root: FakeDir; locks: FakeLocks } {
|
||||||
|
let clock = opts.now ?? 1_000_000_000_000;
|
||||||
|
let n = 0;
|
||||||
|
const root = new FakeDir(() => clock);
|
||||||
|
const locks = new FakeLocks();
|
||||||
|
const pf: TempPlatform = {
|
||||||
|
getDirectory: async () => root,
|
||||||
|
estimate: async () => ({ quota: 100, usage: 40 }),
|
||||||
|
locks: opts.locks === false ? undefined : locks,
|
||||||
|
now: () => clock,
|
||||||
|
randomId: () => `tab-${++n}`,
|
||||||
|
};
|
||||||
|
return { pf, root, locks };
|
||||||
|
}
|
||||||
|
|
||||||
|
const te = new TextEncoder();
|
||||||
|
|
||||||
|
async function write(w: WritableStream<Uint8Array>, ...chunks: string[]): Promise<void> {
|
||||||
|
const writer = w.getWriter();
|
||||||
|
for (const c of chunks) await writer.write(te.encode(c));
|
||||||
|
await writer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createTempFile', () => {
|
||||||
|
it('writes into a directory of its own, under a lock of the same name, and commits on close', async () => {
|
||||||
|
const { pf, root, locks } = platform();
|
||||||
|
const t = await createTempFile(pf);
|
||||||
|
const dir = root.entries.get(TEMP_ROOT) as FakeDir;
|
||||||
|
expect([...dir.entries.keys()]).toEqual(['tab-1']);
|
||||||
|
expect(locks.held).toEqual(new Set([`${TEMP_ROOT}/tab-1`]));
|
||||||
|
await write(t.writable, 'hello, ', 'world');
|
||||||
|
expect(await (await t.file()).text()).toBe('hello, world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps nothing of an aborted output', async () => {
|
||||||
|
const { pf } = platform();
|
||||||
|
const t = await createTempFile(pf);
|
||||||
|
const w = t.writable.getWriter();
|
||||||
|
await w.write(te.encode('partial'));
|
||||||
|
await w.abort(new Error('STREAM failed'));
|
||||||
|
expect((await t.file()).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes the directory and releases the lock once, and never rejects', async () => {
|
||||||
|
const { pf, root, locks } = platform();
|
||||||
|
const t = await createTempFile(pf);
|
||||||
|
await write(t.writable, 'x');
|
||||||
|
await t.remove();
|
||||||
|
await t.remove();
|
||||||
|
expect((root.entries.get(TEMP_ROOT) as FakeDir).entries.size).toBe(0);
|
||||||
|
expect(locks.held.size).toBe(0);
|
||||||
|
|
||||||
|
const u = await createTempFile(pf);
|
||||||
|
(root.entries.get(TEMP_ROOT) as FakeDir).failRemove = true;
|
||||||
|
await expect(u.remove()).resolves.toBeUndefined();
|
||||||
|
expect(locks.held.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up and releases the lock when the file cannot be opened for writing', async () => {
|
||||||
|
const { pf, root, locks } = platform();
|
||||||
|
const dir = await root.getDirectoryHandle(TEMP_ROOT, { create: true });
|
||||||
|
const sub = await dir.getDirectoryHandle('tab-1', { create: true });
|
||||||
|
(await sub.getFileHandle(TEMP_FILE, { create: true })).failWritable = true;
|
||||||
|
await expect(createTempFile(pf)).rejects.toThrow('no writable');
|
||||||
|
expect(dir.entries.size).toBe(0);
|
||||||
|
expect(locks.held.size).toBe(0);
|
||||||
|
// A clean-up that fails too does not hide the error, nor keep the lock.
|
||||||
|
const other = platform();
|
||||||
|
const odir = await other.root.getDirectoryHandle(TEMP_ROOT, { create: true });
|
||||||
|
(await (await odir.getDirectoryHandle('tab-1', { create: true })).getFileHandle(TEMP_FILE, { create: true })).failWritable = true;
|
||||||
|
odir.failRemove = true;
|
||||||
|
await expect(createTempFile(other.pf)).rejects.toThrow('no writable');
|
||||||
|
expect(other.locks.held.size).toBe(0);
|
||||||
|
// Failing before the root exists leaves nothing either.
|
||||||
|
const broken: TempPlatform = { ...pf, getDirectory: () => Promise.reject(new Error('no OPFS')) };
|
||||||
|
await expect(createTempFile(broken)).rejects.toThrow('no OPFS');
|
||||||
|
expect(locks.held.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works without Web Locks', async () => {
|
||||||
|
const { pf } = platform({ locks: false });
|
||||||
|
const t = await createTempFile(pf);
|
||||||
|
await write(t.writable, 'x');
|
||||||
|
expect((await t.file()).size).toBe(1);
|
||||||
|
await t.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cancellable', () => {
|
||||||
|
it('writes, closes and aborts through, until cancelled', async () => {
|
||||||
|
const { pf } = platform();
|
||||||
|
const t = await createTempFile(pf);
|
||||||
|
await write(cancellable(t.writable, () => false), 'all ', 'of it');
|
||||||
|
expect(await (await t.file()).text()).toBe('all of it');
|
||||||
|
|
||||||
|
const u = await createTempFile(pf);
|
||||||
|
const w = cancellable(u.writable, () => false).getWriter();
|
||||||
|
await w.write(te.encode('partial'));
|
||||||
|
await w.abort(new Error('STREAM failed'));
|
||||||
|
expect((await u.file()).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('once cancelled, discards what was written and fails the next write', async () => {
|
||||||
|
const { pf } = platform();
|
||||||
|
const t = await createTempFile(pf);
|
||||||
|
let cancelled = false;
|
||||||
|
const w = cancellable(t.writable, () => cancelled).getWriter();
|
||||||
|
await w.write(te.encode('first chunk'));
|
||||||
|
cancelled = true;
|
||||||
|
await expect(w.write(te.encode('second chunk'))).rejects.toThrow('opening cancelled');
|
||||||
|
// The inner output was aborted, so nothing is ever committed.
|
||||||
|
expect((await t.file()).size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeStaleTempFiles', () => {
|
||||||
|
it('has nothing to do without the directory, and does not create it', async () => {
|
||||||
|
const { pf, root } = platform();
|
||||||
|
expect(await removeStaleTempFiles(pf)).toBe(0);
|
||||||
|
expect(root.entries.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes the directories that no tab holds, never one in use', async () => {
|
||||||
|
const { pf, root } = platform();
|
||||||
|
const mine = await createTempFile(pf);
|
||||||
|
// Left by a tab that ended: a directory and a stray file, no lock.
|
||||||
|
const dir = root.entries.get(TEMP_ROOT) as FakeDir;
|
||||||
|
await (await dir.getDirectoryHandle('old-tab', { create: true })).getFileHandle(TEMP_FILE, { create: true });
|
||||||
|
await dir.getFileHandle('stray', { create: true });
|
||||||
|
expect(await removeStaleTempFiles(pf)).toBe(2);
|
||||||
|
expect([...dir.entries.keys()]).toEqual(['tab-1']);
|
||||||
|
await write(mine.writable, 'still here');
|
||||||
|
expect(await (await mine.file()).text()).toBe('still here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts only what it could delete', async () => {
|
||||||
|
const { pf, root } = platform();
|
||||||
|
const dir = await root.getDirectoryHandle(TEMP_ROOT, { create: true });
|
||||||
|
await dir.getDirectoryHandle('old-tab', { create: true });
|
||||||
|
dir.failRemove = true;
|
||||||
|
expect(await removeStaleTempFiles(pf)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without Web Locks, deletes only what is a day old, or is not a directory of a tab', async () => {
|
||||||
|
const start = 1_000_000_000_000;
|
||||||
|
const { pf, root } = platform({ locks: false, now: start });
|
||||||
|
const dir = await root.getDirectoryHandle(TEMP_ROOT, { create: true });
|
||||||
|
const make = async (name: string, at: number): Promise<void> => {
|
||||||
|
const f = await (await dir.getDirectoryHandle(name, { create: true })).getFileHandle(TEMP_FILE, { create: true });
|
||||||
|
f.lastModified = at;
|
||||||
|
};
|
||||||
|
await make('fresh', start - STALE_MS + 1);
|
||||||
|
await make('stale', start - STALE_MS);
|
||||||
|
await dir.getDirectoryHandle('empty', { create: true });
|
||||||
|
await dir.getFileHandle('stray', { create: true });
|
||||||
|
expect(await removeStaleTempFiles(pf)).toBe(3);
|
||||||
|
expect([...dir.entries.keys()]).toEqual(['fresh']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('freeSpace', () => {
|
||||||
|
it('is the quota less the usage, when the browser gives a quota', async () => {
|
||||||
|
const { pf } = platform();
|
||||||
|
expect(await freeSpace(pf)).toBe(60);
|
||||||
|
expect(await freeSpace({ ...pf, estimate: async () => ({ quota: 10 }) })).toBe(10);
|
||||||
|
expect(await freeSpace({ ...pf, estimate: async () => ({ quota: 10, usage: 12 }) })).toBe(0);
|
||||||
|
expect(await freeSpace({ ...pf, estimate: async () => ({ usage: 12 }) })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('browserPlatform', () => {
|
||||||
|
it('is undefined without the OPFS or without createWritable', () => {
|
||||||
|
vi.stubGlobal('navigator', {});
|
||||||
|
expect(browserPlatform()).toBeUndefined();
|
||||||
|
vi.stubGlobal('navigator', { storage: { getDirectory: () => undefined, estimate: () => undefined } });
|
||||||
|
vi.stubGlobal('FileSystemFileHandle', undefined);
|
||||||
|
expect(browserPlatform()).toBeUndefined();
|
||||||
|
vi.stubGlobal('FileSystemFileHandle', class {});
|
||||||
|
expect(browserPlatform()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps the browser's storage, locks, clock and random ids", async () => {
|
||||||
|
const root = new FakeDir(() => 0);
|
||||||
|
const locks = new FakeLocks();
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
storage: { getDirectory: async () => root, estimate: async () => ({ quota: 5, usage: 1 }) },
|
||||||
|
locks,
|
||||||
|
});
|
||||||
|
vi.stubGlobal(
|
||||||
|
'FileSystemFileHandle',
|
||||||
|
class {
|
||||||
|
createWritable(): void {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const pf = browserPlatform()!;
|
||||||
|
expect(await pf.getDirectory()).toBe(root);
|
||||||
|
expect(await pf.estimate()).toEqual({ quota: 5, usage: 1 });
|
||||||
|
expect(pf.locks).toBe(locks);
|
||||||
|
expect(Math.abs(pf.now() - Date.now())).toBeLessThan(1000);
|
||||||
|
expect(pf.randomId()).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
// No Web Locks API.
|
||||||
|
vi.stubGlobal('navigator', { storage: { getDirectory: async () => root, estimate: async () => ({}) } });
|
||||||
|
expect(browserPlatform()!.locks).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,205 @@
|
|||||||
|
// The private temporary file of an opening (spec §56; plan of phase 2,
|
||||||
|
// decision 7). PAYLOAD_AGE is decrypted into a file of the origin private
|
||||||
|
// file system (OPFS) through FileSystemFileHandle.createWritable, which keeps
|
||||||
|
// the writes in a swap file until close() and discards them on abort(): open
|
||||||
|
// closes it only after step 18, so nothing is committed before the whole
|
||||||
|
// payload is authenticated. The page offers the file for download once the
|
||||||
|
// capsule opened, and deletes it when the person asks, when another capsule
|
||||||
|
// is loaded or opened, when the page is left and, if the browser ended
|
||||||
|
// first, on the next visit.
|
||||||
|
//
|
||||||
|
// Each tab writes into its own directory, datekeys-open/<random id>, and
|
||||||
|
// holds a Web Lock of that name while the directory exists, so that the
|
||||||
|
// clean-up of another tab never deletes a file in use, nor its swap file.
|
||||||
|
// Without the Web Locks API a directory is deleted only once it is a day old.
|
||||||
|
|
||||||
|
/** The directory of the temporary files, in the root of the OPFS. */
|
||||||
|
export const TEMP_ROOT = 'datekeys-open';
|
||||||
|
/** The name of the file inside the directory of a tab. */
|
||||||
|
export const TEMP_FILE = 'plaintext';
|
||||||
|
/** Without Web Locks, a directory older than this is left over. */
|
||||||
|
export const STALE_MS = 24 * 3600_000;
|
||||||
|
|
||||||
|
/** The part of FileSystemDirectoryHandle that this module uses. */
|
||||||
|
export interface TempDirectory {
|
||||||
|
getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<TempDirectory>;
|
||||||
|
getFileHandle(name: string, options?: { create?: boolean }): Promise<TempFileHandle>;
|
||||||
|
removeEntry(name: string, options?: { recursive?: boolean }): Promise<void>;
|
||||||
|
keys(): AsyncIterable<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The part of FileSystemFileHandle that this module uses. */
|
||||||
|
export interface TempFileHandle {
|
||||||
|
createWritable(): Promise<WritableStream<Uint8Array>>;
|
||||||
|
getFile(): Promise<File>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The part of the Web Locks API that this module uses. */
|
||||||
|
export interface TempLocks {
|
||||||
|
request(name: string, options: { ifAvailable?: boolean }, callback: (lock: object | null) => Promise<unknown>): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the browser provides; tests pass their own. */
|
||||||
|
export interface TempPlatform {
|
||||||
|
getDirectory(): Promise<TempDirectory>;
|
||||||
|
estimate(): Promise<{ quota?: number; usage?: number }>;
|
||||||
|
readonly locks: TempLocks | undefined;
|
||||||
|
now(): number;
|
||||||
|
randomId(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The OPFS of this browser, or undefined when it cannot write a temporary
|
||||||
|
* file: no navigator.storage.getDirectory, or no createWritable, which not
|
||||||
|
* every browser offers outside workers.
|
||||||
|
*/
|
||||||
|
export function browserPlatform(): TempPlatform | undefined {
|
||||||
|
const storage = globalThis.navigator?.storage;
|
||||||
|
if (typeof storage?.getDirectory !== 'function' || typeof storage.estimate !== 'function') return undefined;
|
||||||
|
if (typeof globalThis.FileSystemFileHandle?.prototype.createWritable !== 'function') return undefined;
|
||||||
|
const locks = globalThis.navigator.locks as TempLocks | undefined;
|
||||||
|
return {
|
||||||
|
getDirectory: () => storage.getDirectory() as unknown as Promise<TempDirectory>,
|
||||||
|
estimate: () => storage.estimate(),
|
||||||
|
locks: typeof locks?.request === 'function' ? locks : undefined,
|
||||||
|
now: () => Date.now(),
|
||||||
|
randomId: () => crypto.randomUUID(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bytes the origin may still store, or undefined when the browser does not say. */
|
||||||
|
export async function freeSpace(pf: TempPlatform): Promise<number | undefined> {
|
||||||
|
const { quota, usage } = await pf.estimate();
|
||||||
|
return quota === undefined ? undefined : Math.max(0, quota - (usage ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A temporary file of this tab, open for writing. */
|
||||||
|
export interface TempFile {
|
||||||
|
/** The output of open: closed after step 18, aborted on any failure. */
|
||||||
|
readonly writable: WritableStream<Uint8Array>;
|
||||||
|
/** The committed content, once open has closed the output. */
|
||||||
|
file(): Promise<File>;
|
||||||
|
/** Deletes the file and its directory and releases the lock; never rejects. */
|
||||||
|
remove(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An output that writes to `w` until `cancelled()` is true. From then on it
|
||||||
|
* aborts `w`, which discards what was written, and fails the write, so that
|
||||||
|
* open stops decrypting and reports the failure at step 17. The page cancels
|
||||||
|
* an opening this way when the person moves on to another capsule. Closing
|
||||||
|
* and aborting go to `w`.
|
||||||
|
*/
|
||||||
|
export function cancellable(w: WritableStream<Uint8Array>, cancelled: () => boolean): WritableStream<Uint8Array> {
|
||||||
|
const inner = w.getWriter();
|
||||||
|
return new WritableStream<Uint8Array>({
|
||||||
|
async write(chunk) {
|
||||||
|
if (cancelled()) {
|
||||||
|
const reason = new Error('opening cancelled');
|
||||||
|
await inner.abort(reason);
|
||||||
|
throw reason;
|
||||||
|
}
|
||||||
|
await inner.write(chunk);
|
||||||
|
},
|
||||||
|
close: () => inner.close(),
|
||||||
|
abort: (reason: unknown) => inner.abort(reason),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates the directory of this tab, with its lock, and the file open for writing. */
|
||||||
|
export async function createTempFile(pf: TempPlatform): Promise<TempFile> {
|
||||||
|
const id = pf.randomId();
|
||||||
|
const release = pf.locks === undefined ? () => undefined : await hold(pf.locks, `${TEMP_ROOT}/${id}`);
|
||||||
|
let root: TempDirectory | undefined;
|
||||||
|
let handle: TempFileHandle;
|
||||||
|
let writable: WritableStream<Uint8Array>;
|
||||||
|
try {
|
||||||
|
root = await (await pf.getDirectory()).getDirectoryHandle(TEMP_ROOT, { create: true });
|
||||||
|
const dir = await root.getDirectoryHandle(id, { create: true });
|
||||||
|
handle = await dir.getFileHandle(TEMP_FILE, { create: true });
|
||||||
|
writable = await handle.createWritable();
|
||||||
|
} catch (err) {
|
||||||
|
await root?.removeEntry(id, { recursive: true }).catch(() => undefined);
|
||||||
|
release();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
let removed = false;
|
||||||
|
return {
|
||||||
|
writable,
|
||||||
|
file: () => handle.getFile(),
|
||||||
|
async remove(): Promise<void> {
|
||||||
|
if (removed) return;
|
||||||
|
removed = true;
|
||||||
|
try {
|
||||||
|
await root.removeEntry(id, { recursive: true });
|
||||||
|
} catch {
|
||||||
|
// Already gone, or the browser refuses: the next visit retries.
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the directories that no tab holds: left by a tab that ended
|
||||||
|
* before deleting its file. Returns how many it deleted.
|
||||||
|
*/
|
||||||
|
export async function removeStaleTempFiles(pf: TempPlatform): Promise<number> {
|
||||||
|
let root: TempDirectory;
|
||||||
|
try {
|
||||||
|
root = await (await pf.getDirectory()).getDirectoryHandle(TEMP_ROOT);
|
||||||
|
} catch {
|
||||||
|
// No such directory: nothing was ever left.
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const names: string[] = [];
|
||||||
|
for await (const name of root.keys()) names.push(name);
|
||||||
|
let removed = 0;
|
||||||
|
for (const name of names) {
|
||||||
|
// A tab takes the lock before it creates its directory and keeps it
|
||||||
|
// until it deletes it, so a free lock means the tab has ended.
|
||||||
|
const left = pf.locks === undefined ? await isStale(root, name, pf.now()) : await isFree(pf.locks, `${TEMP_ROOT}/${name}`);
|
||||||
|
removed += left && (await tryRemove(root, name)) ? 1 : 0;
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether no tab holds the lock `name`.
|
||||||
|
async function isFree(locks: TempLocks, name: string): Promise<boolean> {
|
||||||
|
return (await locks.request(name, { ifAvailable: true }, async (lock) => lock !== null)) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquires the lock `name` and keeps it until the returned function is called.
|
||||||
|
function hold(locks: TempLocks, name: string): Promise<() => void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let release!: () => void;
|
||||||
|
const held = new Promise<void>((r) => (release = r));
|
||||||
|
locks
|
||||||
|
.request(name, {}, () => {
|
||||||
|
resolve(release);
|
||||||
|
return held;
|
||||||
|
})
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the directory `name` is at least a day old, by its file. Anything
|
||||||
|
// that is not a directory of a tab is left over too.
|
||||||
|
async function isStale(root: TempDirectory, name: string, now: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const file = await (await (await root.getDirectoryHandle(name)).getFileHandle(TEMP_FILE)).getFile();
|
||||||
|
return now - file.lastModified >= STALE_MS;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryRemove(root: TempDirectory, name: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await root.removeEntry(name, { recursive: true });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in new issue