Stage 4b: the Provider Profile and the DateKey, with rounds and times

lib/src/profile.dart ports package profile of datekeys-go at c531e93: the
Deterministic CBOR of a Provider Profile and its profile_hash, Decode and
Validate with rules 1 to 3 of spec §12.1 in their order and the texts of
Go (a period printed as Go prints a time.Duration), the drand schemes that
tlock supports and the group of their key, checked with
checkCompressedPoint, the chain hash of drand's chain.Info, MaxRound, the
pinned Quicknet profile and the registry with Default. Profile implements
PinnedProfile, which the verification of releases of stage 3 reads.

lib/src/datekey.dart ports package datekey: dk1_ strings, parsed with the
four Base64 decoders of Go and a JSON reader with the acceptance of
encoding/json with UseNumber, and numbers read by their exact decimal
value, with the texts of Go and its %v of the values; Resolve, RoundTime,
Validate and UnlockAt; and Instant, seconds and nanoseconds, with the RFC
3339 of Go's time.Parse(time.RFC3339Nano) and of Format, as datekey.ts of
datekeys-ts. A round is an int up to 2^53-1, exact on the web.

The tests, on the VM and compiled to JavaScript, check properties on
values of a fixed seed: dk1_ strings that read back, instants that format
and parse back to the nanosecond, and rounds whose time is the first at or
after the instant, at the end of the range of several profiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.11
dev 2 days ago
parent 6cdfee0d9a
commit 4b22d783d3

