// 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 = {}; 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 last check of an inspection: [step, ok, code, detail]. List last(Inspection r) { final c = r.checks.last; return [c.step, c.ok, c.error, c.detail]; } Uint8List concat(List> 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 infos(List? 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, null, 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()) str(b, 'file'), ]; final mutations = (f['mutations']! as List).cast(); expect(mutations, hasLength(5110)); final texts = (inspectVectors['texts']! as List).cast(); final results = (inspectVectors['results']! as List).cast(); 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, ); 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(); 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 = { '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 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..')); }); }); }