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.
335 lines
11 KiB
335 lines
11 KiB
// Steps 1 to 8 against Go: fixtures/*.inspect.json, byte for byte the
|
|
// output of `datekeys inspect -json`; every mutation of
|
|
// testdata/vectors/inspect_differential.json with its code and step, and the
|
|
// text that capsule.Inspect of Go gives (test/vectors/open_inspect.json);
|
|
// the views of headers with a public note; and the prefix of
|
|
// inspectedLength, which gives the result of the whole file, whatever is
|
|
// after it, from a source read in pieces.
|
|
@TestOn('vm')
|
|
library;
|
|
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:datekeys/datekeys.dart';
|
|
import 'package:datekeys/src/age.dart' show maxAgeHeaderLength;
|
|
import 'package:test/test.dart';
|
|
|
|
import 'open_vectors_support.dart';
|
|
import 'source_support.dart';
|
|
import 'tlock_support.dart' show applyEdits;
|
|
|
|
Json readJson(String path) => jsonDecode(File(path).readAsStringSync()) as Json;
|
|
|
|
final _fixtures = <String, Uint8List>{};
|
|
|
|
Uint8List fixture(String file) => _fixtures.putIfAbsent(
|
|
file,
|
|
() => File('testdata/fixtures/$file').readAsBytesSync(),
|
|
);
|
|
|
|
ExtensionRegistry? registryOf(String name) => switch (name) {
|
|
'standard' => const StandardExtensions(),
|
|
'reject_all' => const RejectAll('not today'),
|
|
_ => null,
|
|
};
|
|
|
|
/// The public note that the record of a fixture gives in its
|
|
/// header_extensions, the data of datekeys.note in the noncritical array as
|
|
/// text, or null.
|
|
String? noteOf(Json rec) {
|
|
final exts = (rec['header_extensions'] as List?)?.cast<Json>() ?? const [];
|
|
for (final e in exts) {
|
|
if (e['id'] == 'datekeys.note' && e['critical'] == false) {
|
|
return decodeUtf8(fromHex(e['data']! as String));
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// The last check of an inspection: [step, ok, code, detail].
|
|
List<Object?> last(Inspection r) {
|
|
final c = r.checks.last;
|
|
return [c.step, c.ok, c.error, c.detail];
|
|
}
|
|
|
|
Uint8List concat(List<List<int>> parts) => concatBytes(parts);
|
|
|
|
/// The prelude of a .dkc of [format] with the two lengths.
|
|
Uint8List preludeWith(int ph, int sc, {int format = 1, int flags = 0}) {
|
|
final b = Uint8List(16);
|
|
b.setRange(0, 4, 'DKC1'.codeUnits);
|
|
b[4] = format;
|
|
b[5] = flags;
|
|
ByteData.sublistView(b)
|
|
..setUint32(8, ph)
|
|
..setUint32(12, sc);
|
|
return b;
|
|
}
|
|
|
|
void main() {
|
|
final names =
|
|
Directory('testdata/fixtures')
|
|
.listSync()
|
|
.map((f) => f.uri.pathSegments.last)
|
|
.where((n) => n.endsWith('.dkc'))
|
|
.toList()
|
|
..sort();
|
|
|
|
group('fixtures/*.inspect.json', () {
|
|
for (final name in names) {
|
|
test(name, () async {
|
|
final want = File(
|
|
'testdata/fixtures/${name.replaceAll('.dkc', '.inspect.json')}',
|
|
).readAsStringSync();
|
|
final dkc = fixture(name);
|
|
final r = inspectCapsule(dkc);
|
|
expect(inspectJson(inspectView(r, file: name)), want);
|
|
final s = await inspectCapsuleSource(ChunkySource(dkc, 1000));
|
|
expect(inspectJson(inspectView(s, file: name)), want);
|
|
// The other fields, from the record of the fixture.
|
|
final rec = readJson(
|
|
'testdata/fixtures/${name.replaceAll('.dkc', '.json')}',
|
|
);
|
|
final p = parsePrelude(dkc);
|
|
List<Object?> infos(List<StanzaInfo>? s) => [
|
|
for (final x in s!) {'type': x.type, 'args': x.args},
|
|
];
|
|
expect(
|
|
[
|
|
r.valid,
|
|
r.format?.version,
|
|
r.payloadOffset,
|
|
toHex(r.publicHeader!),
|
|
r.profile!.id,
|
|
formatRfc3339(r.unlockAt!),
|
|
canonical(infos(r.outerStanzas)),
|
|
canonical(infos(r.payloadStanzas)),
|
|
r.unusableExtensions,
|
|
r.publicNote,
|
|
r.unusableNote,
|
|
],
|
|
[
|
|
true,
|
|
rec['format'],
|
|
16 + p.publicHeaderLen + p.sealedControlLen,
|
|
rec['public_header'],
|
|
'datekeys:quicknet:v1',
|
|
rec['unlock_at'],
|
|
canonical(rec['outer_stanzas']),
|
|
canonical(rec['payload_stanzas']),
|
|
isEmpty,
|
|
noteOf(rec),
|
|
false,
|
|
],
|
|
);
|
|
});
|
|
}
|
|
});
|
|
|
|
final inspectVectors = readJson('test/vectors/open_inspect.json');
|
|
|
|
test(
|
|
'inspect_differential.json: every mutation, with the text of Go',
|
|
() async {
|
|
final f = readJson('testdata/vectors/inspect_differential.json');
|
|
expect(f['spec'], specVersion);
|
|
final bases = [
|
|
for (final b in (f['bases']! as List).cast<Json>()) str(b, 'file'),
|
|
];
|
|
final mutations = (f['mutations']! as List).cast<Json>();
|
|
expect(mutations, hasLength(5110));
|
|
final texts = (inspectVectors['texts']! as List).cast<String>();
|
|
final results = (inspectVectors['results']! as List).cast<int>();
|
|
expect(results, hasLength(mutations.length));
|
|
for (var i = 0; i < mutations.length; i++) {
|
|
final m = mutations[i];
|
|
final dkc = applyEdits(
|
|
fixture(bases[m['base']! as int]),
|
|
m['edits']! as List<Object?>,
|
|
);
|
|
final r = inspectCapsule(dkc);
|
|
final want = m['result'] == 'ok'
|
|
? [8, true, null]
|
|
: [m['step'], false, m['result']];
|
|
expect(last(r).sublist(0, 3), want, reason: 'mutation $i');
|
|
expect(r.valid, m['result'] == 'ok', reason: 'mutation $i');
|
|
if (results[i] >= 0) {
|
|
expect(r.error!.message, texts[results[i]], reason: 'mutation $i');
|
|
}
|
|
// The prefix that inspectedLength names gives the same inspection.
|
|
if (i % 5 == 0) {
|
|
final s = await inspectCapsuleSource(ChunkySource(dkc, 777));
|
|
expect(
|
|
inspectJson(inspectView(s, file: 'x')),
|
|
inspectJson(inspectView(r, file: 'x')),
|
|
reason: 'mutation $i',
|
|
);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
test('open_inspect.json: the views of the notes and the extensions', () {
|
|
final views = (inspectVectors['views']! as List).cast<Json>();
|
|
expect(views, hasLength(10));
|
|
for (final v in views) {
|
|
final dkc = capsuleOf(v, fixture);
|
|
final r = inspectCapsule(dkc, extensions: registryOf(str(v, 'registry')));
|
|
expect(
|
|
inspectJson(inspectView(r, file: 'capsule.dkc')),
|
|
v['view'],
|
|
reason: str(v, 'name'),
|
|
);
|
|
}
|
|
});
|
|
|
|
group('inspectedLength', () {
|
|
final timeOnly = fixture('time_only.dkc');
|
|
final r = inspectCapsule(timeOnly);
|
|
final payloadStart = r.payloadOffset!;
|
|
|
|
test('stops one byte after the largest age header of PAYLOAD_AGE', () {
|
|
expect(
|
|
inspectedLength(1000000000, timeOnly.sublist(0, 16)),
|
|
payloadStart + maxAgeHeaderLength + 1,
|
|
);
|
|
expect(
|
|
inspectedLength(timeOnly.length, timeOnly.sublist(0, 16)),
|
|
timeOnly.length,
|
|
);
|
|
});
|
|
|
|
test('reads short files whole', () {
|
|
expect(inspectedLength(7, timeOnly.sublist(0, 7)), 7);
|
|
expect(inspectedLength(0, Uint8List(0)), 0);
|
|
});
|
|
|
|
final rejected = <String, Uint8List>{
|
|
'not DKC1': Uint8List.fromList('DKC2'.codeUnits + Uint8List(12)),
|
|
'version 4': preludeWith(10, 10, format: 4),
|
|
'FLAGS 1': preludeWith(10, 10, flags: 1),
|
|
'PUBLIC_HEADER_LEN 0': preludeWith(0, 10),
|
|
'SEALED_CONTROL_LEN 0': preludeWith(10, 0),
|
|
'PUBLIC_HEADER_LEN above 1 MiB': preludeWith((1 << 20) + 1, 10),
|
|
'SEALED_CONTROL_LEN above 64 MiB': preludeWith(10, (64 << 20) + 1),
|
|
'both lengths 0xffffffff': preludeWith(0xffffffff, 0xffffffff),
|
|
};
|
|
|
|
test('reads 16 bytes of a prelude that steps 1 and 2 reject', () async {
|
|
final tail = Uint8List(3 << 20)..fillRange(0, 3 << 20, 0xff);
|
|
for (final MapEntry(key: what, value: head) in rejected.entries) {
|
|
expect(inspectedLength(1 << 40, head), 16, reason: what);
|
|
expect(inspectedLength(16, head), 16, reason: what);
|
|
final dkc = concat([head, tail]);
|
|
final s = ChunkySource(dkc);
|
|
final r = await inspectCapsuleSource(s);
|
|
expect(s.farthest, 16, reason: what);
|
|
expect(last(r), last(inspectCapsule(dkc)), reason: what);
|
|
}
|
|
});
|
|
|
|
test('reaches at most the largest sections and one age header', () {
|
|
expect(
|
|
inspectedLength(1 << 40, preludeWith(1 << 20, 64 << 20)),
|
|
16 + (1 << 20) + (64 << 20) + maxAgeHeaderLength + 1,
|
|
);
|
|
});
|
|
|
|
test('reads every fixture whole, and a .dkk in 16 bytes', () async {
|
|
for (final name in names) {
|
|
final dkc = fixture(name);
|
|
final s = ChunkySource(dkc);
|
|
await inspectCapsuleSource(s);
|
|
expect(s.farthest, dkc.length, reason: name);
|
|
}
|
|
final dkk = fixture('time_and_key_portable.dkk');
|
|
final s = ChunkySource(dkk);
|
|
final r = await inspectCapsuleSource(s);
|
|
expect(
|
|
[s.farthest, ...last(r).sublist(0, 3)],
|
|
[16, 1, false, 'ERR_INVALID_MAGIC'],
|
|
);
|
|
});
|
|
|
|
test(
|
|
'skips the payload after its age header, with the same result',
|
|
() async {
|
|
final big = concat([
|
|
timeOnly,
|
|
Uint8List(3 << 20)..fillRange(0, 3 << 20, 0x61),
|
|
]);
|
|
final s = ChunkySource(big, 1 << 20);
|
|
final r = await inspectCapsuleSource(s);
|
|
expect(s.farthest, payloadStart + maxAgeHeaderLength + 1);
|
|
expect(last(r), last(inspectCapsule(big)));
|
|
},
|
|
);
|
|
|
|
test('keeps the result of an age header at and around 2 MiB', () async {
|
|
final parts = splitCapsule(timeOnly);
|
|
Uint8List frame(List<int> payload) =>
|
|
concat([timeOnly.sublist(0, payloadStart), payload]);
|
|
final intro = 'age-encryption.org/v1\n-> X25519 '.codeUnits;
|
|
for (final end in [
|
|
maxAgeHeaderLength - 2,
|
|
maxAgeHeaderLength - 1,
|
|
maxAgeHeaderLength,
|
|
maxAgeHeaderLength + 1,
|
|
]) {
|
|
final line = Uint8List(end - intro.length)
|
|
..fillRange(0, end - intro.length, 0x41);
|
|
final dkc = frame(
|
|
concat([intro, line, '\n\n--- AAAA\n'.codeUnits, Uint8List(1 << 20)]),
|
|
);
|
|
final s = ChunkySource(dkc, 1 << 20);
|
|
final r = await inspectCapsuleSource(s);
|
|
expect(s.farthest, payloadStart + maxAgeHeaderLength + 1);
|
|
expect(last(r), last(inspectCapsule(dkc)), reason: '$end');
|
|
}
|
|
final endless = frame(
|
|
concat([intro, Uint8List(3 << 20)..fillRange(0, 3 << 20, 0x41)]),
|
|
);
|
|
final r = await inspectCapsuleSource(ChunkySource(endless, 1 << 20));
|
|
expect(last(r), last(inspectCapsule(endless)));
|
|
expect(last(r), [
|
|
6,
|
|
false,
|
|
'ERR_INTEGRITY',
|
|
'capsule: PAYLOAD_AGE: agewrap: not an age v1 header: malformed, '
|
|
'truncated or beyond the parser limits: ERR_INTEGRITY',
|
|
]);
|
|
expect(parts.prelude.format, CapsuleFormat.format1);
|
|
});
|
|
});
|
|
|
|
group('maxAccessKeyRead', () {
|
|
String decodeError(Uint8List dkk) {
|
|
try {
|
|
decodeAccessKey(dkk).wipe();
|
|
return 'ok';
|
|
} on DateKeysException catch (e) {
|
|
return e.message;
|
|
}
|
|
}
|
|
|
|
final dkk = fixture('time_and_key_portable.dkk');
|
|
|
|
test('is one byte past the largest valid .dkk, with the same error', () {
|
|
expect(maxAccessKeyRead, 12 + (16 << 20) + 1);
|
|
final long = concat([dkk, Uint8List(16 << 20)]);
|
|
final prefix = long.sublist(0, maxAccessKeyRead);
|
|
expect(decodeError(prefix), decodeError(long));
|
|
expect(decodeError(long), contains('data after BODY_CBOR'));
|
|
final over = concat([
|
|
dkk.sublist(0, 8),
|
|
[1, 0, 0, 1],
|
|
Uint8List((16 << 20) + 8),
|
|
]);
|
|
expect(decodeError(over.sublist(0, maxAccessKeyRead)), decodeError(over));
|
|
expect(decodeError(over), contains('outside 1..'));
|
|
});
|
|
});
|
|
}
|