@ -0,0 +1,892 @@
/// DateKeys (spec §14 to §19), as package datekey of datekeys-go and
/// datekey.ts of datekeys-ts: the canonical dk1_ representation, with the
/// acceptance, codes and texts of Go, and the local resolution of an instant
/// to a round.
///
/// Instants keep the full precision of the nanosecond: [Instant] holds the
/// seconds and the nanoseconds, and [parseRfc3339] accepts exactly what Go's
/// time.Parse(time.RFC3339Nano, s) accepts, never with DateTime.parse, which
/// keeps microseconds on the VM and milliseconds on the web.
///
/// A DateKey is public: not a symmetric key, not a private key, not a .dkk
/// and not a secret (spec §14).
library;
import 'dart:typed_data';
import 'base64.dart';
import 'bytes.dart';
import 'errors.dart';
import 'profile.dart';
/// The prefix of the V1 representation (spec §18).
const dk1Prefix = 'dk1_';
/// The version of the JSON of the V1 representation (spec §18).
const dk1Version = 1;
/// The largest round of a dk1_ string, 2^53-1, so that every
/// implementation, JSON parsers on doubles included, reads the same integer.
/// A profile bounds it further with its maxRound (spec §15).
const maxDateKeyRound = 9007199254740991;
/// The largest input of [parseDateKey], in UTF-8 bytes, checked before
/// anything is decoded.
const maxDateKeyLen = 256;
/// The public descriptor of a time condition: a profile and, for Quicknet, a
/// round (spec §9, §14).
final class DateKey {
/// The DateKey of [round] of the profile [profileId].
const DateKey(this.profileId, this.round);
/// profile_id, the network of the JSON.
final String profileId;
/// The round.
final int round;
@override
bool operator ==(Object other) =>
other is DateKey && other.profileId == profileId && other.round == round;
@override
int get hashCode => Object.hash(profileId, round);
/// The canonical dk1_ string, or `''` when this DateKey is not
/// syntactically valid, as String of Go.
@override
String toString() => compactDateKey(this);
}
// ---------------------------------------------------------------------------
// Instants
/// An instant: the seconds since 1970-01-01T00:00:00Z, rounded down, and
/// the nanoseconds within that second, 0 to 999 999 999.
final class Instant implements Comparable<Instant> {
/// The instant [seconds] and [nanos] after 1970-01-01T00:00:00Z.
Instant(this.seconds, [this.nanos = 0]) {
if (nanos < 0 || nanos > 999999999) {
throw RangeError.range(nanos, 0, 999999999, 'nanos');
}
}
/// The instant of [t]: to the microsecond on the VM, to the millisecond on
/// the web. Its milliseconds since 1970 are exact on both.
factory Instant.fromDateTime(DateTime t) {
final ms = t.millisecondsSinceEpoch;
final s = _floorDiv(ms, 1000);
return Instant(s, (ms - s * 1000) * 1000000 + t.microsecond * 1000);
}
/// Seconds since 1970-01-01T00:00:00Z, rounded down.
final int seconds;
/// Nanoseconds within the second.
final int nanos;
/// This instant as a DateTime in UTC, to the microsecond on the VM and to
/// the millisecond on the web.
DateTime toDateTime() => DateTime.fromMillisecondsSinceEpoch(
seconds * 1000 + nanos ~/ 1000000,
isUtc: true,
).add(Duration(microseconds: nanos ~/ 1000 % 1000));
@override
int compareTo(Instant other) => compareInstants(this, other);
@override
bool operator ==(Object other) =>
other is Instant && other.seconds == seconds && other.nanos == nanos;
@override
int get hashCode => Object.hash(seconds, nanos);
/// The instant in RFC 3339 with its nanoseconds, as Go's RFC3339Nano.
@override
String toString() => formatRfc3339Nano(this);
}
/// Negative, zero or positive as [a] is before, equal to or after [b].
int compareInstants(Instant a, Instant b) {
if (a.seconds != b.seconds) return a.seconds < b.seconds ? -1 : 1;
return a.nanos == b.nanos ? 0 : (a.nanos < b.nanos ? -1 : 1);
}
/// How far ahead an effective unlock time must be for the official SDK to
/// warn that Quicknet V1 is not post-quantum and the ciphertext may be kept
/// for years (spec §53), and that the release of its round must still be
/// available then (spec §50): 365 days. A product policy, not part of the
/// protocol.
const longHorizonSeconds = 365 * 86400;
/// Whether [unlockAt] is more than [longHorizonSeconds] after [now]: the
/// warnings of spec §53 and §50 apply.
bool isLongHorizon(Instant unlockAt, Instant now) =>
compareInstants(
unlockAt,
Instant(now.seconds + longHorizonSeconds, now.nanos),
) >
0;
// ---------------------------------------------------------------------------
// dk1_
/// The canonical JSON of spec §18, such as
/// `{"version":1,"network":"datekeys:quicknet:v1","round":66884212}`, or null
/// when [d] is not syntactically valid, as CanonicalJSON of Go.
String? canonicalJson(DateKey d) {
if (!_validSyntax(d)) return null;
// The alphabet of a profile_id, [a-z0-9:._-], needs no JSON escape.
return '{"version":$dk1Version,"network":"${d.profileId}","round":'
'${d.round}}';
}
/// The canonical dk1_ string of [d] (spec §18), or `''` when [d] is not
/// syntactically valid, as Compact of Go.
String compactDateKey(DateKey d) {
final j = canonicalJson(d);
if (j == null) return '';
return dk1Prefix + goBase64Encode(utf8Bytes(j), url: true, padded: false);
}
bool _validSyntax(DateKey d) =>
validId(d.profileId) && d.round >= 1 && d.round <= maxDateKeyRound;
DateKeysException _invalid(String detail) =>
DateKeysException(ErrorCode.dateKeyInvalid, 'datekey: $detail');
/// Accepts only the unique canonical dk1_ string of a DateKey (spec §19),
/// as Parse of Go: it decodes the Base64URL, parses the JSON, validates the
/// fields, writes the canonical JSON and dk1_ string again and compares them
/// with [s]. Input that cannot be decoded or holds invalid fields is
/// ERR_DATEKEY_INVALID; a valid DateKey in any other encoding is
/// ERR_DATEKEY_NON_CANONICAL. The profile is not looked up.
DateKey parseDateKey(String s) {
// The UTF-16 length is never above the UTF-8 length (a lone surrogate
// takes 3 bytes), so the first test rejects long input without encoding
// it and never rejects a valid one.
if (s.length > maxDateKeyLen || utf8Bytes(s).length > maxDateKeyLen) {
throw _invalid('input longer than $maxDateKeyLen bytes');
}
if (!s.startsWith(dk1Prefix)) {
throw _invalid('missing ${goQuote(utf8Bytes(dk1Prefix))} prefix');
}
final raw = _decodeBase64(utf8Bytes(s.substring(dk1Prefix.length)));
if (raw == null) throw _invalid('payload is not Base64URL');
final d = _parseJson(raw);
final compact = compactDateKey(d);
if (compact != s) {
throw DateKeysException(
ErrorCode.dateKeyNonCanonical,
'datekey: not the canonical encoding $compact',
);
}
return d;
}
// Go's datekey.decodeBase64, step 1 of spec §19: unpadded Base64URL, and
// also the padded and standard-alphabet variants and non-zero trailing bits,
// so that they are reported as non-canonical rather than invalid; the final
// comparison rejects them. CR and LF are rejected first: Go's decoders would
// skip them, and spec §19 allows no character outside the alphabet.
Uint8List? _decodeBase64(Uint8List src) {
if (src.contains(0x0a) || src.contains(0x0d)) return null;
for (final (url, padded) in const [
(true, false),
(true, true),
(false, false),
(false, true),
]) {
try {
return goBase64Decode(src, url: url, padded: padded);
} on Base64Exception {
// The next variant.
}
}
return null;
}
// ---------------------------------------------------------------------------
// JSON, with the semantics of Go's encoding/json Decoder and UseNumber
sealed class _Json {}
// A number, kept as its literal: json.Number.
final class _JsonNumber extends _Json {
_JsonNumber(this.literal);
final String literal;
}
final class _JsonString extends _Json {
_JsonString(this.value);
final String value;
}
// An object: a repeated name keeps its last value, as a Go map.
final class _JsonObject extends _Json {
_JsonObject(this.members);
final Map<String, _Json> members;
}
// true, false, null or an array, kept as Go's %v prints it.
final class _JsonOther extends _Json {
_JsonOther(this.go);
final String go;
}
final class _JsonSyntax implements Exception {
const _JsonSyntax();
}
// Go's encoding/json limit on nesting.
const _jsonMaxDepth = 10000;
final class _JsonParser {
_JsonParser(this._b);
final Uint8List _b;
int pos = 0;
bool get atEnd => pos >= _b.length;
int? _at(int i) => i < _b.length ? _b[i] : null;
void skipSpace() {
while (pos < _b.length) {
final c = _b[pos];
if (c != 0x20 && c != 0x09 && c != 0x0a && c != 0x0d) return;
pos++;
}
}
_Json value(int depth) {
skipSpace();
final c = _at(pos);
if (c == 0x7b || c == 0x5b) {
if (depth >= _jsonMaxDepth) throw const _JsonSyntax();
return c == 0x7b ? _object(depth + 1) : _array(depth + 1);
}
if (c == 0x22) return _JsonString(_string());
if (c == 0x2d || (c != null && c >= 0x30 && c <= 0x39)) {
return _JsonNumber(_number());
}
for (final (lit, go) in const [
('true', 'true'),
('false', 'false'),
('null', '<nil>'),
]) {
if (_startsWith(lit)) {
pos += lit.length;
return _JsonOther(go);
}
}
throw const _JsonSyntax();
}
bool _startsWith(String lit) {
for (var i = 0; i < lit.length; i++) {
if (_at(pos + i) != lit.codeUnitAt(i)) return false;
}
return true;
}
void _expect(int c) {
skipSpace();
if (_at(pos) != c) throw const _JsonSyntax();
pos++;
}
_Json _object(int depth) {
pos++; // {
final members = <String, _Json>{};
skipSpace();
if (_at(pos) == 0x7d) {
pos++;
return _JsonObject(members);
}
for (;;) {
skipSpace();
if (_at(pos) != 0x22) throw const _JsonSyntax();
final key = _string();
_expect(0x3a);
members[key] = value(depth);
skipSpace();
final c = _at(pos++);
if (c == 0x7d) return _JsonObject(members);
if (c != 0x2c) throw const _JsonSyntax();
}
}
_Json _array(int depth) {
pos++; // [
final items = <String>[];
skipSpace();
if (_at(pos) == 0x5d) {
pos++;
return _JsonOther('[]');
}
for (;;) {
items.add(_goValue(value(depth)));
skipSpace();
final c = _at(pos++);
if (c == 0x5d) return _JsonOther('[${items.join(' ')}]');
if (c != 0x2c) throw const _JsonSyntax();
}
}
bool _digit(int i) {
final c = _at(i);
return c != null && c >= 0x30 && c <= 0x39;
}
String _number() {
final start = pos;
if (_at(pos) == 0x2d) pos++;
if (_at(pos) == 0x30) {
pos++;
} else if (_digit(pos)) {
while (_digit(pos)) {
pos++;
}
} else {
throw const _JsonSyntax();
}
if (_at(pos) == 0x2e) {
pos++;
if (!_digit(pos)) throw const _JsonSyntax();
while (_digit(pos)) {
pos++;
}
}
final e = _at(pos);
if (e == 0x65 || e == 0x45) {
pos++;
final sign = _at(pos);
if (sign == 0x2b || sign == 0x2d) pos++;
if (!_digit(pos)) throw const _JsonSyntax();
while (_digit(pos)) {
pos++;
}
}
return String.fromCharCodes(_b, start, pos);
}
// A string, unquoted as Go does: an unpaired surrogate escape becomes
// U+FFFD, one per escape. _parseJson has rejected invalid UTF-8 already
// (spec §19 step 2), so every raw sequence is a valid rune.
String _string() {
pos++; // "
final out = StringBuffer();
for (;;) {
final c = _at(pos);
if (c == null) throw const _JsonSyntax();
if (c == 0x22) {
pos++;
return out.toString();
}
if (c < 0x20) throw const _JsonSyntax();
if (c == 0x5c) {
final e = _at(pos + 1);
final simple = e == null ? null : _simpleEscapes[e];
if (simple != null) {
out.writeCharCode(simple);
pos += 2;
continue;
}
if (e != 0x75) throw const _JsonSyntax();
var r = _hex4(pos + 2);
if (r < 0) throw const _JsonSyntax();
pos += 6;
if (r >= 0xd800 && r < 0xe000) {
final r2 = _at(pos) == 0x5c && _at(pos + 1) == 0x75
? _hex4(pos + 2)
: -1;
if (r < 0xdc00 && r2 >= 0xdc00 && r2 < 0xe000) {
out.writeCharCode(0x10000 + ((r - 0xd800) << 10 | (r2 - 0xdc00)));
pos += 6;
continue;
}
r = 0xfffd;
}
out.writeCharCode(r);
continue;
}
if (c < 0x80) {
out.writeCharCode(c);
pos++;
continue;
}
final (rune, size) = decodeRune(_b, pos);
out.writeCharCode(rune);
pos += size;
}
}
int _hex4(int at) {
var v = 0;
for (var i = 0; i < 4; i++) {
final c = _at(at + i);
final int d;
if (c != null && c >= 0x30 && c <= 0x39) {
d = c - 0x30;
} else if (c != null && (c | 0x20) >= 0x61 && (c | 0x20) <= 0x66) {
d = (c | 0x20) - 0x61 + 10;
} else {
return -1;
}
v = v * 16 + d;
}
return v;
}
}
const _simpleEscapes = {
0x22: 0x22,
0x5c: 0x5c,
0x2f: 0x2f,
0x62: 0x08,
0x66: 0x0c,
0x6e: 0x0a,
0x72: 0x0d,
0x74: 0x09,
};
// Go's datekey.parseJSON: exactly one JSON object with the three fields,
// whatever their spelling; other spellings are rejected afterwards by the
// comparison of the bytes, as spec §19 prescribes.
DateKey _parseJson(Uint8List raw) {
// Spec §19 step 2: invalid UTF-8 fails the step, before any parsing could
// replace it with U+FFFD (Go checks utf8.Valid first).
if (!isValidUtf8(raw)) throw _invalid('payload is not valid UTF-8');
final p = _JsonParser(raw);
final Map<String, _Json> obj;
try {
final v = p.value(0);
if (v is! _JsonObject) throw const _JsonSyntax();
obj = v.members;
} on _JsonSyntax {
throw _invalid('payload is not a JSON object');
}
p.skipSpace();
if (!p.atEnd) throw _invalid('trailing data after the JSON object');
if (obj.length != 3) {
throw _invalid('expected exactly the fields version, network and round');
}
final version = _jsonUint(obj['version']);
if (version != dk1Version) {
throw _invalid('unsupported version ${_goValue(obj['version'])}');
}
final network = obj['network'];
if (network is! _JsonString || !validId(network.value)) {
throw _invalid('invalid network ${_goValue(network)}');
}
final round = _jsonUint(obj['round']);
if (round == null || round == 0 || round > maxDateKeyRound) {
throw _invalid('invalid round ${_goValue(obj['round'])}');
}
return DateKey(network.value, round);
}
// Go's %v of a decoded JSON value: the keys of a map sorted by their bytes,
// as fmt does.
String _goValue(_Json? v) => switch (v) {
null => '<nil>',
_JsonNumber(:final literal) => literal,
_JsonString(:final value) => value,
_JsonOther(:final go) => go,
_JsonObject(:final members) =>
'map[${([...members.keys]..sort((a, b) => compareBytes(utf8Bytes(a), utf8Bytes(b)))).map((k) => '$k:${_goValue(members[k])}').join(' ')}]',
};
// A value above 2^53-1 that fits in 64 bits: every such value is invalid
// here, as a version and as a round, and 2^53 is exact on every platform.
const _aboveMaxRound = 9007199254740992;
const _maxUint64 = '18446744073709551615';
// Go's datekey.jsonUint: the value of a JSON number when it is a
// non-negative integer that fits in 64 bits, whatever its spelling (1000,
// 1000.0, 1e3 and 10E2 are all 1000). A value above 2^53-1 is returned as
// 2^53; anything else is null.
int? _jsonUint(_Json? v) {
if (v is! _JsonNumber) return null;
var lit = v.literal;
final neg = lit.startsWith('-');
if (neg) lit = lit.substring(1);
lit = lit.toLowerCase();
final ei = lit.indexOf('e');
final mantissa = ei < 0 ? lit : lit.substring(0, ei);
final di = mantissa.indexOf('.');
final frac = di < 0 ? '' : mantissa.substring(di + 1);
var digits = _trimLeadingZeros(
(di < 0 ? mantissa : mantissa.substring(0, di)) + frac,
);
if (digits.isEmpty) return 0; // zero, -0, 0.0 and 0e99999 included
var e = 0;
if (ei >= 0) {
// Go's strconv.ParseInt(exp, 10, 16): an optional sign, decimal digits,
// leading zeros allowed, and a value in -32768..32767.
var exp = lit.substring(ei + 1);
var sign = 1;
if (exp.startsWith('+') || exp.startsWith('-')) {
sign = exp.startsWith('-') ? -1 : 1;
exp = exp.substring(1);
}
exp = _trimLeadingZeros(exp);
if (exp.length > 5) return null;
e = sign * (exp.isEmpty ? 0 : int.parse(exp));
if (e < -32768 || e > 32767) return null;
}
if (neg) return null;
e -= frac.length;
while (e < 0 && digits.endsWith('0')) {
digits = digits.substring(0, digits.length - 1);
e++;
}
if (e < 0 || digits.length + e > 20) return null;
final full = digits + '0' * e;
if (full.length == 20 && full.compareTo(_maxUint64) > 0) return null;
if (full.length > 16 ||
(full.length == 16 && full.compareTo('9007199254740991') > 0)) {
return _aboveMaxRound;
}
return int.parse(full);
}
String _trimLeadingZeros(String s) {
var i = 0;
while (i < s.length && s.codeUnitAt(i) == 0x30) {
i++;
}
return s.substring(i);
}
// ---------------------------------------------------------------------------
// Rounds
/// Checks that [d] belongs to [p] and that its round is in the range of [p]
/// (spec §15), as Validate of Go: ERR_PROFILE_MISMATCH for another profile,
/// ERR_DATEKEY_INVALID for a round outside 1..maxRound.
void validateDateKey(DateKey d, Profile p) {
if (d.profileId != p.id) {
throw DateKeysException(
ErrorCode.profileMismatch,
'datekey: profile ${goQuote(utf8Bytes(d.profileId))}, expected '
'${goQuote(utf8Bytes(p.id))}',
);
}
final last = p.maxRound;
if (d.round < 1 || d.round > last || d.round > maxDateKeyRound) {
throw _invalid('round ${d.round} outside 1..$last of ${p.id}');
}
}
/// round_time(r) = genesis_time + (r - 1)·period (spec §15), as RoundTime of
/// Go: ERR_DATEKEY_INVALID for a round outside 1..maxRound of [p].
Instant roundTime(Profile p, int round) {
final last = p.maxRound;
if (round < 1 || round > last) {
throw _invalid('round $round outside 1..$last of ${p.id}');
}
return Instant(p.genesisTime + (round - 1) * p.period);
}
/// The effective unlock time of [d] under [p], or null when [d] is not valid
/// for [p], as UnlockAt of Go.
Instant? unlockAt(DateKey d, Profile p) {
try {
validateDateKey(d, p);
} on DateKeysException {
return null;
}
return roundTime(p, d.round);
}
/// The DateKey of the first round whose round time is at or after [at]
/// (spec §15), as Resolve of Go. The comparison has the full precision of
/// [at]: an instant one nanosecond after the time of a round resolves to
/// the next round, and a round is never taken backwards. It accepts past
/// instants; a writer requires a future one (spec §62.1).
DateKey resolveDateKey(Profile p, Instant at) {
if (p.period <= 0) {
throw DateKeysException(
ErrorCode.unknownProfile,
'datekey: profile ${p.id} has no whole-second period',
);
}
if (at.seconds < p.genesisTime) {
throw _invalid('${formatRfc3339Nano(at)} is before the genesis of ${p.id}');
}
if (at.seconds > maxUnixTime) {
throw _invalid('${formatRfc3339Nano(at)} is after 9999-12-31T23:59:59Z');
}
final delta = at.seconds - p.genesisTime;
// candidate = floor((timestamp - genesis_time) / period) + 1, and one more
// when round_time(candidate) < requested_unlock_at.
var candidate = delta ~/ p.period + 1;
if (delta % p.period != 0 || at.nanos != 0) candidate++;
final d = DateKey(p.id, candidate);
validateDateKey(d, p);
return d;
}
/// Resolves the time [s] in RFC 3339 ([parseRfc3339] and [resolveDateKey]).
DateKey resolveRfc3339(Profile p, String s) =>
resolveDateKey(p, parseRfc3339(s));
// ---------------------------------------------------------------------------
// RFC 3339, as Go's time.Parse(time.RFC3339Nano, s) and time.Format
bool _isLeap(int year) => year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
int _daysIn(int month, int year) {
if (month == 2) return _isLeap(year) ? 29 : 28;
return const [31, 0, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
}
// Floor division, for days and eras before 1970.
int _floorDiv(int a, int b) {
final q = a ~/ b;
return (a % b != 0 && (a < 0) != (b < 0)) ? q - 1 : q;
}
// Days since 1970-01-01 of a date of the proleptic Gregorian calendar
// (Howard Hinnant's days_from_civil).
int _daysFromCivil(int y, int m, int d) {
final yy = m <= 2 ? y - 1 : y;
final era = _floorDiv(yy, 400);
final yoe = yy - era * 400;
final doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) ~/ 5 + d - 1;
final doe = yoe * 365 + yoe ~/ 4 - yoe ~/ 100 + doy;
return era * 146097 + doe - 719468;
}
(int, int, int) _civilFromDays(int z) {
final zz = z + 719468;
final era = _floorDiv(zz, 146097);
final doe = zz - era * 146097;
final yoe = (doe - doe ~/ 1460 + doe ~/ 36524 - doe ~/ 146096) ~/ 365;
final doy = doe - (365 * yoe + yoe ~/ 4 - yoe ~/ 100);
final mp = (5 * doy + 2) ~/ 153;
final d = doy - (153 * mp + 2) ~/ 5 + 1;
final m = mp + (mp < 10 ? 3 : -9);
return (yoe + era * 400 + (m <= 2 ? 1 : 0), m, d);
}
Instant _instant(
int year,
int month,
int day,
int hour,
int min,
int sec,
int nanos,
int offset,
) => Instant(
_daysFromCivil(year, month, day) * 86400 +
hour * 3600 +
min * 60 +
sec -
offset,
nanos,
);
bool _isDigitAt(String s, int i) {
if (i >= s.length) return false;
final c = s.codeUnitAt(i);
return c >= 0x30 && c <= 0x39;
}
bool _allDigits(String s) {
for (var i = 0; i < s.length; i++) {
if (!_isDigitAt(s, i)) return false;
}
return true;
}
// Go's parseNanoseconds: value[0] is '.' or ',', then nbytes-1 digits, of
// which the first nine count.
int _nanoseconds(String value, int nbytes) {
final n = nbytes < 10 ? nbytes : 10;
var ns = int.parse(value.substring(1, n));
for (var i = n; i < 10; i++) {
ns *= 10;
}
return ns;
}
// Go's parseRFC3339, the fast path of time.Parse for the layouts of RFC 3339.
Instant? _parseFast(String s) {
var ok = true;
// Go's parseUint: all digits and within [min, max], or ok = false.
int number(String t, int min, int max) {
if (!_allDigits(t)) {
ok = false;
return min;
}
final x = int.parse(t);
if (x < min || x > max) {
ok = false;
return min;
}
return x;
}
if (s.length < 19) return null;
final year = number(s.substring(0, 4), 0, 9999);
final month = number(s.substring(5, 7), 1, 12);
final day = number(s.substring(8, 10), 1, _daysIn(month, year));
final hour = number(s.substring(11, 13), 0, 23);
final min = number(s.substring(14, 16), 0, 59);
final sec = number(s.substring(17, 19), 0, 59);
if (!ok ||
s[4] != '-' ||
s[7] != '-' ||
s[10] != 'T' ||
s[13] != ':' ||
s[16] != ':') {
return null;
}
var rest = s.substring(19);
var nanos = 0;
if (rest.length >= 2 && rest[0] == '.' && _isDigitAt(rest, 1)) {
var n = 2;
while (_isDigitAt(rest, n)) {
n++;
}
nanos = _nanoseconds(rest, n);
rest = rest.substring(n);
}
if (rest == 'Z') return _instant(year, month, day, hour, min, sec, nanos, 0);
if (rest.length != 6) return null;
final hr = number(rest.substring(1, 3), 0, 23);
final mm = number(rest.substring(4, 6), 0, 59);
if (!ok || !(rest[0] == '-' || rest[0] == '+') || rest[3] != ':') {
return null;
}
final offset = (hr * 60 + mm) * 60 * (rest[0] == '-' ? -1 : 1);
return _instant(year, month, day, hour, min, sec, nanos, offset);
}
// Go's general time.parse for the layout 2006-01-02T15:04:05.999999999Z07:00,
// when the fast path fails. It also accepts an hour of one digit, a comma
// before the fraction and offsets of up to 24 hours and 60 minutes.
Instant? _parseLayout(String s) {
var v = s;
// Go's getnum: one or two digits, exactly two when fixed.
int? getnum({required bool fixed}) {
if (!_isDigitAt(v, 0)) return null;
if (!_isDigitAt(v, 1)) {
if (fixed) return null;
final x = v.codeUnitAt(0) - 0x30;
v = v.substring(1);
return x;
}
final x = int.parse(v.substring(0, 2));
v = v.substring(2);
return x;
}
bool literal(String c) {
if (v.isEmpty || v[0] != c) return false;
v = v.substring(1);
return true;
}
if (v.length < 4 || !_allDigits(v.substring(0, 4))) return null;
final year = int.parse(v.substring(0, 4));
v = v.substring(4);
if (!literal('-')) return null;
final month = getnum(fixed: true);
if (month == null || month < 1 || month > 12 || !literal('-')) return null;
final day = getnum(fixed: true);
if (day == null || !literal('T')) return null;
final hour = getnum(fixed: false);
if (hour == null || hour > 23 || !literal(':')) return null;
final min = getnum(fixed: true);
if (min == null || min > 59 || !literal(':')) return null;
final sec = getnum(fixed: true);
if (sec == null || sec > 59) return null;
var nanos = 0;
if (v.length >= 2 && (v[0] == '.' || v[0] == ',') && _isDigitAt(v, 1)) {
var i = 0;
while (_isDigitAt(v, i + 1)) {
i++;
}
nanos = _nanoseconds(v, 1 + i);
v = v.substring(1 + i);
}
var offset = 0;
if (v.isNotEmpty && v[0] == 'Z') {
v = v.substring(1);
} else {
if (v.length < 6 || v[3] != ':') return null;
final sign = v[0];
final hh = v.substring(1, 3);
final mm = v.substring(4, 6);
v = v.substring(6);
if (hh.length != 2 || mm.length != 2 || !_allDigits(hh + mm)) return null;
final hr = int.parse(hh);
final mi = int.parse(mm);
if (hr > 24 || mi > 60 || (sign != '+' && sign != '-')) return null;
offset = (hr * 60 + mi) * 60 * (sign == '-' ? -1 : 1);
}
if (v.isNotEmpty || day < 1 || day > _daysIn(month, year)) return null;
return _instant(year, month, day, hour, min, sec, nanos, offset);
}
/// Parses an RFC 3339 time with a zone and up to nanosecond precision,
/// accepting exactly what Go's time.Parse(time.RFC3339Nano, s) accepts.
/// Throws a [FormatException] otherwise.
Instant parseRfc3339(String s) {
// Every accepted form is printable ASCII; Go compares bytes, and a Dart
// String holds UTF-16 code units.
var ascii = true;
for (var i = 0; i < s.length; i++) {
final c = s.codeUnitAt(i);
if (c < 0x21 || c > 0x7e) {
ascii = false;
break;
}
}
final t = ascii ? (_parseFast(s) ?? _parseLayout(s)) : null;
if (t == null) {
throw FormatException(
'invalid time ${goQuote(utf8Bytes(s))}: RFC 3339 with a time zone is '
'required',
);
}
return t;
}
String _pad(int n, int width) => '$n'.padLeft(width, '0');
// A year as Go's time.Format writes it: the sign, then at least 4 digits.
String _padYear(int y) => y < 0 ? '-${_pad(-y, 4)}' : _pad(y, 4);
/// [t] in UTC as Go's time.RFC3339 writes it, in whole seconds.
String formatRfc3339(Instant t) {
final days = _floorDiv(t.seconds, 86400);
final secs = t.seconds - days * 86400;
final (y, m, d) = _civilFromDays(days);
return '${_padYear(y)}-${_pad(m, 2)}-${_pad(d, 2)}T${_pad(secs ~/ 3600, 2)}'
':${_pad(secs ~/ 60 % 60, 2)}:${_pad(secs % 60, 2)}Z';
}
/// [t] in UTC as Go's time.RFC3339Nano writes it: the nanoseconds without
/// trailing zeros, and without a fraction when they are zero.
String formatRfc3339Nano(Instant t) {
final base = formatRfc3339(t);
if (t.nanos == 0) return base;
var frac = _pad(t.nanos, 9);
while (frac.endsWith('0')) {
frac = frac.substring(0, frac.length - 1);
}
return '${base.substring(0, base.length - 1)}.${frac}Z';
}

