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.
298 lines
9.5 KiB
298 lines
9.5 KiB
|
2 days ago
|
// 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);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|