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.
564 lines
17 KiB
564 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,
|
|
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 ?? 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<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 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<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;
|
|
}
|