// The head, the note, the inspection and the opening, on the VM and // compiled to JavaScript: the parts of the vectors of Go that // test/vectors/open_vectors.g.dart holds, with the small fixtures they edit, // and what the API does with the caller: its errors, the output and the // sinks, the evaluator of the security area and accept. library; import 'dart:convert'; import 'dart:typed_data'; import 'package:datekeys/datekeys.dart'; import 'package:test/test.dart'; import 'open_support.dart'; import 'tlock_support.dart' show applyEdits; import 'vectors/open_vectors.g.dart'; final Map _fixtures = { for (final e in (jsonDecode(openFixturesJson) as Json).entries) e.key: fromHex(e.value! as String), }; Uint8List fixture(String file) => _fixtures[file]!; final Json _cases = jsonDecode(openCasesJson) as Json; /// The case of the part of open_cases.json named [name]. Json caseNamed(String name) => [ ...(_cases['fixtures']! as List).cast(), ...(_cases['steps']! as List).cast(), ].firstWhere((c) => c['name'] == name); /// The options of the opening of the case [name], with [output] and [sink]. OpenOptions optionsOf( Json c, { ByteSink? output, FileSink? sink, SecurityEvaluator? evaluator, Map authorKeys = const {}, void Function(Verdicts v)? accept, ReleaseSource? source, }) { final rel = c['release']! as Json; final file = c['dkk_file'] as String?; return OpenOptions( source: source ?? suppliedRelease( Release(rel['round']! as int, fromHex(str(rel, 'signature'))), ), now: () => parseRfc3339(str(c, 'now')), accessKeyFile: file == null ? null : fromHex(file), output: output, sink: sink, evaluator: evaluator ?? evaluateSecurityInput, authorKeys: authorKeys, accept: accept, ); } void main() { group('the vectors of Go', () { for (final section in ['fixtures', 'steps']) { final cases = (_cases[section]! as List).cast(); for (var i = 0; i < cases.length; i++) { final c = cases[i]; test('$section: ${c['name']}', () async { final dkc = capsuleOf(c, fixture); expect(differences(c, await openCase(c, dkc)), isEmpty); // From a source, every fourth: the others run on the VM. if (i % 4 == 0) { expect( differences(c, await openCase(c, dkc, fromSource: true)), isEmpty, ); } }); } } test('the heads', () { final h = jsonDecode(openHeadsJson) as Json; checkDecodeCases((h['decode']! as List).cast()); checkEncodeCases((h['encode']! as List).cast()); }); test('the notes', () { final n = jsonDecode(openNotesJson) as Json; checkNoteCases( (n['check']! as List).cast(), (n['note']! as List).cast(), (n['standard']! as List).cast(), ); }); test('the inspection: the views and the mutations', () { final v = jsonDecode(openInspectJson) as Json; for (final c in (v['views']! as List).cast()) { final r = inspectCapsule( capsuleOf(c, fixture), extensions: switch (c['registry']) { 'standard' => const StandardExtensions(), 'reject_all' => const RejectAll('not today'), _ => null, }, ); expect( inspectJson(inspectView(r, file: 'capsule.dkc')), c['view'], reason: str(c, 'name'), ); } final mutations = (v['mutations']! as List).cast(); expect(mutations, hasLength(greaterThan(300))); for (final m in mutations) { final dkc = applyEdits(fixture(str(m, 'base')), m['edits']! as List); final r = inspectCapsule(dkc); final c = r.checks.last; expect( [c.step, c.ok, c.error ?? 'ok', if (!c.ok) c.detail], [ m['step'] ?? 8, m['result'] == 'ok', m['result'], if (m['result'] != 'ok') m['text'], ], reason: 'mutation ${m['index']}', ); } }); }); group('the caller', () { final single = caseNamed('format3_single'); final keyed = caseNamed('time_and_key_recipients: the .dkk, decoded'); final plain = caseNamed('time_only_extensions'); test('gets an ArgumentError for options that do not go together, with ' 'the output aborted', () async { final dkc = capsuleOf(keyed, fixture); final key = decodeAccessKey(fromHex(str(keyed, 'dkk'))); final out = RecordingOutput(); await expectLater( openCapsule( dkc, OpenOptions( source: suppliedRelease(), now: () => Instant(0), accessKey: key, accessKeyFile: fromHex(str(keyed, 'dkk')), output: out, ), ), throwsArgumentError, ); expect([out.aborted, out.closed], [isArgumentError, false]); await expectLater( openCapsule( dkc, OpenOptions( source: suppliedRelease(), now: () => Instant(0), identities: [Uint8List(31)], output: RecordingOutput(), ), ), throwsArgumentError, ); }); test('needs the sink for format 3 and the output for formats 1 and 2, ' 'right after step 2, before any request', () async { final calls = CaseSource(null, null); final dkc = capsuleOf(single, fixture); final out = RecordingOutput(); await expectLater( openCapsule(dkc, optionsOf(single, source: calls, output: out)), throwsA( isArgumentError.having( (e) => '${e.message}', 'message', 'open: a format 3 capsule holds files: OpenOptions.sink is ' 'required', ), ), ); expect( [calls.calls, out.aborted, out.log], [ 0, isArgumentError, ['abort'], ], ); // Steps 3 to 8 do not matter: a truncated PUBLIC_HEADER after a valid // prelude. await expectLater( openCapsule(dkc.sublist(0, 20), optionsOf(single, source: calls)), throwsArgumentError, ); await expectLater( openCapsule( capsuleOf(plain, fixture), optionsOf(plain, source: calls, sink: MemoryFileSink()), ), throwsA( isArgumentError.having( (e) => '${e.message}', 'message', 'open: a capsule of format 1 or 2 holds one content: ' 'OpenOptions.output is required', ), ), ); expect(calls.calls, 0); // Steps 1 and 2 fail first: no format, nothing needed. final r = await openCapsule(dkc.sublist(0, 10), optionsOf(single)); expect([r.error?.code, r.checks.last.step], [ErrorCode.integrity, 1]); }); test('leaves the sink untouched in formats 1 and 2, and the output in ' 'format 3', () async { final sink = RecordingSink(); final out = RecordingOutput(); final r = await openCapsule( capsuleOf(plain, fixture), optionsOf(plain, output: out, sink: sink), ); expect([r.ok, sink.log, out.closed], [true, isEmpty, true]); expect(out.log.last, 'close'); final out3 = RecordingOutput(); final s = await openCapsule( capsuleOf(single, fixture), optionsOf(single, output: out3, sink: MemoryFileSink()), ); expect([s.ok, out3.log], [true, isEmpty]); }); test('hands the sink the files in the order of the head, each closed ' 'before the next, and commits last', () async { final sink = RecordingSink(); final r = await openCapsule( capsuleOf(single, fixture), optionsOf(single, sink: sink), ); expect(r.ok, isTrue); expect(sink.log, [ 'begin', 'create 0', for (final l in sink.log.where((l) => l.startsWith('write 0 '))) l, 'close 0', 'commit', ]); final written = sink.log .where((l) => l.startsWith('write 0 ')) .map((l) => int.parse(l.split(' ')[2])) .fold(0, (a, b) => a + b); expect(written, r.head!.files.single.size); }); test('keeps the files in a MemoryFileSink, given once committed', () async { final sink = MemoryFileSink(); final r = await openCapsule( capsuleOf(single, fixture), optionsOf(single, sink: sink), ); expect(sink.head, same(r.head)); expect(sink.files!.single, hasLength(r.head!.files.single.size)); expect( canonical( filesOf(Outcome(r, RecordingOutput(), _asRecording(sink), 0, [])), ), canonical(single['files']), ); expect(sink.aborted, isFalse); }); test('gives the evaluator what Go\'s newSecurityContext takes, once the ' 'head is read, and never fails for it', () async { final seen = []; final dkc = capsuleOf(single, fixture); final keys = {'dkauthor1x': 'Ana'}; final r = await openCapsule( dkc, optionsOf( single, sink: MemoryFileSink(), authorKeys: keys, evaluator: (input) { seen.add(input); // The control is whole while the evaluator runs. expect(input.control.payloadIdentity.any((b) => b != 0), isTrue); return Verdicts( signature: Verdict.noSignature, seal: Verdict.noSeal, ); }, ), ); expect(r.ok, isTrue); final input = seen.single; final s = splitCapsule(dkc); expect( [ input.format, input.roundTime, input.authorKeys, toHex(input.control.headerBinding), decodeHead(input.head).files.single.path, input.security.isNotEmpty, input.security.length <= r.areaLen!, ], [ CapsuleFormat.format3, r.inspection.unlockAt, keys, toHex(headerBinding(s.preludeBytes, s.publicHeader)), r.head!.files.single.path, true, true, ], ); expect( [r.verdicts!.signature, r.verdicts!.seal], [Verdict.noSignature, Verdict.noSeal], ); // I_PAYLOAD is wiped once the opening ends. expect(input.control.payloadIdentity.every((b) => b == 0), isTrue); // An evaluator that throws: the capsule opens, not evaluated. final t = await openCapsule( dkc, optionsOf( single, sink: MemoryFileSink(), evaluator: (_) => throw StateError('broken'), ), ); expect( [t.ok, t.verdicts!.evaluated, t.verdicts!.error], [true, false, isStateError], ); // The default evaluates as Go: an area without a signature or a seal // is F0 and S0. expect( OpenOptions( source: suppliedRelease(r.release!), now: () => r.inspection.unlockAt!, ).evaluator, same(evaluateSecurityInput), ); final d = await openCapsule( dkc, optionsOf(single, sink: MemoryFileSink()), ); final want = single['verdicts']! as Json; expect( [ d.verdicts!.signature?.code, d.verdicts!.seal?.code, d.verdicts!.lines, ], [want['signature'], want['seal'], want['lines']], ); expect(want['signature'], 'F0'); }); test('shows the verdicts to accept before step 18, which may refuse ' 'them', () async { final dkc = capsuleOf(single, fixture); final verdicts = Verdicts( signature: Verdict.signedOther, seal: Verdict.noSeal, authorKey: Uint8List(32), ); Verdicts? shown; final sink = RecordingSink(); final out = RecordingOutput(); final r = await openCapsule( dkc, optionsOf( single, sink: sink, output: out, evaluator: (_) => verdicts, accept: (v) { shown = v; expect(sink.log, isNot(contains('commit'))); throw 'not signed by Ana'; }, ), ); expect(shown, same(verdicts)); expect( [r.ok, r.error, r.refusal, r.head, r.verdicts], [false, null, 'not signed by Ana', null, null], ); expect(sink.state, 'aborted'); expect(out.aborted, 'not signed by Ana'); expect( [r.checks.last.step, r.checks.last.ok, r.checks.last.detail], [ 17, true, 'payload authenticated; the caller refused its verdicts and nothing ' 'was published', ], ); // Accepted, they are those of the result. final a = await openCapsule( dkc, optionsOf( single, sink: MemoryFileSink(), evaluator: (_) => verdicts, accept: (v) {}, ), ); expect([a.ok, a.verdicts], [true, same(verdicts)]); }); test('does not wipe the credentials of the caller, and wipes the .dkk it ' 'decodes', () async { final dkc = capsuleOf(keyed, fixture); final key = decodeAccessKey(fromHex(str(keyed, 'dkk'))); final material = Uint8List.fromList(key.material); final identity = Uint8List.fromList(material); final out = MemoryByteSink(); final rel = keyed['release']! as Json; final r = await openCapsule( dkc, OpenOptions( source: suppliedRelease( Release(rel['round']! as int, fromHex(str(rel, 'signature'))), ), now: () => parseRfc3339(str(keyed, 'now')), accessKey: key, identities: [identity], output: out, ), ); expect(r.ok, isTrue); expect([key.material, identity], [material, material]); expect(out.bytes, isNotNull); }); }); group('the sinks of memory', () { test('MemoryByteSink gives its bytes once closed, and wipes them on ' 'abort', () { final s = MemoryByteSink(); final a = Uint8List.fromList([1, 2, 3]); s.add(a); s.add(Uint8List.fromList([4])); expect(s.bytes, isNull); s.close(); expect(s.bytes, [1, 2, 3, 4]); expect(() => s.add(Uint8List(1)), throwsStateError); final t = MemoryByteSink(); final b = Uint8List.fromList([9, 9]); t.add(b); t.abort('failed'); expect( [t.bytes, t.abortReason, b], [ null, 'failed', [0, 0], ], ); expect(t.close, throwsStateError); }); test('MemoryFileSink gives its files only once committed', () { final s = MemoryFileSink(); final h = Head( salt: Uint8List(32), files: [ HeadFile(path: 'a', size: 1, start: 0, end: 1, sha256: Uint8List(32)), HeadFile(path: 'b', size: 0, start: 1, end: 1, sha256: Uint8List(32)), ], ); s.begin(h); s.create(0) ..add(Uint8List.fromList([7])) ..close(); s.create(1).close(); expect([s.head, s.files], [null, null]); s.commit(); expect(s.head, same(h)); expect(s.files, [ [7], isEmpty, ]); final t = MemoryFileSink()..begin(h); final f = t.create(0)..add(Uint8List.fromList([5])); t.abort('no'); expect([t.aborted, t.files, f.bytes], [true, null, null]); }); }); group('the sources', () { test('BytesSource reads views of its bytes, and readRange keeps ' 'reading', () async { final b = Uint8List.fromList(List.generate(100, (i) => i)); final s = BytesSource(b); expect(s.length, 100); expect(await s.read(98, 10), [98, 99]); expect(await s.read(100, 10), isEmpty); final c = ChunkySource(b, 7); final r = await readSource(c, 3, 50); expect(r, List.generate(50, (i) => i + 3)); expect(c.reads, 8); expect(await readSource(c, 90, 50), List.generate(10, (i) => 90 + i)); }); test('a source that gives nothing before its end is an error of the ' 'caller', () async { final r = await openCapsuleSource( _Short(capsuleOf(singleCase(), fixture)), optionsOf(singleCase(), sink: MemoryFileSink()), ).then((o) => o, onError: (Object e) => e); expect(r, isStateError); }); }); } Json singleCase() => caseNamed('format3_single'); /// readRange of source.dart, which lib/datekeys.dart does not export. Future readSource(ByteSource s, int offset, int n) async { final out = BytesBuilder(); for (var at = offset; at < offset + n && at < s.length;) { final piece = await s.read(at, offset + n - at); if (piece.isEmpty) break; out.add(piece); at += piece.length; } return out.takeBytes(); } // A source whose reads past its first 100 bytes give nothing. final class _Short implements ByteSource { _Short(this._b); final Uint8List _b; @override int get length => _b.length; @override Future read(int offset, int n) async { if (offset >= 100) return Uint8List(0); final end = offset + n < 100 ? offset + n : 100; return Uint8List.sublistView(_b, offset, end); } } // A RecordingSink with the files of [m], for filesOf. RecordingSink _asRecording(MemoryFileSink m) { final r = RecordingSink(); r.files = [for (final f in m.files!) BytesBuilder()..add(f)]; return r; }