Nothing reaches the sink before the signature; the sink receives the prelude first, each buffer its own; a sink that fails stops the writing with its error and is aborted, one that fails to close is not; a hook or a source that throws a DateKeysException keeps its code, anything else is a CapsuleWriteException with its cause; a string that is not well-formed UTF-16 is not valid UTF-8 for Go; a source is read in streaming; the result and its .dkk; and, on the VM, two capsules of the CSPRNG differ and open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>v0.11
parent
9fd06f2990
commit
cf2a36d6e5
@ -0,0 +1,366 @@
|
||||
// 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:datekeys/src/encrypt3.dart';
|
||||
import 'package:datekeys/src/random.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,
|
||||
AuthorKey? 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 AuthorKey {
|
||||
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) {}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
// The writer of capsules with the CSPRNG of the platform, which Node.js does
|
||||
// not give inside the tests: two capsules of the same options differ in
|
||||
// every random value, and both open.
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:datekeys/datekeys.dart';
|
||||
import 'package:datekeys/src/encrypt3.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'capsule_writer_support.dart';
|
||||
|
||||
void main() {
|
||||
test('two capsules of the same options differ, and both open', () async {
|
||||
final releases = {
|
||||
1000: fromHex(
|
||||
'b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125'
|
||||
'e342b73a8dd2bacbe47e4b6b63ed5e39',
|
||||
),
|
||||
};
|
||||
final written = <(EncryptResult, CapsuleSink)>[];
|
||||
for (var i = 0; i < 2; i++) {
|
||||
final sink = CapsuleSink();
|
||||
final res = await encryptFiles(
|
||||
sink,
|
||||
[FileSource.bytes('a.txt', utf8.encode('Hola.\n'))],
|
||||
EncryptOptions(
|
||||
profile: quicknet(),
|
||||
unlockAt: roundTime(quicknet(), 1000),
|
||||
now: () => genesis,
|
||||
policy: AccessPolicy.timeAndKey,
|
||||
newPortableKey: true,
|
||||
),
|
||||
);
|
||||
written.add((res, sink));
|
||||
}
|
||||
final [(a, sa), (b, sb)] = written;
|
||||
expect(a.capsuleId, isNot(b.capsuleId));
|
||||
expect(a.head.salt, isNot(b.head.salt));
|
||||
expect(a.portableKey!.material, isNot(b.portableKey!.material));
|
||||
expect(a.portableKey!.credentialId, isNot(b.portableKey!.credentialId));
|
||||
expect(sa.length, sb.length);
|
||||
for (final (res, sink) in written) {
|
||||
final o = await openedJson(
|
||||
const {'policy': 'time_and_key'},
|
||||
sink.bytes,
|
||||
releases,
|
||||
dkk: encodeAccessKey(res.portableKey!),
|
||||
);
|
||||
expect(o['result'], 'ok');
|
||||
expect(o['files'], [
|
||||
{
|
||||
'path': 'a.txt',
|
||||
'size': 6,
|
||||
'sha256': toHex(sha256Of(utf8.encode('Hola.\n'))),
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue