// The differential of the formats against the Go reference: every case of // test/vectors/formats_*.json, which tool/formats_go_vectors.go writes with // the packages capsule, accesskey, profile, extension, datekey and codec of // datekeys-go and Go's time, run through the Dart code, with the same // result, normative code and text. It reads no file itself, so that the // tests compiled to JavaScript run it too, on the part of the cases that // formats_vectors.g.dart holds; the cases that need a fixture run only when // a reader of fixtures is given. library; 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/cbor.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:datekeys/src/sha256.dart'; import 'package:test/test.dart'; typedef Json = Map; /// The cases of [section] of [file]. List cases(Json file, String section) => (file[section]! as List).cast(); String str(Json c, String key) => c[key]! as String; int number(Json c, String key) => c[key]! as int; /// What [body] gives, as the vectors write it: `ok`, the normative code of a /// [DateKeysException] with its text, or `error` and the text of an error /// without a normative code, which this library throws as an /// [ArgumentError], as Go returns it without a code. (String, String?) outcomeOf(void Function() body) { try { body(); return ('ok', null); } on DateKeysException catch (e) { return (e.code.code, e.message); } on ArgumentError catch (e) { return ('error', '${e.message}'); } } /// Expects the outcome of [body] to be the one of the case [c]: its /// `result` and its `text`, or those of [resultKey] and [textKey]. void expectOutcome( Json c, void Function() body, { String resultKey = 'result', String textKey = 'text', }) { final (result, text) = outcomeOf(body); expect((result, text), (c[resultKey], c[textKey]), reason: '$c'); } /// An extension of the vectors: [id, version, data in hexadecimal or null]. Extension extensionOf(Object? v) { final l = v! as List; final data = l[2] as String?; return Extension( l[0]! as String, l[1]! as int, data == null ? null : fromHex(data), ); } List extensionsOf(Object? v) => [ for (final e in v! as List) extensionOf(e), ]; /// [exts] as the vectors write them. List extensionsJson(List exts) => [ for (final e in exts) [e.id, e.version, e.data == null ? null : toHex(e.data!)], ]; /// The objects of the extension registries, by their names in the /// specification. final objectsByName = {for (final o in ExtensionObject.values) o.specName: o}; final arraysByName = {for (final a in ExtensionArray.values) a.specName: a}; /// The registry placed of the generator: org.a v1 and org.b v1 known /// everywhere; org.ctl v1 registered only in the critical array of /// CONTROL_CBOR and org.note v1 only in the noncritical array of /// PUBLIC_HEADER. Data is valid when it is "ok"; org.b with "ko!" is /// rejected with an error of code ERR_EXTENSION_DATA_INVALID, as a note that /// breaks its rules is, and any other data with a text without a code. final class PlacedRegistry extends ExtensionRegistry { const PlacedRegistry(); @override bool known(String id, int version) => version == 1 && (id == 'org.a' || id == 'org.b' || id == 'org.ctl' || id == 'org.note'); @override Object? validateData(Extension e) { final data = e.data; if (data != null && equalBytes(data, 'ok'.codeUnits)) return null; if (e.id == 'org.b' && data != null && equalBytes(data, 'ko!'.codeUnits)) { return DateKeysException( ErrorCode.extensionDataInvalid, 'a data of ${data.length} bytes', ); } return 'want "ok"'; } @override bool registeredIn( String id, int version, ExtensionObject obj, ExtensionArray arr, ) => switch (id) { 'org.ctl' => obj == ExtensionObject.control && arr == ExtensionArray.critical, 'org.note' => obj == ExtensionObject.publicHeader && arr == ExtensionArray.noncritical, _ => true, }; } /// The profile of the fields [f] of a vector. Profile profileFrom(Json f) => Profile( id: str(f, 'id'), provider: str(f, 'provider'), network: str(f, 'network'), chainHash: fromHex(str(f, 'chain_hash')), publicKey: fromHex(str(f, 'public_key')), period: number(f, 'period'), genesisTime: number(f, 'genesis_time'), scheme: str(f, 'scheme'), genesisSeed: fromHex(str(f, 'genesis_seed')), ); /// The profile of the cases of times: Quicknet with [c]'s genesis time and /// period. Profile profileOf(Json c) => quicknet().copyWith( genesisTime: number(c, 'genesis_time'), period: number(c, 'period'), ); /// Defines the tests of every section of [files], formats_.json by /// . [testdata] reads a file of testdata/, such as /// `fixtures/time_only.dkc`; without it, the cases that need a fixture are /// left out. void formatsDifferential( Map files, { Uint8List Function(String path)? testdata, }) { final framing = files['framing']!; group('framing', () { test('prelude', () { for (final c in cases(framing, 'prelude')) { expectOutcome(c, () { final p = parsePrelude(fromHex(str(c, 'hex'))); expect( [p.format.version, p.publicHeaderLen, p.sealedControlLen], [c['format'], c['public_header_len'], c['sealed_control_len']], ); expect(toHex(preludeBytes(p)), str(c, 'hex')); }); } }); if (testdata != null) { test('split: steps 1 to 3 of the inspection', () { for (final c in cases(framing, 'split')) { var dkc = testdata('fixtures/${str(c, 'fixture')}.dkc') .sublist(0, number(c, 'cut')); for (final e in (c['edits'] as List?) ?? const []) { final edit = e! as List; final at = edit[0]! as int; dkc = Uint8List.fromList(dkc) ..setAll(at, fromHex(edit[2]! as String)); } final step = number(c, 'step'); try { final s = splitCapsule(dkc); expect(step == 0 || step > 3, isTrue, reason: '$c'); // Go finds a short SEALED_CONTROL at step 5; anything else // that fails later has it whole. final short = step == 5 && c['text'] == 'capsule: truncated SEALED_CONTROL: ' 'ERR_INTEGRITY'; expect(s.sealedControl == null, short, reason: '$c'); if (short) { expect(truncatedSealedControl().message, c['text']); } else { expect(s.sealedControl!.length, s.prelude.sealedControlLen); } expect(s.publicHeader.length, s.prelude.publicHeaderLen); } on FramingException catch (e) { expect( (e.step, e.error.code.code, e.error.message), (step, c['result'], c['text']), reason: '$c', ); } } }); } test('dkk', () { for (final c in cases(framing, 'dkk')) { final input = fromHex(str(c, 'hex')); expectOutcome(c, () { final k = decodeAccessKey(input); expect( [ toHex(k.credentialId), toHex(k.capsuleId), k.type, toHex(k.material), k.verification == null ? null : toHex(k.verification!.capsuleDigest), extensionsJson(k.critical), extensionsJson(k.noncritical), ], [ c['credential_id'], c['capsule_id'], c['type'], c['material'], c['capsule_digest'], c['critical'], c['noncritical'], ], ); if (c.containsKey('encode_result')) { expectOutcome( c, () => encodeAccessKey(k), resultKey: 'encode_result', textKey: 'encode_text', ); } else { expect(toHex(encodeAccessKey(k)), str(c, 'hex')); } }); } }); }); // Every fault of a list, alone and with every other, on a valid object: // the code of the first layer of spec §69.1 that fails. test('precedence of the layers of spec §69.1', () { for (final c in cases(files['header']!, 'precedence')) { expectOutcome(c, () => decodeHeader(fromHex(str(c, 'hex')))); } for (final c in cases(files['control']!, 'precedence')) { final format = CapsuleFormat.fromVersion(number(c, 'format'))!; expectOutcome(c, () => decodeControl(fromHex(str(c, 'hex')), format)); } for (final c in cases(files['profile']!, 'precedence')) { expectOutcome(c, () => decodeProfile(fromHex(str(c, 'hex')))); } for (final c in cases(framing, 'dkk_precedence')) { expectOutcome(c, () => decodeAccessKey(fromHex(str(c, 'hex')))); } }); test('header', () { for (final c in cases(files['header']!, 'header')) { expectOutcome(c, () { final h = decodeHeader(fromHex(str(c, 'hex'))); expect( [ h.capsuleIdHex, compactDateKey(h.dateKey), h.policy.label, extensionsJson(h.critical), extensionsJson(h.noncritical), ], [ c['capsule_id'], c['datekey'], c['policy'], c['critical'], c['noncritical'], ], ); expect(toHex(encodeHeader(h)), str(c, 'hex')); }); } }); test('control', () { for (final c in cases(files['control']!, 'control')) { final format = CapsuleFormat.fromVersion(number(c, 'format'))!; expectOutcome(c, () { final ctl = decodeControl(fromHex(str(c, 'hex')), format); expect( [ toHex(ctl.headerBinding), toHex(ctl.payloadIdentity), ctl.payloadLength, ctl.padding?.code, extensionsJson(ctl.critical), extensionsJson(ctl.noncritical), ], [ c['header_binding'], c['payload_identity'], c['payload_length'], c['padding'], c['critical'], c['noncritical'], ], ); expect(toHex(encodeControl(ctl, format)), str(c, 'hex')); }); } }); final profiles = files['profile']!; group('profile', () { Json fields(Profile p) => { 'id': p.id, 'provider': p.provider, 'network': p.network, 'chain_hash': toHex(p.chainHash), 'public_key': toHex(p.publicKey), 'period': p.period, 'genesis_time': p.genesisTime, 'scheme': p.scheme, 'genesis_seed': toHex(p.genesisSeed), 'max_round': p.maxRound, }; test('decode', () { for (final c in cases(profiles, 'decode')) { expectOutcome(c, () { final p = decodeProfile(fromHex(str(c, 'hex'))); expect(fields(p), c['profile']); expect(toHex(canonicalCbor(p)), str(c, 'hex')); }); } }); test('validate', () { for (final c in cases(profiles, 'validate')) { final f = c['profile']! as Json; final p = profileFrom(f); expect(p.maxRound, f['max_round'], reason: '$c'); expectOutcome(c, () => validateProfile(p)); } }); }); final extensions = files['extension']!; group('extension', () { test('array', () { for (final c in cases(extensions, 'array')) { expectOutcome(c, () { late List exts; unmarshalCbor( fromHex(str(c, 'hex')), (d) => exts = decodeExtensionArray(d), (e) => encodeExtensionArray(e, exts), ); expect(extensionsJson(exts), c['extensions']); }); } }); test('canonical', () { for (final c in cases(extensions, 'canonical')) { expectOutcome(c, () { final sorted = canonicalExtensions(extensionsOf(c['extensions'])); expect(extensionsJson(sorted), c['sorted']); }); } }); test('disjoint', () { for (final c in cases(extensions, 'disjoint')) { expectOutcome( c, () => checkDisjoint( extensionsOf(c['critical']), extensionsOf(c['noncritical']), ), ); } }); test('registry', () { for (final c in cases(extensions, 'registry')) { final reg = c['registry'] == 'none' ? null : const PlacedRegistry(); final obj = objectsByName[c['object']]; expectOutcome( c, () => checkCritical(extensionsOf(c['critical']), reg, obj), ); final unusable = checkNoncritical( extensionsOf(c['noncritical']), reg, obj, ); expect( [ for (final u in unusable) [u.id, u.version, u.error.message, u.error.code], ], [ for (final u in c['unusable']! as List) [...(u! as List), ErrorCode.extensionDataInvalid], ], reason: '$c', ); } }); test('write', () { for (final c in cases(extensions, 'write')) { expectOutcome( c, () => checkWrite( // Go's extension.Standard, without ValidateCapsule. const StandardExtensions(validateCapsule: null), objectsByName[c['object']]!, arraysByName[c['array']]!, extensionsOf(c['extensions']), ), ); } }); }); test('datekey', () { for (final c in cases(files['datekey']!, 'parse')) { expectOutcome(c, () { final d = parseDateKey(str(c, 'input')); expect([d.profileId, d.round], [c['network'], c['round']]); }); } }); final times = files['time']!; group('time', () { test('parse', () { for (final c in cases(times, 'parse')) { Instant? t; try { t = parseRfc3339(str(c, 'input')); } on FormatException { t = null; } expect( t == null ? ['error'] : ['ok', t.seconds, t.nanos], c['result'] == 'ok' ? ['ok', c['seconds'], c['nanos']] : [c['result']], reason: '$c', ); } }); test('format', () { for (final c in cases(times, 'format')) { final t = Instant(number(c, 'seconds'), number(c, 'nanos')); expect( [formatRfc3339(t), formatRfc3339Nano(t)], [c['rfc3339'], c['rfc3339nano']], ); } }); test('resolve', () { for (final c in cases(times, 'resolve')) { final p = profileOf(c); expectOutcome(c, () { final d = resolveDateKey( p, Instant(number(c, 'seconds'), number(c, 'nanos')), ); expect(d.profileId, p.id); expect( [d.round, roundTime(p, d.round).seconds], [c['round'], c['round_time']], ); }); } }); test('round_time', () { for (final c in cases(times, 'round_time')) { expectOutcome(c, () { final t = roundTime(profileOf(c), number(c, 'round')); expect([t.seconds, t.nanos], [c['seconds'], 0]); }); } }); test('validate', () { for (final c in cases(times, 'validate')) { expectOutcome( c, () => validateDateKey( DateKey(str(c, 'profile_id'), number(c, 'round')), profileOf(c), ), ); } }); test('max_round', () { for (final c in cases(times, 'max_round')) { expect(profileOf(c).maxRound, c['max_round']); } }); }); test('padding', () { for (final c in cases(files['padding']!, 'padded')) { final l = number(c, 'l'); for (final rule in PaddingRule.values) { final want = c[rule.name] as int?; if (want == null) { expect(outcomeOf(() => paddedLength(l, rule)), ( 'error', c['text'], ), reason: '$c'); continue; } final p = paddedLength(l, rule); expect( [p, payloadAgeLength(p)], [want, c['payload_age_${rule.name}']], reason: '$c', ); } } }); { // The cases of a fixture whose content is empty need no file. test('padding: the check of the plaintext against L and P', () { for (final c in cases(files['padding']!, 'check')) { final l = number(c, 'l'); if (l > 0 && testdata == null) continue; final p = number(c, 'p'); final length = number(c, 'length'); final content = l == 0 ? Uint8List(0) : testdata!('fixtures/${str(c, 'fixture')}.plaintext'); final plain = Uint8List(length) ..setRange(0, length < l ? length : l, content); if (c['at'] != null) plain[number(c, 'at')] = number(c, 'value'); // Format 3 has read BODY, L bytes, before the padding. final start = number(c, 'format') == 3 ? l : 0; final delivered = BytesBuilder(); expectOutcome(c, () { final check = PaddingCheck(l, p, start: start); // Pieces of every size, as age delivers its chunks. var at = start; for (var size = 1; at < length; size = size * 3 + 1) { final end = at + size < length ? at + size : length; delivered.add(check.add(Uint8List.sublistView(plain, at, end))); at = end; } expect(check.received, length); check.close(); }); // In format 2 the content is delivered, never the padding: all of // it when the plaintext is valid, and a part of it otherwise. final got = delivered.takeBytes(); final offered = Uint8List.sublistView( plain, 0, start > 0 ? 0 : (length < l ? length : l), ); if (c['result'] == 'ok') { expect(got, offered, reason: '$c'); } else { expect(got.length <= offered.length, isTrue, reason: '$c'); expect(got, offered.sublist(0, got.length), reason: '$c'); } } }); } final encode = files['encode']!; group('encode', () { // The encoding of an encoder that succeeded, as the vectors write it. void written(Json c, Uint8List b) { if (c.containsKey('hex')) { expect(toHex(b), c['hex']); } else { expect([b.length, toHex(sha256(b))], [c['length'], c['sha256']]); } } List withBig(Json c, List exts) { final n = c['big'] as int?; return [ ...exts, if (n != null) Extension('a', 1, Uint8List(n)..fillRange(0, n, 1)), ]; } test('header', () { for (final c in cases(encode, 'header')) { final h = Header( capsuleId: Uint8List(16)..fillRange(0, 16, 7), dateKey: DateKey(str(c, 'profile_id'), number(c, 'round')), policy: AccessPolicy.fromCode(number(c, 'policy'))!, critical: extensionsOf(c['critical']), noncritical: withBig(c, extensionsOf(c['noncritical'])), ); expectOutcome(c, () => written(c, encodeHeader(h))); } }); test('control', () { for (final c in cases(encode, 'control')) { final format = CapsuleFormat.fromVersion(number(c, 'format'))!; final l = number(c, 'payload_length'); // Go's Control holds 0 for an L and a code that are not set. final ctl = Control( headerBinding: Uint8List(32)..fillRange(0, 32, 1), payloadIdentity: Uint8List(32)..fillRange(0, 32, 5), critical: extensionsOf(c['critical']), noncritical: extensionsOf(c['noncritical']), payloadLength: format.isPadded || l != 0 ? l : null, padding: PaddingRule.fromCode(number(c, 'padding')), ); expectOutcome(c, () => written(c, encodeControl(ctl, format))); } }); test('dkk', () { for (final c in cases(encode, 'dkk')) { final digest = c['capsule_digest'] as String?; final k = AccessKey( credentialId: Uint8List(16)..fillRange(0, 16, 1), capsuleId: Uint8List(16)..fillRange(0, 16, 2), type: str(c, 'type'), material: fromHex(str(c, 'material')), verification: digest == null ? null : Verification(fromHex(digest)), critical: extensionsOf(c['critical']), noncritical: withBig(c, extensionsOf(c['noncritical'])), ); expectOutcome(c, () => written(c, encodeAccessKey(k))); } }); test('registry', () { for (final c in cases(encode, 'registry')) { final pins = [ for (final pin in ((c['pins'] as List?) ?? const []).cast()) Pin( profileFrom(pin['profile']! as Json), fromHex(str(pin, 'hash')), ), ]; expectOutcome(c, () { final reg = newRegistry(pins); for (final pin in pins) { final p = reg.lookup(pin.profile.id)!; expect(p, pin.profile); // A copy: the registry keeps its own. p.chainHash[0] ^= 1; expect(reg.lookup(pin.profile.id), pin.profile); } }); } }); test('compact', () { for (final c in cases(encode, 'compact')) { final d = DateKey(str(c, 'profile_id'), number(c, 'round')); expect( [canonicalJson(d), compactDateKey(d), '$d'], [c['json'], c['compact'], c['compact']], ); } }); if (testdata != null) { test('limits of spec §57', () { for (final c in cases(encode, 'limits')) { switch (c['what']) { case 'header': expectOutcome( c, () => decodeHeader(Uint8List(number(c, 'zeros'))), ); case 'body': expectOutcome( c, () => decodeAccessKeyBody(Uint8List(number(c, 'zeros'))), ); case 'dkk': final n = number(c, 'zeros'); final dkk = Uint8List(12 + n)..setAll(0, dkkPreludeBytes(n)); expectOutcome(c, () => decodeAccessKey(dkk)); case 'extension_data': final n = number(c, 'len'); final b = Uint8List(13 + n) ..setAll(0, fromHex('81a30061610101025a')) ..fillRange(13, 13 + n, 7); ByteData.sublistView(b).setUint32(9, n); expectOutcome(c, () { late List exts; unmarshalCbor( b, (d) => exts = decodeExtensionArray(d), (e) => encodeExtensionArray(e, exts), ); expect(exts.single.data!.length, n); }); case 'extension_canonical': final n = number(c, 'len'); expectOutcome( c, () => canonicalExtensions([Extension('a', 1, Uint8List(n))]), ); default: fail('unknown limit ${c['what']}'); } } }); } }); final body = files['body']!; group('body', () { test('frame', () { for (final c in cases(body, 'frame')) { final l = number(c, 'l'); expectOutcome(c, () { final f = parseBodyFrame(fromHex(str(c, 'hex')), l); expect( [f.areaLen, f.securityLen, f.headLen, contentLength(f, l)], [ c['area_len'], c['security_len'], c['head_len'], c['content_length'], ], ); expect(toHex(bodyFrameBytes(f)), str(c, 'hex')); }); } }); test('area', () { for (final c in cases(body, 'area')) { expectOutcome( c, () => checkArea(fromHex(str(c, 'hex')), number(c, 'security_len')), ); } }); }); }