// Helpers of the tests of the opening: the cases of // test/vectors/open_cases.json and of the mutation corpus replayed as // capsule.Open of the Go reference was run on them, and the outcome of an // opening in the form of those vectors. They read no file, so that the // tests that run on Node.js can use them. library; import 'dart:async'; import 'dart:typed_data'; import 'package:datekeys/datekeys.dart'; import 'package:datekeys/src/age.dart' show X25519Identity; import 'package:datekeys/src/security.dart' show evaluateSecurityInput; import 'package:datekeys/src/sha256.dart' show sha256; import 'open_vectors_support.dart'; import 'security_support.dart'; import 'source_support.dart'; export 'open_vectors_support.dart'; export 'security_support.dart'; export 'source_support.dart'; /// A source that answers every request with the release of a case, or /// fails as its source_error says, and counts the requests. final class CaseSource implements ReleaseSource { CaseSource(this.release, this.error); final Release? release; final Object? error; int calls = 0; @override Future fetch(PinnedProfile p, int round) async { calls++; final e = error; if (e != null) throw e; final r = release; if (r == null) { throw DateKeysException( ErrorCode.releaseUnavailable, 'testkit: no release', ); } return r; } } /// The failure of a source of a case: a DateKeysException of its code, or /// its text alone, thrown as a String, whose text the opening keeps. Object? sourceError(Json c) { final e = c['source_error'] as Json?; if (e == null) return null; final code = e['code'] as String?; if (code == null) return str(e, 'text'); return DateKeysException( ErrorCode.values.firstWhere((x) => x.code == code), str(e, 'text'), ); } /// An output that records what it receives, and fails at add when [fail]. final class RecordingOutput implements ByteSink { RecordingOutput({this.fail = false}); final bool fail; final BytesBuilder received = BytesBuilder(); bool closed = false; Object? aborted; final List log = []; @override void add(Uint8List bytes) { log.add('add ${bytes.length}'); if (closed || aborted != null) throw StateError('add after the end'); if (fail) throw 'disk full'; received.add(bytes); } @override void close() { log.add('close'); if (aborted != null) throw StateError('close after abort'); closed = true; } @override void abort(Object reason) { log.add('abort'); aborted = reason; } } /// A file sink that keeps the files and fails at the call named, as the /// recording sink of tool/open_go_vectors.go: begin, create i, write i, /// close i or commit, with the text `no` and the call. final class RecordingSink implements FileSink { RecordingSink([this.fail = '']); final String fail; List files = []; Head? head; bool begun = false; bool committed = false; Object? aborted; final List log = []; @override void begin(Head head) { log.add('begin'); if (fail == 'begin') throw 'no begin'; begun = true; this.head = head; files = [for (final _ in head.files) BytesBuilder()]; } @override ByteSink create(int i) { log.add('create $i'); if (fail == 'create $i') throw 'no create $i'; return _RecordingFile(this, i); } @override void commit() { log.add('commit'); if (fail == 'commit') throw 'no commit'; committed = true; } @override void abort(Object reason) { log.add('abort'); aborted = reason; } /// The state, as the vectors record it. String get state => committed ? 'committed' : aborted != null ? 'aborted' : begun ? 'begun' : 'untouched'; } final class _RecordingFile implements ByteSink { _RecordingFile(this.s, this.i); final RecordingSink s; final int i; @override void add(Uint8List bytes) { s.log.add('write $i ${bytes.length}'); if (s.fail == 'write $i') throw 'no write $i'; s.files[i].add(bytes); } @override void close() { s.log.add('close $i'); if (s.fail == 'close $i') throw 'no close $i'; } @override void abort(Object reason) { s.log.add('abort $i'); } } /// The outcome of an opening in the form of the vectors. final class Outcome { Outcome(this.opened, this.output, this.sink, this.calls, this.securities); final Opened opened; final RecordingOutput output; final RecordingSink sink; final int calls; /// SECURITY_CBOR of each evaluation, a copy. final List securities; String get result => opened.error?.code.code ?? (opened.refusal != null ? 'refused' : 'ok'); String get text => opened.error?.message ?? (opened.refusal != null ? '${opened.refusal}' : 'ok'); List> get checks => [ for (final c in opened.checks) [c.step, c.name, c.ok, c.detail, c.error ?? ''], ]; } /// The author keys that a case saves, with their labels. Map authorKeysOf(Json c) => (c['author_keys'] as Map?)?.cast() ?? const {}; /// Opens the capsule of the case [c] with its options, in memory or from /// a source, with [evaluator], the default of the opening when null. Future openCase( Json c, Uint8List dkc, { bool fromSource = false, SecurityEvaluator? evaluator, }) async { final rel = c['release'] as Json?; final source = CaseSource( rel == null ? null : Release(rel['round']! as int, fromHex(str(rel, 'signature'))), sourceError(c), ); AccessKey? key; final dkk = c['dkk'] as String?; if (dkk != null) { key = decodeAccessKey(fromHex(dkk)); final material = c['dkk_material'] as String?; if (material != null) { key = AccessKey( credentialId: key.credentialId, capsuleId: key.capsuleId, type: key.type, material: fromHex(material), verification: key.verification, critical: key.critical, noncritical: key.noncritical, ); } } final file = c['dkk_file'] as String?; final output = RecordingOutput(fail: c['output_fail'] == true); final sink = RecordingSink(c['sink_fail'] as String? ?? ''); final securities = []; final evaluate = evaluator ?? evaluateSecurityInput; final options = OpenOptions( source: source, now: () => parseRfc3339(str(c, 'now')), registry: c['registry'] == 'empty' ? newRegistry([]) : null, extensions: caseExtensions(c), identities: [ for (final s in (c['identities'] as List? ?? const [])) X25519Identity.parse(s! as String).secretKey, ], accessKey: key, accessKeyFile: file == null ? null : fromHex(file), output: output, sink: sink, authorKeys: authorKeysOf(c), evaluator: (input) { securities.add(Uint8List.fromList(input.security)); return evaluate(input); }, accept: c['accept'] == 'refuse' ? (_) => throw 'not trusted' : null, ); final opened = fromSource ? await openCapsuleSource(ChunkySource(dkc), options) : await openCapsule(dkc, options); key?.wipe(); return Outcome(opened, output, sink, source.calls, securities); } /// What the vectors record of the content of a capsule of format 1 or 2 /// that opens: its length and its SHA-256. Json contentOf(Outcome o) { final b = o.output.received.toBytes(); return {'length': b.length, 'sha256': toHex(sha256(b))}; } /// What the vectors record of the files of a capsule of format 3 that /// opens: [path, size, SHA-256 of what the sink received]. List> filesOf(Outcome o) => [ for (var i = 0; i < o.opened.head!.files.length; i++) [ o.opened.head!.files[i].path, o.opened.head!.files[i].size, toHex(sha256(o.sink.files[i].toBytes())), ], ]; /// The unusable extensions of each object, as the vectors record them. Json unusableOf(Opened o) { List> u(List us) => [ for (final x in us) [x.id, x.version, x.error.message], ]; return { if (o.inspection.unusableExtensions.isNotEmpty) 'header': u(o.inspection.unusableExtensions), if (o.unusableControlExtensions.isNotEmpty) 'control': u(o.unusableControlExtensions), if (o.unusableAccessKeyExtensions.isNotEmpty) 'dkk': u(o.unusableAccessKeyExtensions), if (o.unusableHeadExtensions.isNotEmpty) 'head': u(o.unusableHeadExtensions), }; } /// Checks the outcome [o] of the case [c] against what Go gave, field by /// field; returns the differences, empty when there is none. List differences(Json c, Outcome o) { final out = []; void same(String what, Object? got, Object? want) { final g = canonical(got); final w = canonical(want); if (g != w) out.add('$what: got $g, want $w'); } same('result', o.result, c['result']); same('text', o.text, c['text']); same('checks', o.checks, c['checks']); same('release requests', o.calls, c['release_requests']); same('sink', o.sink.state, c['sink']); if (c['content'] != null) same('content', contentOf(o), c['content']); if (c['files'] != null) same('files', filesOf(o), c['files']); same('unusable', unusableOf(o.opened), c['unusable'] ?? {}); // The output: closed only for a capsule of format 1 or 2 that opens, // aborted after any failure, untouched by a capsule of format 3 that // opens (spec ยง56). final format3 = o.opened.format == CapsuleFormat.format3; final outputState = o.output.closed ? 'closed' : o.output.aborted != null ? 'aborted' : 'untouched'; same( 'output', outputState, o.result == 'ok' ? (format3 ? 'untouched' : 'closed') : 'aborted', ); if (o.result != 'ok') { same('received before the failure was published', o.output.closed, false); same('head of a failure', o.opened.head == null, true); same('verdicts of a failure', o.opened.verdicts == null, true); same('payload length of a failure', o.opened.payloadLength, null); } // The verdicts of a capsule of format 3 that opens, evaluated once. final verdicts = c['verdicts'] as Json?; same('verdicts recorded', verdicts != null, o.result == 'ok' && format3); if (verdicts != null && o.opened.verdicts != null) { same('evaluations', o.securities.length, 1); if (o.securities.length == 1) { out.addAll( verdictDifferences( verdicts, o.opened.verdicts!, cmsParts(o.securities.single), ), ); } } return out; }