Show the plaintext of any capsule that opens, and help with age keys

/inspect showed the plaintext only of the official fixtures; a capsule
of one's own was only offered for download. Now the start of the
plaintext is shown whenever the capsule opens and it is text:
opener.ts reads its first 128 KiB (PREVIEW_BYTES), from memory or from
the temporary file, and plaintextPreview in opening.ts shows up to
100 000 characters of printable UTF-8, cut on a whole character. A text
written on Windows shows too: CR LF as a line feed, no BOM. The
download keeps the exact bytes.

The pages now explain age keys: /create, in a folding block, what an
age1… recipient is and how to get one with age-keygen; /inspect, next
to the identities, which line of the age-keygen file to paste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
dev 1 week ago
parent 3a9d2b11fe
commit da2000a16c

@ -6,6 +6,11 @@ Cambios notables de la librería TypeScript y de la página. El proyecto usa ver
Fase 3: la escritura de cápsulas de formato 2, según `PLAN_fase3_escritura.md` (v3, en `../docs`). Hecha, pasos 0 a 7; la versión sigue sin publicar hasta que el autor la cierre.
### Después del paso 7: ayuda de `age` y el texto en claro a la vista
- `/inspect` muestra el principio del texto en claro de cualquier cápsula que se abre, no solo de los fixtures, si es texto: UTF-8 imprimible, hasta 100 000 caracteres de sus primeros 128 KiB. Se ve también un texto escrito en Windows, con CR LF o BOM; la descarga conserva los bytes exactos. `opener.ts` da esos primeros bytes (`PREVIEW_BYTES`), y `opening.ts` decide qué se muestra (`plaintextPreview`).
- Ayuda de las claves de `age`. En `/create`, un bloque plegable explica qué es un destinatario `age1…` y cómo se consigue con `age-keygen`. En `/inspect`, el campo de identidades dice qué pegar: la línea `AGE-SECRET-KEY-1…` del fichero de `age-keygen`, o el fichero entero.
### Pasos 6 y 7: la página `/create`
- El autor confirma las decisiones de la página con cada recomendación: `time_only` por defecto, la zona del dispositivo con un selector, los avisos de §53 y §50 desde 365 días, un aviso de protocolo preliminar y los nombres `capsula-<apertura en UTC>`.

