From 2ec7102f1b383a88c41e62f5f2727464e43eed29 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 1 Oct 2026 02:10:44 +0200 Subject: [PATCH] /inspect asks drand for the release when the person asks The author found copying the release of the round tedious. A button, "Pedir la firma a drand", fetches it from the three public relays of the CLI of the reference, as its client does: raced, 6 s, at most 8 KiB an answer, no redirects, and the randomness checked against the signature; step 10 still verifies the signature with the pinned key, so a relay cannot make the page accept a false one. It is the only connection the page makes to another site, and only on that click: the CSP allows those three origins in connect-src, check-build.mjs requires exactly them, and the footer says so. Pasting by hand still works. Checked in Chromium: api2.drand.sh gave the release of round 32668196 and the capsule opened. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- scripts/check-build.mjs | 5 +- src/lib/components/OpenPanel.svelte | 35 ++++++++- src/lib/inspector/drand.test.ts | 82 ++++++++++++++++++++ src/lib/inspector/drand.ts | 112 ++++++++++++++++++++++++++++ src/routes/+layout.svelte | 2 +- svelte.config.js | 7 +- vitest.config.ts | 1 + 8 files changed, 237 insertions(+), 11 deletions(-) create mode 100644 src/lib/inspector/drand.test.ts create mode 100644 src/lib/inspector/drand.ts diff --git a/README.md b/README.md index bcfabf5..185729d 100644 --- a/README.md +++ b/README.md @@ -204,12 +204,12 @@ Rendimiento, informativo, en ese navegador con la ventana en segundo plano: una `kit.csp` (`svelte.config.js`, modo `hash`) pone en cada página prerenderizada, como primer elemento que carga algo, un ``: ``` -default-src 'self'; frame-src 'none'; worker-src 'none'; connect-src 'self'; font-src 'self'; +default-src 'self'; frame-src 'none'; worker-src 'none'; connect-src 'self' https://api.drand.sh https://api2.drand.sh https://api3.drand.sh; font-src 'self'; img-src 'self'; manifest-src 'self'; object-src 'none'; script-src 'self' 'sha256-…'; style-src 'self'; style-src-attr 'unsafe-hashes' 'sha256-…'; base-uri 'none'; form-action 'none' ``` -- `connect-src 'self'`: `fetch` solo llega al propio origen, y solo se usa para los fixtures. Tampoco llega a URL `blob:`. La descarga del texto en claro es una navegación a una URL `blob:` y el enlace a drand lo abre la persona en otra pestaña: ninguno es una conexión de la página. +- `connect-src`: `fetch` llega al propio origen, para los fixtures, y a los tres relays públicos de drand que usa la CLI de la referencia, solo cuando la persona pulsa «Pedir la firma a drand» en `/inspect` (`drand.ts`: en carrera, 6 s, como mucho 8 KiB, sin redirecciones, y la aleatoriedad comprobada contra la firma, que el paso 10 verifica con la clave fijada). El relay ve la IP y la ronda pedida. Tampoco llega a URL `blob:`: la descarga del texto en claro es una navegación. - `script-src`: los módulos del sitio y el hash SHA-256 del único script en línea, el arranque de SvelteKit (los nonces no sirven en HTML prerenderizado). - `style-src 'self'`: solo hojas de estilo del sitio; sin fuentes web ni CDN, con las fuentes del sistema. - `style-src-attr`: solo el atributo `style` del anunciador de rutas de SvelteKit, por su hash (`ANNOUNCER_STYLE_HASH`, válido para `@sveltejs/kit` 2.70.3; `app.css` lo oculta también si el navegador bloquea el atributo). diff --git a/scripts/check-build.mjs b/scripts/check-build.mjs index 300f5f6..c3c6fe4 100644 --- a/scripts/check-build.mjs +++ b/scripts/check-build.mjs @@ -4,7 +4,8 @@ // // - a route of src/routes has no prerendered HTML page; // - a page lacks the Content-Security-Policy , or the policy is not -// the one promised (default-src 'self', connect-src 'self', object-src +// the one promised (default-src 'self', connect-src 'self' and the three +// relays of drand, object-src // 'none', base-uri 'none', form-action 'none', scripts and styles from the // origin only), allows another origin, a scheme or 'unsafe-*', or comes // after anything the browser could fetch; @@ -88,7 +89,7 @@ const unescapeHtml = (s) => const REQUIRED = { 'default-src': ["'self'"], - 'connect-src': ["'self'"], + 'connect-src': ["'self'", 'https://api.drand.sh', 'https://api2.drand.sh', 'https://api3.drand.sh'], 'style-src': ["'self'"], 'img-src': ["'self'"], 'font-src': ["'self'"], diff --git a/src/lib/components/OpenPanel.svelte b/src/lib/components/OpenPanel.svelte index 650ec12..2d75711 100644 --- a/src/lib/components/OpenPanel.svelte +++ b/src/lib/components/OpenPanel.svelte @@ -29,6 +29,7 @@ import { errorGloss, escapeInvisible, formatByteCount, formatDateTime, formatInteger } from '$lib/inspector/format.ts'; 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 type { Report } from '$lib/inspector/report.ts'; import { browserPlatform, cancellable, createTempFile, freeSpace, type TempFile } from '$lib/inspector/tempfile.ts'; @@ -107,6 +108,24 @@ 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)); + + // The release from the relays of drand, only when the person asks for it: + // the one connection the page makes to another site (drand.ts). + let asking = $state(false); + let askNote = $state(''); + async function askDrand(): Promise { + asking = true; + askNote = ''; + try { + const r = await fetchRelease(report.profile!.chainHash, round); + releaseInput = releaseText(r.round, r.signature); + askNote = `Firma recibida de ${new URL(r.relay).host}. Se comprueba al abrir la cápsula.`; + } catch (err) { + askNote = `${err instanceof Error ? err.message : String(err)} Puedes pegarla a mano.`; + } finally { + asking = false; + } + } const payloadLength = $derived(report.prelude?.payloadLength ?? 0); // The temporary file and the object URLs of the last opening. @@ -400,15 +419,25 @@ aria-invalid={problemField === 'release' ? 'true' : undefined} aria-describedby={problemField === 'release' ? 'open-problem release-hint' : 'release-hint'} > + {#if fixture?.release === undefined} +
+ +
+ {#if askNote !== ''} +

{askNote}

+ {/if} + {/if}

{#if fixture?.release !== undefined} Viene del registro del fixture: es la que publicó drand para la ronda {round}, como muestra su página en drand. Cámbiala para ver cómo la rechaza el paso 10. {:else} - Abre la firma de la ronda {round} en drand 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ú. + «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 la firma de la ronda {round} en drand en otra + pestaña, copiar lo que muestra y pegarlo aquí; vale también la firma sola, en hexadecimal. {/if} Solo se leen la ronda y la firma, que se verifican aquí con la clave pública del perfil fijado (§51).

diff --git a/src/lib/inspector/drand.test.ts b/src/lib/inspector/drand.test.ts new file mode 100644 index 0000000..cc29a50 --- /dev/null +++ b/src/lib/inspector/drand.test.ts @@ -0,0 +1,82 @@ +// Tests of drand.ts: the release of a round from the public relays, raced, +// with a fetch that stands for the relays. + +import { describe, expect, it } from 'vitest'; +import { fetchRelease, RELAYS } from './drand.ts'; + +const CHAIN = '52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971'; +// The release of round 32668196, as api.drand.sh gave it. +const SIG = '8d7fdb1526df070026a1da478690f84e39daf609407f9516096da4ed2ae43641f250b8fc470bebc0de133b8d0a76e38a'; +const RANDOMNESS = '46b86c3aaf32c9f0695ec2cdf6fa4b77812791917ef74e6544b8473a2b9c6315'; +const ROUND = 32668196; + +type Answer = Response | Error | 'hang'; + +// A fetch that answers each relay as `answers` says, and records what it was asked. +function relays(answers: Record, asked: string[] = []): typeof fetch { + return ((url: string, init?: RequestInit) => { + asked.push(url); + const a = answers[new URL(url).host]!; + if (a === 'hang') { + return new Promise((_, reject) => init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason))); + } + return a instanceof Error ? Promise.reject(a) : Promise.resolve(a); + }) as typeof fetch; +} + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); +const good = () => json({ round: ROUND, signature: SIG, randomness: RANDOMNESS }); + +describe('fetchRelease', () => { + it('asks every relay for the round of the chain, and takes the first release that holds together', async () => { + const asked: string[] = []; + const r = await fetchRelease(CHAIN, ROUND, relays({ 'api.drand.sh': new TypeError('offline'), 'api2.drand.sh': good(), 'api3.drand.sh': 'hang' }, asked)); + expect(r).toEqual({ round: ROUND, signature: SIG, relay: 'https://api2.drand.sh' }); + expect(asked).toEqual(RELAYS.map((h) => `${h}/v2/chains/${CHAIN}/rounds/${ROUND}`)); + const noRandomness = await fetchRelease(CHAIN, ROUND, relays({ 'api.drand.sh': json({ round: ROUND, signature: SIG.toUpperCase() }), 'api2.drand.sh': 'hang', 'api3.drand.sh': 'hang' })); + expect(noRandomness.signature).toBe(SIG); + }); + + it('says when drand has not published the round yet', async () => { + const notYet = () => new Response('not found', { status: 404 }); + await expect(fetchRelease(CHAIN, ROUND, relays({ 'api.drand.sh': notYet(), 'api2.drand.sh': notYet(), 'api3.drand.sh': notYet() }))).rejects.toThrow( + `drand aún no ha publicado la firma de la ronda ${ROUND}.`, + ); + }); + + it('names what went wrong with each relay', async () => { + const answers: Record = { + 'api.drand.sh': new Response('nope', { status: 500 }), + 'api2.drand.sh': new Response('', { status: 200 }), + 'api3.drand.sh': json({ round: ROUND + 1, signature: SIG }), + }; + await expect(fetchRelease(CHAIN, ROUND, relays(answers))).rejects.toThrow( + `Ningún relay de drand dio la firma: api.drand.sh respondió HTTP 500; api2.drand.sh respondió algo que no es una firma; api3.drand.sh dio la ronda ${ROUND + 1}, no la ${ROUND}.`, + ); + const worse: Record = { + 'api.drand.sh': json({ round: ROUND, signature: 'xyz' }), + 'api2.drand.sh': json({ round: ROUND, signature: SIG, randomness: '00' }), + 'api3.drand.sh': new Response('x'.repeat(9000), { status: 200 }), + }; + await expect(fetchRelease(CHAIN, ROUND, relays(worse))).rejects.toThrow( + 'Ningún relay de drand dio la firma: api.drand.sh dio una firma que no es hexadecimal; api2.drand.sh dio una aleatoriedad que no es la de su firma; api3.drand.sh respondió demasiado.', + ); + const odd: Record = { + 'api.drand.sh': new Response(null, { status: 200 }), + 'api2.drand.sh': json(null), + 'api3.drand.sh': new TypeError('blocked'), + }; + await expect(fetchRelease(CHAIN, ROUND, relays(odd))).rejects.toThrow( + 'Ningún relay de drand dio la firma: api.drand.sh respondió algo que no es una firma; api2.drand.sh dio la ronda undefined, no la 32668196; api3.drand.sh no se pudo conectar.', + ); + }); + + it('gives up after its timeout', async () => { + await expect(fetchRelease(CHAIN, ROUND, relays({ 'api.drand.sh': 'hang', 'api2.drand.sh': 'hang', 'api3.drand.sh': 'hang' }), 20)).rejects.toThrow( + 'Ningún relay de drand dio la firma: api.drand.sh no contestó a tiempo; api2.drand.sh no contestó a tiempo; api3.drand.sh no contestó a tiempo.', + ); + const late = ((_: string, init?: RequestInit) => + new Promise((_, reject) => init!.signal!.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))))) as typeof fetch; + await expect(fetchRelease(CHAIN, ROUND, late, 20)).rejects.toThrow('api.drand.sh no contestó a tiempo'); + }); +}); diff --git a/src/lib/inspector/drand.ts b/src/lib/inspector/drand.ts new file mode 100644 index 0000000..0ae5d29 --- /dev/null +++ b/src/lib/inspector/drand.ts @@ -0,0 +1,112 @@ +// The release of a round from the public relays of drand, only when the +// person asks for it: the one connection the page makes to another site. +// The relays are those of the CLI of the reference, raced as its client +// races them (provider/drand/client.go): the same path, a timeout of 6 s, at +// most 8 KiB an answer, no redirects, and the randomness checked against the +// signature. The signature itself is verified in step 10 with the pinned +// public key, so a relay cannot make the page accept a false one. A relay +// sees the address of the person and the round asked for. + +/** The relays the page may reach, as the Content-Security-Policy lists them. */ +export const RELAYS = ['https://api.drand.sh', 'https://api2.drand.sh', 'https://api3.drand.sh'] as const; + +const TIMEOUT_MS = 6000; +const MAX_RESPONSE = 8 << 10; + +/** A release as a relay gave it: its round, its signature in hexadecimal, and who gave it. */ +export interface FetchedRelease { + readonly round: number; + readonly signature: string; + readonly relay: string; +} + +// A failure of one relay, in the words of the page. +class RelayError extends Error { + readonly notYet: boolean; + + constructor(message: string, notYet = false) { + super(message); + this.notYet = notYet; + } +} + +const host = (relay: string): string => new URL(relay).host; + +/** + * The release of `round` of the chain `chainHash` (hexadecimal), from the + * first relay that answers with one that holds together; otherwise an + * Error that says why, in Spanish. + */ +export async function fetchRelease(chainHash: string, round: number, fetcher: typeof fetch = fetch, timeoutMs = TIMEOUT_MS): Promise { + const stop = new AbortController(); + const timer = setTimeout(() => stop.abort(new RelayError('no contestó a tiempo')), timeoutMs); + const one = async (relay: string): Promise => { + let res: Response; + try { + res = await fetcher(`${relay}/v2/chains/${chainHash}/rounds/${round}`, { + signal: stop.signal, + headers: { Accept: 'application/json' }, + redirect: 'error', + credentials: 'omit', + referrerPolicy: 'no-referrer', + }); + } catch (err) { + throw err instanceof RelayError ? err : new RelayError(stop.signal.aborted ? 'no contestó a tiempo' : 'no se pudo conectar'); + } + if (res.status === 404) throw new RelayError('aún no la ha publicado', true); + if (res.status !== 200) throw new RelayError(`respondió HTTP ${res.status}`); + const body = await bounded(res); + let wire: unknown; + try { + wire = JSON.parse(body); + } catch { + throw new RelayError('respondió algo que no es una firma'); + } + const { round: got, signature, randomness } = (typeof wire === 'object' && wire !== null ? wire : {}) as Record; + if (got !== round) throw new RelayError(`dio la ronda ${String(got)}, no la ${round}`); + if (typeof signature !== 'string' || !/^(?:[0-9a-f]{2})+$/i.test(signature)) throw new RelayError('dio una firma que no es hexadecimal'); + if (randomness !== undefined && (typeof randomness !== 'string' || randomness.toLowerCase() !== (await sha256Hex(signature)))) { + throw new RelayError('dio una aleatoriedad que no es la de su firma'); + } + return { round, signature: signature.toLowerCase(), relay }; + }; + try { + return await Promise.any(RELAYS.map(one)); + } catch (err) { + const failures = (err as AggregateError).errors as RelayError[]; + if (failures.every((f) => f.notYet)) throw new Error(`drand aún no ha publicado la firma de la ronda ${round}.`); + throw new Error(`Ningún relay de drand dio la firma: ${failures.map((f, i) => `${host(RELAYS[i]!)} ${f.message}`).join('; ')}.`); + } finally { + clearTimeout(timer); + stop.abort(); + } +} + +// The body of an answer, read up to MAX_RESPONSE bytes and not one more. +async function bounded(res: Response): Promise { + const reader = res.body?.getReader(); + if (reader === undefined) return ''; + const parts: Uint8Array[] = []; + let n = 0; + for (let r = await reader.read(); !r.done; r = await reader.read()) { + n += r.value.length; + if (n > MAX_RESPONSE) { + await reader.cancel(); + throw new RelayError('respondió demasiado'); + } + parts.push(r.value); + } + const all = new Uint8Array(n); + let at = 0; + for (const p of parts) { + all.set(p, at); + at += p.length; + } + return new TextDecoder().decode(all); +} + +async function sha256Hex(hex: string): Promise { + const bytes = Uint8Array.from(hex.match(/../g)!, (b) => Number.parseInt(b, 16)); + const sum = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); + return Array.from(sum, (b) => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 1e4ad7b..cac1add 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -38,7 +38,7 @@

Protocolo DateKeys {SPEC_VERSION}, librería {VERSION}. La página se ejecuta entera en este navegador y su política de - seguridad no le permite conectarse a ningún otro sitio. Licencias del código de terceros. + seguridad no le permite conectarse a ningún otro sitio, salvo a drand cuando pides la firma de una ronda. Licencias del código de terceros.

diff --git a/svelte.config.js b/svelte.config.js index d588bed..352f5ea 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -26,8 +26,9 @@ const config = { // external files (inlineStyleThreshold stays 0), so style-src needs no // hash, and style-src-attr allows the announcer's style attribute alone. // Nonces cannot work in prerendered HTML. The fixtures are fetched - // from the same origin, so connect-src 'self' is enough: no other origin - // can be reached from the page. + // from the same origin; the only other origins the page may reach are + // the public relays of drand, when the person asks for the release of a + // round (src/lib/inspector/drand.ts, as the CLI of the reference). csp: { mode: 'hash', directives: { @@ -37,7 +38,7 @@ const config = { 'style-src-attr': ['unsafe-hashes', ANNOUNCER_STYLE_HASH], 'img-src': ['self'], 'font-src': ['self'], - 'connect-src': ['self'], + 'connect-src': ['self', 'https://api.drand.sh', 'https://api2.drand.sh', 'https://api3.drand.sh'], 'manifest-src': ['self'], 'frame-src': ['none'], 'worker-src': ['none'], diff --git a/vitest.config.ts b/vitest.config.ts index 873953a..f7f39aa 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -65,6 +65,7 @@ export default defineConfig({ 'src/lib/inspector/files.ts': { 100: true }, 'src/lib/inspector/create-files.ts': { 100: true }, 'src/lib/inspector/create-check.ts': { 100: true }, + 'src/lib/inspector/drand.ts': { 100: true }, 'src/lib/dkc/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 }, // The page model and helpers of the inspector (plan §8, phase 1). 'src/lib/inspector/**/*.ts': { statements: 95, branches: 90, functions: 95, lines: 95 },