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.
985 lines
32 KiB
985 lines
32 KiB
/// Releases (spec §45 to §52): their local verification, provider.Verify of
|
|
/// the Go reference (spec §17, §51, §63 step 10), the sources that deliver
|
|
/// them (provider.ReleaseSource), as release.ts of datekeys-ts, and, since
|
|
/// spec v0.15, the release object (§47.1), drand's JSON as an input of the
|
|
/// caller, a release in the caller's hand (provider.Supplier) and the lookup
|
|
/// of a local release archive (§50).
|
|
///
|
|
/// [verifyRelease] checks, in the order of the reference and with its
|
|
/// texts: the round against the range of the profile (ERR_DATEKEY_INVALID);
|
|
/// the chain hash that the release names, if any, against the pinned profile
|
|
/// (ERR_PROFILE_MISMATCH, spec v0.15);
|
|
/// the round of the release against the expected one (ERR_ROUND_MISMATCH),
|
|
/// before the signature; the length of the signature; the pinned public key
|
|
/// (ERR_UNKNOWN_PROFILE if it is not the canonical encoding of a point); and
|
|
/// the signature (ERR_RELEASE_INVALID): the canonical encoding of a point of
|
|
/// G1 other than the point at infinity (spec §12.2) that verifies as the BLS
|
|
/// signature of the round under the pinned key.
|
|
///
|
|
/// Only the scheme of Quicknet, bls-unchained-g1-rfc9380, is verified, as in
|
|
/// datekeys-ts: a profile of another scheme fails with ERR_UNKNOWN_PROFILE
|
|
/// after the round checks, where the reference would verify it.
|
|
///
|
|
/// The HTTP client is not part of the library: the application supplies a
|
|
/// [ReleaseSource], as OpenOptions.Source in Go.
|
|
///
|
|
/// Not constant time (see bls12381_fp.dart): everything it handles is
|
|
/// public, the signature of a round once drand publishes it.
|
|
library;
|
|
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'bls12381_curve.dart';
|
|
import 'bls12381_hash.dart';
|
|
import 'bls12381_pairing.dart';
|
|
import 'bytes.dart';
|
|
import 'cbor.dart';
|
|
import 'errors.dart';
|
|
import 'ibe.dart';
|
|
import 'schema.dart';
|
|
import 'sha256.dart';
|
|
import 'source.dart';
|
|
|
|
/// The drand scheme of Quicknet, the only one this library verifies.
|
|
const quicknetScheme = 'bls-unchained-g1-rfc9380';
|
|
|
|
/// What the verification of a release and the tlock stanza read of a
|
|
/// locally pinned Provider Profile (spec §10, §13): its id, its drand
|
|
/// scheme, its public key, its chain hash and the last round of its range
|
|
/// (spec §15). The Provider Profile of the library implements it.
|
|
abstract interface class PinnedProfile {
|
|
/// profile_id, such as `datekeys:quicknet:v1`.
|
|
String get id;
|
|
|
|
/// The drand scheme, such as `bls-unchained-g1-rfc9380`.
|
|
String get scheme;
|
|
|
|
/// The compressed public key of the drand network.
|
|
Uint8List get publicKey;
|
|
|
|
/// The 32 bytes of the chain hash.
|
|
Uint8List get chainHash;
|
|
|
|
/// The last round whose round time is not after 9999-12-31T23:59:59Z
|
|
/// (spec §15), 0 when there is none.
|
|
int get maxRound;
|
|
}
|
|
|
|
/// The material that satisfies a round: for drand, the BLS signature of the
|
|
/// round.
|
|
final class Release {
|
|
/// The release of [round] with [signature], which it copies, and the
|
|
/// chain it names, [chainHash], which it copies too.
|
|
Release(this.round, List<int> signature, {List<int>? chainHash})
|
|
: signature = Uint8List.fromList(signature),
|
|
chainHash = chainHash == null ? null : Uint8List.fromList(chainHash);
|
|
|
|
/// The round.
|
|
final int round;
|
|
|
|
/// The compressed signature of the round.
|
|
final Uint8List signature;
|
|
|
|
/// The chain the release names, key 2 of a release object (spec v0.15,
|
|
/// §47.1), or null when it names none, as the answer of a relay and
|
|
/// drand's JSON. [verifyRelease] compares it with the pinned profile.
|
|
final Uint8List? chainHash;
|
|
}
|
|
|
|
/// Verifies a release of [round] locally against the pinned profile [p], as
|
|
/// provider.Verify does. Throws a [DateKeysException]; returns normally when
|
|
/// the release is valid.
|
|
void verifyRelease(PinnedProfile p, int round, Release r) {
|
|
verifiedSignature(p, round, r);
|
|
}
|
|
|
|
/// [verifyRelease], returning the point of the verified signature, for the
|
|
/// decryption of the tlock stanza that follows.
|
|
G1Point verifiedSignature(PinnedProfile p, int round, Release r) {
|
|
if (round < 1 || round > p.maxRound) {
|
|
throw DateKeysException(
|
|
ErrorCode.dateKeyInvalid,
|
|
'provider: round $round outside the range of ${p.id}',
|
|
);
|
|
}
|
|
final chain = r.chainHash;
|
|
if (chain != null && !equalBytes(chain, p.chainHash)) {
|
|
throw DateKeysException(
|
|
ErrorCode.profileMismatch,
|
|
'provider: release of chain ${toHex(chain)}, the pinned profile '
|
|
'${p.id} is chain ${toHex(p.chainHash)}',
|
|
);
|
|
}
|
|
if (r.round != round) {
|
|
throw DateKeysException(
|
|
ErrorCode.roundMismatch,
|
|
'provider: release for round ${r.round}, expected $round',
|
|
);
|
|
}
|
|
if (p.scheme != quicknetScheme) {
|
|
throw DateKeysException(
|
|
ErrorCode.unknownProfile,
|
|
'provider: profile ${p.id} uses scheme ${p.scheme}; only '
|
|
'$quicknetScheme releases are verified here',
|
|
);
|
|
}
|
|
if (r.signature.length != signatureLength) {
|
|
throw DateKeysException(
|
|
ErrorCode.releaseInvalid,
|
|
'provider: signature is ${r.signature.length} bytes, $quicknetScheme '
|
|
'uses $signatureLength',
|
|
);
|
|
}
|
|
final key = pinnedKey(p.publicKey);
|
|
if (key == null) {
|
|
throw DateKeysException(
|
|
ErrorCode.unknownProfile,
|
|
'provider: pinned public key of ${p.id} is not the canonical encoding '
|
|
'of a point of the key group',
|
|
);
|
|
}
|
|
final last = _lastVerified;
|
|
if (last != null &&
|
|
last.round == r.round &&
|
|
equalBytes(last.signature, r.signature) &&
|
|
equalBytes(last.publicKey, p.publicKey)) {
|
|
return last.point;
|
|
}
|
|
// kyber decodes the point at infinity as a key, and with it, the point at
|
|
// infinity as a signature verifies (both pairs drop out of kilic's
|
|
// check). Spec §63 step 10 rejects such a signature, and so does this
|
|
// code, with any key: a profile with that key is never pinned (spec
|
|
// §12.1).
|
|
final signature = G1Point.decode(r.signature);
|
|
if (key.isInfinity ||
|
|
signature == null ||
|
|
signature.isInfinity ||
|
|
!_verifies(signature, r.round, key)) {
|
|
throw DateKeysException(
|
|
ErrorCode.releaseInvalid,
|
|
'provider: the signature is not a canonical point encoding, or does not '
|
|
'verify as the BLS signature of round ${r.round} under ${p.id}',
|
|
);
|
|
}
|
|
_lastVerified = (
|
|
publicKey: Uint8List.fromList(p.publicKey),
|
|
round: r.round,
|
|
signature: Uint8List.fromList(r.signature),
|
|
point: signature,
|
|
);
|
|
return signature;
|
|
}
|
|
|
|
// The last release that verified, under its key: the opening verifies the
|
|
// same release at step 10 and again in the tlock identity of step 11, as Go
|
|
// does, and the second time costs no pairing. Only the BLS check is skipped:
|
|
// the checks before it run every time.
|
|
({Uint8List publicKey, int round, Uint8List signature, G1Point point})?
|
|
_lastVerified;
|
|
|
|
// BLS on G1, as Verify of kyber's sign/bls with NewSchemeOnG1: e(H(m), key)
|
|
// = e(signature, G2), with m = SHA-256(uint64be(round)), the message drand
|
|
// signs for an unchained scheme, hashed to G1 with the DST of RFC 9380.
|
|
bool _verifies(G1Point signature, int round, G2Point key) => pairingCheck([
|
|
(hashToG1(roundIdentity(round), quicknetDst), key),
|
|
(-signature, G2Point.generator),
|
|
]);
|
|
|
|
// The last public key decoded: the pinned key of Quicknet, every time.
|
|
Uint8List? _lastKeyBytes;
|
|
G2Point? _lastKey;
|
|
|
|
/// The point of the pinned public key [bytes], or null when it is not the
|
|
/// canonical encoding of a point of G2 (the point at infinity is returned
|
|
/// as such). The last key decoded is kept, so that the pinned key of a
|
|
/// profile is decoded once.
|
|
G2Point? pinnedKey(List<int> bytes) {
|
|
final last = _lastKeyBytes;
|
|
if (last != null && equalBytes(last, bytes)) return _lastKey;
|
|
final key = G2Point.decode(bytes);
|
|
_lastKeyBytes = Uint8List.fromList(bytes);
|
|
_lastKey = key;
|
|
return key;
|
|
}
|
|
|
|
/// A source of releases (spec §45 to §50), as provider.ReleaseSource:
|
|
/// [fetch] returns the release of a round of the profile, or throws.
|
|
///
|
|
/// A source that fetches releases over a network (a relay, the Release API
|
|
/// or a cache) verifies each response with [verifyRelease] and discards the
|
|
/// one that fails; when none passes, it throws ERR_RELEASE_UNAVAILABLE,
|
|
/// which the opening reports at step 9. Only a release that the caller
|
|
/// supplies directly gets the codes of step 10 (spec §63 steps 9 and 10).
|
|
/// Whatever a source throws, step 9 reports it with ERR_RELEASE_UNAVAILABLE
|
|
/// and no other code, keeping only its text (spec §76, correction 6): see
|
|
/// [fetchRelease].
|
|
abstract interface class ReleaseSource {
|
|
/// The release of [round] of the profile [p].
|
|
Future<Release> fetch(PinnedProfile p, int round);
|
|
}
|
|
|
|
/// A source that hands [release] over for any round, unverified, so that
|
|
/// step 10 checks it; without a release, it has none to give
|
|
/// (ERR_RELEASE_UNAVAILABLE). It is a [ReleaseSource], so the opening does
|
|
/// not ask it before the round time; a release in the caller's hand, which
|
|
/// is not compared with the clock (spec v0.15, §63 step 9.c), is a
|
|
/// [ReleaseSupplier] such as [EncodedRelease], given as OpenOptions.release.
|
|
ReleaseSource suppliedRelease([Release? release]) => _Supplied(release);
|
|
|
|
final class _Supplied implements ReleaseSource {
|
|
_Supplied(this.release);
|
|
|
|
final Release? release;
|
|
|
|
@override
|
|
Future<Release> fetch(PinnedProfile p, int round) async {
|
|
final r = release;
|
|
if (r == null) {
|
|
throw DateKeysException(
|
|
ErrorCode.releaseUnavailable,
|
|
'release: no release supplied for round $round',
|
|
);
|
|
}
|
|
return r;
|
|
}
|
|
}
|
|
|
|
/// The release of [round] from [source], as step 9 of the opening obtains
|
|
/// it (spec §63): whatever the source throws becomes ERR_RELEASE_UNAVAILABLE,
|
|
/// the one code of that step. A [DateKeysException] of that code is kept as
|
|
/// it is; anything else, of another code or none, is kept as text only:
|
|
/// `capsule: release source: <text>: ERR_RELEASE_UNAVAILABLE`, as
|
|
/// sourceFailure of capsule.Open in Go.
|
|
Future<Release> fetchRelease(
|
|
ReleaseSource source,
|
|
PinnedProfile p,
|
|
int round,
|
|
) => _step9(() => source.fetch(p, round));
|
|
|
|
// Whatever [body] throws, as sourceFailure of capsule.Open: a
|
|
// DateKeysException of ERR_RELEASE_UNAVAILABLE as it is, anything else as
|
|
// text only.
|
|
Future<T> _step9<T>(Future<T> Function() body) async {
|
|
try {
|
|
return await body();
|
|
} on Object catch (e, stack) {
|
|
if (e is DateKeysException && e.code == ErrorCode.releaseUnavailable) {
|
|
rethrow;
|
|
}
|
|
Error.throwWithStackTrace(
|
|
DateKeysException(
|
|
ErrorCode.releaseUnavailable,
|
|
'capsule: release source: $e',
|
|
),
|
|
stack,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The release object (spec v0.15, §47.1)
|
|
|
|
/// The type tag of the release object.
|
|
const releaseTypeTag = 'datekeys-release';
|
|
|
|
/// The schema version of the release object.
|
|
const releaseSchemaVersion = 1;
|
|
|
|
/// The largest release object a reader decodes, in bytes (spec v0.15,
|
|
/// §47.1): the object has no frame, so a larger input is rejected before it
|
|
/// is decoded, with ERR_NON_CANONICAL_CBOR. No valid encoding comes close.
|
|
const maxReleaseObjectSize = 1024;
|
|
|
|
/// The longest signature of a release object: a compressed point of G2.
|
|
/// Quicknet signs with 48 bytes, a point of G1.
|
|
const maxReleaseSignatureLength = 96;
|
|
|
|
/// The largest drand JSON that [parseRelease] reads, in bytes, the bound of
|
|
/// a relay response in provider/drand of Go.
|
|
const maxReleaseJsonSize = 8 << 10;
|
|
|
|
// The keys of the release object, all required.
|
|
const _releaseKeys = 5;
|
|
|
|
void _encodeReleaseWire(CborEncoder e, Release r) {
|
|
e
|
|
..map(_releaseKeys)
|
|
..uint(0)
|
|
..text(releaseTypeTag)
|
|
..uint(1)
|
|
..uint(releaseSchemaVersion)
|
|
..uint(2)
|
|
..bstr(r.chainHash!)
|
|
..uint(3)
|
|
..uint(r.round)
|
|
..uint(4)
|
|
..bstr(r.signature);
|
|
}
|
|
|
|
DateKeysException _nonCanonical(String context) =>
|
|
DateKeysException(ErrorCode.nonCanonicalCbor, context);
|
|
|
|
// 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.
|
|
Release _decodeReleaseWire(CborDecoder d) {
|
|
final pairs = d.map(_releaseKeys);
|
|
if (pairs != _releaseKeys) {
|
|
throw _nonCanonical('$pairs keys, want all $_releaseKeys');
|
|
}
|
|
late Uint8List chainHash;
|
|
late int round;
|
|
late Uint8List signature;
|
|
for (var want = 0; want < _releaseKeys; want++) {
|
|
final k = d.key();
|
|
if (k != want) throw _nonCanonical('key $k where key $want was expected');
|
|
inKey(k, () {
|
|
switch (want) {
|
|
case 0:
|
|
d.text(releaseTypeTag.length);
|
|
case 1:
|
|
d.uint(releaseSchemaVersion);
|
|
case 2:
|
|
chainHash = d.bstr(32, 32);
|
|
case 3:
|
|
round = d.uint();
|
|
if (round == 0) throw _nonCanonical('round 0');
|
|
default:
|
|
signature = d.bstr(1, maxReleaseSignatureLength);
|
|
}
|
|
});
|
|
}
|
|
d.endMap();
|
|
return Release(round, signature, chainHash: chainHash);
|
|
}
|
|
|
|
/// The release object of [r], its chain hash, its round and its signature
|
|
/// (spec v0.15, §47.1), as EncodeRelease of Go. It does not verify the
|
|
/// release: [verifyRelease] does, against the pinned profile. A chain hash
|
|
/// other than 32 bytes, a round outside 1..2^53-1 or a signature outside
|
|
/// 1..96 bytes is ERR_NON_CANONICAL_CBOR.
|
|
Uint8List encodeRelease(Release r) {
|
|
final chain = r.chainHash;
|
|
if (chain == null || chain.length != 32) {
|
|
throw _nonCanonical(
|
|
'provider: release object: chain hash of ${chain?.length ?? 0} bytes, '
|
|
'want 32',
|
|
);
|
|
}
|
|
if (r.round < 1 || r.round > maxSafeUint) {
|
|
throw _nonCanonical(
|
|
'provider: release object: round ${r.round} outside 1..$maxSafeUint',
|
|
);
|
|
}
|
|
if (r.signature.isEmpty || r.signature.length > maxReleaseSignatureLength) {
|
|
throw _nonCanonical(
|
|
'provider: release object: signature of ${r.signature.length} bytes '
|
|
'outside 1..$maxReleaseSignatureLength',
|
|
);
|
|
}
|
|
final e = CborEncoder();
|
|
_encodeReleaseWire(e, r);
|
|
return e.out();
|
|
}
|
|
|
|
/// Decodes a release object (spec v0.15, §47.1) with the layers of spec
|
|
/// §69.1 that it has, as DecodeRelease of Go: its size, 1 to
|
|
/// [maxReleaseObjectSize] 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, before the
|
|
/// round and the signature.
|
|
Release decodeRelease(List<int> b) {
|
|
if (b.isEmpty || b.length > maxReleaseObjectSize) {
|
|
throw _nonCanonical(
|
|
'provider: release object of ${b.length} bytes, outside '
|
|
'1..$maxReleaseObjectSize',
|
|
);
|
|
}
|
|
return withContext('provider: release object', () {
|
|
checkSchema(b, releaseTypeTag, releaseSchemaVersion);
|
|
late Release r;
|
|
unmarshalCbor(
|
|
b,
|
|
(d) => r = _decodeReleaseWire(d),
|
|
(e) => _encodeReleaseWire(e, r),
|
|
);
|
|
return r;
|
|
});
|
|
}
|
|
|
|
/// Reads a release that the caller supplies, as ParseRelease of Go: drand's
|
|
/// JSON when its first byte other than a JSON space is `{`, 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 the SHA-256 of the signature,
|
|
/// in hexadecimal; other fields are ignored. It does not name its chain, so
|
|
/// the release has no chain hash. Any failure to read it, and an input of
|
|
/// more than [maxReleaseJsonSize] bytes, is ERR_RELEASE_INVALID. It is an
|
|
/// input, never written.
|
|
Release parseRelease(List<int> b) {
|
|
for (final c in b) {
|
|
if (c == 0x20 || c == 0x09 || c == 0x0d || c == 0x0a) continue;
|
|
if (c == 0x7b) return _parseDrandJson(b);
|
|
break;
|
|
}
|
|
return decodeRelease(b);
|
|
}
|
|
|
|
DateKeysException _invalidJson(String context) =>
|
|
DateKeysException(ErrorCode.releaseInvalid, 'provider: drand JSON$context');
|
|
|
|
// The name of a JSON key as encoding/json of Go matches it with the name of
|
|
// a field: without case, with the two letters of Unicode that fold to an
|
|
// ASCII letter of these names, the long s and the Kelvin sign.
|
|
String _foldKey(String k) => k
|
|
.replaceAll(String.fromCharCode(0x017f), 's')
|
|
.replaceAll(String.fromCharCode(0x212a), 'k')
|
|
.toLowerCase();
|
|
|
|
// Reads the JSON of a drand relay, as parseDrandJSON of Go, with the rules
|
|
// of encoding/json for its three fields: keys without case, the last one
|
|
// wins, null unsets round and signature and leaves randomness as it is, a
|
|
// value of another type is an error, and round is an unsigned integer
|
|
// written as such, without a sign, a fraction or an exponent. Its own reader
|
|
// ([_GoJson]), because jsonDecode of Dart does not keep how a number is
|
|
// written, and on the web reads 1000.0 as the int 1000.
|
|
Release _parseDrandJson(List<int> b) {
|
|
if (b.length > maxReleaseJsonSize) {
|
|
throw _invalidJson(
|
|
' of ${b.length} bytes, larger than $maxReleaseJsonSize',
|
|
);
|
|
}
|
|
final malformed = _invalidJson(': malformed, or without round or signature');
|
|
final List<(String, _JsonValue)> fields;
|
|
try {
|
|
fields = _GoJson(b).topObject();
|
|
} on FormatException {
|
|
throw malformed;
|
|
}
|
|
String? round;
|
|
String? signature;
|
|
var randomness = '';
|
|
var typeError = false;
|
|
for (final (key, value) in fields) {
|
|
switch (key) {
|
|
case 'round':
|
|
if (value.isNull) {
|
|
round = null;
|
|
} else if (value.number != null &&
|
|
RegExp(r'^(0|[1-9][0-9]*)$').hasMatch(value.number!)) {
|
|
round = value.number;
|
|
} else {
|
|
typeError = true;
|
|
}
|
|
case 'signature':
|
|
if (value.isNull) {
|
|
signature = null;
|
|
} else if (value.string != null) {
|
|
signature = value.string;
|
|
} else {
|
|
typeError = true;
|
|
}
|
|
case 'randomness':
|
|
if (value.string != null) {
|
|
randomness = value.string!;
|
|
} else if (!value.isNull) {
|
|
typeError = true;
|
|
}
|
|
}
|
|
}
|
|
if (typeError || round == null || signature == null) throw malformed;
|
|
// A round above 2^53-1 does not fit an int on every platform: it is an
|
|
// unsigned integer of Go up to 2^64-1, and no DateKey has it (spec §58).
|
|
final wide = BigInt.parse(round);
|
|
if (wide > maxUint64) throw malformed;
|
|
if (wide > BigInt.from(maxSafeUint)) {
|
|
throw _invalidJson(
|
|
': round $round above $maxSafeUint, the largest round of a DateKey',
|
|
);
|
|
}
|
|
final Uint8List sig;
|
|
try {
|
|
sig = fromHex(signature);
|
|
} on FormatException {
|
|
throw _invalidJson(': signature is not hex');
|
|
}
|
|
if (randomness.isNotEmpty && randomness.toLowerCase() != toHex(sha256(sig))) {
|
|
throw _invalidJson(': randomness does not match the signature');
|
|
}
|
|
return Release(wide.toInt(), sig);
|
|
}
|
|
|
|
// A value of the drand JSON that [_parseDrandJson] reads: null, a string, the
|
|
// literal of a number, or anything else (a boolean, an array or an object).
|
|
final class _JsonValue {
|
|
const _JsonValue({this.isNull = false, this.string, this.number});
|
|
|
|
final bool isNull;
|
|
final String? string;
|
|
final String? number;
|
|
}
|
|
|
|
// A reader of JSON with the syntax that encoding/json of Go accepts (RFC
|
|
// 8259, with invalid UTF-8 in strings read as U+FFFD): the members of the top
|
|
// object whose names fold to round, signature or randomness, in order, each
|
|
// one that appears, as Go sets them one after another. It
|
|
// throws a FormatException for anything else than one object between JSON
|
|
// spaces.
|
|
final class _GoJson {
|
|
_GoJson(this._b);
|
|
|
|
final List<int> _b;
|
|
int _i = 0;
|
|
|
|
Never _bad() => throw const FormatException('malformed JSON');
|
|
|
|
void _space() {
|
|
while (_i < _b.length) {
|
|
final c = _b[_i];
|
|
if (c != 0x20 && c != 0x09 && c != 0x0a && c != 0x0d) return;
|
|
_i++;
|
|
}
|
|
}
|
|
|
|
int _peek() => _i < _b.length ? _b[_i] : -1;
|
|
|
|
void _expect(int c) {
|
|
if (_peek() != c) _bad();
|
|
_i++;
|
|
}
|
|
|
|
List<(String, _JsonValue)> topObject() {
|
|
_space();
|
|
final out = <(String, _JsonValue)>[];
|
|
_object(out);
|
|
_space();
|
|
if (_i != _b.length) _bad();
|
|
return out;
|
|
}
|
|
|
|
// Reads an object; with [out], keeps its members of the three names.
|
|
void _object([List<(String, _JsonValue)>? out]) {
|
|
_expect(0x7b);
|
|
_space();
|
|
if (_peek() == 0x7d) {
|
|
_i++;
|
|
return;
|
|
}
|
|
for (;;) {
|
|
_space();
|
|
final name = _string();
|
|
_space();
|
|
_expect(0x3a);
|
|
_space();
|
|
final v = _value();
|
|
if (out != null) {
|
|
final k = _foldKey(name);
|
|
if (k == 'round' || k == 'signature' || k == 'randomness') {
|
|
// In the order of the input: Go sets each in turn.
|
|
out.add((k, v));
|
|
}
|
|
}
|
|
_space();
|
|
if (_peek() == 0x2c) {
|
|
_i++;
|
|
continue;
|
|
}
|
|
_expect(0x7d);
|
|
return;
|
|
}
|
|
}
|
|
|
|
void _array() {
|
|
_expect(0x5b);
|
|
_space();
|
|
if (_peek() == 0x5d) {
|
|
_i++;
|
|
return;
|
|
}
|
|
for (;;) {
|
|
_space();
|
|
_value();
|
|
_space();
|
|
if (_peek() == 0x2c) {
|
|
_i++;
|
|
continue;
|
|
}
|
|
_expect(0x5d);
|
|
return;
|
|
}
|
|
}
|
|
|
|
_JsonValue _value() {
|
|
final c = _peek();
|
|
switch (c) {
|
|
case 0x7b:
|
|
_object();
|
|
return const _JsonValue();
|
|
case 0x5b:
|
|
_array();
|
|
return const _JsonValue();
|
|
case 0x22:
|
|
return _JsonValue(string: _string());
|
|
case 0x74:
|
|
_literal('true');
|
|
return const _JsonValue();
|
|
case 0x66:
|
|
_literal('false');
|
|
return const _JsonValue();
|
|
case 0x6e:
|
|
_literal('null');
|
|
return const _JsonValue(isNull: true);
|
|
}
|
|
if (c == 0x2d || (c >= 0x30 && c <= 0x39)) {
|
|
return _JsonValue(number: _number());
|
|
}
|
|
_bad();
|
|
}
|
|
|
|
void _literal(String word) {
|
|
for (final c in word.codeUnits) {
|
|
_expect(c);
|
|
}
|
|
}
|
|
|
|
bool _digit() {
|
|
final c = _peek();
|
|
return c >= 0x30 && c <= 0x39;
|
|
}
|
|
|
|
String _number() {
|
|
final start = _i;
|
|
if (_peek() == 0x2d) _i++;
|
|
if (_peek() == 0x30) {
|
|
_i++;
|
|
} else if (_digit()) {
|
|
while (_digit()) {
|
|
_i++;
|
|
}
|
|
} else {
|
|
_bad();
|
|
}
|
|
if (_peek() == 0x2e) {
|
|
_i++;
|
|
if (!_digit()) _bad();
|
|
while (_digit()) {
|
|
_i++;
|
|
}
|
|
}
|
|
if (_peek() == 0x65 || _peek() == 0x45) {
|
|
_i++;
|
|
if (_peek() == 0x2b || _peek() == 0x2d) _i++;
|
|
if (!_digit()) _bad();
|
|
while (_digit()) {
|
|
_i++;
|
|
}
|
|
}
|
|
return String.fromCharCodes(_b.sublist(start, _i));
|
|
}
|
|
|
|
int _hex4() {
|
|
if (_i + 4 > _b.length) _bad();
|
|
var v = 0;
|
|
for (var k = 0; k < 4; k++) {
|
|
final c = _b[_i++];
|
|
final d = c >= 0x30 && c <= 0x39
|
|
? c - 0x30
|
|
: (c | 0x20) >= 0x61 && (c | 0x20) <= 0x66
|
|
? (c | 0x20) - 0x61 + 10
|
|
: -1;
|
|
if (d < 0) _bad();
|
|
v = v << 4 | d;
|
|
}
|
|
return v;
|
|
}
|
|
|
|
String _string() {
|
|
_expect(0x22);
|
|
final out = StringBuffer();
|
|
final raw = <int>[];
|
|
void flush() {
|
|
if (raw.isEmpty) return;
|
|
out.write(utf8.decode(raw, allowMalformed: true));
|
|
raw.clear();
|
|
}
|
|
|
|
for (;;) {
|
|
if (_i >= _b.length) _bad();
|
|
final c = _b[_i++];
|
|
if (c == 0x22) break;
|
|
if (c < 0x20) _bad();
|
|
if (c != 0x5c) {
|
|
raw.add(c);
|
|
continue;
|
|
}
|
|
flush();
|
|
if (_i >= _b.length) _bad();
|
|
final e = _b[_i++];
|
|
switch (e) {
|
|
case 0x22 || 0x5c || 0x2f:
|
|
out.writeCharCode(e);
|
|
case 0x62:
|
|
out.writeCharCode(0x08);
|
|
case 0x66:
|
|
out.writeCharCode(0x0c);
|
|
case 0x6e:
|
|
out.writeCharCode(0x0a);
|
|
case 0x72:
|
|
out.writeCharCode(0x0d);
|
|
case 0x74:
|
|
out.writeCharCode(0x09);
|
|
case 0x75:
|
|
var u = _hex4();
|
|
if (u >= 0xd800 && u < 0xdc00) {
|
|
// A pair of surrogates is one character; a lone one is U+FFFD.
|
|
final save = _i;
|
|
if (_i + 6 <= _b.length && _b[_i] == 0x5c && _b[_i + 1] == 0x75) {
|
|
_i += 2;
|
|
final low = _hex4();
|
|
if (low >= 0xdc00 && low < 0xe000) {
|
|
u = 0x10000 + ((u - 0xd800) << 10) + (low - 0xdc00);
|
|
} else {
|
|
_i = save;
|
|
u = 0xfffd;
|
|
}
|
|
} else {
|
|
u = 0xfffd;
|
|
}
|
|
} else if (u >= 0xdc00 && u < 0xe000) {
|
|
u = 0xfffd;
|
|
}
|
|
out.writeCharCode(u);
|
|
default:
|
|
_bad();
|
|
}
|
|
}
|
|
flush();
|
|
return out.toString();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// A release in the caller's hand (spec v0.15, §49, §63 step 9.c)
|
|
|
|
/// Hands over a release that the caller has in hand, as provider.Supplier of
|
|
/// Go: a release object, drand's JSON that the person saved, or an entry of
|
|
/// a local release archive. It makes no network request, so the opening 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 the opening reads with
|
|
/// [parseRelease] and verifies at step 10, with the codes of that step.
|
|
/// Without a release for the round it throws ERR_RELEASE_UNAVAILABLE, the
|
|
/// code of step 9; whatever else it throws is reported at step 9 with that
|
|
/// code alone, keeping only its text ([supplyRelease]).
|
|
abstract interface class ReleaseSupplier {
|
|
/// The encoding of the release of [round] of the profile [p].
|
|
Future<Uint8List> supply(PinnedProfile p, int round);
|
|
}
|
|
|
|
/// A release in hand, already read: the bytes of a release object or of
|
|
/// drand's JSON, as provider.Encoded of Go. It supplies itself whatever the
|
|
/// round; step 10 compares its round with the DateKey.
|
|
final class EncodedRelease implements ReleaseSupplier {
|
|
/// The release encoded in [bytes], which it copies.
|
|
EncodedRelease(List<int> bytes) : _bytes = Uint8List.fromList(bytes);
|
|
|
|
final Uint8List _bytes;
|
|
|
|
@override
|
|
Future<Uint8List> supply(PinnedProfile p, int round) async =>
|
|
Uint8List.fromList(_bytes);
|
|
}
|
|
|
|
/// The encoding of the release of [round] from [supplier], as step 9 of the
|
|
/// opening obtains it: whatever the supplier throws becomes
|
|
/// ERR_RELEASE_UNAVAILABLE, as [fetchRelease] does for a source.
|
|
Future<Uint8List> supplyRelease(
|
|
ReleaseSupplier supplier,
|
|
PinnedProfile p,
|
|
int round,
|
|
) => _step9(() => supplier.supply(p, round));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The local release archive (spec v0.15, §50, informative)
|
|
|
|
/// The type tag of the header of a release archive.
|
|
const releaseArchiveTypeTag = 'datekeys-release-archive';
|
|
|
|
/// The schema version of the header of a release archive.
|
|
const releaseArchiveSchemaVersion = 1;
|
|
|
|
// The bound of the header of an archive: its five keys take at most
|
|
// 1 + 26 + 2 + 35 + 9 + 9 bytes.
|
|
const _maxArchiveHeader = 128;
|
|
|
|
const _archiveKeys = 5;
|
|
|
|
// The header of an archive, as archiveHeader of Go.
|
|
final class _ArchiveHeader {
|
|
Uint8List chainHash = Uint8List(0);
|
|
int first = 0;
|
|
int count = 0;
|
|
|
|
void encode(CborEncoder e) {
|
|
e
|
|
..map(_archiveKeys)
|
|
..uint(0)
|
|
..text(releaseArchiveTypeTag)
|
|
..uint(1)
|
|
..uint(releaseArchiveSchemaVersion)
|
|
..uint(2)
|
|
..bstr(chainHash)
|
|
..uint(3)
|
|
..uint(first)
|
|
..uint(4)
|
|
..uint(count);
|
|
}
|
|
|
|
// Throws a DateKeysException of the codec, whose text Go prints with its
|
|
// code, or an _ArchiveError, a text of Go without a code.
|
|
void decode(CborDecoder d) {
|
|
final pairs = d.map(_archiveKeys);
|
|
if (pairs != _archiveKeys) {
|
|
throw _ArchiveError('$pairs keys, want all $_archiveKeys');
|
|
}
|
|
for (var want = 0; want < _archiveKeys; want++) {
|
|
final k = d.key();
|
|
if (k != want) {
|
|
throw _ArchiveError('key $k where key $want was expected');
|
|
}
|
|
inKey(k, () {
|
|
switch (want) {
|
|
case 0:
|
|
d.text(releaseArchiveTypeTag.length);
|
|
case 1:
|
|
d.uint(releaseArchiveSchemaVersion);
|
|
case 2:
|
|
chainHash = d.bstr(32, 32);
|
|
case 3:
|
|
first = d.uint();
|
|
default:
|
|
count = d.uint();
|
|
}
|
|
});
|
|
}
|
|
d.endMap();
|
|
}
|
|
}
|
|
|
|
final class _ArchiveError implements Exception {
|
|
_ArchiveError(this.text);
|
|
final String text;
|
|
}
|
|
|
|
/// A local release archive, the informative format of spec v0.15, §50, as
|
|
/// provider.Archive of Go: 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.
|
|
///
|
|
/// Read locally, it is a release in hand: its entry is the release object of
|
|
/// the round, with the chain hash of the header, decoded and verified at
|
|
/// step 10 like any other. A round it lacks, a header it cannot read, an
|
|
/// archive of another chain or of another length, and a failure to read the
|
|
/// [ByteSource], are failures to supply a release: ERR_RELEASE_UNAVAILABLE at
|
|
/// step 9, with the texts of Go. The format has no codes of its own.
|
|
final class ReleaseArchive implements ReleaseSupplier {
|
|
/// The archive that [source] reads by ranges. Nothing is read until
|
|
/// [supply].
|
|
ReleaseArchive(this.source);
|
|
|
|
/// The bytes of the archive.
|
|
final ByteSource source;
|
|
|
|
@override
|
|
Future<Uint8List> supply(PinnedProfile p, int round) async {
|
|
DateKeysException unavailable(String text) => DateKeysException(
|
|
ErrorCode.releaseUnavailable,
|
|
'provider: release archive: $text',
|
|
);
|
|
final size = source.length;
|
|
final Uint8List head;
|
|
try {
|
|
head = await readRange(
|
|
source,
|
|
0,
|
|
size < _maxArchiveHeader ? size : _maxArchiveHeader,
|
|
);
|
|
} on Object catch (e) {
|
|
throw unavailable('$e');
|
|
}
|
|
try {
|
|
checkSchema(head, releaseArchiveTypeTag, releaseArchiveSchemaVersion);
|
|
} on DateKeysException {
|
|
throw unavailable(
|
|
'not an archive of version $releaseArchiveSchemaVersion',
|
|
);
|
|
}
|
|
final h = _ArchiveHeader();
|
|
try {
|
|
h.decode(CborDecoder(head));
|
|
} on DateKeysException catch (e) {
|
|
throw unavailable('its header does not decode: ${e.message}');
|
|
} on _ArchiveError catch (e) {
|
|
throw unavailable('its header does not decode: ${e.text}');
|
|
}
|
|
// The header is the deterministic encoding of what it says: its length
|
|
// is that of the encoding, and the signatures follow it.
|
|
final e = CborEncoder();
|
|
h.encode(e);
|
|
final enc = e.out();
|
|
final shown = enc.length < head.length ? enc.length : head.length;
|
|
if (!equalBytes(enc, Uint8List.sublistView(head, 0, shown))) {
|
|
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.scheme != quicknetScheme) {
|
|
throw unavailable(
|
|
'profile ${p.id} uses scheme ${p.scheme}; only $quicknetScheme '
|
|
'archives are read here',
|
|
);
|
|
}
|
|
const n = signatureLength;
|
|
// count is at most 2^53-1: the product is exact on the VM, and on the
|
|
// web it is inexact only above 2^53, which no source measures.
|
|
if (size != enc.length + h.count * n) {
|
|
throw unavailable(
|
|
'$size bytes, its header announces ${h.count} rounds of $n bytes',
|
|
);
|
|
}
|
|
final Uint8List sig;
|
|
try {
|
|
sig = await readRange(source, enc.length + (round - h.first) * n, n);
|
|
} on Object catch (e) {
|
|
throw unavailable('$e');
|
|
}
|
|
if (sig.every((b) => b == 0)) {
|
|
throw unavailable('round $round is missing: its entry is zeros');
|
|
}
|
|
return encodeRelease(Release(round, sig, chainHash: h.chainHash));
|
|
}
|
|
}
|