You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dateKeys-dart/test/formats_differential.dart

774 lines
24 KiB

// 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<String, Object?>;
/// The cases of [section] of [file].
List<Json> cases(Json file, String section) =>
(file[section]! as List).cast<Json>();
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<Object?>;
final data = l[2] as String?;
return Extension(
l[0]! as String,
l[1]! as int,
data == null ? null : fromHex(data),
);
}
List<Extension> extensionsOf(Object? v) => [
for (final e in v! as List<Object?>) extensionOf(e),
];
/// [exts] as the vectors write them.
List<Object?> extensionsJson(List<Extension> 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_<name>.json by
/// <name>. [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<String, Json> 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<Object?>?) ?? const []) {
final edit = e! as List<Object?>;
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<Extension> 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<Object?>)
[...(u! as List<Object?>), 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<Extension> withBig(Json c, List<Extension> 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<Object?>?) ?? const []).cast<Json>())
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<Extension> 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')),
);
}
});
});
}

Powered by TurnKey Linux.