@ -117,9 +117,9 @@ Todo el texto leído de la cápsula pasa por interpolación de texto de Svelte (
Tras los pasos 1 a 8, si la cápsula es válida y su fecha de apertura ya pasó según el reloj del dispositivo, la página ofrece abrirla: pasos 9 a 18 de §63 con `open` (fase 2, paso 8). Antes de la fecha dice que nadie puede abrirla todavía y no pide nada.
- **El release lo da quien abre** (decisión 4 del plan de la fase 2, confirmada el 28-09-2026): la página nunca lo pide a la red. Se pega la respuesta JSON de drand o la firma sola en hexadecimal (`release-input.ts`), y solo se leen `round` y `signature`: cualquier otro campo, como una clave pública, se ignora, porque la raíz de confianza es el perfil fijado (§11, §13). La página enlaza la URL de drand de esa ronda (`https://api.drand.sh/<chain hash>/public/<ronda>`, con `rel="noopener noreferrer"`), que abre la persona en otra pestaña. Es un release que suministra quien llama: el paso 10 lo verifica y da sus códigos (`ERR_ROUND_MISMATCH`, `ERR_RELEASE_INVALID`). En los fixtures oficiales el campo viene relleno con el release de su registro, que es el que publicó drand.
- **Credenciales**, solo en `time_and_key`: una `.dkk` (se leen como mucho 16 MiB + 13 bytes, `readAccessKey`) o identidades `AGE-SECRET-KEY-1…`, una por línea, como un fichero de identidades de `age`. Un error de una línea se da por su número, nunca por su contenido, y el foco va al campo; las identidades se borran tras usarlas. En `time_only` la página no las pide y `open` no las usa.
- **Credenciales**, solo en `time_and_key`: una `.dkk` (se leen como mucho 16 MiB + 13 bytes, `readAccessKey`) o identidades `AGE-SECRET-KEY-1…`, una por línea, como un fichero de identidades de `age`. La página explica de dónde sale la identidad: del fichero que se creó con `age-keygen`, que se puede pegar entero. Un error de una línea se da por su número, nunca por su contenido, y el foco va al campo; las identidades se borran tras usarlas. En `time_only` la página no las pide y `open` no las usa.
- **El código de la apertura se carga bajo demanda**: la página importa `opener.ts` con `import()` al pulsar "Abrir", y con él `open.ts`, noble y `age-encryption`. `opening.ts`, que construye lo que se muestra, solo importa tipos de `open.ts`. `check-build.mjs` comprueba que ninguna página carga noble, `@scure/base` ni `age-encryption` en la primera carga.
- **El texto en claro** de un fixture se abre en memoria y se muestra, con su SHA-256 comparado con el del registro. El de un fichero propio va a un fichero temporal del almacenamiento privado del navegador (OPFS, `tempfile.ts`), escrito con `createWritable`. El navegador guarda lo escrito en un fichero de intercambio que solo se confirma al cerrarlo, y `open` lo cierra tras el paso 18 y lo aborta ante cualquier fallo (§56). Después se ofrece para descargar, con el nombre del `.dkc` sin la extensión, como hace `age`, y cada carácter no imprimible cambiado por `_`, para que un carácter de control bidireccional no disfrace la extensión. Antes de empezar se compara el tamaño de `PAYLOAD_AGE` con la cuota libre (`navigator.storage.estimate()`).
- **El texto en claro** se muestra, cuando la cápsula se abre y es texto (`plaintextPreview`): UTF-8 imprimible, hasta 100 000 caracteres de sus primeros 128 KiB. Se muestra el CR LF de Windows como salto de línea y se omite el BOM, como hace un editor; la descarga conserva los bytes exactos. Lo que no es texto solo se ofrece para descargar. El de un fixture se abre en memoria, con su SHA-256 comparado con el del registro. El de un fichero propio va a un fichero temporal del almacenamiento privado del navegador (OPFS, `tempfile.ts`), escrito con `createWritable`. El navegador guarda lo escrito en un fichero de intercambio que solo se confirma al cerrarlo, y `open` lo cierra tras el paso 18 y lo aborta ante cualquier fallo (§56). Después se ofrece para descargar, con el nombre del `.dkc` sin la extensión, como hace `age`, y cada carácter no imprimible cambiado por `_`, para que un carácter de control bidireccional no disfrace la extensión. Antes de empezar se compara el tamaño de `PAYLOAD_AGE` con la cuota libre (`navigator.storage.estimate()`).
- **El fichero temporal se borra** al pulsar "Borrar", al abrir o cargar otra cápsula y al salir de la página (`pagehide`). Si el navegador terminó antes, se borra en la siguiente visita. Cada pestaña escribe en su propio directorio, `datekeys-open/<id>`, y tiene un Web Lock con ese nombre mientras existe. Así la limpieza de otra pestaña nunca borra un fichero en uso; sin Web Locks, solo borra lo que tiene más de un día. Si el navegador no tiene OPFS o `createWritable`, o los rechaza (una ventana privada, datos del sitio bloqueados), o la cuota no alcanza, la página abre en memoria hasta 64 MiB. Una apertura en curso se detiene si se carga otra cápsula: su salida deja de aceptar datos, `open` falla en el paso 17 y su fichero se borra. Si la página vuelve de la caché de atrás y adelante tras borrar el fichero, ya no lo ofrece.
- **La fecha** se vuelve a mirar cuando llega y cuando la pestaña vuelve a verse, así que una cápsula inspeccionada antes de su fecha se puede abrir sin cargarla otra vez. `open` comprueba de nuevo el reloj en el paso 9.
- **El resultado** muestra cada paso de 9 a 18 como muestra los de 1 a 8, con los checks que registra la referencia (desde la v0.9, también el 17 cuando se supera), el release verificado, las extensiones de CONTROL_CBOR, el tamaño y el SHA-256 del texto en claro y, en el formato 2, la regla de relleno y P. También avisa de que el texto está autenticado, pero no prueba quién lo escribió ni que sea el original si otros abrieron la cápsula antes (§55.1).
@ -130,7 +130,7 @@ Medido en Chromium (el navegador de la app de escritorio) sobre la compilación
`/create` (plan de la fase 3, sección 9, con las decisiones del paso 6) cifra un fichero propio en un `.dkc` de formato 2 y, si se pide, en una `.dkk` portable, todo en el navegador y sin red.
- **El formulario** (`create-input.ts`) pide el fichero, elegido o soltado en la página; su nombre no entra en la cápsula. Después, el día y la hora, en la zona del dispositivo, en otra de las que conoce el navegador o en UTC. Y la política: «solo con la fecha» (`time_only`, la de por defecto) o «con la fecha y una clave» (`time_and_key`). Esta lleva destinatarios `age1…`, uno por línea y comprobados mientras se escriben (`recipient.ts`, sin noble), y la casilla de la clave portable, marcada por defecto; como mucho 16 credenciales. Los campos se comprueban en su orden, y el foco va al campo del primer problema.
- **El formulario** (`create-input.ts`) pide el fichero, elegido o soltado en la página; su nombre no entra en la cápsula. Después, el día y la hora, en la zona del dispositivo, en otra de las que conoce el navegador o en UTC. Y la política: «solo con la fecha» (`time_only`, la de por defecto) o «con la fecha y una clave» (`time_and_key`). Esta lleva destinatarios `age1…`, uno por línea y comprobados mientras se escriben (`recipient.ts`, sin noble), y la casilla de la clave portable, marcada por defecto; como mucho 16 credenciales. Una ayuda plegable explica qué es un destinatario de `age` y cómo se consigue: quien abrirá la cápsula crea su par con `age-keygen` y envía solo su `age1…`. Los campos se comprueban en su orden, y el foco va al campo del primer problema.
- **La hora local** (`localtime.ts`) se convierte al instante UTC con `Intl` y las reglas de zona que conoce hoy el navegador. Una hora que la zona se salta al adelantar los relojes se rechaza. De una que repite al atrasarlos se toma la más tardía, para no abrir nunca antes de lo querido. La cápsula no guarda la zona.
- **Antes de cifrar**, la página muestra el instante efectivo, que es el de la ronda, en la zona elegida y en UTC, y cuánto cae después del pedido. También la ronda, la `dk1_`, la hora del dispositivo junto a la UTC, el tamaño exacto del `.dkc` (`capsuleLength`) y lo que la cápsula deja ver hasta la fecha (§55.2). Y los avisos: el de protocolo preliminar (§74), siempre; los de §53 y §50, con sus textos, si el instante efectivo está a más de 365 días (`LONG_HORIZON_SECONDS`); y uno informativo si está a menos de una hora. La hora del dispositivo se lee cada segundo mientras la pestaña se ve y al crear la cápsula; la página no la pregunta a ningún servidor. Si el navegador no conoce la zona del dispositivo (V8 da entonces `Etc/Unknown`), la página empieza en UTC.
- **El writer se carga bajo demanda**: la página importa `creator.ts` con `import()` al pulsar «Crear la cápsula», y con él `encrypt.ts`, noble y `age-encryption`. El relleno es siempre `reforzado`, y no hay extensiones. Lo escrito no se ofrece si no mide lo que se mostró o si los pasos 1 a 8 lo rechazan, y ante cualquier fallo se borra la `.dkk`. Si la fecha llega mientras la página se prepara para escribir, el writer la rechaza con su propio reloj (§62.1, regla 2), y la página lo dice en el campo de la fecha.
@ -320,8 +320,8 @@ Y según `check-build.mjs` el 29-09-2026, con la página de crear:
| `/create` | JavaScript | gzip |
|---|---|---|
| Primera carga, con el formulario y el informe de los pasos 1 a 8 | 207 348 B | 76 525 B |
| Bajo demanda, al crear: `creator.ts`, `encrypt.ts`, noble y `age-encryption` | 212 515 B | 76 534 B |
| Primera carga, con el formulario y el informe de los pasos 1 a 8 | 208 755 B | 76 942 B |
| Bajo demanda, al crear: `creator.ts`, `encrypt.ts`, noble y `age-encryption` | 212 685 B | 76 617 B |
Los 9,5 KB con gzip que crece la primera carga de `/inspect` son la interfaz de la apertura (`OpenPanel.svelte`) y sus módulos sin noble. Las cifras exactas cambian unos bytes en cada compilación, por la versión que SvelteKit incrusta.

@ -7,18 +7,20 @@
// 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
// The plaintext of a fixture is opened in memory. 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.
// the browser has no such file or refuses it. Once the capsule opened, the
// start of the plaintext is shown when it is text (plaintextPreview). 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 { errorGloss, escapeInvisible, formatByteCount, formatInteger } from '$lib/inspector/format.ts';
import { buildOpenReport, type OpenReport, plaintextFileName, plaintextPreview, SHOWN_TEXT } 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';
@ -43,8 +45,6 @@
/** 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;
@ -188,9 +188,10 @@
return;
}
const opened = attempt.opened.error === undefined;
let text: string | undefined;
if (opened && fixture !== undefined && attempt.plaintext !== undefined) {
text = printableText(attempt.plaintext);
let shown: { text: string; cut: boolean } | undefined;
if (opened && attempt.preview !== undefined) {
shown = plaintextPreview(attempt.preview.bytes, attempt.preview.whole);
attempt.preview.bytes.fill(0);
}
const r = buildOpenReport(
attempt.opened,
@ -199,7 +200,7 @@
: {
...attempt.digest,
...(fixture?.plaintextSHA256 === undefined ? {} : { expectedSHA256: fixture.plaintextSHA256 }),
...(text === undefined ? {} : { text }),
...(shown === undefined ? {} : { text: shown.text, textCut: shown.cut }),
},
);
let download: Result['download'];
@ -297,7 +298,12 @@
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>
<p id="ids-hint" class="hint">
Si te enviaron la cápsula como destinatario de age, pega aquí tu identidad secreta: la línea
<code>AGE-SECRET-KEY-1…</code> del fichero que creaste con <code>age-keygen</code>. Sirve pegar el fichero entero,
porque las líneas que empiezan por <code>#</code> se ignoran. Una por línea. Solo se usa en este navegador, para
probar cuál de los stanzas de la cápsula abre, y se borra después.
</p>
</div>
</fieldset>
{/if}
@ -335,8 +341,8 @@
<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
El texto descifrado se escribe en un fichero temporal privado de este navegador y solo se muestra y se ofrece
si age lo autentica entero (§56); si es texto, la página muestra el principio. 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}
@ -425,13 +431,21 @@
{#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>
<pre class="plaintext">{p.textCut ? `${p.text}\n…` : p.text}</pre>
{#if p.textCut}
<p class="muted">
Se muestra el principio, como mucho {formatInteger(SHOWN_TEXT)} caracteres.{fixture === undefined
? ' Descárgalo para verlo entero.'
: ''}
</p>
{/if}
{/if}
{:else if fixture !== undefined}
<p class="muted">No es texto UTF-8 imprimible, así que no se muestra.</p>
{:else}
<p class="muted">
No es texto UTF-8 imprimible, así que la página no lo muestra{fixture === undefined
? ': descárgalo y ábrelo con el programa que corresponda.'
: '.'}
</p>
{/if}
{#if result.download}
{@const d = result.download}

@ -1,7 +1,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { equalBytes, fromHex, type Instant } from '../dkc/index.ts';
import { encrypt } from '../dkc/encrypt.ts';
import { equalBytes, fromHex, type Instant, quicknet, roundTime, TIME_ONLY } from '../dkc/index.ts';
import { listTestdata, readBytes, readJSON } from '../dkc/testing/testdata.ts';
import { openCapsule, type OpenRequest, parseIdentities, systemClock } from './opener.ts';
import { openCapsule, type OpenRequest, parseIdentities, PREVIEW_BYTES, systemClock } from './opener.ts';
import type { TempFile } from './tempfile.ts';
afterEach(() => {
@ -106,6 +107,29 @@ describe('openCapsule', () => {
expect(r.opened.error?.code).toBe('ERR_RELEASE_INVALID');
expect(r.digest).toBeUndefined();
expect(r.plaintext).toBeUndefined();
expect(r.preview).toBeUndefined();
});
it('gives the start of the plaintext to show, whole up to PREVIEW_BYTES, from memory and from the temporary file', async () => {
const f = fixture('time_only');
const plaintext = readBytes('fixtures/time_only.plaintext');
const inMemory = await openCapsule(f.request);
const inFile = await openCapsule({ ...f.request, capsule: blob(f.dkc), output: memoryTemp() });
for (const r of [inMemory, inFile]) {
if (!r.ok) throw new Error(r.problem);
expect([r.preview!.whole, equalBytes(r.preview!.bytes, plaintext)]).toEqual([true, true]);
}
// A plaintext longer than PREVIEW_BYTES, sealed for the round of the fixture.
const body = Uint8Array.from({ length: PREVIEW_BYTES + 1000 }, (_, i) => 0x61 + (i % 26));
const round = f.record.release.round;
const p = quicknet();
const sealed = await encrypt(body, { profile: p, unlockAt: roundTime(p, round), policy: TIME_ONLY, now: () => ({ seconds: 0, nanos: 0 }) });
const big = { ...f.request, capsule: sealed.dkc! };
for (const r of [await openCapsule(big), await openCapsule({ ...big, capsule: blob(sealed.dkc!), output: memoryTemp() })]) {
if (!r.ok) throw new Error(r.problem);
expect([r.preview!.bytes.length, r.preview!.whole, equalBytes(r.preview!.bytes, body.subarray(0, PREVIEW_BYTES))]).toEqual([PREVIEW_BYTES, false, true]);
}
});
it('does not run open when an identity line is not an identity, and never quotes it', async () => {

@ -43,10 +43,19 @@ export type OpenAttempt =
readonly plaintext?: Uint8Array;
/** Length and SHA-256 of the plaintext, when the capsule opened. */
readonly digest?: { readonly length: number; readonly sha256: string };
/**
* The first PREVIEW_BYTES of the plaintext, when the capsule opened,
* and whether they are all of it: what the page may show. The caller
* wipes them.
*/
readonly preview?: { readonly bytes: Uint8Array; readonly whole: boolean };
/** How long open took, in milliseconds. */
readonly ms: number;
};
/** How much of the plaintext the page reads to show it. */
export const PREVIEW_BYTES = 128 << 10;
/** The system clock as an Instant. */
export function systemClock(): Instant {
const ms = Date.now();
@ -101,10 +110,13 @@ export async function openCapsule(req: OpenRequest): Promise<OpenAttempt> {
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 digest = { length: file.size, sha256: toHex(await sha256Stream(file.stream())) };
const bytes = new Uint8Array(await file.slice(0, PREVIEW_BYTES).arrayBuffer());
return { ok: true, opened, digest, preview: { bytes, whole: file.size <= PREVIEW_BYTES }, ms };
}
const plaintext = opened.plaintext!;
return { ok: true, opened, plaintext, digest: { length: plaintext.length, sha256: toHex(await sha256(plaintext)) }, ms };
const preview = { bytes: plaintext.slice(0, PREVIEW_BYTES), whole: plaintext.length <= PREVIEW_BYTES };
return { ok: true, opened, plaintext, digest: { length: plaintext.length, sha256: toHex(await sha256(plaintext)) }, preview, ms };
} finally {
for (const id of ids) id.fill(0);
}

