diff --git a/lib/src/accesskey.dart b/lib/src/accesskey.dart new file mode 100644 index 0000000..fb24f0e --- /dev/null +++ b/lib/src/accesskey.dart @@ -0,0 +1,342 @@ +/// The DateKeys Access Key, the portable .dkk credential (spec §38, §40 to +/// §44), as package accesskey of datekeys-go and accesskey.ts of +/// datekeys-ts: the same layers of spec §69.1 in the same order, with the +/// same codes and texts. +/// +/// A .dkk is a sensitive capability (spec §7.4): its X25519 identity is kept +/// as 32 raw bytes in [AccessKey.material]. Nothing here prints it, every +/// copy that decoding makes is wiped, and [AccessKey.wipe] wipes the one it +/// returns. +library; + +import 'dart:typed_data'; + +import 'bytes.dart'; +import 'cbor.dart'; +import 'errors.dart'; +import 'extension.dart'; +import 'framing.dart'; +import 'schema.dart'; + +/// The type tag of BODY_CBOR (spec §41). +const accessKeyTypeTag = 'datekeys-access-key'; + +/// The schema version of BODY_CBOR (spec §41). +const accessKeyVersion = 1; + +/// The only access_type of V1 (spec §41). +const accessTypeX25519 = 'x25519'; + +const _idSize = 16; +const _digestSize = 32; +const _x25519Size = 32; + +/// verification_metadata (spec §43): a fast failure for a wrong file and +/// help for the interface, never a security property. +final class Verification { + /// The metadata of [capsuleDigest]. + Verification(this.capsuleDigest); + + /// Key 0, capsule_digest: the SHA-256 of the exact bytes of the .dkc. + final Uint8List capsuleDigest; + + @override + bool operator ==(Object other) => + other is Verification && equalBytes(other.capsuleDigest, capsuleDigest); + + @override + int get hashCode => Object.hashAll(capsuleDigest); +} + +/// A decoded .dkk (spec §41). +final class AccessKey { + /// A .dkk of the fields of keys 2 to 8. + AccessKey({ + required this.credentialId, + required this.capsuleId, + required this.type, + required this.material, + this.verification, + this.critical = const [], + this.noncritical = const [], + }); + + /// Key 2, credential_id: 16 random and opaque bytes (spec §42). + final Uint8List credentialId; + + /// Key 3, the capsule_id of the only capsule this credential is for + /// (spec §38). + final Uint8List capsuleId; + + /// Key 4, access_type. + final String type; + + /// Key 5, access_material: the 32 raw bytes of an X25519 identity. A + /// secret. + final Uint8List material; + + /// Key 6, verification_metadata, null when absent (spec §43, §58.1). + final Verification? verification; + + /// Key 7, critical_extensions. + final List critical; + + /// Key 8, noncritical_extensions. + final List noncritical; + + /// Overwrites the material with zeros. + void wipe() => material.fillRange(0, material.length, 0); + + /// The key without its material, as String of Go. + @override + String toString() => + 'AccessKey{credential_id=${toHex(credentialId)} ' + 'capsule_id=${toHex(capsuleId)} type=$type material=REDACTED}'; +} + +/// Checks access_type and access_material of [k], as Identity of Go does +/// before it builds the X25519 identity: ERR_ACCESS_INVALID unless they are +/// `x25519` and 32 bytes. [decodeAccessKey] has checked those of the keys it +/// returns. +void checkAccessKeyMaterial(AccessKey k) => + _validateMaterial(k.type, k.material); + +void _validateMaterial(String type, Uint8List material) { + if (type != accessTypeX25519) { + throw DateKeysException( + ErrorCode.accessInvalid, + 'accesskey: access_type ${goQuote(utf8Bytes(type))} is not supported ' + 'by V1', + ); + } + if (material.length != _x25519Size) { + throw DateKeysException( + ErrorCode.accessInvalid, + 'accesskey: x25519 access_material is ${material.length} bytes, want ' + '$_x25519Size', + ); + } +} + +// BODY_CBOR as it is encoded: keys 2 to 8, keys 0 and 1 being the constants +// accessKeyTypeTag and accessKeyVersion. +final class _Wire { + Uint8List credentialId = Uint8List(0); + Uint8List capsuleId = Uint8List(0); + String accessType = ''; + // A secret. + Uint8List material = Uint8List(0); + // capsule_digest, the only key of verification_metadata (key 6); null + // when key 6 is omitted. + Uint8List? digest; + List critical = const []; + List noncritical = const []; +} + +DateKeysException _nonCanonical(String context) => + DateKeysException(ErrorCode.nonCanonicalCbor, context); + +// Reads BODY_CBOR with every rule of the CDDL whose violation is +// ERR_NON_CANONICAL_CBOR (layer 3 of spec §69.1); access_type and +// access_material, which have a code of their own (spec §57), are checked +// afterwards. The caller wipes w.material, whatever the result. +void _decodeWire(CborDecoder d, _Wire w) { + final pairs = d.map(9); + final seen = {}; + for (var i = 0; i < pairs; i++) { + final k = d.key(); + switch (k) { + case 0: + inKey(k, () => d.text(accessKeyTypeTag.length)); + case 1: + inKey(k, () => d.uint(accessKeyVersion)); + case 2: + w.credentialId = inKey(k, () => d.bstr(_idSize, _idSize)); + case 3: + w.capsuleId = inKey(k, () => d.bstr(_idSize, _idSize)); + case 4: + w.accessType = inKey(k, () => d.text(maxDkkBodyLen)); + case 5: + w.material = inKey(k, () => d.bstr(0, maxDkkBodyLen)); + case 6: + w.digest = inKey(k, () => _decodeVerification(d)); + case 7: + w.critical = inKey(k, () => decodeExtensionArray(d)); + case 8: + w.noncritical = inKey(k, () => decodeExtensionArray(d)); + default: + throw keyNotDefined(k); + } + seen.add(k as int); + } + requireKeys(seen, 6); + d.endMap(); +} + +// Reads verification_metadata, {0: capsule_digest}. It is present only when +// it holds a digest: an empty map does not stand for absence (spec §43, +// §58.1). +Uint8List _decodeVerification(CborDecoder d) { + final pairs = d.map(1); + if (pairs == 0) { + throw _nonCanonical( + 'empty verification_metadata; an absent one omits key 6', + ); + } + final k = d.key(); + if (k != 0) { + throw _nonCanonical('verification_metadata key $k is not defined'); + } + final digest = withContext( + 'capsule_digest', + () => d.bstr(_digestSize, _digestSize), + ); + d.endMap(); + return digest; +} + +void _encodeWire(CborEncoder e, _Wire w) { + final digest = w.digest; + e + ..map( + 6 + + (digest == null ? 0 : 1) + + presence(w.critical) + + presence(w.noncritical), + ) + ..uint(0) + ..text(accessKeyTypeTag) + ..uint(1) + ..uint(accessKeyVersion) + ..uint(2) + ..bstr(w.credentialId) + ..uint(3) + ..bstr(w.capsuleId) + ..uint(4) + ..text(w.accessType) + ..uint(5) + ..bstr(w.material); + if (digest != null) { + e + ..uint(6) + ..map(1) + ..uint(0) + ..bstr(digest); + } + encodeExtensionArrays(e, 7, w.critical, w.noncritical); +} + +DateKeysException _tooLong(int n) => DateKeysException( + ErrorCode.integrity, + 'accesskey: BODY_CBOR of $n bytes exceeds $maxDkkBodyLen', +); + +/// Validates and decodes BODY_CBOR, which the limit of the body of a .dkk +/// of spec §57 bounds whatever it was read from, in the layers of spec +/// §69.1, as DecodeBody of Go: the limit (ERR_INTEGRITY); the type tag and +/// the schema version; the CBOR profile, the re-encoding and the CDDL, +/// verification_metadata and the extension arrays included, and no +/// extension_id in both arrays (ERR_NON_CANONICAL_CBOR); and only then +/// access_type and access_material (ERR_ACCESS_INVALID). Whether its +/// critical extensions are known is decided by the consumer (spec §63 step +/// 9.a). The caller wipes the key it gets. +AccessKey decodeAccessKeyBody(List body) { + if (body.length > maxDkkBodyLen) throw _tooLong(body.length); + final w = _Wire(); + try { + withContext('accesskey', () { + checkSchema(body, accessKeyTypeTag, accessKeyVersion); + unmarshalCbor(body, (d) => _decodeWire(d, w), (e) => _encodeWire(e, w)); + checkDisjoint(w.critical, w.noncritical); + }); + _validateMaterial(w.accessType, w.material); + final digest = w.digest; + return AccessKey( + credentialId: w.credentialId, + capsuleId: w.capsuleId, + type: w.accessType, + material: Uint8List.fromList(w.material), + verification: digest == null ? null : Verification(digest), + critical: List.unmodifiable(w.critical), + noncritical: List.unmodifiable(w.noncritical), + ); + } finally { + w.material.fillRange(0, w.material.length, 0); + } +} + +/// Decodes exactly one .dkk, as Decode of Go: its frame ([splitAccessKey]), +/// its canonical body and its fields ([decodeAccessKeyBody]). Whether its +/// critical extensions are known is decided by the consumer. +AccessKey decodeAccessKey(List dkk) => + decodeAccessKeyBody(splitAccessKey(dkk)); + +/// BODY_CBOR of [k] (spec §41), after checking that [decodeAccessKeyBody] +/// accepts it (spec §72), as MarshalBody of Go. The extensions of [k] are +/// written in canonical order once they pass the rules of spec §54, and the +/// extensions of the specification only where spec §72 registers them +/// ([checkWrite] with [StandardExtensions]): datekeys.capsule in the +/// noncritical array, with data, which is the locator's and is not checked +/// here, and never datekeys.note. The caller wipes the result. +Uint8List marshalAccessKeyBody(AccessKey k) { + _validateMaterial(k.type, k.material); + if (k.credentialId.length != _idSize || k.capsuleId.length != _idSize) { + throw _nonCanonical( + 'accesskey: credential_id and capsule_id must be $_idSize bytes', + ); + } + final verification = k.verification; + if (verification != null && + verification.capsuleDigest.length != _digestSize) { + // An empty map does not stand for absence (spec §43). + throw _nonCanonical('accesskey: capsule_digest must be $_digestSize bytes'); + } + final w = _Wire() + ..credentialId = k.credentialId + ..capsuleId = k.capsuleId + ..accessType = k.type + ..material = k.material + ..digest = verification?.capsuleDigest + ..critical = canonicalExtensions(k.critical) + ..noncritical = canonicalExtensions(k.noncritical); + checkDisjoint(w.critical, w.noncritical); + // Spec §72: datekeys.capsule goes only in the noncritical array of a .dkk, + // and datekeys.note never in a .dkk. + for (final (arr, exts) in [ + (ExtensionArray.critical, w.critical), + (ExtensionArray.noncritical, w.noncritical), + ]) { + checkWrite( + const StandardExtensions(), + ExtensionObject.accessKey, + arr, + exts, + context: 'accesskey', + ); + } + final e = CborEncoder(); + _encodeWire(e, w); + final b = e.out(); + if (b.length > maxDkkBodyLen) { + b.fillRange(0, b.length, 0); + throw _tooLong(b.length); + } + // The self-check of spec §72: the reader accepts what is written. + try { + decodeAccessKeyBody(b).wipe(); + } on DateKeysException catch (err) { + b.fillRange(0, b.length, 0); + throw err.wrap('accesskey: self-check: the reader rejects this body'); + } + return b; +} + +/// A whole .dkk: the prelude of 12 bytes and BODY_CBOR (spec §40), as +/// Encode of Go. The body it encodes is wiped once copied; the caller wipes +/// the result. +Uint8List encodeAccessKey(AccessKey k) { + final body = marshalAccessKeyBody(k); + final out = concatBytes([dkkPreludeBytes(body.length), body]); + body.fillRange(0, body.length, 0); + return out; +} diff --git a/lib/src/control.dart b/lib/src/control.dart new file mode 100644 index 0000000..8d82ced --- /dev/null +++ b/lib/src/control.dart @@ -0,0 +1,269 @@ +/// CONTROL_CBOR (spec §31, §63 step 14), as DecodeControl and EncodeControl +/// of package capsule of datekeys-go and control.ts of datekeys-ts, schema +/// versions 1 to 3: the same layers of spec §69.1 in the same order, with the +/// same codes and texts. Its schema version is the format of its capsule, +/// which is not part of [Control]: [decodeControl] and [encodeControl] +/// receive it. +/// +/// payload_identity is I_PAYLOAD, a secret: every copy that decoding makes +/// is wiped, and [Control.wipe] wipes the one it returns. +library; + +import 'dart:typed_data'; + +import 'big_endian.dart'; +import 'bytes.dart'; +import 'cbor.dart'; +import 'errors.dart'; +import 'extension.dart'; +import 'framing.dart'; +import 'padding.dart'; +import 'schema.dart'; + +/// The type tag of CONTROL_CBOR (spec §31). +const controlTypeTag = 'datekeys-control'; + +// payload_length has a fixed size, so that the length of CONTROL_CBOR never +// depends on L (spec §31, §55.2). +const _payloadLengthSize = 8; + +/// CONTROL_CBOR (spec §31). +final class Control { + /// A control of the fields of keys 2 to 7. [payloadLength] and [padding] + /// exist in formats 2 and 3 only, and are null in format 1. + Control({ + required this.headerBinding, + required this.payloadIdentity, + this.critical = const [], + this.noncritical = const [], + this.payloadLength, + this.padding, + }); + + /// Key 2, header_binding: SHA-256(PRELUDE || PUBLIC_HEADER), 32 bytes. + final Uint8List headerBinding; + + /// Key 3, payload_identity: the 32 raw bytes of the X25519 identity + /// I_PAYLOAD. A secret. + final Uint8List payloadIdentity; + + /// Key 4, critical_extensions. + final List critical; + + /// Key 5, noncritical_extensions. + final List noncritical; + + /// Key 6, L: the length of the content, at most L_MAX; in format 3, the + /// length of BODY. Formats 2 and 3 only (spec §29.1, §29.2, §31). + final int? payloadLength; + + /// Key 7, the padding rule of PAYLOAD_AGE. Formats 2 and 3 only. + final PaddingRule? padding; + + /// Overwrites I_PAYLOAD with zeros. + void wipe() => payloadIdentity.fillRange(0, payloadIdentity.length, 0); + + /// The control without I_PAYLOAD, as String of Go. + @override + String toString() => + 'Control{header_binding=${toHex(headerBinding)} ' + 'payload_identity=REDACTED payload_length=${payloadLength ?? 0} ' + 'padding=${padding?.code ?? 0}}'; +} + +// CONTROL_CBOR as it is encoded: keys 2 to 7, keys 0 and 1 being the +// constant controlTypeTag and the format. +final class _Wire { + _Wire(this.format); + + // Key 1, the schema version. + final CapsuleFormat format; + Uint8List headerBinding = Uint8List(0); + // A secret. + Uint8List payloadIdentity = Uint8List(0); + List critical = const []; + List noncritical = const []; + // Key 6, formats 2 and 3 only: 8 bytes, big-endian. + Uint8List? payloadLength; + // Key 7, formats 2 and 3 only. + int? padding; +} + +DateKeysException _nonCanonical(String context) => + DateKeysException(ErrorCode.nonCanonicalCbor, context); + +// L as read, at most L_MAX, and reported exactly when it is not, whatever +// its 64 bits. +Uint8List _checkPayloadLength(Uint8List b) { + final l = readUint64BE(b, 0); + if (l is! int || l > maxPayloadLength) { + throw _nonCanonical('payload_length $l exceeds L_MAX = $maxPayloadLength'); + } + return b; +} + +// Reads CONTROL_CBOR with every rule of the CDDL of the schema version of +// w.format: keys 6 and 7 are required in versions 2 and 3 and not defined in +// version 1. The caller wipes w.payloadIdentity, whatever the result. +void _decodeWire(CborDecoder d, _Wire w) { + final padded = w.format.isPadded; + final pairs = d.map(padded ? 8 : 6); + final seen = {}; + for (var i = 0; i < pairs; i++) { + final k = d.key(); + if ((k == 6 || k == 7) && !padded) throw keyNotDefined(k); + switch (k) { + case 0: + inKey(k, () => d.text(controlTypeTag.length)); + case 1: + inKey(k, () => d.uint(w.format.version)); + case 2: + w.headerBinding = inKey(k, () => d.bstr(32, 32)); + case 3: + w.payloadIdentity = inKey(k, () => d.bstr(32, 32)); + case 4: + w.critical = inKey(k, () => decodeExtensionArray(d)); + case 5: + w.noncritical = inKey(k, () => decodeExtensionArray(d)); + case 6: + w.payloadLength = inKey( + k, + () => _checkPayloadLength( + d.bstr(_payloadLengthSize, _payloadLengthSize), + ), + ); + case 7: + // Compared as read, so that 257 and the like never pass as a + // defined code. + w.padding = inKey(k, () { + final v = d.uint(); + if (PaddingRule.fromCode(v) == null) { + throw _nonCanonical('padding code $v is not defined'); + } + return v; + }); + default: + throw keyNotDefined(k); + } + seen.add(k as int); + } + requireKeys(seen, 4); + if (padded) { + for (final k in const [6, 7]) { + if (!seen.contains(k)) throw _nonCanonical('key $k is missing'); + } + } + d.endMap(); +} + +void _encodeWire(CborEncoder e, _Wire w) { + final padded = w.format.isPadded; + e + ..map(4 + presence(w.critical) + presence(w.noncritical) + (padded ? 2 : 0)) + ..uint(0) + ..text(controlTypeTag) + ..uint(1) + ..uint(w.format.version) + ..uint(2) + ..bstr(w.headerBinding) + ..uint(3) + ..bstr(w.payloadIdentity); + encodeExtensionArrays(e, 4, w.critical, w.noncritical); + if (padded) { + e + ..uint(6) + ..bstr(w.payloadLength ?? Uint8List(0)) + ..uint(7) + ..uint(w.padding ?? 0); + } +} + +/// Validates and decodes the CONTROL_CBOR of a capsule of [format] (spec +/// §31, §63 step 14), in the layers of spec §69.1, as DecodeControl of Go: +/// the type tag and the schema version, which must be the format (another +/// is ERR_UNSUPPORTED_VERSION, whatever follows); then the CBOR profile, the +/// re-encoding and the CDDL of that version; then no extension_id in both +/// extension arrays. In versions 2 and 3, payload_length is 8 bytes of at +/// most L_MAX and padding is 1 or 2. A non-canonical encoding is rejected, +/// although CONTROL_CBOR is not hashed. Its critical extensions (layer 4) +/// are checked by the caller. The caller wipes the control it gets. +Control decodeControl(List b, CapsuleFormat format) => + withContext('capsule: CONTROL_CBOR', () { + checkSchema(b, controlTypeTag, format.version); + final w = _Wire(format); + // I_PAYLOAD is wiped on every path: the copy that decoding reads and + // the re-encoding, which unmarshalCbor wipes. + try { + unmarshalCbor(b, (d) => _decodeWire(d, w), (e) => _encodeWire(e, w)); + checkDisjoint(w.critical, w.noncritical); + final l = w.payloadLength; + return Control( + headerBinding: w.headerBinding, + payloadIdentity: Uint8List.fromList(w.payloadIdentity), + critical: List.unmodifiable(w.critical), + noncritical: List.unmodifiable(w.noncritical), + // _decodeWire bounds both, so the conversions are exact. + payloadLength: l == null ? null : readUint64BE(l, 0) as int, + padding: w.padding == null ? null : PaddingRule.fromCode(w.padding!), + ); + } finally { + w.payloadIdentity.fillRange(0, w.payloadIdentity.length, 0); + } + }); + +/// The Deterministic CBOR of [c] as the CONTROL_CBOR of a capsule of +/// [format], as EncodeControl of Go: schema version 1 without keys 6 and 7, +/// or schema version 2 or 3 with them (spec §31). Its value must be one that +/// [decodeControl] returns: a field that breaks a rule of the schema is +/// ERR_NON_CANONICAL_CBOR, and keys 6 and 7 present in format 1, or absent +/// in formats 2 and 3, or an L above L_MAX, an error of the caller without +/// a normative code, as in Go. The caller wipes the result: it holds +/// I_PAYLOAD. +Uint8List encodeControl(Control c, CapsuleFormat format) { + if (c.headerBinding.length != 32 || c.payloadIdentity.length != 32) { + throw _nonCanonical( + 'capsule: header_binding and payload_identity must be 32 bytes', + ); + } + final w = _Wire(format) + ..headerBinding = c.headerBinding + ..payloadIdentity = c.payloadIdentity; + if (!format.isPadded) { + if (c.payloadLength != null || c.padding != null) { + throw ArgumentError( + 'capsule: CONTROL_CBOR of format 1 has no payload_length and no ' + 'padding', + ); + } + } else { + // Go's Control holds a padding code and an L that are 0 when unset. + final padding = c.padding; + if (padding == null) { + throw ArgumentError('capsule: padding code 0 is not defined'); + } + final l = c.payloadLength; + if (l == null) { + throw ArgumentError( + 'capsule: CONTROL_CBOR of format ${format.version} needs ' + 'payload_length', + ); + } + if (l < 0 || l > maxPayloadLength) { + throw ArgumentError( + 'capsule: payload_length $l exceeds L_MAX = $maxPayloadLength', + ); + } + final length = Uint8List(_payloadLengthSize); + writeUint64BE(length, 0, l); + w + ..payloadLength = length + ..padding = padding.code; + } + w + ..critical = canonicalExtensions(c.critical) + ..noncritical = canonicalExtensions(c.noncritical); + checkDisjoint(w.critical, w.noncritical); + final e = CborEncoder(capacity: 103); + _encodeWire(e, w); + return e.out(); +} diff --git a/lib/src/header.dart b/lib/src/header.dart new file mode 100644 index 0000000..177f342 --- /dev/null +++ b/lib/src/header.dart @@ -0,0 +1,232 @@ +/// PUBLIC_HEADER (spec §24, §27), as DecodeHeader and EncodeHeader of +/// package capsule of datekeys-go and header.ts of datekeys-ts: the same +/// layers of spec §69.1 in the same order, with the same codes and texts. +library; + +import 'dart:typed_data'; + +import 'bytes.dart'; +import 'cbor.dart'; +import 'datekey.dart'; +import 'errors.dart'; +import 'extension.dart'; +import 'framing.dart'; +import 'schema.dart'; + +/// The type tag of PUBLIC_HEADER (spec §24). +const headerTypeTag = 'datekeycap'; + +/// The schema version of PUBLIC_HEADER, the same in the three formats. +const headerVersion = 1; + +/// The size of capsule_id (spec §21). +const capsuleIdSize = 16; + +/// The declared access policy of a capsule (spec §25). +enum AccessPolicy { + /// 0, time_only: the date alone opens the capsule. + timeOnly(0, 'time_only'), + + /// 1, time_and_key: the date and an access credential. + timeAndKey(1, 'time_and_key'); + + const AccessPolicy(this.code, this.label); + + /// The value of access_policy, key 4 of PUBLIC_HEADER. + final int code; + + /// The name of Go's Policy.String: time_only or time_and_key. + final String label; + + /// The policy of [code], or null when V1 defines none. + static AccessPolicy? fromCode(int code) => switch (code) { + 0 => timeOnly, + 1 => timeAndKey, + _ => null, + }; + + @override + String toString() => label; +} + +/// The policy named [s], `time_only` or `time_and_key`, as ParsePolicy of +/// Go, which has no normative code for another name. +AccessPolicy parsePolicy(String s) => switch (s) { + 'time_only' => AccessPolicy.timeOnly, + 'time_and_key' => AccessPolicy.timeAndKey, + _ => throw ArgumentError( + 'capsule: unknown access policy ${goQuote(utf8Bytes(s))}', + ), +}; + +/// PUBLIC_HEADER (spec §24). It has no profile_id of its own: the profile is +/// the one of the DateKey, the single source of truth. +final class Header { + /// A header of the fields of keys 2 to 6. + Header({ + required this.capsuleId, + required this.dateKey, + required this.policy, + this.critical = const [], + this.noncritical = const [], + }); + + /// Key 2, capsule_id, 16 bytes. + final Uint8List capsuleId; + + /// Key 3, the canonical dk1_ DateKey. + final DateKey dateKey; + + /// Key 4, access_policy. + final AccessPolicy policy; + + /// Key 5, critical_extensions. + final List critical; + + /// Key 6, noncritical_extensions. + final List noncritical; + + /// capsule_id in hexadecimal. + String get capsuleIdHex => toHex(capsuleId); +} + +// PUBLIC_HEADER as it is encoded: keys 2 to 6, keys 0 and 1 being the +// constants headerTypeTag and headerVersion. +final class _Wire { + Uint8List capsuleId = Uint8List(0); + String dateKey = ''; + int policy = 0; + List critical = const []; + List noncritical = const []; +} + +// Reads PUBLIC_HEADER with every rule of the CDDL whose violation is +// ERR_NON_CANONICAL_CBOR, the extension arrays included (layer 3 of spec +// §69.1); the DateKey, which has codes of its own (spec §57), is parsed +// afterwards. +_Wire _decodeWire(CborDecoder d) { + final w = _Wire(); + final pairs = d.map(7); + final seen = {}; + for (var i = 0; i < pairs; i++) { + final k = d.key(); + switch (k) { + case 0: + inKey(k, () => d.text(headerTypeTag.length)); + case 1: + inKey(k, () => d.uint(headerVersion)); + case 2: + w.capsuleId = inKey(k, () => d.bstr(capsuleIdSize, capsuleIdSize)); + case 3: + w.dateKey = inKey(k, () => d.text(maxPublicHeaderLen)); + case 4: + // Compared as read, before any narrowing, which would let 256, 257 + // or 2^32 pass as a policy of V1. + w.policy = inKey(k, () { + final p = d.uint(); + if (p > AccessPolicy.timeAndKey.code) { + throw DateKeysException( + ErrorCode.nonCanonicalCbor, + 'access_policy $p is not defined in V1', + ); + } + return p; + }); + case 5: + w.critical = inKey(k, () => decodeExtensionArray(d)); + case 6: + w.noncritical = inKey(k, () => decodeExtensionArray(d)); + default: + throw keyNotDefined(k); + } + seen.add(k as int); + } + requireKeys(seen, 5); + d.endMap(); + return w; +} + +void _encodeWire(CborEncoder e, _Wire w) { + e + ..map(5 + presence(w.critical) + presence(w.noncritical)) + ..uint(0) + ..text(headerTypeTag) + ..uint(1) + ..uint(headerVersion) + ..uint(2) + ..bstr(w.capsuleId) + ..uint(3) + ..text(w.dateKey) + ..uint(4) + ..uint(w.policy); + encodeExtensionArrays(e, 5, w.critical, w.noncritical); +} + +DateKeysException _tooLong(int n) => DateKeysException( + ErrorCode.integrity, + 'capsule: PUBLIC_HEADER of $n bytes exceeds $maxPublicHeaderLen', +); + +/// Validates and decodes the bytes of PUBLIC_HEADER (spec §24, §27, §63 +/// step 4), in the layers of spec §69.1, as DecodeHeader of Go: the limit of +/// spec §57 (layer 1, ERR_INTEGRITY); the type tag and the schema version +/// (layer 2); the CBOR profile, the re-encoding and the CDDL, with a +/// capsule_id of 16 bytes, an access_policy of V1 and well-formed extension +/// arrays, and no extension_id in both arrays (layer 3, +/// ERR_NON_CANONICAL_CBOR); and only then a canonical DateKey (layer 4). +/// Whether the profile is pinned and the critical extensions are known is +/// decided by the caller, still in layer 4. +Header decodeHeader(List b) { + if (b.length > maxPublicHeaderLen) throw _tooLong(b.length); + return withContext('capsule: PUBLIC_HEADER', () { + checkSchema(b, headerTypeTag, headerVersion); + late _Wire w; + unmarshalCbor(b, (d) => w = _decodeWire(d), (e) => _encodeWire(e, w)); + checkDisjoint(w.critical, w.noncritical); + final dateKey = parseDateKey(w.dateKey); + return Header( + capsuleId: w.capsuleId, + dateKey: dateKey, + // _decodeWire bounds the policy to 0 or 1. + policy: AccessPolicy.fromCode(w.policy)!, + critical: List.unmodifiable(w.critical), + noncritical: List.unmodifiable(w.noncritical), + ); + }); +} + +/// The Deterministic CBOR of [h], at most [maxPublicHeaderLen] bytes (spec +/// §57), as EncodeHeader of Go: a DateKey that is not valid is +/// ERR_DATEKEY_INVALID, the extensions are written in canonical order, and +/// a header above the limit is ERR_INTEGRITY. The rule of spec §72 for the +/// extensions of the specification is the writer's ([checkWrite]). +Uint8List encodeHeader(Header h) { + final compact = compactDateKey(h.dateKey); + if (compact.isEmpty) { + throw DateKeysException( + ErrorCode.dateKeyInvalid, + 'capsule: invalid DateKey', + ); + } + if (h.capsuleId.length != capsuleIdSize) { + throw DateKeysException( + ErrorCode.nonCanonicalCbor, + 'capsule: capsule_id is ${h.capsuleId.length} bytes, want ' + '$capsuleIdSize', + ); + } + final critical = canonicalExtensions(h.critical); + final noncritical = canonicalExtensions(h.noncritical); + checkDisjoint(critical, noncritical); + final w = _Wire() + ..capsuleId = h.capsuleId + ..dateKey = compact + ..policy = h.policy.code + ..critical = critical + ..noncritical = noncritical; + final e = CborEncoder(); + _encodeWire(e, w); + final b = Uint8List.fromList(e.out()); + if (b.length > maxPublicHeaderLen) throw _tooLong(b.length); + return b; +} diff --git a/test/formats_fixtures_test.dart b/test/formats_fixtures_test.dart new file mode 100644 index 0000000..8104919 --- /dev/null +++ b/test/formats_fixtures_test.dart @@ -0,0 +1,235 @@ +// Every official fixture of testdata/ through the formats: the PRELUDE, the +// sections and header_binding of each .dkc, its PUBLIC_HEADER and its +// CONTROL_CBOR, decoded and written back to the same bytes, the profile and +// the round time of its DateKey, P = rule(L), and in format 3 the frame of +// BODY, the security area and the place of the head and of the files; and +// each .dkk, whose capsule_digest is the SHA-256 of its .dkc. The values are +// those of the records .json and .dkk.json, which Go wrote. +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:datekeys/src/accesskey.dart'; +import 'package:datekeys/src/body.dart'; +import 'package:datekeys/src/bytes.dart'; +import 'package:datekeys/src/control.dart'; +import 'package:datekeys/src/datekey.dart'; +import 'package:datekeys/src/digest.dart'; +import 'package:datekeys/src/errors.dart'; +import 'package:datekeys/src/extension.dart'; +import 'package:datekeys/src/framing.dart'; +import 'package:datekeys/src/header.dart'; +import 'package:datekeys/src/padding.dart'; +import 'package:datekeys/src/profile.dart'; +import 'package:test/test.dart'; + +typedef Json = Map; + +Json readJson(String path) => jsonDecode(File(path).readAsStringSync()) as Json; + +Uint8List readBytes(String path) => File(path).readAsBytesSync(); + +/// The extensions of a record, as `{critical, id, version, data}`. +List recordExtensions(Object? v) => [ + for (final e in ((v as List?) ?? const []).cast()) + [e['critical'], e['id'], e['version'], e['data']], +]; + +List extensionsOf(List critical, List non) => [ + for (final (c, list) in [(true, critical), (false, non)]) + for (final e in list) + [c, e.id, e.version, e.data == null ? null : toHex(e.data!)], +]; + +String codeOf(void Function() body) { + try { + body(); + return 'ok'; + } on DateKeysException catch (e) { + return e.code.code; + } +} + +void main() { + final names = + Directory('testdata/fixtures') + .listSync() + .map((f) => f.uri.pathSegments.last) + .where((n) => n.endsWith('.dkc')) + .map((n) => n.substring(0, n.length - 4)) + .toList() + ..sort(); + final keys = + Directory('testdata/fixtures') + .listSync() + .map((f) => f.uri.pathSegments.last) + .where((n) => n.endsWith('.dkk')) + .toList() + ..sort(); + + test('there are the 24 capsules and the 6 access keys of spec §67, §68', () { + expect(names, hasLength(24)); + expect(keys, hasLength(6)); + }); + + for (final name in names) { + test(name, () { + final r = readJson('testdata/fixtures/$name.json'); + final dkc = readBytes('testdata/fixtures/$name.dkc'); + expect(toHex(capsuleDigest(dkc)), r['sha256']); + + // The frame. + final format = CapsuleFormat.fromVersion(r['format']! as int)!; + final prelude = parsePrelude(dkc.sublist(0, dkcPreludeSize)); + expect(prelude.format, format); + expect(toHex(preludeBytes(prelude)), r['prelude']); + final s = splitCapsule(dkc); + expect(s.prelude, prelude); + expect(toHex(s.preludeBytes), r['prelude']); + expect(toHex(s.publicHeader), r['public_header']); + expect(s.sealedControl, hasLength(prelude.sealedControlLen)); + expect(s.payload.length, dkc.length - payloadOffset(prelude)); + expect( + toHex(headerBinding(s.preludeBytes, s.publicHeader)), + r['header_binding'], + ); + + // PUBLIC_HEADER, its profile and its round. + final h = decodeHeader(s.publicHeader); + expect( + [h.capsuleIdHex, compactDateKey(h.dateKey), h.policy.label], + [r['capsule_id'], r['datekey'], r['access_policy']], + ); + expect( + extensionsOf(h.critical, h.noncritical), + recordExtensions(r['header_extensions']), + ); + expect(toHex(encodeHeader(h)), r['public_header']); + final p = defaultRegistry().lookup(h.dateKey.profileId)!; + validateDateKey(h.dateKey, p); + expect(formatRfc3339(unlockAt(h.dateKey, p)!), r['unlock_at']); + + // CONTROL_CBOR, in its format and in no other. + final control = decodeControl( + fromHex(r['control_cbor']! as String), + format, + ); + expect( + [toHex(control.headerBinding), toHex(control.payloadIdentity)], + [r['header_binding'], r['payload_identity']], + ); + expect( + extensionsOf(control.critical, control.noncritical), + recordExtensions(r['control_extensions']), + ); + expect(toHex(encodeControl(control, format)), r['control_cbor']); + for (final other in CapsuleFormat.values.where((f) => f != format)) { + expect( + codeOf( + () => decodeControl(fromHex(r['control_cbor']! as String), other), + ), + 'ERR_UNSUPPORTED_VERSION', + ); + } + final plaintext = readBytes('testdata/fixtures/${r['plaintext_file']}'); + if (!format.isPadded) { + expect([control.payloadLength, control.padding], [null, null]); + expect(r.containsKey('padding'), isFalse); + expect(plaintext.length, r['payload_length']); + return; + } + final l = control.payloadLength!; + final rule = control.padding!; + expect([l, rule.code], [r['payload_length'], r['padding']]); + final padded = paddedLength(l, rule); + expect(padded, r['padded_length']); + expect(plaintext.length, l); + + // The plaintext of PAYLOAD_AGE is the content and zeros up to P. + final check = PaddingCheck(l, padded); + final content = BytesBuilder() + ..add(check.add(plaintext)) + ..add(check.add(Uint8List(padded - l))); + check.close(); + expect(content.takeBytes(), plaintext); + if (format != CapsuleFormat.format3) return; + + // BODY: its frame, the security area, the head and the files. + final frame = parseBodyFrame(plaintext.sublist(0, bodyFrameSize), l); + expect(frame.areaLen, r['area_len']); + final area = plaintext.sublist( + bodyFrameSize, + bodyFrameSize + frame.areaLen, + ); + checkArea(area, frame.securityLen); + expect(toHex(area.sublist(0, frame.securityLen)), r['security_cbor']); + final headStart = bodyFrameSize + frame.areaLen; + expect( + toHex(plaintext.sublist(headStart, headStart + frame.headLen)), + r['head_cbor'], + ); + expect(headStart + frame.headLen, r['content_offset']); + final files = (r['files'] as List?) ?? const []; + expect( + contentLength(frame, l), + [ + 0, + for (final f in files) (f! as Json)['size']! as int, + ].reduce((a, b) => a + b), + ); + }); + } + + for (final key in keys) { + test(key, () { + final r = readJson('testdata/fixtures/$key.json'); + final raw = readBytes('testdata/fixtures/$key'); + expect(toHex(capsuleDigest(raw)), r['sha256']); + final k = decodeAccessKey(raw); + expect( + [ + toHex(k.credentialId), + toHex(k.capsuleId), + k.type, + toHex(k.material), + k.verification == null ? null : toHex(k.verification!.capsuleDigest), + ], + [ + r['credential_id'], + r['capsule_id'], + r['access_type'], + r['access_material'], + r['capsule_digest'], + ], + ); + expect( + extensionsOf(k.critical, k.noncritical), + recordExtensions(r['extensions']), + ); + checkAccessKeyMaterial(k); + // capsule_digest is the SHA-256 of the exact bytes of its .dkc, and its + // capsule_id that of the header of that capsule. + final dkc = readBytes('testdata/fixtures/${r['capsule']}'); + checkCapsuleDigest(capsuleDigest(dkc), k.verification!.capsuleDigest); + expect( + decodeHeader(splitCapsule(dkc).publicHeader).capsuleId, + k.capsuleId, + ); + expect( + codeOf( + () => checkCapsuleDigest( + capsuleDigest(raw), + k.verification!.capsuleDigest, + ), + ), + 'ERR_ACCESS_INVALID', + ); + expect(toHex(encodeAccessKey(k)), toHex(raw)); + k.wipe(); + expect(k.material.every((b) => b == 0), isTrue); + }); + } +} diff --git a/test/formats_objects_test.dart b/test/formats_objects_test.dart new file mode 100644 index 0000000..cfaf538 --- /dev/null +++ b/test/formats_objects_test.dart @@ -0,0 +1,297 @@ +// PUBLIC_HEADER, CONTROL_CBOR, the .dkk and the Provider Profile +// (lib/src/header.dart, control.dart, accesskey.dart and profile.dart), as +// header.test.ts, control.test.ts, accesskey.test.ts and profile.test.ts of +// datekeys-ts: values of a fixed seed that encode and decode back to +// themselves, the copies of the secrets and their wiping, the texts that +// hide them, and the pinned registry. The values and texts of Go are in the +// differential (formats_header.json, formats_control.json, +// formats_framing.json, formats_profile.json and formats_encode.json). It +// reads no file: it runs on the VM and compiled to JavaScript. +library; + +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:datekeys/src/accesskey.dart'; +import 'package:datekeys/src/bytes.dart'; +import 'package:datekeys/src/control.dart'; +import 'package:datekeys/src/datekey.dart'; +import 'package:datekeys/src/errors.dart'; +import 'package:datekeys/src/extension.dart'; +import 'package:datekeys/src/framing.dart'; +import 'package:datekeys/src/header.dart'; +import 'package:datekeys/src/padding.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; + } +} + +Uint8List bytesOf(Random r, int n) => + Uint8List.fromList([for (var i = 0; i < n; i++) r.nextInt(256)]); + +// 0 to 3 extensions with distinct identifiers, unsorted. +List extensionsOf(Random r, String prefix) => [ + for (var i = 0; i < r.nextInt(4); i++) + Extension( + '$prefix${r.nextInt(1000)}x$i', + r.nextInt(3), + r.nextBool() ? bytesOf(r, 1 + r.nextInt(5)) : null, + ), +]; + +void main() { + test('headers encode and decode back to themselves', () { + final r = Random(17); + for (var i = 0; i < 200; i++) { + final h = Header( + capsuleId: bytesOf(r, 16), + dateKey: DateKey('datekeys:quicknet:v1', 1 + r.nextInt(1 << 30)), + policy: AccessPolicy.values[r.nextInt(2)], + critical: extensionsOf(r, 'c'), + noncritical: extensionsOf(r, 'n'), + ); + final b = encodeHeader(h); + final back = decodeHeader(b); + expect( + [back.capsuleId, back.dateKey, back.policy], + [h.capsuleId, h.dateKey, h.policy], + ); + expect(back.critical, canonicalExtensions(h.critical)); + expect(back.noncritical, canonicalExtensions(h.noncritical)); + expect(encodeHeader(back), b); + expect(back.capsuleIdHex, toHex(h.capsuleId)); + } + }); + + test('access policies by code and by name', () { + expect( + [ + for (final p in AccessPolicy.values) [p.code, p.label, '$p'], + ], + [ + [0, 'time_only', 'time_only'], + [1, 'time_and_key', 'time_and_key'], + ], + ); + expect([0, 1, 2].map(AccessPolicy.fromCode).toList(), [ + AccessPolicy.timeOnly, + AccessPolicy.timeAndKey, + null, + ]); + expect(parsePolicy('time_and_key'), AccessPolicy.timeAndKey); + expect(() => parsePolicy('x'), throwsArgumentError); + expect( + codeOf( + () => encodeHeader( + Header( + capsuleId: Uint8List(15), + dateKey: const DateKey('a', 1), + policy: AccessPolicy.timeOnly, + ), + ), + ), + 'ERR_NON_CANONICAL_CBOR', + ); + }); + + test('controls encode and decode back, of a length that does not depend ' + 'on L', () { + final r = Random(19); + for (var i = 0; i < 200; i++) { + final format = CapsuleFormat.values[i % 3]; + final c = Control( + headerBinding: bytesOf(r, 32), + payloadIdentity: bytesOf(r, 32), + critical: extensionsOf(r, 'c'), + noncritical: extensionsOf(r, 'n'), + payloadLength: format.isPadded + ? [0, 1, 4294967297, maxPayloadLength, r.nextInt(1 << 30)][i % 5] + : null, + padding: format.isPadded ? PaddingRule.values[r.nextInt(2)] : null, + ); + final b = encodeControl(c, format); + final back = decodeControl(b, format); + expect( + [back.headerBinding, back.payloadIdentity, back.payloadLength], + [c.headerBinding, c.payloadIdentity, c.payloadLength], + ); + expect(back.padding, c.padding); + expect(encodeControl(back, format), b); + if (c.critical.isEmpty && c.noncritical.isEmpty) { + expect(b.length, format.isPadded ? 103 : 91); + expect(b[20], format.version); + } + for (final other in CapsuleFormat.values.where((f) => f != format)) { + expect( + codeOf(() => decodeControl(b, other)), + 'ERR_UNSUPPORTED_VERSION', + ); + } + } + }); + + test('a control keeps its own copy of I_PAYLOAD, wipes it, and never ' + 'prints it', () { + final c = Control( + headerBinding: Uint8List(32), + payloadIdentity: Uint8List(32)..fillRange(0, 32, 0xab), + payloadLength: 1, + padding: PaddingRule.reforzado, + ); + final b = encodeControl(c, CapsuleFormat.format2); + final back = decodeControl(b, CapsuleFormat.format2); + b.fillRange(0, b.length, 0); + expect(back.payloadIdentity, Uint8List(32)..fillRange(0, 32, 0xab)); + expect('$back', isNot(contains('abab'))); + expect('$back', contains('REDACTED')); + back.wipe(); + expect(back.payloadIdentity, Uint8List(32)); + expect(() => encodeControl(c, CapsuleFormat.format1), throwsArgumentError); + expect( + () => encodeControl( + Control( + headerBinding: Uint8List(32), + payloadIdentity: Uint8List(32), + padding: PaddingRule.bloque256, + ), + CapsuleFormat.format3, + ), + throwsArgumentError, + ); + }); + + test('access keys encode and decode back, and never print their ' + 'material', () { + final r = Random(23); + for (var i = 0; i < 200; i++) { + final k = AccessKey( + credentialId: bytesOf(r, 16), + capsuleId: bytesOf(r, 16), + type: accessTypeX25519, + material: bytesOf(r, 32), + verification: r.nextBool() ? Verification(bytesOf(r, 32)) : null, + critical: extensionsOf(r, 'c'), + noncritical: extensionsOf(r, 'n'), + ); + final dkk = encodeAccessKey(k); + final back = decodeAccessKey(dkk); + expect( + [back.credentialId, back.capsuleId, back.type, back.material], + [k.credentialId, k.capsuleId, k.type, k.material], + ); + expect(back.verification, k.verification); + expect(back.critical, canonicalExtensions(k.critical)); + expect(encodeAccessKey(back), dkk); + expect('$back', isNot(contains(toHex(k.material)))); + // The key keeps its own copy of the material. + dkk.fillRange(0, dkk.length, 0); + expect(back.material, k.material); + back.wipe(); + expect(back.material, Uint8List(32)); + expect(codeOf(() => checkAccessKeyMaterial(back)), 'ok'); + } + expect( + codeOf( + () => checkAccessKeyMaterial( + AccessKey( + credentialId: Uint8List(16), + capsuleId: Uint8List(16), + type: 'X25519', + material: Uint8List(32), + ), + ), + ), + 'ERR_ACCESS_INVALID', + ); + expect( + codeOf( + () => marshalAccessKeyBody( + AccessKey( + credentialId: Uint8List(15), + capsuleId: Uint8List(16), + type: accessTypeX25519, + material: Uint8List(32), + ), + ), + ), + 'ERR_NON_CANONICAL_CBOR', + ); + }); + + group('Provider Profiles', () { + final q = quicknet(); + + test('Quicknet is its pinned CBOR, and its range ends in 9999', () { + expect(toHex(canonicalCbor(q)), quicknetCanonicalCbor); + expect(toHex(profileHash(q)), quicknetProfileHash); + expect(decodeProfile(fromHex(quicknetCanonicalCbor)), q); + validateProfile(q); + expect(q.chainHashHex, quicknetChainHash); + expect( + formatRfc3339(roundTime(q, q.maxRound)), + startsWith('9999-12-31T23:59:5'), + ); + expect(q.copyWith(genesisTime: maxUnixTime).maxRound, 1); + expect(q.copyWith(genesisTime: maxUnixTime + 1).maxRound, 0); + expect(q.copyWith(period: 0).maxRound, 0); + }); + + test('the default registry is built once and hands out copies', () { + final reg = defaultRegistry(); + expect(defaultRegistry(), same(reg)); + final p = reg.lookup(quicknetId)!; + expect(p, q); + p.chainHash.fillRange(0, 32, 0); + expect(reg.lookup(quicknetId), q); + expect(reg.lookup('datekeys:evmnet:v1'), isNull); + final copy = q.copy(); + copy.publicKey[0] ^= 1; + expect(quicknet(), isNot(copy)); + }); + + test('a network "default", or none, is left out of the chain hash', () { + final d = q.copyWith(network: 'default'); + expect(chainInfoHash(d), chainInfoHash(q.copyWith(network: ''))); + expect( + chainInfoHash(d), + isNot(chainInfoHash(q.copyWith(network: 'defaults'))), + ); + validateProfile(d.copyWith(chainHash: chainInfoHash(d))); + expect( + codeOf(() => chainInfoHash(q.copyWith(period: maxChainHashPeriod + 1))), + 'ERR_UNKNOWN_PROFILE', + ); + expect( + chainInfoHash(q.copyWith(period: maxChainHashPeriod)), + hasLength(32), + ); + }); + + test('a profile_id is [a-z0-9:._-], from 1 to 128, the first of ' + '[a-z0-9]', () { + for (final s in ['datekeys:quicknet:v1', '0${'a' * 127}', 'a', '0:._-']) { + expect(validId(s), isTrue, reason: s); + } + for (final s in [ + '', + 'A', + ':a', + '.a', + 'a b', + 'a/b', + 'é', + '0${'a' * 128}', + 'a\n', + ]) { + expect(validId(s), isFalse, reason: s); + } + }); + }); +} diff --git a/test/formats_vectors_test.dart b/test/formats_vectors_test.dart new file mode 100644 index 0000000..d5d23e5 --- /dev/null +++ b/test/formats_vectors_test.dart @@ -0,0 +1,269 @@ +// The shared vectors of testdata/ that the formats read, as vectors.test.ts +// of datekeys-ts: dk1.json, quicknet_rounds.json, profile_quicknet.json, +// padding.json and the schemas block of cbor.json, each object through the +// decoder of its schema and written back to the same bytes. +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:datekeys/src/accesskey.dart'; +import 'package:datekeys/src/bytes.dart'; +import 'package:datekeys/src/control.dart'; +import 'package:datekeys/src/datekey.dart'; +import 'package:datekeys/src/errors.dart'; +import 'package:datekeys/src/framing.dart'; +import 'package:datekeys/src/header.dart'; +import 'package:datekeys/src/padding.dart'; +import 'package:datekeys/src/profile.dart'; +import 'package:test/test.dart'; + +typedef Json = Map; + +Json readJson(String path) => jsonDecode(File(path).readAsStringSync()) as Json; + +List section(Json file, String name) => + (file[name]! as List).cast(); + +/// The code of the [DateKeysException] that [body] throws, or `ok`. +String resultOf(void Function() body) { + try { + body(); + return 'ok'; + } on DateKeysException catch (e) { + return e.code.code; + } +} + +void main() { + group('testdata/vectors/dk1.json', () { + final file = readJson('testdata/vectors/dk1.json'); + final vectors = section(file, 'vectors'); + + test('is of this specification and holds valid and invalid strings', () { + expect(file['spec'], '0.11'); + expect(vectors.where((v) => v.containsKey('dk1')), isNotEmpty); + expect(vectors.where((v) => v.containsKey('input')), isNotEmpty); + }); + + for (final v in vectors) { + test(v['name'], () { + if (v.containsKey('input')) { + expect( + resultOf(() => parseDateKey(v['input']! as String)), + v['error'], + ); + return; + } + final d = parseDateKey(v['dk1']! as String); + expect([d.profileId, d.round], [v['network'], v['round']]); + expect(canonicalJson(d), v['canonical_json']); + expect(compactDateKey(d), v['dk1']); + expect(compactDateKey(d), 'dk1_${v['base64url']}'); + }); + } + }); + + group('testdata/vectors/quicknet_rounds.json', () { + final file = readJson('testdata/vectors/quicknet_rounds.json'); + final p = quicknet(); + + test('is for the pinned Quicknet profile', () { + expect(file['profile'], quicknetId); + expect(section(file, 'vectors'), isNotEmpty); + }); + + for (final v in section(file, 'vectors')) { + test(v['name'], () { + final at = parseRfc3339(v['requested']! as String); + if (v.containsKey('error')) { + expect(resultOf(() => resolveDateKey(p, at)), v['error']); + return; + } + final d = resolveDateKey(p, at); + expect(d.round, v['round']); + expect(formatRfc3339Nano(unlockAt(d, p)!), v['effective']); + expect(resolveRfc3339(p, v['requested']! as String), d); + }); + } + }); + + test('testdata/vectors/profile_quicknet.json', () { + final v = readJson('testdata/vectors/profile_quicknet.json'); + final p = quicknet(); + expect( + [ + p.id, + p.provider, + p.network, + toHex(p.chainHash), + toHex(p.publicKey), + p.period, + p.genesisTime, + toHex(p.genesisSeed), + p.scheme, + ], + [ + v['profile_id'], + v['provider'], + v['network'], + v['chain_hash'], + v['public_key'], + v['period_seconds'], + v['genesis_time'], + v['genesis_seed'], + v['scheme'], + ], + ); + expect(toHex(canonicalCbor(p)), v['canonical_cbor']); + expect(toHex(profileHash(p)), v['profile_hash']); + expect(decodeProfile(fromHex(v['canonical_cbor']! as String)), p); + expect( + [quicknetCanonicalCbor, quicknetProfileHash], + [v['canonical_cbor'], v['profile_hash']], + ); + }); + + group('testdata/vectors/padding.json', () { + final file = readJson('testdata/vectors/padding.json'); + final vectors = section(file, 'vectors'); + + test('names L_MAX and covers the boundaries of spec §29.1', () { + expect(file['l_max'], maxPayloadLength); + final ls = vectors.map((v) => v['l']).toList(); + for (final l in [ + 0, + 256, + 257, + 2113929216, + 2113929217, + 4227858432, + 4227858433, + 4294967295, + 4294967297, + 562949953421311, + maxPayloadLength, + ]) { + expect(ls, contains(l)); + } + expect(file['rejected'], contains(maxPayloadLength + 1)); + }); + + for (final v in vectors) { + test('L = ${v['l']}', () { + final l = v['l']! as int; + final b = paddedLength(l, PaddingRule.bloque256); + final r = paddedLength(l, PaddingRule.reforzado); + expect( + [b, r, payloadAgeLength(b), payloadAgeLength(r)], + [ + v['bloque256'], + v['reforzado'], + v['payload_age_bloque256'], + v['payload_age_reforzado'], + ], + ); + if (l > 256) { + final (:e, :s, :lastBits) = padmeParameters(l); + expect([e, s, lastBits], [v['e'], v['s'], v['last_bits']]); + } else { + expect(v.containsKey('e'), isFalse); + } + }); + } + + test('rejects the lengths above L_MAX', () { + for (final l in (file['rejected']! as List).cast()) { + for (final rule in PaddingRule.values) { + expect( + () => paddedLength(l, rule), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'capsule: content of $l bytes exceeds L_MAX = ' + '$maxPayloadLength', + ), + ), + ); + } + } + }); + }); + + group('testdata/vectors/cbor.json: the schemas', () { + final file = readJson('testdata/vectors/cbor.json'); + final schemas = section(file, 'schemas'); + // The decoder of each schema, for the capsule format of the vector; it + // returns the writer of the object it decoded. + final decoders = , int)>{ + 'provider_profile': (b, _) { + final p = decodeProfile(b); + return () => canonicalCbor(p); + }, + 'public_header': (b, _) { + final h = decodeHeader(b); + return () => encodeHeader(h); + }, + 'control_cbor': (b, format) { + final f = CapsuleFormat.fromVersion(format)!; + final c = decodeControl(b, f); + return () => encodeControl(c, f); + }, + 'dkk_body': (b, _) { + final k = decodeAccessKeyBody(b); + return () => marshalAccessKeyBody(k); + }, + }; + const blocks = { + 'provider_profile': 'provider_profile', + 'public_header': 'public_header', + 'control_cbor': 'control_cbor', + 'dkk_body': 'dkk_body', + 'verification_metadata': 'dkk_body', + 'extension': 'public_header', + }; + + test('has every block, with CONTROL_CBOR in the three formats', () { + for (final block in blocks.keys) { + expect( + schemas.where((v) => v['block'] == block).length, + greaterThanOrEqualTo(5), + reason: block, + ); + } + for (final v in schemas) { + expect(blocks[v['block']], v['schema'], reason: '${v['name']}'); + expect( + v.containsKey('format'), + v['schema'] == 'control_cbor' && + (v['name']! as String).startsWith('format '), + reason: '${v['name']}', + ); + } + final controls = schemas.where((v) => v['schema'] == 'control_cbor'); + for (final f in [2, 3]) { + expect( + controls.where((v) => v['format'] == f).length, + greaterThanOrEqualTo(5), + ); + } + }); + + for (final v in schemas) { + test('${v['block']} (${v['schema']}): ${v['name']}', () { + final input = fromHex(v['hex']! as String); + Object Function()? write; + final result = resultOf(() { + write = decoders[v['schema']]!(input, (v['format'] as int?) ?? 1); + }); + expect(result, v['result']); + // An accepted object is written back to the same bytes. + if (write != null) { + expect(toHex(write!() as List), v['hex']); + } + }); + } + }); +}