@ -0,0 +1,685 @@
/// Provider Profiles (spec §10 to §13), as package profile of datekeys-go and
/// profile.ts of datekeys-ts: their Deterministic CBOR, profile_hash, their
/// validation by the rules of spec §12.1 in their order, with the codes and
/// texts of Go, and the registry of pinned profiles that is the root of
/// trust (spec §13).
library;
import 'dart:typed_data';
import 'bls12381_curve.dart' show BlsGroup, PointVerdict, checkCompressedPoint;
import 'bytes.dart';
import 'cbor.dart';
import 'errors.dart';
import 'release.dart' show PinnedProfile, quicknetScheme;
import 'schema.dart';
import 'sha256.dart';
/// The type tag of the Provider Profile map (spec §11).
const profileTypeTag = 'datekeys-provider-profile';
/// The schema version of the Provider Profile map (spec §11).
const profileSchemaVersion = 1;
/// The only provider of V1 (spec §12).
const providerDrand = 'drand';
/// 9999-12-31T23:59:59Z in seconds since 1970-01-01 UTC, the last instant of
/// spec §15: no round time may be later, so that every effective time is
/// representable in RFC 3339.
const maxUnixTime = 253402300799;
/// The largest period that the chain hash encodes, 2^32-1 seconds (spec
/// §12.1).
const maxChainHashPeriod = 4294967295;
// Limits of the implementation of the reference (spec §74). _maxPeriod also
// keeps the period within the 32 bits of the chain hash.
const _maxIdLen = 128;
const _maxNameLen = 64;
const _maxPublicKeyLen = 1024;
const _maxPeriod = 86400;
/// A Provider Profile (spec §10). Its values are meant to be immutable: a
/// registry hands out copies.
final class Profile implements PinnedProfile {
/// A profile of the fields of keys 2 to 10 of spec §11.
Profile({
required this.id,
required this.provider,
required this.network,
required this.chainHash,
required this.publicKey,
required this.period,
required this.genesisTime,
required this.scheme,
required this.genesisSeed,
});
/// Key 2, profile_id, such as `datekeys:quicknet:v1`.
@override
final String id;
/// Key 3, such as `drand`.
final String provider;
/// Key 4, the network of the provider, such as `quicknet`.
final String network;
/// Key 5, the 32 bytes of the chain hash.
@override
final Uint8List chainHash;
/// Key 6, the public key of the group of the provider.
@override
final Uint8List publicKey;
/// Key 7, the period in whole seconds.
final int period;
/// Key 8, the genesis time in seconds since 1970-01-01 UTC.
final int genesisTime;
/// Key 9, the drand scheme, such as `bls-unchained-g1-rfc9380`.
@override
final String scheme;
/// Key 10, the 32 bytes of the genesis seed.
final Uint8List genesisSeed;
/// The last round whose round time is not after 9999-12-31T23:59:59Z
/// (spec §15), or 0 when there is none; a round time of exactly
/// 9999-12-31T23:59:59Z is still valid. As MaxRound of Go.
@override
int get maxRound {
if (period <= 0 || genesisTime > maxUnixTime) return 0;
return (maxUnixTime - genesisTime) ~/ period + 1;
}
/// The chain hash in lowercase hexadecimal, as tlock stanzas and drand
/// relays write it.
String get chainHashHex => toHex(chainHash);
/// A deep copy of this profile.
Profile copy() => Profile(
id: id,
provider: provider,
network: network,
chainHash: Uint8List.fromList(chainHash),
publicKey: Uint8List.fromList(publicKey),
period: period,
genesisTime: genesisTime,
scheme: scheme,
genesisSeed: Uint8List.fromList(genesisSeed),
);
/// This profile with the fields given replaced, for tests and for
/// profiles that an application builds.
Profile copyWith({
String? id,
String? provider,
String? network,
List<int>? chainHash,
List<int>? publicKey,
int? period,
int? genesisTime,
String? scheme,
List<int>? genesisSeed,
}) => Profile(
id: id ?? this.id,
provider: provider ?? this.provider,
network: network ?? this.network,
chainHash: Uint8List.fromList(chainHash ?? this.chainHash),
publicKey: Uint8List.fromList(publicKey ?? this.publicKey),
period: period ?? this.period,
genesisTime: genesisTime ?? this.genesisTime,
scheme: scheme ?? this.scheme,
genesisSeed: Uint8List.fromList(genesisSeed ?? this.genesisSeed),
);
@override
bool operator ==(Object other) =>
other is Profile &&
other.id == id &&
other.provider == provider &&
other.network == network &&
equalBytes(other.chainHash, chainHash) &&
equalBytes(other.publicKey, publicKey) &&
other.period == period &&
other.genesisTime == genesisTime &&
other.scheme == scheme &&
equalBytes(other.genesisSeed, genesisSeed);
@override
int get hashCode => Object.hash(
id,
provider,
network,
Object.hashAll(chainHash),
Object.hashAll(publicKey),
period,
genesisTime,
scheme,
Object.hashAll(genesisSeed),
);
@override
String toString() => 'Profile($id)';
}
// ---------------------------------------------------------------------------
// The pinned Quicknet profile (spec §12)
/// profile_id of the Quicknet Provider Profile V1.
const quicknetId = 'datekeys:quicknet:v1';
/// The network of Quicknet.
const quicknetNetwork = 'quicknet';
/// The chain hash of Quicknet.
const quicknetChainHash =
'52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971';
/// The public key of Quicknet, a compressed point of G2.
const quicknetPublicKey =
'83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b'
'6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809'
'bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a';
/// The genesis seed of Quicknet.
const quicknetGenesisSeed =
'f477d5c89f21a17c863a7f937c6a6d15859414d2be09cd448d4279af331c5d3e';
/// The genesis time of Quicknet: 2023-08-23T15:09:27Z.
const quicknetGenesisTime = 1692803367;
/// The period of Quicknet, in seconds.
const quicknetPeriod = 3;
/// The exact Deterministic CBOR of the Quicknet profile, the first official
/// vector (testdata/vectors/profile_quicknet.json) and part of the root of
/// trust of spec §13: [defaultRegistry] refuses to build if the parameters
/// compiled in do not reproduce it and [quicknetProfileHash].
const quicknetCanonicalCbor =
'ab007819646174656b6579732d70726f76696465722d70726f66696c6501010274646174'
'656b6579733a717569636b6e65743a763103656472616e640468717569636b6e65740558'
'2052db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971065860'
'83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b'
'6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809'
'bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a0703081a64e6212709781862'
'6c732d756e636861696e65642d67312d726663393338300a5820f477d5c89f21a17c863a'
'7f937c6a6d15859414d2be09cd448d4279af331c5d3e';
/// profile_hash of the Quicknet profile: the SHA-256 of
/// [quicknetCanonicalCbor] (spec §11).
const quicknetProfileHash =
'4147645109798ecbc9f630c2f709835bb5846fe7911a6bd5c5755e25ade2ada4';
/// A fresh copy of the pinned Quicknet Provider Profile V1.
Profile quicknet() => Profile(
id: quicknetId,
provider: providerDrand,
network: quicknetNetwork,
chainHash: fromHex(quicknetChainHash),
publicKey: fromHex(quicknetPublicKey),
period: quicknetPeriod,
genesisTime: quicknetGenesisTime,
scheme: quicknetScheme,
genesisSeed: fromHex(quicknetGenesisSeed),
);
// ---------------------------------------------------------------------------
// CBOR (spec §11)
// The map of spec §11 as it is encoded, keys 2 to 10; keys 0 and 1 are the
// constants profileTypeTag and profileSchemaVersion. Every key is required.
final class _Wire {
String id = '';
String provider = '';
String network = '';
Uint8List chainHash = Uint8List(0);
Uint8List publicKey = Uint8List(0);
int period = 0;
int genesisTime = 0;
String scheme = '';
Uint8List genesisSeed = Uint8List(0);
}
const _wireKeys = 11;
// Bounds a field only by the input: its rule carries a code of its own
// (spec §57), checked with the fields after decoding.
const _unbounded = maxSafeUint;
DateKeysException _nonCanonical(String context) =>
DateKeysException(ErrorCode.nonCanonicalCbor, context);
DateKeysException _unknown(String context) =>
DateKeysException(ErrorCode.unknownProfile, context);
// Reads the map with every rule of the CDDL whose violation is
// ERR_NON_CANONICAL_CBOR (rule 1 of spec §12.1); the names and the public
// key are left to the rules of the fields.
_Wire _decodeWire(CborDecoder d) {
final w = _Wire();
final pairs = d.map(_wireKeys);
if (pairs != _wireKeys) {
throw _nonCanonical('$pairs keys, want all $_wireKeys');
}
for (var want = 0; want < _wireKeys; 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(profileTypeTag.length);
case 1:
d.uint(profileSchemaVersion);
case 2:
w.id = d.text(_unbounded);
case 3:
w.provider = d.text(_unbounded);
case 4:
w.network = d.text(_unbounded);
case 5:
w.chainHash = d.bstr(32, 32);
case 6:
w.publicKey = d.bstr(0, _unbounded);
case 7:
// Spec §11: period in 1..2^53-1.
w.period = d.uint();
if (w.period == 0) throw _nonCanonical('period 0');
case 8:
// Spec §11: genesis_time in 0..2^53-1, unsigned.
w.genesisTime = d.uint();
case 9:
w.scheme = d.text(_unbounded);
default:
w.genesisSeed = d.bstr(32, 32);
}
});
}
d.endMap();
return w;
}
void _encodeWire(CborEncoder e, Profile p) {
e
..map(_wireKeys)
..uint(0)
..text(profileTypeTag)
..uint(1)
..uint(profileSchemaVersion)
..uint(2)
..text(p.id)
..uint(3)
..text(p.provider)
..uint(4)
..text(p.network)
..uint(5)
..bstr(p.chainHash)
..uint(6)
..bstr(p.publicKey)
..uint(7)
..uint(p.period)
..uint(8)
..uint(p.genesisTime)
..uint(9)
..text(p.scheme)
..uint(10)
..bstr(p.genesisSeed);
}
// Go's time.Duration.String of a period of whole seconds, as the texts of
// the reference print it: 0s, 1m1s, 24h0m1s, -1s.
String _goDuration(int seconds) {
if (seconds == 0) return '0s';
var u = seconds < 0 ? -seconds : seconds;
final s = u % 60;
u ~/= 60;
final m = u % 60;
final h = u ~/ 60;
final text = h > 0 ? '${h}h${m}m${s}s' : (m > 0 ? '${m}m${s}s' : '${s}s');
return seconds < 0 ? '-$text' : text;
}
/// The exact Deterministic CBOR of [p] (spec §11), as CanonicalCBOR of Go.
Uint8List canonicalCbor(Profile p) {
if (p.period <= 0) {
throw _nonCanonical(
'profile: period ${_goDuration(p.period)} is not a positive whole '
'number of seconds',
);
}
// Spec §11: genesis_time in 0..2^53-1.
if (p.genesisTime < 0 || p.genesisTime > maxSafeUint) {
throw _nonCanonical(
'profile: genesis time ${p.genesisTime} outside 0..$maxSafeUint',
);
}
if (p.chainHash.length != 32 || p.genesisSeed.length != 32) {
throw _nonCanonical(
'profile: chain hash and genesis seed must be 32 bytes',
);
}
final e = CborEncoder();
_encodeWire(e, p);
return Uint8List.fromList(e.out());
}
/// profile_hash = SHA-256 of the exact Deterministic CBOR of [p] (spec §11).
/// A profile_hash that a remote party declares has no security value: the
/// security comes from the profile pinned locally (spec §11, §13).
Uint8List profileHash(Profile p) => sha256(canonicalCbor(p));
/// Decodes the Deterministic CBOR of a Provider Profile and validates it
/// with rules 1 to 3 of spec §12.1, in their order, as Decode of Go: the
/// type tag, the schema version and the CDDL with the limit of the period
/// (ERR_NON_CANONICAL_CBOR or ERR_UNSUPPORTED_VERSION), the rules of the
/// fields (ERR_UNKNOWN_PROFILE) and the check of the chain hash
/// (ERR_PROFILE_MISMATCH). It does not make the profile trusted: only a
/// registry that the caller builds does (spec §13).
Profile decodeProfile(List<int> b) {
final w = withContext('profile', () {
checkSchema(b, profileTypeTag, profileSchemaVersion);
late _Wire w;
unmarshalCbor(
b,
(d) => w = _decodeWire(d),
(e) => _encodeWire(e, _profileOf(w)),
);
return w;
});
if (w.period > _maxPeriod) {
throw _nonCanonical('profile: period ${w.period} s out of range');
}
final p = _profileOf(w);
validateProfile(p);
return p;
}
Profile _profileOf(_Wire w) => Profile(
id: w.id,
provider: w.provider,
network: w.network,
chainHash: w.chainHash,
publicKey: w.publicKey,
period: w.period,
genesisTime: w.genesisTime,
scheme: w.scheme,
genesisSeed: w.genesisSeed,
);
// ---------------------------------------------------------------------------
// Validation (spec §12.1)
/// Applies rules 1 to 3 of spec §12.1 to the profile [p], in their order, so
/// that it reports the code that [decodeProfile] reports for its encoding
/// (spec §69.1), as Validate of Go:
///
/// 1. the rules of the schema that a value can break
/// (ERR_NON_CANONICAL_CBOR): a period that is not in 1..86400, the limit
/// of the implementation of spec §74; a genesis time outside 0..2^53-1; a
/// name that is not well-formed Unicode; a chain hash or a genesis seed
/// that is not 32 bytes;
/// 2. the rules of each field (ERR_UNKNOWN_PROFILE): the alphabets of the
/// names and their lengths, the length of the public key, a genesis time
/// in 1..253402300798, the provider drand, a scheme that tlock supports
/// and a public key that is the canonical encoding of a point of the key
/// group of the scheme other than the point at infinity;
/// 3. the check of the chain hash (ERR_PROFILE_MISMATCH): it is the hash of
/// the drand chain information of the other parameters.
void validateProfile(Profile p) {
final id = goQuote(utf8Bytes(p.id));
if (p.period <= 0 || p.period > _maxPeriod) {
throw _nonCanonical(
'profile $id: period ${_goDuration(p.period)} is not a whole number of '
'seconds in 1..$_maxPeriod',
);
}
if (p.genesisTime < 0 || p.genesisTime > maxSafeUint) {
throw _nonCanonical(
'profile $id: genesis time ${p.genesisTime} outside 0..$maxSafeUint',
);
}
for (final s in [p.id, p.provider, p.network, p.scheme]) {
if (!isWellFormedUtf16(s)) {
throw _nonCanonical(
'profile $id: name ${goQuote(utf8Bytes(s))} is not valid UTF-8',
);
}
}
if (p.chainHash.length != 32 || p.genesisSeed.length != 32) {
throw _nonCanonical(
'profile $id: chain hash and genesis seed must be 32 bytes',
);
}
if (!validId(p.id)) throw _unknown('profile: invalid profile_id $id');
if (!_validName(p.provider) ||
!_validName(p.network) ||
!_validName(p.scheme)) {
throw _unknown('profile ${p.id}: invalid provider, network or scheme name');
}
if (p.publicKey.isEmpty || p.publicKey.length > _maxPublicKeyLen) {
throw _unknown(
'profile ${p.id}: invalid public key length ${p.publicKey.length}',
);
}
if (p.genesisTime <= 0 || p.genesisTime >= maxUnixTime) {
throw _unknown('profile ${p.id}: invalid genesis time ${p.genesisTime}');
}
if (p.provider != providerDrand) {
throw _unknown(
'profile ${p.id}: unsupported provider ${goQuote(utf8Bytes(p.provider))}',
);
}
_validateDrand(p);
}
// The key group of each drand scheme that tlock supports; the other schemes
// that drand knows are refused, and any other name is not a drand scheme,
// as SchemeFromName of drand v2.1.7 and the switch of validateDrand of Go.
const _tlockSchemes = {
'bls-unchained-g1-rfc9380': BlsGroup.g2,
'pedersen-bls-unchained': BlsGroup.g1,
'bls-unchained-on-g1': BlsGroup.g2,
};
const _otherDrandSchemes = {
'pedersen-bls-chained',
'bls-bn254-unchained-on-g1',
};
void _validateDrand(Profile p) {
final scheme = goQuote(utf8Bytes(p.scheme));
final group = _tlockSchemes[p.scheme];
if (group == null) {
if (_otherDrandSchemes.contains(p.scheme)) {
throw _unknown(
'profile ${p.id}: scheme $scheme is not supported by tlock',
);
}
throw _unknown('profile ${p.id}: $scheme is not a drand scheme');
}
final verdict = checkCompressedPoint(group, p.publicKey);
if (verdict == PointVerdict.invalid) {
throw _unknown(
'profile ${p.id}: public key is not the canonical encoding of a point '
'of the key group of ${p.scheme}',
);
}
if (verdict == PointVerdict.identity) {
throw _unknown('profile ${p.id}: public key is the identity element');
}
final hash = chainInfoHash(p);
if (!equalBytes(hash, p.chainHash)) {
throw DateKeysException(
ErrorCode.profileMismatch,
'profile ${p.id}: parameters hash to chain ${toHex(hash)}, not the '
'pinned ${toHex(p.chainHash)}',
);
}
}
/// The hash of the drand chain information of rule 3 of spec §12.1, as Hash
/// of drand's chain.Info:
///
/// SHA-256(uint32_be(period) || int64_be(genesis_time) || public_key ||
/// genesis_seed || network)
///
/// with network the UTF-8 bytes of key 4, left out when it is `default`;
/// profile_id, provider and scheme are not hashed. A period outside
/// 1..2^32-1 has no chain hash: rule 2 rejects it with ERR_UNKNOWN_PROFILE,
/// and so does this function, rather than truncate it to 32 bits.
Uint8List chainInfoHash(Profile p) {
if (p.period < 1 || p.period > maxChainHashPeriod) {
throw _unknown(
'profile ${p.id}: period ${p.period}s does not fit the 32 bits of the '
'chain hash',
);
}
final fixed = Uint8List(12);
_putUint(fixed, 0, 4, p.period);
_putInt64(fixed, 4, p.genesisTime);
final h = Sha256()
..add(fixed)
..add(p.publicKey)
..add(p.genesisSeed);
// drand's IsDefaultBeaconID: "default" or empty.
if (p.network != 'default' && p.network.isNotEmpty) {
h.add(utf8Bytes(p.network));
}
return h.finish();
}
// Writes the unsigned v, below 2^(8·n), in n bytes big-endian, with
// divisions only: exact on the web.
void _putUint(Uint8List b, int offset, int n, int v) {
var x = v;
for (var i = n - 1; i >= 0; i--) {
final q = x ~/ 256;
b[offset + i] = x - q * 256;
x = q;
}
}
// Writes v, a signed int of at most 2^53 in absolute value, as int64
// big-endian, two's complement.
void _putInt64(Uint8List b, int offset, int v) {
if (v >= 0) {
_putUint(b, offset, 8, v);
return;
}
// 2^64 + v, as the bytes of -v - 1 complemented.
_putUint(b, offset, 8, -v - 1);
for (var i = 0; i < 8; i++) {
b[offset + i] = 0xff - b[offset + i];
}
}
bool _alnum(int c) => (c >= 0x61 && c <= 0x7a) || (c >= 0x30 && c <= 0x39);
/// Whether [s] is a valid profile_id: 1 to 128 characters of [a-z0-9:._-],
/// the first of [a-z0-9] (spec §12.1, §18), as ValidID of Go. The alphabet
/// keeps the JSON of dk1_ free of escapes.
bool validId(String s) {
if (s.isEmpty || s.length > _maxIdLen || !_alnum(s.codeUnitAt(0))) {
return false;
}
for (var i = 0; i < s.length; i++) {
final c = s.codeUnitAt(i);
if (!_alnum(c) && c != 0x3a && c != 0x2e && c != 0x5f && c != 0x2d) {
return false;
}
}
return true;
}
bool _validName(String s) {
if (s.isEmpty || s.length > _maxNameLen || !_alnum(s.codeUnitAt(0))) {
return false;
}
for (var i = 0; i < s.length; i++) {
final c = s.codeUnitAt(i);
if (!_alnum(c) && c != 0x2e && c != 0x5f && c != 0x2d) return false;
}
return true;
}
// ---------------------------------------------------------------------------
// The registry (spec §13)
/// Resolves a profile_id to a Provider Profile trusted locally. A client
/// must never accept a profile or a key that the endpoint that delivers the
/// release supplies (spec §13).
abstract interface class ProfileRegistry {
/// A copy of the pinned profile [id], or null.
Profile? lookup(String id);
}
/// A profile together with the profile_hash that the caller expects it to
/// have (spec §13: the hash is known in advance).
final class Pin {
/// The pin of [profile] with [hash].
Pin(this.profile, List<int> hash) : hash = Uint8List.fromList(hash);
/// The profile.
final Profile profile;
/// Its expected profile_hash.
final Uint8List hash;
}
final class _Registry implements ProfileRegistry {
_Registry(this._profiles);
final Map<String, Profile> _profiles;
@override
Profile? lookup(String id) => _profiles[id]?.copy();
}
/// Validates each profile of [pins], checks it against its profile_hash and
/// returns an immutable registry of private copies, as NewRegistry of Go.
/// Each profile goes through the rules of spec §12.1 in their order, as its
/// encoding would: it is encoded and decoded again (rules 1 to 3, with the
/// codes of [decodeProfile]), and only then compared with its pinned
/// profile_hash (rule 4, ERR_PROFILE_MISMATCH). A profile pinned twice is an
/// error of the caller, without a normative code.
ProfileRegistry newRegistry(List<Pin> pins) {
final m = <String, Profile>{};
for (final pin in pins) {
final b = canonicalCbor(pin.profile);
final p = decodeProfile(b);
final h = sha256(b);
if (!equalBytes(h, pin.hash)) {
throw DateKeysException(
ErrorCode.profileMismatch,
'profile ${p.id}: profile_hash ${toHex(h)} does not match the pinned '
'${toHex(pin.hash)}',
);
}
if (m.containsKey(p.id)) {
throw ArgumentError('profile ${p.id}: pinned twice');
}
m[p.id] = p;
}
return _Registry(m);
}
ProfileRegistry? _default;
/// The default registry: the Quicknet profile alone, checked against its
/// exact pinned CBOR and [quicknetProfileHash], as Default of Go. It is
/// built once.
ProfileRegistry defaultRegistry() {
final built = _default;
if (built != null) return built;
final q = quicknet();
if (!equalBytes(canonicalCbor(q), fromHex(quicknetCanonicalCbor))) {
throw DateKeysException(
ErrorCode.profileMismatch,
'profile: the Quicknet parameters do not reproduce the pinned CBOR',
);
}
return _default = newRegistry([Pin(q, fromHex(quicknetProfileHash))]);
}