@ -4,7 +4,7 @@ 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';
import { buildOpenReport, plaintextFileName, plaintextPreview, SHOWN_TEXT } from './opening.ts';
interface Record {
release: { round: number; signature: string };
@ -168,3 +168,51 @@ describe('plaintextFileName', () => {
expect(plaintextFileName('a\u200bb\u0000.dkc')).toBe('a_b_');
});
});
describe('plaintextPreview', () => {
const te = new TextEncoder();
const whole = (s: string) => plaintextPreview(te.encode(s), true);
// The first bytes of a longer plaintext: `s` cut after `n` bytes.
const start = (s: string, n: number) => plaintextPreview(te.encode(s).subarray(0, n), false);
it('shows a whole text as it is, and an empty one as empty', () => {
expect(whole('Hola desde DateKeys\n\tlínea 2 ✓')).toEqual({ text: 'Hola desde DateKeys\n\tlínea 2 ✓', cut: false });
expect(whole('')).toEqual({ text: '', cut: false });
});
it('shows a text written on Windows: CR LF as a line feed and no BOM; the download keeps the bytes', () => {
expect(whole('primera\r\nsegunda\r\n')).toEqual({ text: 'primera\nsegunda\n', cut: false });
// A lone CR, or a BOM that is not at the start, is not a printable text.
expect(whole('a\rb')).toBeUndefined();
expect(whole('ab')).toBeUndefined();
});
it('shows nothing of what is not UTF-8 made of printable characters', () => {
expect(plaintextPreview(new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x00]), true)).toBeUndefined();
expect(plaintextPreview(new Uint8Array([0xff, 0xfe, 0x41, 0x00]), true)).toBeUndefined();
expect(whole('a‮b')).toBeUndefined();
});
it('drops a character of 2, 3 or 4 bytes that the first bytes cut, and a CR whose LF they cut', () => {
expect(start('añ', 2)).toEqual({ text: 'a', cut: true });
expect(start('añ', 3)).toEqual({ text: 'añ', cut: true });
expect(start('a€', 2)).toEqual({ text: 'a', cut: true });
expect(start('a€', 3)).toEqual({ text: 'a', cut: true });
expect(start('a€', 4)).toEqual({ text: 'a€', cut: true });
expect(start('a😀', 4)).toEqual({ text: 'a', cut: true });
expect(start('a😀', 5)).toEqual({ text: 'a😀', cut: true });
expect(start('ab', 2)).toEqual({ text: 'ab', cut: true });
expect(start('a\r\nb', 2)).toEqual({ text: 'a', cut: true });
expect(start('\r\nb', 1)).toEqual({ text: '', cut: true });
// Continuation bytes with no lead byte are not UTF-8, cut or not.
expect(plaintextPreview(new Uint8Array([0x61, 0x80, 0x80, 0x80, 0x80]), false)).toBeUndefined();
expect(plaintextPreview(new Uint8Array([0x80]), false)).toBeUndefined();
expect(plaintextPreview(new Uint8Array(0), false)).toEqual({ text: '', cut: true });
});
it('shows at most SHOWN_TEXT characters, counted as characters and not UTF-16 units', () => {
const long = '😀'.repeat(SHOWN_TEXT + 1);
expect(whole(long)).toEqual({ text: '😀'.repeat(SHOWN_TEXT), cut: true });
expect(whole('😀'.repeat(SHOWN_TEXT))).toEqual({ text: '😀'.repeat(SHOWN_TEXT), cut: false });
});
});

