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.
365 lines
10 KiB
365 lines
10 KiB
// The writer of capsules beyond the vectors of Go: what reaches the sink and
|
|
// when, the errors of the sink, of the sources and of the hooks with their
|
|
// codes, the strings that Go cannot hold, and the result. The seeded source
|
|
// keeps them on Node.js too.
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:datekeys/datekeys.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
import 'capsule_writer_support.dart';
|
|
import 'vectors/capsule_writer.g.dart';
|
|
|
|
final Json doc = jsonDecode(capsuleWriterJson) as Json;
|
|
|
|
Json caseNamed(String name) =>
|
|
listOf(doc['cases'])
|
|
.firstWhere((c) => (c['recipe']! as Json)['name'] == name);
|
|
|
|
EncryptOptions small({
|
|
RandomSource? random,
|
|
AccessPolicy policy = AccessPolicy.timeOnly,
|
|
bool portable = false,
|
|
AuthorSigner? authorKey,
|
|
CmsSigner? cmsSigner,
|
|
Sealer? sealer,
|
|
String comment = '',
|
|
}) => EncryptOptions(
|
|
profile: quicknet(),
|
|
unlockAt: roundTime(quicknet(), 1000),
|
|
now: () => genesis,
|
|
policy: policy,
|
|
newPortableKey: portable,
|
|
authorKey: authorKey,
|
|
cmsSigner: cmsSigner,
|
|
sealer: sealer,
|
|
comment: comment,
|
|
testVectors: true,
|
|
testAreaLen: 512,
|
|
random: random ?? seeded('encrypt3 test'),
|
|
);
|
|
|
|
/// A sink that fails at its add number [failAt], or at close.
|
|
final class FailingSink implements ByteSink {
|
|
FailingSink({this.failAt, this.failClose = false});
|
|
|
|
final int? failAt;
|
|
final bool failClose;
|
|
int adds = 0;
|
|
Object? aborted;
|
|
bool closed = false;
|
|
|
|
@override
|
|
void add(Uint8List bytes) {
|
|
if (++adds == failAt) throw const TextError('disk full');
|
|
}
|
|
|
|
@override
|
|
void close() {
|
|
if (failClose) throw const TextError('cannot rename');
|
|
closed = true;
|
|
}
|
|
|
|
@override
|
|
void abort(Object reason) => aborted = reason;
|
|
}
|
|
|
|
/// The author key of the case of alg 1, which records whether anything of
|
|
/// the capsule had reached the sink when it was asked to sign.
|
|
final class WatchingKey implements AuthorSigner {
|
|
WatchingKey(this.hooks, this.sink);
|
|
|
|
final Json hooks;
|
|
final CapsuleSink sink;
|
|
int? addsWhenSigning;
|
|
|
|
@override
|
|
Uint8List get publicKey => hexOf(hooks['author_public']);
|
|
|
|
@override
|
|
Future<Uint8List> sign(Uint8List message) async {
|
|
addsWhenSigning = sink.adds;
|
|
expect(toHex(message), hooks['author_message']);
|
|
return hexOf(hooks['author_signature']);
|
|
}
|
|
}
|
|
|
|
final class ThrowingSigner implements CmsSigner, Sealer {
|
|
ThrowingSigner(this.error);
|
|
|
|
final Object error;
|
|
|
|
@override
|
|
List<Uint8List> get signers => [Uint8List(32)];
|
|
|
|
@override
|
|
Uint8List sign(Uint8List message) => throw error;
|
|
|
|
@override
|
|
Uint8List seal(Uint8List subject) => throw error;
|
|
}
|
|
|
|
void main() {
|
|
test(
|
|
'nothing reaches the sink before the signature, which is awaited',
|
|
() async {
|
|
final c = caseNamed('signed with alg 1, area of 512');
|
|
final r = c['recipe']! as Json;
|
|
final sink = CapsuleSink();
|
|
final key = WatchingKey(c['hooks']! as Json, sink);
|
|
final res = await encryptFiles(
|
|
sink,
|
|
sourcesOf(r),
|
|
small(random: seeded(r['seed']! as String), authorKey: key),
|
|
);
|
|
expect(key.addsWhenSigning, 0);
|
|
expect(sink.closed, isTrue);
|
|
expect(toHex(sink.bytes), (c['written']! as Json)['dkc']);
|
|
expect(res.head.files.single.path, 'nota.txt');
|
|
},
|
|
);
|
|
|
|
test('the sink receives the prelude first, then the header and the rest, '
|
|
'each buffer its own', () async {
|
|
final sink = CapsuleSink();
|
|
final parts = <Uint8List>[];
|
|
final res = await encryptFiles(_Tee(sink, parts), [
|
|
FileSource.bytes('a.txt', utf8.encode('a')),
|
|
], small());
|
|
expect(parts.first.length, 16);
|
|
expect(ascii.decode(parts.first.sublist(0, 4)), 'DKC1');
|
|
// No two parts share a buffer.
|
|
final buffers = {for (final p in parts) p.buffer};
|
|
expect(buffers.length, parts.length);
|
|
expect(sink.closed, isTrue);
|
|
expect(res.head.files.single.size, 1);
|
|
});
|
|
|
|
test(
|
|
'a sink that fails stops the writing, with its error, and is aborted',
|
|
() async {
|
|
for (final at in [1, 2, 3, 4, 5]) {
|
|
final sink = FailingSink(failAt: at);
|
|
await expectLater(
|
|
encryptFiles(sink, [
|
|
FileSource.bytes('a.txt', utf8.encode('a')),
|
|
], small()),
|
|
throwsA(isA<TextError>()),
|
|
);
|
|
expect(sink.adds, at);
|
|
expect(sink.aborted, isA<TextError>());
|
|
expect(sink.closed, isFalse);
|
|
}
|
|
},
|
|
);
|
|
|
|
test('a sink that fails to close is not aborted', () async {
|
|
final sink = FailingSink(failClose: true);
|
|
await expectLater(
|
|
encryptFiles(sink, [
|
|
FileSource.bytes('a.txt', utf8.encode('a')),
|
|
], small()),
|
|
throwsA(isA<TextError>()),
|
|
);
|
|
expect(sink.aborted, isNull);
|
|
});
|
|
|
|
test(
|
|
'a hook or a source that throws a DateKeysException keeps its code',
|
|
() async {
|
|
final e = DateKeysException(ErrorCode.accessInvalid, 'hook');
|
|
for (final (opts, want) in [
|
|
(
|
|
small(cmsSigner: ThrowingSigner(e)),
|
|
'capsule: signing: hook: ERR_ACCESS_INVALID',
|
|
),
|
|
(
|
|
small(sealer: ThrowingSigner(e)),
|
|
'capsule: sealing: hook: ERR_ACCESS_INVALID',
|
|
),
|
|
]) {
|
|
final sink = CapsuleSink();
|
|
await expectLater(
|
|
encryptFiles(sink, [
|
|
FileSource.bytes('a', const [1]),
|
|
], opts),
|
|
throwsA(
|
|
isA<DateKeysException>()
|
|
.having((x) => x.message, 'message', want)
|
|
.having((x) => x.code, 'code', ErrorCode.accessInvalid),
|
|
),
|
|
);
|
|
expect(sink.adds, 0);
|
|
expect(sink.aborted, isNotNull);
|
|
}
|
|
final src = FileSource(path: 'a', size: 1, open: () => Stream.error(e));
|
|
await expectLater(
|
|
encryptFiles(CapsuleSink(), [src], small()),
|
|
throwsA(
|
|
isA<DateKeysException>().having(
|
|
(x) => x.message,
|
|
'message',
|
|
'capsule: file "a": hook: ERR_ACCESS_INVALID',
|
|
),
|
|
),
|
|
);
|
|
// Anything else is a CapsuleWriteException with its text and its cause.
|
|
final cause = StateError('broken');
|
|
final src2 = FileSource(path: 'b', size: 1, open: () => throw cause);
|
|
await expectLater(
|
|
encryptFiles(CapsuleSink(), [src2], small()),
|
|
throwsA(
|
|
isA<CapsuleWriteException>()
|
|
.having(
|
|
(x) => x.message,
|
|
'message',
|
|
'capsule: file "b": Bad state: broken',
|
|
)
|
|
.having((x) => x.cause, 'cause', same(cause)),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'a path or a text that is not well-formed UTF-16 is not UTF-8 for Go',
|
|
() async {
|
|
Future<String> rejection(
|
|
List<FileSource> files, {
|
|
String comment = '',
|
|
}) async {
|
|
try {
|
|
await encryptFiles(CapsuleSink(), files, small(comment: comment));
|
|
} on ArgumentError catch (e) {
|
|
return '${e.message}';
|
|
}
|
|
throw StateError('no error');
|
|
}
|
|
|
|
expect(
|
|
await rejection([
|
|
FileSource.bytes('a\uD800', const [1]),
|
|
]),
|
|
r'capsule: path "a\xed\xa0\x80": R1: not valid UTF-8',
|
|
);
|
|
expect(
|
|
await rejection([
|
|
FileSource.bytes('a', const [1]),
|
|
], comment: 'x\uDC00'),
|
|
'capsule: comment: not valid UTF-8',
|
|
);
|
|
},
|
|
);
|
|
|
|
test('a source is read in streaming: what it gives reaches the sink before '
|
|
'it gives much more', () async {
|
|
const chunk = 16 << 10;
|
|
const chunks = 64;
|
|
var given = 0;
|
|
var maxAhead = 0;
|
|
final sink = _SlowSink();
|
|
Stream<List<int>> open() async* {
|
|
for (var i = 0; i < chunks; i++) {
|
|
given += chunk;
|
|
final ahead = given - sink.received;
|
|
if (ahead > maxAhead) maxAhead = ahead;
|
|
yield Uint8List(chunk);
|
|
}
|
|
}
|
|
|
|
final src = FileSource(
|
|
path: 'big',
|
|
size: chunk * chunks,
|
|
open: () {
|
|
given = 0;
|
|
maxAhead = 0;
|
|
return open();
|
|
},
|
|
);
|
|
await encryptFiles(sink, [src], small());
|
|
// The second reading: at most a STREAM chunk of 64 KiB is held, with
|
|
// the bytes of the frame, the area and the head that precede the file,
|
|
// and the piece being given.
|
|
expect(maxAhead, lessThan((64 << 10) + 2 * chunk + 4096));
|
|
expect(given, chunk * chunks);
|
|
});
|
|
|
|
test(
|
|
'the result describes the capsule written, and its .dkk opens it',
|
|
() async {
|
|
final c = caseNamed('time_and_key, a portable key');
|
|
final r = c['recipe']! as Json;
|
|
final w = await writeRecipe(r, const {});
|
|
final res = w.result!;
|
|
expect(res.format, CapsuleFormat.format3);
|
|
expect(res.dateKey.toString(), startsWith('dk1_'));
|
|
expect(res.unlockAt, roundTime(quicknet(), 1000));
|
|
expect(res.paddedLength, paddedLength(res.length, PaddingRule.reforzado));
|
|
final k = res.portableKey!;
|
|
expect(k.capsuleId, res.capsuleId);
|
|
expect(k.verification!.capsuleDigest, sha256Of(w.dkc));
|
|
expect(k.critical, isEmpty);
|
|
expect(k.noncritical, isEmpty);
|
|
},
|
|
);
|
|
|
|
test('the options keep their defaults', () {
|
|
final o = EncryptOptions(
|
|
profile: quicknet(),
|
|
unlockAt: genesis,
|
|
now: () => genesis,
|
|
);
|
|
expect(o.policy, AccessPolicy.timeOnly);
|
|
expect(o.padding, isNull);
|
|
expect(o.random, same(secureRandom));
|
|
expect(o.testAreaLen, 0);
|
|
expect(o.recipients, isEmpty);
|
|
expect(o.words, isEmpty);
|
|
});
|
|
}
|
|
|
|
final class _Tee implements ByteSink {
|
|
_Tee(this.sink, this.parts);
|
|
|
|
final CapsuleSink sink;
|
|
final List<Uint8List> parts;
|
|
|
|
@override
|
|
void add(Uint8List bytes) {
|
|
parts.add(bytes);
|
|
sink.add(bytes);
|
|
}
|
|
|
|
@override
|
|
void close() => sink.close();
|
|
|
|
@override
|
|
void abort(Object reason) => sink.abort(reason);
|
|
}
|
|
|
|
/// A sink that takes its time, and counts the bytes of PAYLOAD_AGE it
|
|
/// received, as plaintext: each chunk of the STREAM is 16 bytes more.
|
|
final class _SlowSink implements ByteSink {
|
|
int received = 0;
|
|
bool _payload = false;
|
|
int _adds = 0;
|
|
|
|
@override
|
|
Future<void> add(Uint8List bytes) async {
|
|
await Future<void>.delayed(Duration.zero);
|
|
// The prelude, the header, SEALED_CONTROL, then the header and the
|
|
// nonce of PAYLOAD_AGE, then its chunks.
|
|
if (++_adds > 5) _payload = true;
|
|
if (_payload) received += bytes.length - 16;
|
|
}
|
|
|
|
@override
|
|
void close() {}
|
|
|
|
@override
|
|
void abort(Object reason) {}
|
|
}
|