// The release object of spec v0.15, §47.1, as provider/release.go, // provider/drandjson.go and provider/archive.go of the Go reference: the // release of a round as data that is kept: an entry of a release cache or // archive and the answer of a release cache or of the Release API; drand's // JSON, which a reader accepts too as the input of the caller, read strictly // since spec v0.16; the sources of a release in the caller's hand // (provider.Supplier); and a local release archive, the informative format // of §50. The texts of the errors are those of the reference. // // No noble here: the verification of a release is release.ts. This module // only decodes, so that the page can read what the person gives before // loading the code that opens a capsule. import { decodeUtf8, equalBytes, fromHex, goQuote, sha256, toHex, utf8Length, } from "./bytes.ts"; import { checkSchema, Decoder, Encoder, MAX_SAFE_UINT, peek, unmarshal, } from "./cbor.ts"; import { DateKeysError, withContext } from "./errors.ts"; import { QUICKNET_SCHEME, type Profile } from "./profile.ts"; /** The type tag of the release object (key 0). */ export const RELEASE_TYPE_TAG = "datekeys-release"; /** The schema version of the release object (key 1). */ export const RELEASE_SCHEMA_VERSION = 1; /** * The largest release object a reader decodes (spec v0.15, §47.1): the * object has no frame, and an input of more bytes, or of none, is rejected * before it is decoded, with ERR_NON_CANONICAL_CBOR. No valid encoding comes * close to it. */ export const MAX_RELEASE_OBJECT_SIZE = 1024; /** The longest signature of the schema: a compressed point of G2 of BLS12-381. */ export const MAX_SIGNATURE_LEN = 96; /** The largest drand JSON read as a release, the bound of a relay response. */ export const MAX_RELEASE_JSON_SIZE = 8 << 10; /** The type tag of the header of a release archive (spec v0.15, §50). */ export const ARCHIVE_TYPE_TAG = "datekeys-release-archive"; /** The schema version of the header of a release archive. */ export const ARCHIVE_SCHEMA_VERSION = 1; const RELEASE_KEYS = 5; const ARCHIVE_KEYS = 5; // Its five keys take at most 1 + 26 + 2 + 35 + 9 + 9 bytes. const MAX_ARCHIVE_HEADER = 128; // The length of a signature of Quicknet, a compressed point of G1. const QUICKNET_SIGNATURE_LEN = 48; /** The material that satisfies a round: for drand, the BLS signature of the round. */ export interface Release { readonly round: number; readonly signature: Uint8Array; /** * The chain the release names, key 2 of a release object (spec v0.15, * §47.1), or undefined when it names none, as the answer of a relay and * drand's JSON. verifyRelease compares it with the pinned profile. */ readonly chainHash?: Uint8Array; } /** * A release as the caller gives it, before step 10. Since spec v0.16 the * round of drand's JSON is at most 2^53 - 1, as that of a release object * (§47.1), so it is a Release. */ export type ParsedRelease = Release; // --------------------------------------------------------------------------- // The release object interface ReleaseWire { chainHash: Uint8Array; round: number; signature: Uint8Array; } function encodeWire(e: Encoder, w: ReleaseWire): void { e.map(RELEASE_KEYS); e.uint(0); e.text(RELEASE_TYPE_TAG); e.uint(1); e.uint(RELEASE_SCHEMA_VERSION); e.uint(2); e.bstr(w.chainHash); e.uint(3); e.uint(w.round); e.uint(4); e.bstr(w.signature); } // Reads the map with every CDDL rule of the release object, all of them // ERR_NON_CANONICAL_CBOR: what each field means against the pinned profile // and the DateKey is checked by verifyRelease, at step 10. function decodeWire(d: Decoder): ReleaseWire { const w: ReleaseWire = { chainHash: new Uint8Array(0), round: 0, signature: new Uint8Array(0), }; const pairs = d.map(RELEASE_KEYS); if (pairs !== RELEASE_KEYS) throw new DateKeysError( "ERR_NON_CANONICAL_CBOR", `${pairs} keys, want all ${RELEASE_KEYS}`, ); for (let want = 0; want < RELEASE_KEYS; want++) { const k = d.key(); if (k !== want) throw new DateKeysError( "ERR_NON_CANONICAL_CBOR", `key ${k} where key ${want} was expected`, ); withContext(`key ${k}`, () => { switch (want) { case 0: d.text(utf8Length(RELEASE_TYPE_TAG)); break; case 1: d.uint(RELEASE_SCHEMA_VERSION); break; case 2: w.chainHash = d.bstr(32, 32); break; case 3: w.round = d.uint(MAX_SAFE_UINT); if (w.round === 0) throw new DateKeysError("ERR_NON_CANONICAL_CBOR", "round 0"); break; default: w.signature = d.bstr(1, MAX_SIGNATURE_LEN); } }); } d.endMap(); return w; } /** * The release object of `r`, as a release cache or archive keeps it (spec v0.15, * §47.1): its chain hash, its round and its signature, as * provider.EncodeRelease. It does not verify the release: verifyRelease * does, against the pinned profile. */ export function encodeRelease(r: Release): Uint8Array { const chainHash = r.chainHash ?? new Uint8Array(0); if (chainHash.length !== 32) { throw new DateKeysError( "ERR_NON_CANONICAL_CBOR", `provider: release object: chain hash of ${chainHash.length} bytes, want 32`, ); } if (!Number.isSafeInteger(r.round) || r.round < 1) { throw new DateKeysError( "ERR_NON_CANONICAL_CBOR", `provider: release object: round ${r.round} outside 1..${MAX_SAFE_UINT}`, ); } if (r.signature.length === 0 || r.signature.length > MAX_SIGNATURE_LEN) { throw new DateKeysError( "ERR_NON_CANONICAL_CBOR", `provider: release object: signature of ${r.signature.length} bytes outside 1..${MAX_SIGNATURE_LEN}`, ); } const e = new Encoder(); encodeWire(e, { chainHash, round: r.round, signature: r.signature }); return e.out(); } /** * Decodes a release object (spec v0.15, §47.1) with the layers of spec §69.1 * that it has, as provider.DecodeRelease: its size, 1 to * MAX_RELEASE_OBJECT_SIZE bytes; its type and schema version * (ERR_NON_CANONICAL_CBOR, then ERR_UNSUPPORTED_VERSION); its encoding and * schema (ERR_NON_CANONICAL_CBOR). The release names its chain, which * verifyRelease checks against the pinned profile at step 10 of spec §63. */ export function decodeRelease(b: Uint8Array): Release { if (b.length === 0 || b.length > MAX_RELEASE_OBJECT_SIZE) { throw new DateKeysError( "ERR_NON_CANONICAL_CBOR", `provider: release object of ${b.length} bytes, outside 1..${MAX_RELEASE_OBJECT_SIZE}`, ); } const w = withContext("provider: release object", () => { checkSchema(b, RELEASE_TYPE_TAG, RELEASE_SCHEMA_VERSION); return unmarshal(b, decodeWire, encodeWire); }); return { round: w.round, signature: w.signature, chainHash: w.chainHash }; } /** * Reports whether `b` is read as drand's JSON rather than as a release * object: its first byte other than a JSON space is "{" (spec v0.15, §47.1). */ export function isDrandJSON(b: Uint8Array): boolean { for (const c of b) { if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) continue; return c === 0x7b; } return false; } /** * Reads a release that the caller supplies, as provider.ParseRelease: * drand's JSON when isDrandJSON, read strictly by parseDrandJSON, or else a * release object, with decodeRelease. drand's JSON is the answer of a relay, * {"round": …, "signature": "…"}, with an optional "randomness" that must be * SHA-256 of the signature; it does not name its chain, so the release has no * chain hash, and any failure to read it is ERR_RELEASE_INVALID. It is * accepted as input, never written. */ export async function parseRelease(b: Uint8Array): Promise { return isDrandJSON(b) ? parseDrandJSON(b) : decodeRelease(b); } // --------------------------------------------------------------------------- // drand's JSON, read strictly // // The strict reading of drand's JSON (spec v0.16, §47.1), as // provider/drandjson.go of the reference: JSON of RFC 8259, in UTF-8, whose // value is an object; no object of the JSON repeats a name, and names are // compared exactly, code point by code point, once their escapes are // decoded, so that "\u0072ound" is round and Round is another name; and an // escape of a surrogate that does not pair with the next one makes the JSON // malformed. A common JSON reader keeps the last of two repeated names, or // does not tell upper from lower case in them, and two readers would see two // rounds in the same input. /** * A member of the outer object of drand's JSON: its name, decoded; the kind * of its value, its first character ('"', '{', '[', 't', 'f' or 'n'), or '0' * for a number; the text of the value as written; and for a string, the * string decoded. */ export interface JSONMember { readonly name: string; readonly kind: string; readonly raw: string; readonly str: string; } /** * Reads `b` as a JSON object with the strict rules of spec v0.16, §47.1, and * returns the members of the outer object in their order, or undefined when * `b` breaks one: it is not UTF-8, its value is not an object, an object * repeats a name, or a string escapes a surrogate without its pair. */ export function strictJSON(b: Uint8Array): JSONMember[] | undefined { const text = decodeUtf8(b); if (text === undefined) return undefined; const r = new JSONReader(text); r.space(); if (!r.at("{")) return undefined; const members = r.object(true); r.space(); return members !== undefined && r.i === text.length ? members : undefined; } // Reads JSON from the text s at i. The text is decoded UTF-8, so it holds no // lone surrogate, and a byte order mark at its start stays, as a character // that is not a space of JSON. class JSONReader { i = 0; private readonly s: string; constructor(s: string) { this.s = s; } at(c: string): boolean { return this.s[this.i] === c; } // Skips the four spaces of JSON. space(): void { while (this.i < this.s.length && " \t\n\r".includes(this.s[this.i]!)) this.i++; } // One value of any kind, undefined when it breaks the grammar. value(): Omit | undefined { const start = this.i; const c = this.s[this.i]; let kind = c; let str = ""; let ok = false; if (c === "{") ok = this.object(false) !== undefined; else if (c === "[") ok = this.array(); else if (c === '"') { const v = this.string(); ok = v !== undefined; str = v ?? ""; } else if (c === "t") ok = this.literal("true"); else if (c === "f") ok = this.literal("false"); else if (c === "n") ok = this.literal("null"); else if (c === "-" || (c !== undefined && c >= "0" && c <= "9")) { kind = "0"; ok = this.number(); } return ok ? { kind: kind!, raw: this.s.slice(start, this.i), str } : undefined; } // An object, with no name twice; its members when keep is set. object(keep: boolean): JSONMember[] | undefined { this.i++; // { this.space(); const out: JSONMember[] = []; if (this.at("}")) { this.i++; return out; } const seen = new Set(); for (;;) { this.space(); if (!this.at('"')) return undefined; const name = this.string(); if (name === undefined || seen.has(name)) return undefined; seen.add(name); this.space(); if (!this.at(":")) return undefined; this.i++; this.space(); const m = this.value(); if (m === undefined) return undefined; if (keep) out.push({ name, ...m }); this.space(); if (this.at(",")) this.i++; else if (this.at("}")) { this.i++; return out; } else return undefined; } } array(): boolean { this.i++; // [ this.space(); if (this.at("]")) { this.i++; return true; } for (;;) { this.space(); if (this.value() === undefined) return false; this.space(); if (this.at(",")) this.i++; else if (this.at("]")) { this.i++; return true; } else return false; } } // A string, with its escapes decoded; a surrogate escaped alone, without // its pair, breaks it. string(): string | undefined { this.i++; // " let out = ""; while (this.i < this.s.length) { const c = this.s[this.i]!; if (c === '"') { this.i++; return out; } if (c < " ") return undefined; if (c !== "\\") { out += c; this.i++; continue; } const e = this.s[this.i + 1]; this.i += 2; const k = e === undefined ? -1 : '"\\/bfnrt'.indexOf(e); if (k >= 0) { out += '"\\/\b\f\n\r\t'[k]; continue; } if (e !== "u") return undefined; let u = this.hex4(); if (u === undefined || (u >= 0xdc00 && u <= 0xdfff)) return undefined; if (u >= 0xd800 && u <= 0xdbff) { if (!this.s.startsWith("\\u", this.i)) return undefined; this.i += 2; const low = this.hex4(); if (low === undefined || low < 0xdc00 || low > 0xdfff) return undefined; u = 0x10000 + ((u - 0xd800) << 10) + (low - 0xdc00); } out += String.fromCodePoint(u); } return undefined; } // The four hexadecimal digits of an escape \u. hex4(): number | undefined { const h = this.s.slice(this.i, this.i + 4); if (!/^[0-9a-fA-F]{4}$/.test(h)) return undefined; this.i += 4; return parseInt(h, 16); } literal(word: string): boolean { if (!this.s.startsWith(word, this.i)) return false; this.i += word.length; return true; } // A number of the grammar of RFC 8259: a minus, an integer part without // leading zeros, and an optional fraction and exponent. number(): boolean { const digits = (): number => { const from = this.i; while (this.i < this.s.length && /[0-9]/.test(this.s[this.i]!)) this.i++; return this.i - from; }; if (this.at("-")) this.i++; if (this.at("0")) this.i++; else if (digits() === 0) return false; if (this.at(".")) { this.i++; if (digits() === 0) return false; } if (this.at("e") || this.at("E")) { this.i++; if (this.at("+") || this.at("-")) this.i++; if (digits() === 0) return false; } return true; } } /** * The round of drand's JSON (spec v0.16, §47.1): a number without sign, * fraction or exponent, from 1 to 2^53 - 1, as in the release object; * undefined for any other member. */ export function jsonRound(m: JSONMember): number | undefined { if (m.kind !== "0" || !/^[1-9][0-9]{0,15}$/.test(m.raw)) return undefined; const n = Number(m.raw); return n <= MAX_SAFE_UINT ? n : undefined; } /** * Reads the JSON of a drand relay with the strict rules of spec v0.16, §47.1, * as provider.ParseDrandJSON: at most MAX_RELEASE_JSON_SIZE bytes of JSON * whose value is an object, with no repeated name and names compared exactly * once their escapes are decoded; "round" a number without sign, fraction or * exponent, from 1 to 2^53 - 1; "signature" a string of hexadecimal, in lower * or upper case; and "randomness", when present, a string with SHA-256 of the * signature in hexadecimal. Other members are ignored. Any failure is * ERR_RELEASE_INVALID, with the texts of the reference. The release names no * chain. The page reads the answers of the relays of drand with it too. */ export async function parseDrandJSON(b: Uint8Array): Promise { if (b.length > MAX_RELEASE_JSON_SIZE) { throw new DateKeysError( "ERR_RELEASE_INVALID", `provider: drand JSON of ${b.length} bytes, larger than ${MAX_RELEASE_JSON_SIZE}`, ); } const malformed = (): DateKeysError => new DateKeysError( "ERR_RELEASE_INVALID", "provider: drand JSON: malformed, or without round or signature", ); const members = strictJSON(b); if (members === undefined) throw malformed(); let round: number | undefined; let signature: string | undefined; let randomness: string | undefined; for (const m of members) { if (m.name === "round") { round = jsonRound(m); if (round === undefined) throw malformed(); } else if (m.name === "signature" || m.name === "randomness") { if (m.kind !== '"') throw malformed(); if (m.name === "signature") signature = m.str; else randomness = m.str; } } if (round === undefined || signature === undefined) throw malformed(); if (!/^([0-9a-fA-F]{2})*$/.test(signature)) { throw new DateKeysError( "ERR_RELEASE_INVALID", "provider: drand JSON: signature is not hex", ); } const sig = fromHex(signature); if ( randomness !== undefined && randomness.toLowerCase() !== toHex(await sha256(sig)) ) { throw new DateKeysError( "ERR_RELEASE_INVALID", "provider: drand JSON: randomness does not match the signature", ); } return { round, signature: sig }; } /** * The release object of a release of the profile `p`, with the chain hash * of `p`, as provider.NewReleaseObject: what a reader would keep after * verifying the release (spec v0.15, §47.1). */ export function newReleaseObject(p: Profile, r: Release): Uint8Array { return encodeRelease({ round: r.round, signature: r.signature, chainHash: p.chainHash, }); } // --------------------------------------------------------------------------- // A release in the caller's hand /** * A release that the caller has in hand (spec v0.15, §49, §63 step 9.c), as * provider.Supplier: a release object read from a file, drand's JSON that * the person saved, or an entry of a local archive. It makes no network * request, so open asks it for the release without comparing its clock with * the round time: a valid signature proves that the round was published. * * `supply` returns the encoding of the release of `round`, as it is: a * release object or drand's JSON, which open decodes and verifies at step * 10 with the codes of that step. Without a release for `round` it throws * ERR_RELEASE_UNAVAILABLE, the code of step 9. */ export interface ReleaseSupplier { supply(p: Profile, round: number): Promise; } /** * A release in hand, already read, as provider.Encoded: the bytes of a * release object or of drand's JSON. It supplies itself whatever the round; * step 10 compares its round with the DateKey. */ export function encodedRelease(b: Uint8Array): ReleaseSupplier { return { supply: () => Promise.resolve(b) }; } // --------------------------------------------------------------------------- // A local release archive (informative, spec v0.15, §50) // The text of what a read threw, for an error of the archive. const errText = (err: unknown): string => err instanceof Error ? err.message : String(err); interface ArchiveHeader { chainHash: Uint8Array; first: number; count: number; } function encodeArchiveWire(e: Encoder, h: ArchiveHeader): void { e.map(ARCHIVE_KEYS); e.uint(0); e.text(ARCHIVE_TYPE_TAG); e.uint(1); e.uint(ARCHIVE_SCHEMA_VERSION); e.uint(2); e.bstr(h.chainHash); e.uint(3); e.uint(h.first); e.uint(4); e.uint(h.count); } // The header decoder of the reference, whose own failures carry no code: the // format is informative, and every failure is ERR_RELEASE_UNAVAILABLE. function decodeArchiveWire(d: Decoder): ArchiveHeader { const h: ArchiveHeader = { chainHash: new Uint8Array(0), first: 0, count: 0 }; const pairs = d.map(ARCHIVE_KEYS); if (pairs !== ARCHIVE_KEYS) throw new Error(`${pairs} keys, want all ${ARCHIVE_KEYS}`); for (let want = 0; want < ARCHIVE_KEYS; want++) { const k = d.key(); if (k !== want) throw new Error(`key ${k} where key ${want} was expected`); try { switch (want) { case 0: d.text(utf8Length(ARCHIVE_TYPE_TAG)); break; case 1: d.uint(ARCHIVE_SCHEMA_VERSION); break; case 2: h.chainHash = d.bstr(32, 32); break; case 3: h.first = d.uint(MAX_SAFE_UINT); break; default: h.count = d.uint(MAX_SAFE_UINT); } } catch (err) { /* v8 ignore next -- @preserve: the decoder throws only DateKeysError */ throw new Error(`key ${k}: ${errText(err)}`); } } d.endMap(); return h; } /** * The header of an archive of `count` rounds of the chain `chainHash` from * the round `first` (spec v0.15, §50), as provider.EncodeArchiveHeader. The * signatures follow it, one after another, each with the length of a * signature of the chain, and a round the archive lacks is written as zeros. */ export function encodeArchiveHeader( chainHash: Uint8Array, first: number, count: number, ): Uint8Array { if ( chainHash.length !== 32 || !Number.isSafeInteger(first) || !Number.isSafeInteger(count) || first < 1 || count < 1 || first > MAX_SAFE_UINT - count + 1 ) { throw new Error( `provider: archive header: chain hash of ${chainHash.length} bytes, rounds ${first} to ${first} + ${count} - 1`, ); } const e = new Encoder(); encodeArchiveWire(e, { chainHash, first, count }); return e.out(); } /** Reports whether `b`, the start of a file, is the start of a release archive. */ export function isReleaseArchive(b: Uint8Array): boolean { try { return peek(b).typeTag === ARCHIVE_TYPE_TAG; } catch { return false; } } /** * A local release archive, the informative format of spec v0.15, §50, as * provider.Archive: a header in deterministic CBOR, {0: * "datekeys-release-archive", 1: 1, 2: chain_hash, 3: first round, 4: number * of rounds}, followed by the signatures, so that the one of round r starts * at the end of the header plus (r - first)·n, with n the length of a * signature of the chain, 48 bytes in Quicknet. A round written as zeros is * missing. * * A local archive is a release in hand: its entry is decoded and verified at * step 10 like a release object. A round it lacks, a header it cannot read, * an archive of another chain or of another length are failures to supply a * release, ERR_RELEASE_UNAVAILABLE at step 9: the format has no codes of its * own. Of a Blob only the header and one signature are read. */ export class ReleaseArchive implements ReleaseSupplier { readonly #data: Uint8Array | Blob; constructor(data: Uint8Array | Blob) { this.#data = data; } async #read(start: number, length: number): Promise { const d = this.#data; if (d instanceof Uint8Array) return d.subarray(start, start + length); return new Uint8Array(await d.slice(start, start + length).arrayBuffer()); } async supply(p: Profile, round: number): Promise { const unavailable = (detail: string): DateKeysError => new DateKeysError( "ERR_RELEASE_UNAVAILABLE", `provider: release archive: ${detail}`, ); const size = this.#data instanceof Uint8Array ? this.#data.length : this.#data.size; let head: Uint8Array; try { head = await this.#read(0, Math.min(size, MAX_ARCHIVE_HEADER)); } catch (err) { throw unavailable(errText(err)); } try { checkSchema(head, ARCHIVE_TYPE_TAG, ARCHIVE_SCHEMA_VERSION); } catch { throw unavailable(`not an archive of version ${ARCHIVE_SCHEMA_VERSION}`); } let h: ArchiveHeader; try { h = decodeArchiveWire(new Decoder(head)); } catch (err) { throw unavailable(`its header does not decode: ${errText(err)}`); } // The header is the deterministic encoding of what it says: its length // is that of the encoding, and the signatures follow it. The strict // decoder already refuses any other encoding; the check is the one of // the reference. const e = new Encoder(); encodeArchiveWire(e, h); const enc = e.out(); /* v8 ignore next 3 -- @preserve: what the strict decoder read encodes back to the same bytes */ if (!equalBytes(enc, head.subarray(0, Math.min(enc.length, head.length)))) { throw unavailable( "its header is not the deterministic encoding of its value", ); } if (!equalBytes(h.chainHash, p.chainHash)) { throw unavailable( `archive of chain ${toHex(h.chainHash)}, the pinned profile ${p.id} is chain ${toHex(p.chainHash)}`, ); } if ( h.first === 0 || h.count === 0 || round < h.first || round - h.first >= h.count ) { throw unavailable( `round ${round} is not in the archive, which holds ${h.count} rounds from ${h.first}`, ); } if (p.provider !== "drand") throw unavailable( `profile ${p.id}: provider ${goQuote(p.provider)} is not drand: ERR_UNKNOWN_PROFILE`, ); if (p.scheme !== QUICKNET_SCHEME) throw unavailable( `profile ${p.id}: ${goQuote(p.scheme)} is not a drand scheme: ERR_UNKNOWN_PROFILE`, ); const n = QUICKNET_SIGNATURE_LEN; const want = BigInt(enc.length) + BigInt(h.count) * BigInt(n); if (BigInt(h.count) > (1n << 62n) / BigInt(n) || BigInt(size) !== want) { throw unavailable( `${size} bytes, its header announces ${h.count} rounds of ${n} bytes`, ); } let sig: Uint8Array; try { sig = await this.#read(enc.length + (round - h.first) * n, n); } catch (err) { throw unavailable(errText(err)); } if (sig.every((x) => x === 0)) throw unavailable(`round ${round} is missing: its entry is zeros`); return encodeRelease({ chainHash: h.chainHash, round, signature: sig }); } }