/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 <noreply@anthropic.com>
main
dev 7 days ago
parent 0d98480846
commit 2ec7102f1b

@ -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 `<meta http-equiv="content-security-policy">`:
```
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).

@ -4,7 +4,8 @@
//
// - a route of src/routes has no prerendered HTML page;
// - a page lacks the Content-Security-Policy <meta>, 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'"],

@ -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<void> {
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'}
></textarea>
{#if fixture?.release === undefined}
<div class="actions">
<button class="button quiet" type="button" onclick={askDrand} disabled={asking || busy}>
{asking ? 'Pidiendo la firma…' : 'Pedir la firma a drand'}
</button>
</div>
{#if askNote !== ''}
<p class="hint" role="status">{askNote}</p>
{/if}
{/if}
<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ú.
«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
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).
</p>

@ -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<string, Answer>, 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<string, Answer> = {
'api.drand.sh': new Response('nope', { status: 500 }),
'api2.drand.sh': new Response('<html>', { 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<string, Answer> = {
'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<string, Answer> = {
'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');
});
});

@ -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<FetchedRelease> {
const stop = new AbortController();
const timer = setTimeout(() => stop.abort(new RelayError('no contestó a tiempo')), timeoutMs);
const one = async (relay: string): Promise<FetchedRelease> => {
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<string, unknown>;
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<string> {
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<string> {
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('');
}

@ -38,7 +38,7 @@
<div class="wrap">
<p>
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. <a href={licenses} data-sveltekit-reload>Licencias del código de terceros</a>.
seguridad no le permite conectarse a ningún otro sitio, salvo a drand cuando pides la firma de una ronda. <a href={licenses} data-sveltekit-reload>Licencias del código de terceros</a>.
</p>
</div>
</footer>

@ -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'],

@ -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 },

Loading…
Cancel
Save

Powered by TurnKey Linux.