lib/src/header.dart, control.dart and accesskey.dart port DecodeHeader, EncodeHeader, DecodeControl and EncodeControl of package capsule and package accesskey of datekeys-go at c531e93, in the layers of spec §69.1 and with the texts of Go: the limit of the frame, the type tag and the schema version, the CBOR profile with the re-encoding and the CDDL, keys 6 and 7 of CONTROL_CBOR versions 2 and 3, and only then the DateKey of the header and access_type and access_material of the .dkk. The .dkk is written with the rule of spec §72 for the extensions of the specification and read back before it is returned, as MarshalBody. I_PAYLOAD and access_material are copied once and wiped on every path; their objects print without them. The access policy is the enum AccessPolicy. The tests read every fixture of testdata/: the PRELUDE, the sections and header_binding of the 24 capsules, their header and control decoded and written back, the round time of their DateKey, P, and in format 3 the frame of BODY, the area, the head and the files; the six .dkk with their capsule_digest. And the shared vectors dk1.json, quicknet_rounds.json, profile_quicknet.json, padding.json and the 172 schemas of cbor.json, which stage 1 left aside, each decoded by its schema and written back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>v0.11
parent
4b22d783d3
commit
8ca969037d
@ -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<Extension> critical;
|
||||
|
||||
/// Key 8, noncritical_extensions.
|
||||
final List<Extension> 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<Extension> critical = const [];
|
||||
List<Extension> 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 = <int>{};
|
||||
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<int> 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<int> 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;
|
||||
}
|
||||
@ -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<Extension> critical;
|
||||
|
||||
/// Key 5, noncritical_extensions.
|
||||
final List<Extension> 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<Extension> critical = const [];
|
||||
List<Extension> 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 = <int>{};
|
||||
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<int> 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();
|
||||
}
|
||||
@ -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<Extension> critical;
|
||||
|
||||
/// Key 6, noncritical_extensions.
|
||||
final List<Extension> 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<Extension> critical = const [];
|
||||
List<Extension> 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 = <int>{};
|
||||
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<int> 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;
|
||||
}
|
||||
@ -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 <name>.json and <name>.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<String, Object?>;
|
||||
|
||||
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<Object?> recordExtensions(Object? v) => [
|
||||
for (final e in ((v as List<Object?>?) ?? const []).cast<Json>())
|
||||
[e['critical'], e['id'], e['version'], e['data']],
|
||||
];
|
||||
|
||||
List<Object?> extensionsOf(List<Extension> critical, List<Extension> 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<Object?>?) ?? 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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<Extension> 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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<String, Object?>;
|
||||
|
||||
Json readJson(String path) => jsonDecode(File(path).readAsStringSync()) as Json;
|
||||
|
||||
List<Json> section(Json file, String name) =>
|
||||
(file[name]! as List).cast<Json>();
|
||||
|
||||
/// 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<int>()) {
|
||||
for (final rule in PaddingRule.values) {
|
||||
expect(
|
||||
() => paddedLength(l, rule),
|
||||
throwsA(
|
||||
isA<ArgumentError>().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 = <String, Object Function() Function(List<int>, 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<int>), v['hex']);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue