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.
DateKeys-App/src/lib/dkc/releaseobject.ts

614 lines
21 KiB

// The release object of spec v0.15, §47.1, as provider/release.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; 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 { equalBytes, 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. Its round is a bigint
* only when drand's JSON names a round above 2^53-1, which no DateKey has:
* step 10 then reports ERR_ROUND_MISMATCH with its exact digits, as Go's
* uint64 does.
*/
export interface ParsedRelease {
readonly round: number | bigint;
readonly signature: Uint8Array;
readonly chainHash?: Uint8Array;
}
// ---------------------------------------------------------------------------
// 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, 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);
}
// The JSON of a drand relay, read as Go's encoding/json reads it into
// {Round *uint64; Signature *string; Randomness string}: the members of the
// object in order, each matched to a field by its name without case, the
// last one winning; null leaves a field as it was for Randomness and unset
// for the other two; a value of another type, or a round that is not an
// integer of 0 to 2^64-1, fails the whole input.
async function parseDrandJSON(b: Uint8Array): Promise<ParsedRelease> {
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 text = new TextDecoder().decode(b);
const members = jsonMembers(text);
if (members === undefined) throw malformed();
let round: bigint | undefined;
let signature: string | undefined;
let randomness = "";
let typeError = false;
for (const [key, raw] of members) {
const name = foldName(key);
if (name === "round") {
if (raw === "null") round = undefined;
else if (/^(0|[1-9][0-9]*)$/.test(raw) && BigInt(raw) < 2n ** 64n)
round = BigInt(raw);
else typeError = true;
} else if (name === "signature" || name === "randomness") {
if (raw === "null") {
if (name === "signature") signature = undefined;
} else if (raw.startsWith('"')) {
const v = JSON.parse(raw) as string;
if (name === "signature") signature = v;
else randomness = v;
} else {
typeError = true;
}
}
}
if (typeError || 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 = Uint8Array.from(signature.match(/../g) ?? [], (h) =>
parseInt(h, 16),
);
if (
randomness !== "" &&
randomness.toLowerCase() !== toHex(await sha256(sig))
) {
throw new DateKeysError(
"ERR_RELEASE_INVALID",
"provider: drand JSON: randomness does not match the signature",
);
}
return {
round: round <= BigInt(MAX_SAFE_UINT) ? Number(round) : round,
signature: sig,
};
}
// Go's encoding/json matches a key to a field name without case: ASCII
// letters in lower case, and any other character as unicode.ToLower of
// unicode.ToUpper, so that U+017F (long s) is an s and U+212A (Kelvin) a k.
function foldName(s: string): string {
let out = "";
for (const c of s)
out +=
c.charCodeAt(0) < 0x80 ? c.toLowerCase() : c.toUpperCase().toLowerCase();
return out;
}
// The members of the object that `text` is, as [key, raw value text] in
// their order, or undefined when `text` is not one JSON object. JSON.parse
// checks the grammar, the same as Go's; the scan then only splits valid text.
function jsonMembers(text: string): [string, string][] | undefined {
// Its first byte other than a space is "{": valid JSON is an object.
try {
JSON.parse(text);
} catch {
return undefined;
}
const out: [string, string][] = [];
let i = 0;
const ws = (): void => {
while (i < text.length && " \t\n\r".includes(text[i]!)) i++;
};
const skipString = (): void => {
i++;
while (text[i] !== '"') i += text[i] === "\\" ? 2 : 1;
i++;
};
const skipValue = (): void => {
if (text[i] === '"') return skipString();
if (text[i] === "{" || text[i] === "[") {
let depth = 0;
do {
const c = text[i]!;
if (c === '"') {
skipString();
continue;
}
if (c === "{" || c === "[") depth++;
else if (c === "}" || c === "]") depth--;
i++;
} while (depth > 0);
return;
}
while (i < text.length && !",}] \t\n\r".includes(text[i]!)) i++;
};
ws();
i++; // {
ws();
while (text[i] !== "}") {
const k0 = i;
skipString();
const key = JSON.parse(text.slice(k0, i)) as string;
ws();
i++; // :
ws();
const v0 = i;
skipValue();
out.push([key, text.slice(v0, i)]);
ws();
if (text[i] === ",") {
i++;
ws();
}
}
return out;
}
/**
* 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 });
}
}

Powered by TurnKey Linux.