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