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/encrypt3_test.dart

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) {}
}

Powered by TurnKey Linux.