@ -0,0 +1,165 @@
// DateKeys, instants and rounds (lib/src/datekey.dart), as datekey.test.ts
// of datekeys-ts: properties that hold for every value, on random values of
// a fixed seed: dk1_ strings that read back, instants that format and parse
// back to the nanosecond, and rounds whose time is the first at or after
// the instant. The values and the texts of Go are in the differential
// (formats_datekey.json and formats_time.json). It reads no file: it runs on
// the VM and compiled to JavaScript.
library;
import 'dart:math';
import 'package:datekeys/src/datekey.dart';
import 'package:datekeys/src/errors.dart';
import 'package:datekeys/src/profile.dart';
import 'package:test/test.dart';
String codeOf(void Function() body) {
try {
body();
return 'ok';
} on DateKeysException catch (e) {
return e.code.code;
}
}
// A random int in 0..n-1 for n up to 2^53, from two halves.
int below(Random r, int n) {
final hi = r.nextInt(1 << 21);
final lo = r.nextInt(4294967296);
return (hi * 4294967296 + lo) % n;
}
void main() {
final q = quicknet();
test('dk1_ strings of valid DateKeys read back, and invalid ones have '
'none', () {
final r = Random(7);
for (var i = 0; i < 300; i++) {
final d = DateKey(
['datekeys:quicknet:v1', 'a', '0:._-', 'z' * 128][i % 4],
1 + below(r, maxDateKeyRound),
);
final s = compactDateKey(d);
expect(s, startsWith(dk1Prefix));
expect(parseDateKey(s), d);
expect('$d', s);
expect(
canonicalJson(d),
'{"version":1,"network":"${d.profileId}","round":${d.round}}',
);
}
for (final d in [
const DateKey('A', 1),
const DateKey('a', 0),
const DateKey('a', maxDateKeyRound + 1),
const DateKey('', 1),
]) {
expect([canonicalJson(d), compactDateKey(d)], [null, '']);
}
});
test('instants order by seconds, then nanoseconds', () {
Instant t(int s, int ns) => Instant(s, ns);
expect(compareInstants(t(1, 0), t(1, 0)), 0);
expect(compareInstants(t(1, 0), t(1, 1)), -1);
expect(compareInstants(t(1, 999999999), t(2, 0)), -1);
expect(compareInstants(t(2, 0), t(1, 999999999)), 1);
expect(compareInstants(t(-5, 3), t(-5, 2)), 1);
expect(t(3, 4), t(3, 4));
expect(t(3, 4).hashCode, t(3, 4).hashCode);
expect([t(2, 0), t(1, 5), t(1, 4)]..sort(), [t(1, 4), t(1, 5), t(2, 0)]);
expect(() => Instant(0, -1), throwsRangeError);
expect(() => Instant(0, 1000000000), throwsRangeError);
});
test('instants format and parse back, to the nanosecond', () {
final r = Random(11);
// From 0000-01-01 to 9999-12-31, in UTC.
const first = -62167219200;
const span = 253402300799 - first + 1;
for (var i = 0; i < 2000; i++) {
final t = Instant(
first + below(r, span),
[0, 1, 999999999, r.nextInt(1000000000)][i % 4],
);
expect(parseRfc3339(formatRfc3339Nano(t)), t);
final whole = parseRfc3339(formatRfc3339(t));
expect([whole.seconds, whole.nanos], [t.seconds, 0]);
expect('$t', formatRfc3339Nano(t));
}
expect(() => parseRfc3339('2023-08-23'), throwsFormatException);
});
test('instants convert to and from DateTime', () {
final t = DateTime.utc(2030, 1, 2, 3, 4, 5, 678);
expect(
Instant.fromDateTime(t),
Instant(t.millisecondsSinceEpoch ~/ 1000, 678000000),
);
expect(Instant.fromDateTime(t).toDateTime(), t);
final before = DateTime.utc(1969, 12, 31, 23, 59, 59, 500);
expect(Instant.fromDateTime(before), Instant(-1, 500000000));
expect(Instant(-1, 500000000).toDateTime(), before);
});
test('a long horizon is more than 365 days ahead, to the nanosecond', () {
final now = Instant(1790000000, 500);
Instant at(int s, int ns) => Instant(now.seconds + s, ns);
expect(longHorizonSeconds, 365 * 24 * 60 * 60);
expect(isLongHorizon(at(longHorizonSeconds, 500), now), isFalse);
expect(isLongHorizon(at(longHorizonSeconds, 501), now), isTrue);
expect(isLongHorizon(at(1, 0), now), isFalse);
});
test('the round of an instant is the first whose time is at or after it, '
'on profiles of other periods too', () {
final r = Random(13);
for (final p in [
q,
q.copyWith(period: 1),
q.copyWith(period: 7, genesisTime: 1),
q.copyWith(period: 86400, genesisTime: 253402300799 - 86400 * 10),
]) {
final last = p.maxRound;
final lastTime = roundTime(p, last).seconds;
expect(lastTime <= maxUnixTime, isTrue);
expect(lastTime + p.period > maxUnixTime, isTrue);
expect(codeOf(() => roundTime(p, last + 1)), 'ERR_DATEKEY_INVALID');
expect(codeOf(() => roundTime(p, 0)), 'ERR_DATEKEY_INVALID');
for (var i = 0; i < 300; i++) {
final at = Instant(
p.genesisTime + below(r, lastTime - p.genesisTime + 1),
[0, 0, 1, r.nextInt(1000000000)][i % 4],
);
final d = resolveDateKey(p, at);
final t = roundTime(p, d.round);
expect(compareInstants(t, at) >= 0, isTrue);
if (d.round > 1) {
expect(compareInstants(roundTime(p, d.round - 1), at) < 0, isTrue);
}
expect(unlockAt(d, p), t);
validateDateKey(d, p);
}
// Before the genesis and after the last round.
expect(
codeOf(() => resolveDateKey(p, Instant(p.genesisTime - 1, 999999999))),
'ERR_DATEKEY_INVALID',
);
expect(
codeOf(() => resolveDateKey(p, Instant(lastTime, 1))),
'ERR_DATEKEY_INVALID',
);
}
expect(unlockAt(const DateKey('other', 2), q), isNull);
expect(
codeOf(() => validateDateKey(const DateKey('other', 2), q)),
'ERR_PROFILE_MISMATCH',
);
expect(
codeOf(() => resolveDateKey(q.copyWith(period: 0), Instant(0))),
'ERR_UNKNOWN_PROFILE',
);
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.