You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
765 lines
25 KiB
765 lines
25 KiB
// 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<ParsedRelease> {
|
|
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<JSONMember, "name"> | 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<string>();
|
|
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<Release> {
|
|
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<Uint8Array>;
|
|
}
|
|
|
|
/**
|
|
* 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<Uint8Array> {
|
|
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<Uint8Array> {
|
|
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 });
|
|
}
|
|
}
|