// 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 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 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 = []; 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()), ); expect(sink.adds, at); expect(sink.aborted, isA()); 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()), ); 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() .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().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() .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 rejection( List 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> 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 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 add(Uint8List bytes) async { await Future.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) {} }