@ -5,7 +5,7 @@
import type { Opened } from '../dkc/open.ts';
import { type ErrorCode, paddingName, TIME_AND_KEY, toHex } from '../dkc/index.ts';
import { OPEN_STEPS_TIME_AND_KEY, OPEN_STEPS_TIME_ONLY, openStepGloss, safeFileName } from './format.ts';
import { OPEN_STEPS_TIME_AND_KEY, OPEN_STEPS_TIME_ONLY, openStepGloss, printableText, 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. */
@ -17,6 +17,43 @@ export interface PlaintextFacts {
readonly expectedSHA256?: string;
/** The plaintext as text, when the page shows it and it is printable. */
readonly text?: string;
/** The text is only the start of the plaintext. */
readonly textCut?: boolean;
}
/** The most characters of a plaintext that the page shows. */
export const SHOWN_TEXT = 100_000;
/**
* What the page shows of a plaintext from its first bytes, `whole` when they
* are all of it: the text, when it is UTF-8 made of printable characters,
* tabs and line feeds (printableText), up to SHOWN_TEXT characters, and
* whether it is cut. A leading BOM is dropped and CR LF shown as a line feed,
* as a text editor does, so that a text file written on Windows shows too;
* the download keeps the exact bytes. Bytes cut in the middle of a character
* lose that character. Undefined when the plaintext is not such a text.
*/
export function plaintextPreview(head: Uint8Array, whole: boolean): { text: string; cut: boolean } | undefined {
let end = head.length;
if (!whole) {
// Back to the lead byte of the last character, dropped if incomplete.
let lead = end - 1;
while (lead > 0 && lead > end - 4 && (head[lead]! & 0xc0) === 0x80) lead--;
const b = head[lead] ?? 0;
const size = b < 0x80 ? 1 : b >= 0xf0 ? 4 : b >= 0xe0 ? 3 : 2;
// Continuation bytes with no lead byte are left for printableText to refuse.
if ((b & 0xc0) !== 0x80 && lead + size > end) end = lead;
// A CR whose LF was cut off.
if (head[end - 1] === 0x0d) end--;
}
const start = head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf ? 3 : 0;
const bytes: number[] = [];
for (let i = start; i < end; i++) if (!(head[i] === 0x0d && head[i + 1] === 0x0a)) bytes.push(head[i]!);
const text = printableText(Uint8Array.from(bytes));
if (text === undefined) return undefined;
const chars = [...text];
if (chars.length > SHOWN_TEXT) return { text: chars.slice(0, SHOWN_TEXT).join(''), cut: true };
return { text, cut: !whole };
}
export interface OpenReport {

@ -577,6 +577,27 @@
Opcional. Cada uno abrirá la cápsula con su identidad de age (AGE-SECRET-KEY-1…), que nunca se pega aquí. Como
mucho 15 con la clave portable, o 16 sin ella.
</p>
<details class="help">
<summary>Qué es un destinatario de age y cómo se consigue</summary>
<ol>
<li>
Quien vaya a abrir la cápsula crea su par de claves con la herramienta <code>age</code>:
<code>age-keygen -o clave.txt</code>. El fichero guarda su identidad secreta (<code>AGE-SECRET-KEY-1…</code>),
y el comando muestra su clave pública (<code>age1…</code>).
</li>
<li>Te manda solo la línea <code>age1…</code>, por el canal que sea: es pública.</li>
<li>
La pegas aquí, una por persona. Llegada la fecha, esa persona abre la cápsula en el
<a href={resolve('/inspect')}>inspector</a> con la firma de la ronda y su identidad secreta, que no sale de su
equipo.
</li>
</ol>
<p>
Solo valen claves X25519, las <code>age1…</code> de <code>age-keygen</code>: no valen las de SSH ni las
poscuánticas, que empiezan por <code>age1pq1</code>. Sin <code>age</code>, la clave portable (<code>.dkk</code>) hace
el mismo papel: la genera esta página y la envías tú.
</p>
</details>
</div>
<label class="check">
<input id="portable-input" type="checkbox" bind:checked={portable} aria-invalid={invalid('portable')} aria-describedby={described('portable')} />
@ -979,6 +1000,26 @@
color: var(--ink-muted);
max-width: var(--measure);
}
.help {
font-size: var(--t-small);
color: var(--ink-muted);
max-width: var(--measure);
}
.help summary {
width: fit-content;
font-weight: 600;
color: var(--ink);
cursor: pointer;
}
.help ol {
margin: 0.5rem 0;
padding-left: 1.25rem;
display: grid;
gap: 0.35rem;
}
.help code {
overflow-wrap: anywhere;
}
.inline-problem {
display: block;
color: var(--fail);

Loading…
Cancel
Save

Powered by TurnKey Linux.