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_test.dart

550 lines
17 KiB

// 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<String, Uint8List> _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<Json>(),
...(_cases['steps']! as List).cast<Json>(),
].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 = notEvaluated,
Map<String, String> 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,
authorKeys: authorKeys,
accept: accept,
);
}
void main() {
group('the vectors of Go', () {
for (final section in ['fixtures', 'steps']) {
final cases = (_cases[section]! as List).cast<Json>();
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<Json>());
checkEncodeCases((h['encode']! as List).cast<Json>());
});
test('the notes', () {
final n = jsonDecode(openNotesJson) as Json;
checkNoteCases(
(n['check']! as List).cast<Json>(),
(n['note']! as List).cast<Json>(),
(n['standard']! as List).cast<Json>(),
);
});
test('the inspection: the views and the mutations', () {
final v = jsonDecode(openInspectJson) as Json;
for (final c in (v['views']! as List).cast<Json>()) {
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<Json>();
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 = <SecurityInput>[];
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: not evaluated.
final d = await openCapsule(
dkc,
optionsOf(single, sink: MemoryFileSink()),
);
expect(
[d.verdicts, d.verdicts!.evaluated],
[same(Verdicts.notEvaluated), false],
);
});
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<Object?>((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<Uint8List> 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<Uint8List> 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;
}

Powered by TurnKey Linux.