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.
354 lines
10 KiB
354 lines
10 KiB
// 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<Release> 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<String> 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<BytesBuilder> files = [];
|
|
Head? head;
|
|
bool begun = false;
|
|
bool committed = false;
|
|
Object? aborted;
|
|
final List<String> 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<Uint8List> 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<List<Object?>> 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<String, String> authorKeysOf(Json c) =>
|
|
(c['author_keys'] as Map?)?.cast<String, String>() ?? 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<Outcome> 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 = <Uint8List>[];
|
|
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<Object?>? ?? 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<List<Object?>> 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<List<Object?>> u(List<Unusable> 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<String> differences(Json c, Outcome o) {
|
|
final out = <String>[];
|
|
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'] ?? <String, Object?>{});
|
|
// 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;
|
|
}
|