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
parent
0d98480846
commit
2ec7102f1b
@ -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('');
|
||||||
|
}
|
||||||
Loading…
Reference in new issue