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.
dateKeys-dart/test/open_support.dart

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;
}

Powered by TurnKey Linux.