Inspector page: the release in hand, pasted or from a file

- The release field takes a file too: a release object, drand's JSON or a
  local release archive (spec v0.15, §47.1, §50). opener.ts gives it to
  open as OpenOptions.release, a release in hand, never compared with the
  clock: an archive is read only for its header and one signature, and a
  file too large to be a release, and not an archive, is not read.
- What is pasted goes to open as drand's JSON, which names no chain.
- With a clock before the round time the form stays, without the request to
  drand, and a release in hand opens the capsule; the result says the clock
  seems to be behind (OpenReport.clockBehind).
- The glosses of steps 9 and 10 say what v0.15 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
dev 23 hours ago
parent 3c95d1a523
commit 935b005b58

@ -1,8 +1,9 @@
<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
// the person has in hand, pasted from drand, chosen as a file (a release
// object, drand's JSON or a release archive, spec v0.15) or taken from the
// record of an official fixture, 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.
@ -30,7 +31,7 @@
import type { OpenedFiles } from '$lib/inspector/opener.ts';
import { buildOpenReport, contentExtension, type OpenReport, plaintextFileName, plaintextPreview, SHOWN_TEXT } from '$lib/inspector/opening.ts';
import { fetchRelease } from '$lib/inspector/drand.ts';
import { drandReleaseURL, parseReleaseText, releaseText } from '$lib/inspector/release-input.ts';
import { drandReleaseURL, parseReleaseText, releaseText, type SuppliedRelease } 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';
@ -96,6 +97,8 @@
};
let releaseInput = $state(initialRelease);
// A file with the release, which takes the place of the text.
let releaseFile: File | undefined = $state();
let identities = $state('');
let accessKey: File | undefined = $state();
let problem: string | undefined = $state();
@ -186,6 +189,10 @@
announcement = 'Fichero temporal borrado.';
}
function onReleaseFile(event: Event & { currentTarget: HTMLInputElement }): void {
releaseFile = event.currentTarget.files?.[0];
}
function onAccessKey(event: Event & { currentTarget: HTMLInputElement }): void {
accessKey = event.currentTarget.files?.[0];
}
@ -195,11 +202,19 @@
if (busy) return;
problem = undefined;
problemField = undefined;
// A file with the release goes as it is to step 10; the text is read
// first, for a plain explanation of what cannot be a release.
let release: SuppliedRelease | Blob;
if (releaseFile !== undefined) {
release = releaseFile;
} else {
const parsed = parseReleaseText(releaseInput, round);
if (!parsed.ok) {
await fail(parsed.problem, 'release');
return;
}
release = parsed.release;
}
const id = ++openId;
const stale = (): boolean => id !== openId;
busy = true;
@ -238,7 +253,7 @@
const out = t;
const attempt = await opener.openCapsule({
capsule,
release: parsed.release,
release,
...(timeAndKey ? { identities } : {}),
...(timeAndKey && accessKey !== undefined ? { accessKey } : {}),
...(timeAndKey && words.trim() !== '' ? { words: { text: words, chainHash: report.profile!.chainHash, round, capsuleId: report.capsule!.capsuleId } } : {}),
@ -352,7 +367,8 @@
announcement = '';
await tick();
announcement = message;
document.getElementById(field === undefined ? 'open-problem' : FIELD_IDS[field])?.focus();
const id = field === 'release' && releaseFile !== undefined ? 'release-file' : field === undefined ? 'open-problem' : FIELD_IDS[field];
document.getElementById(id)?.focus();
}
function seconds(ms: number): string {
@ -368,9 +384,10 @@
{#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).
firma de la ronda {round} y nadie puede abrir la cápsula, tampoco esta página. Si ya tienes esa firma porque el reloj
va atrasado, puedes darla abajo: una firma válida prueba que la ronda se publicó, así que no se compara con el reloj.
</p>
{:else}
{/if}
<form class="form" onsubmit={submit} novalidate>
{#if timeAndKey}
<fieldset>
@ -428,7 +445,7 @@
aria-invalid={problemField === 'release' ? 'true' : undefined}
aria-describedby={problemField === 'release' ? 'open-problem release-hint' : 'release-hint'}
></textarea>
{#if fixture?.release === undefined}
{#if fixture?.release === undefined && due}
<div class="actions">
<button class="button quiet" type="button" onclick={askDrand} disabled={asking || busy}>
{asking ? 'Pidiendo la firma…' : 'Pedir la firma a drand'}
@ -443,6 +460,9 @@
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 if !due}
Pega la respuesta de drand que guardaste, o la firma sola en hexadecimal. La página no la pide a drand mientras
este reloj diga que la fecha no ha llegado.
{:else}
«Pedir la firma a drand» la pide a sus relays públicos, que ven tu dirección IP y qué ronda pides. También puedes
abrir <a href={drandURL} target="_blank" rel="noopener noreferrer">la firma de la ronda {round} en drand</a> en otra
@ -452,6 +472,22 @@
</p>
</div>
<div class="field">
<label for="release-file">O un fichero con el release</label>
<input
id="release-file"
type="file"
onchange={onReleaseFile}
aria-invalid={problemField === 'release' && releaseFile !== undefined ? 'true' : undefined}
aria-describedby={problemField === 'release' && releaseFile !== undefined ? 'open-problem release-file-hint' : 'release-file-hint'}
/>
<p id="release-file-hint" class="hint">
Un release que guardaste: el objeto release de DateKeys, la respuesta de drand en JSON o un archivo de releases que
tenga la ronda {round}. Si eliges un fichero, se usa en lugar del texto de arriba. No sale de este navegador y se
comprueba igual (§47.1).
</p>
</div>
{#if problem}
<p id="open-problem" class="problem" tabindex="-1">{problem}</p>
{/if}
@ -468,7 +504,6 @@
{/if}
</div>
</form>
{/if}
<p class="visually-hidden" role="status">{announcement}</p>
@ -484,6 +519,12 @@
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>
{#if r.clockBehind}
<p>
El reloj de este dispositivo dice que la fecha de apertura no ha llegado, pero la firma de la ronda {round} es
válida, así que drand ya la publicó: el reloj parece ir atrasado.
</p>
{/if}
{:else if r.failure}
<p class="code">{r.failure.code}</p>
<p>{errorGloss(r.failure.code)}</p>

@ -91,9 +91,9 @@ export function openStepGloss(name: string): string {
case 'access credential':
return 'Solo en time_and_key, antes de usar el release: la .dkk como objeto, su vínculo con esta cápsula (capsule_id y, si lo trae, capsule_digest) y que haya al menos una credencial.';
case 'release':
return 'Antes de usarlo, sin red, se comprueba con el reloj de este dispositivo que la fecha de apertura ya pasó; después se toma el release que has dado, la firma de la ronda.';
return 'Se toma el release que has dado, la firma de la ronda. Como ya lo tienes, no se compara con el reloj de este dispositivo: si la firma es válida, la ronda se publicó. Solo antes de pedirlo a la red se comprueba con el reloj que la fecha ya pasó.';
case 'release verification':
return 'Primero la ronda del release, que debe ser la de la DateKey; después la firma, que debe ser la codificación canónica de un punto de G1 y verificar con la clave pública del perfil fijado (§17, §51).';
return 'Primero, si es un objeto release, su forma y que sea de la cadena del perfil fijado; si es la respuesta de drand, que se pueda leer. Después la ronda, que debe ser la de la DateKey, y la firma, que debe ser la codificación canónica de un punto de G1 y verificar con la clave pública del perfil fijado (§17, §47.1, §51).';
case 'open sealed control':
return 'Se abre OUTER_TIME_AGE con la firma verificada: exactamente un stanza tlock con la ronda y la cadena del perfil, su cuerpo IBE de 128 bytes y el MAC de la cabecera age.';
case 'policy structure':

@ -198,6 +198,43 @@ describe('openCapsule', () => {
expect(r).toEqual({ ok: false, problem: 'La línea 3 no es una identidad X25519 de age (AGE-SECRET-KEY-1…).', field: 'identities' });
});
it('opens with a release from a file: an object, the JSON of drand or an archive, even with a clock behind', async () => {
const f = fixture('time_only');
const object = readBytes(listTestdata('releases', '').find((p) => p.startsWith('releases/1000.'))!);
const behind: () => Instant = () => ({ seconds: 1_600_000_000, nanos: 0 });
for (const file of [
blob(object),
blob(new TextEncoder().encode(`{"round":1000,"signature":"${f.record.release.signature}"}`)),
blob(readBytes('releases/archive_1000_1004.bin')),
]) {
const r = await openCapsule({ ...f.request, release: file, now: behind });
expect(r.ok && [r.opened.error, r.opened.clockBehind]).toEqual([undefined, true]);
}
// A file is read at step 10, with its code.
const empty = await openCapsule({ ...f.request, release: blob(new Uint8Array(0)) });
expect(empty.ok && [empty.opened.error?.code, empty.opened.inspection.checks.at(-1)!.step]).toEqual(['ERR_NON_CANONICAL_CBOR', 10]);
});
it('does not read a release file too large to be one, nor one it cannot read', async () => {
const f = fixture('time_only');
expect(await openCapsule({ ...f.request, release: blob(new Uint8Array(8193)) })).toEqual({
ok: false,
problem: 'El fichero del release ocupa 8193 bytes: no es un archivo de releases, y un release ocupa como mucho 1 KiB, o 8 KiB si es la respuesta de drand en JSON.',
field: 'release',
});
const failing = (reason: unknown): Blob => {
const b = Object.create(Blob.prototype) as Blob;
Object.defineProperty(b, 'slice', { value: () => ({ arrayBuffer: () => Promise.reject(reason) }) });
return b;
};
expect(await openCapsule({ ...f.request, release: failing(new Error('NotReadableError')) })).toEqual({
ok: false,
problem: 'No se pudo leer el fichero del release: NotReadableError',
field: 'release',
});
expect(await openCapsule({ ...f.request, release: failing('gone') })).toMatchObject({ problem: 'No se pudo leer el fichero del release: gone' });
});
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;

@ -2,8 +2,9 @@
// 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. The files of a format 3
// noble. No release is fetched: the person has it in hand (spec v0.15, §49,
// §63 step 9.c), pasted, from a file or from the record of a fixture, so it
// is not compared with the clock and step 10 decodes and verifies it. The files of a format 3
// capsule go to a sink: the ZipSink of the temporary file, or memory; what
// the page shows of them comes from files.ts, which this module brings on
// demand with the Unicode tables of the paths.
@ -17,17 +18,22 @@ import { open, type Opened } from '../dkc/open.ts';
import { verdictLines } from '../dkc/security.ts';
import { MemorySink } from '../dkc/sink.ts';
import { downloads, type Downloads, type FileFacts, fileFacts } from './files.ts';
import { suppliedRelease } from '../dkc/release.ts';
import { encodedRelease, isReleaseArchive, MAX_RELEASE_JSON_SIZE, ReleaseArchive, type ReleaseSupplier } from '../dkc/releaseobject.ts';
import { parseX25519Identity } from '../dkc/x25519.ts';
import type { SuppliedRelease } from './release-input.ts';
import { releaseText, type SuppliedRelease } from './release-input.ts';
import type { TempFile } from './tempfile.ts';
import { NoRoom, type ZipMode, zipMode, ZipSink, zipOf } from './zipsink.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;
/**
* The release the person has in hand, verified at step 10: the round and
* signature read from what she pasted, or a file, which holds a release
* object, drand's JSON or a local release archive (spec v0.15, §47.1,
* §50).
*/
readonly release: SuppliedRelease | Blob;
/** age X25519 identities (AGE-SECRET-KEY-1…), one per line, for time_and_key. */
readonly identities?: string;
/** A .dkk file, for time_and_key. */
@ -57,7 +63,7 @@ export type OpenAttempt =
readonly ok: false;
readonly problem: string;
/** The input at fault. */
readonly field: 'identities' | 'accessKey';
readonly field: 'identities' | 'accessKey' | 'release';
}
| {
readonly ok: true;
@ -158,6 +164,27 @@ async function firstBytes(file: Blob): Promise<{ bytes: Uint8Array; whole: boole
return { bytes: new Uint8Array(await file.slice(0, PREVIEW_BYTES).arrayBuffer()), whole: file.size <= PREVIEW_BYTES };
}
/**
* The release in hand of a request: what was pasted, as drand's JSON, which
* names no chain; or a file, a local archive when its header says so, and
* otherwise its bytes, which step 10 reads. A file too large to be either a
* release object or drand's JSON, and not an archive, is not read.
*/
export async function releaseSupplier(r: SuppliedRelease | Blob): Promise<ReleaseSupplier | string> {
if (!(r instanceof Blob)) return encodedRelease(new TextEncoder().encode(releaseText(r.round, toHex(r.signature))));
try {
if (isReleaseArchive(new Uint8Array(await r.slice(0, 128).arrayBuffer()))) return new ReleaseArchive(r);
if (r.size > MAX_RELEASE_JSON_SIZE) {
return `El fichero del release ocupa ${formatBytes(r.size)}: no es un archivo de releases, y un release ocupa como mucho 1 KiB, o 8 KiB si es la respuesta de drand en JSON.`;
}
return encodedRelease(new Uint8Array(await r.arrayBuffer()));
} catch (err) {
return `No se pudo leer el fichero del release: ${err instanceof Error ? err.message : String(err)}`;
}
}
const formatBytes = (n: number): string => `${new Intl.NumberFormat('es-ES').format(n)} bytes`;
/** 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 ?? '');
@ -166,6 +193,8 @@ export async function openCapsule(req: OpenRequest): Promise<OpenAttempt> {
const words = req.words === undefined ? [] : await normalizeWords(req.words.text);
if (words.length > 0) ids.push(await wordKey(words, req.words!.chainHash, req.words!.round, fromHex(req.words!.capsuleId)));
try {
const release = await releaseSupplier(req.release);
if (typeof release === 'string') return { ok: false, problem: release, field: 'release' };
let accessKeyFile: Uint8Array | undefined;
if (req.accessKey !== undefined) {
try {
@ -181,7 +210,7 @@ export async function openCapsule(req: OpenRequest): Promise<OpenAttempt> {
const memory = req.output === undefined ? new MemorySink() : undefined;
const zip = req.output === undefined ? undefined : new ZipSink(req.output.writable, req.roundTime ?? 0, req.room);
const opened = await open(req.capsule, {
source: suppliedRelease(req.release),
release,
now: req.now ?? systemClock,
identities: ids,
...(accessKeyFile === undefined ? {} : { accessKeyFile }),

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { fromHex, type Instant, toHex } from '../dkc/index.ts';
import { fromHex, type Instant, quicknet, toHex } from '../dkc/index.ts';
import { open, type OpenOptions } from '../dkc/open.ts';
import { suppliedRelease } from '../dkc/release.ts';
import { encodedRelease, newReleaseObject, 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, contentExtension, plaintextFileName, plaintextPreview, SHOWN_TEXT } from './opening.ts';
@ -25,6 +25,18 @@ const rows = (r: ReturnType<typeof buildOpenReport>): [number, string, string, s
r.steps.map((s) => (s.code === undefined ? [s.step, s.name, s.state] : [s.step, s.name, s.state, s.code]));
describe('buildOpenReport', () => {
it('says the clock is behind only when a release in hand opened the capsule before the round time', async () => {
const f = fixture('time_only');
const object = newReleaseObject(quicknet(), { round: f.record.release.round, signature: fromHex(f.record.release.signature) });
const early: () => Instant = () => ({ seconds: 1_600_000_000, nanos: 0 });
const behind = buildOpenReport(await open(f.dkc, { release: encodedRelease(object), now: early }));
expect([behind.opened, behind.clockBehind]).toEqual([true, true]);
const onTime = buildOpenReport(await open(f.dkc, { release: encodedRelease(object), now: later }));
expect([onTime.opened, onTime.clockBehind]).toEqual([true, undefined]);
const rejected = buildOpenReport(await open(f.dkc, { release: encodedRelease(object.subarray(1)), now: early }));
expect([rejected.opened, rejected.clockBehind]).toEqual([false, undefined]);
});
it('lists the checks of steps 9 to 18 as the reference records them', async () => {
for (const name of [
'time_only',

@ -67,6 +67,12 @@ export interface OpenReport {
readonly steps: readonly StepRow[];
/** The release, once verified at step 10. */
readonly release?: { readonly round: number; readonly signature: string };
/**
* The release was in the person's hand and the clock of the device was
* before the round time: the release proves the round was published, so
* the clock is probably behind (spec v0.15, §63 step 9.c).
*/
readonly clockBehind?: boolean;
/** The extensions of CONTROL_CBOR, once it is decoded (step 14). */
readonly controlExtensions?: readonly ExtensionRow[];
readonly plaintext?: PlaintextFacts;
@ -89,6 +95,7 @@ export function buildOpenReport(o: Opened, plaintext?: PlaintextFacts): OpenRepo
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 (o.clockBehind === true && o.release !== undefined) out.clockBehind = true;
if (checks.some((c) => c.step === 14 && c.ok)) {
out.controlExtensions = [
...o.controlCritical.map((e) => extensionRow(e, true, o.unusableControlExtensions, undefined)),

Loading…
Cancel
Save

Powered by TurnKey Linux.