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

608 lines
21 KiB

// The age writer against test/vectors/age_writer.json, which Go wrote with
// filippo.io/age v1.3.2 and agewrap while crypto/rand read the keystream of
// SeededRandomSource (tool/age_writer_go_vectors.go): with the same seed,
// AgeEncryptor draws the same values in the same order and writes the same
// bytes, whole or in pieces, and this library opens what it writes. Also
// the errors of the writer with Go's texts, the recipients, the STREAM and
// the lengths. The vectors come from a Dart constant, so that these tests
// also run compiled to JavaScript; there the expensive cases are left out.
import 'dart:convert';
import 'dart:typed_data';
import 'package:datekeys/datekeys.dart';
import 'package:datekeys/src/age.dart';
import 'package:datekeys/src/age_writer.dart';
import 'package:datekeys/src/agewrap.dart';
import 'package:datekeys/src/base64.dart';
import 'package:datekeys/src/chacha20poly1305.dart';
import 'package:datekeys/src/random.dart';
import 'package:datekeys/src/recipient.dart';
import 'package:datekeys/src/sha256.dart';
import 'package:test/test.dart';
import 'age_writer_support.dart';
import 'vectors/age_writer.g.dart';
final Json vectors = jsonDecode(ageWriterJson) as Json;
bool affordable(Json c) => !isWeb || c['node'] != false;
String? ageError(void Function() f) {
try {
f();
return null;
} on AgeException catch (e) {
return e.message;
} on DateKeysException catch (e) {
return e.message;
}
}
void main() {
group('the files of Go', () {
for (final c in listOf(vectors['encrypt']).where(affordable)) {
final name = c['name']! as String;
test(name, () {
final specs = listOf(c['recipients']);
final n = c['length']! as int;
final plain = pattern(n);
final source = RecordingSource(seeded(c['seed']! as String));
final file = ageEncrypt(plain, [
for (final s in specs) recipientOf(s),
], random: source);
// The same draws, in the order and of the sizes of Go.
expect(source.json, c['draws']);
final header = fromHex(c['header']! as String);
expect(
toHex(Uint8List.sublistView(file, 0, header.length)),
c['header'],
);
expect(file.length, c['file_length']);
expect(toHex(sha256(file)), c['file_sha256']);
if (c['file'] != null) expect(toHex(file), c['file']);
// The lengths, from the shapes of the stanzas alone.
final lengths = stanzaLengths(header);
expect(ageHeaderLength(lengths), header.length);
expect(ageFileLength(lengths, n), file.length);
for (var i = 0; i < specs.length; i++) {
final r = recipientOf(specs[i]);
final want = switch (r) {
X25519Recipient(:final stanzaLength) => stanzaLength,
ScryptRecipient(:final stanzaLength) => stanzaLength,
TimeRecipient(:final stanzaLength) => stanzaLength,
_ => -1,
};
expect(lengths[i], want, reason: 'stanza $i');
}
// The same file when the plaintext arrives in pieces.
if (n > 0 && n <= 300000) {
final again = encryptInPieces(
plain,
[for (final s in specs) recipientOf(s)],
seeded(c['seed']! as String),
[1, 7, 65535, 65537, 1000],
);
expect(again, file);
}
// This library opens it with each identity that the case knows.
for (final s in specs) {
final id = identityOf(s);
if (id == null) continue;
expect(toHex(sha256(ageDecrypt(file, [id]))), toHex(sha256(plain)));
}
});
}
});
group('the errors of age.Encrypt, with the draws before them', () {
for (final c in listOf(vectors['errors']).where(affordable)) {
test(c['name'], () {
final source = RecordingSource(seeded(c['seed']! as String));
final rs = [for (final s in listOf(c['recipients'])) recipientOf(s)];
expect(
ageError(() => ageEncrypt(pattern(10), rs, random: source)),
c['error'],
);
expect(source.json, c['draws']);
});
}
});
test('the errors of the constructors', () {
for (final c in listOf(vectors['constructors'])) {
if (c['work_factor'] case final int wf) {
expect(
() => ScryptRecipient('p', workFactor: wf),
throwsA(
isA<ArgumentError>().having(
(e) => e.message,
'message',
c['panic'],
),
),
reason: c['name'] as String?,
);
} else if (c['round'] case final int round) {
expect(ageError(() => TimeRecipient(quicknet(), round)), c['error']);
} else if (c['max_round'] case final int max) {
expect(quicknet().maxRound, max);
expect(TimeRecipient(quicknet(), max).round, max);
expect(TimeRecipient(quicknet(), 1).round, 1);
} else {
expect(ageError(() => ScryptRecipient('')), c['error']);
expect(ageError(() => ScryptRecipient.bytes(const [])), c['error']);
}
}
});
test('the STREAM once closed, with the texts of Go', () {
final texts = vectors['stream']! as Json;
final e = AgeEncryptor([
X25519Recipient.of(labelIdentity('stream')),
], random: seeded('age writer stream'));
e.close();
Matcher closed(Object? text) =>
throwsA(isA<StateError>().having((e) => e.message, 'message', text));
expect(() => e.add([1]), closed(texts['write_after_close']));
expect(() => e.add(const []), closed(texts['empty_write_after_close']));
expect(e.close, closed(texts['close_after_close']));
// An abandoned one refuses everything too.
final w = AgeEncryptor([
X25519Recipient.of(labelIdentity('stream')),
], random: seeded('abandoned'));
w.add(pattern(70000));
w.wipe();
expect(
w.close,
throwsA(
isA<StateError>().having(
(e) => e.message,
'message',
'the encryption of the payload was abandoned',
),
),
);
e.wipe(); // nothing once closed
});
test('age1… recipients, parsed with the texts of Go', () {
for (final c in listOf(vectors['parse'])) {
final input = c['input']! as String;
if (c['error'] case final String text) {
expect(
ageError(() => X25519Recipient.parse(input)),
text,
reason: input,
);
} else {
final r = X25519Recipient.parse(input);
expect(toHex(r.publicKey), c['raw']);
expect(toHex(rawX25519Recipient(r)), c['raw']);
expect(r.toString(), c['string']);
}
}
expect(
ageError(() => X25519Recipient(Uint8List(31))),
'invalid X25519 public key',
);
});
test(
'the rules of spec §37, with the texts of agewrap.CheckX25519Recipient',
() {
for (final c in listOf(vectors['check'])) {
final r = X25519Recipient(fromHex(c['raw']! as String));
expect(r.toString(), c['recipient']);
final text = c['error'] as String?;
if (text == null) {
checkX25519Recipient(r);
} else {
expect(
() => checkX25519Recipient(r),
throwsA(
isA<ArgumentError>().having((e) => e.message, 'message', text),
),
reason: c['raw'] as String?,
);
}
}
},
);
test('X25519 identities generated as age.GenerateX25519Identity', () {
for (final c in listOf(vectors['generate'])) {
final source = RecordingSource(seeded(c['seed']! as String));
final id = generateX25519Identity(source);
expect(source.json, c['draws']);
expect(toHex(rawX25519Identity(id)), c['raw']);
expect(id.toString(), c['identity']);
expect(X25519Recipient.of(id).toString(), c['recipient']);
expect(id.recipientString, c['recipient']);
}
});
test('the lengths of Go: testkit.StreamLen and capsule.PayloadAgeLength', () {
final l = vectors['lengths']! as Json;
for (final c in listOf(l['stream'])) {
expect(ageStreamLength(c['n']! as int), c['length'], reason: '${c['n']}');
}
for (final c in listOf(l['payload_age'])) {
final p = c['p']! as int;
expect(ageFileLength([x25519StanzaLength], p), c['length']);
expect(payloadAgeLength(p), c['length']);
}
});
test('the lengths of spec §62.1, note', () {
int c(int n) => n == 0 ? 1 : (n + 65535) ~/ 65536;
for (final n in [0, 1, 103, 127, 65535, 65536, 65537, 1 << 20]) {
expect(ageFileLength([x25519StanzaLength], n), 184 + n + 16 * c(n));
final inner = ageFileLength(List.filled(16, x25519StanzaLength), n);
expect(inner, 86 + 98 * 16 + n + 16 * c(n));
for (final round in [1, 1000, 83903165811]) {
final d = '$round'.length;
expect(
ageFileLength([tlockStanzaLength(round)], n),
335 + d + n + 16 * c(n),
);
}
}
// SEALED_CONTROL_LEN with C = 103 at round 1000: 458 and 2128.
expect(ageFileLength([tlockStanzaLength(1000)], 103), 458);
final inner = ageFileLength(List.filled(16, x25519StanzaLength), 103);
expect(ageFileLength([tlockStanzaLength(1000)], inner), 2128);
// A scrypt stanza: 78 bytes and the digits of the work factor.
expect(scryptStanzaLength(1), 79);
expect(scryptStanzaLength(16), 80);
expect(ScryptRecipient('p', workFactor: 9).stanzaLength, 79);
expect(ageStanzaLength('X25519', ['a' * 43], 32), x25519StanzaLength);
// The body lines: 48 bytes fill a line, and an empty one ends it.
expect(ageStanzaLength('t', const [], 0), 6);
expect(ageStanzaLength('t', const [], 47), 6 + 63);
expect(ageStanzaLength('t', const [], 48), 6 + 65);
expect(ageStanzaLength('t', const ['ab', 'c'], 49), 10 + 68);
for (final n in [0, 1, 47, 48, 49, 95, 96, 97, 300]) {
final s = AgeStanza('t', const ['ab', 'c'], Uint8List(n));
expect(
marshalAgeHeaderWithoutMac([s]).length - 25,
ageStanzaLength('t', const ['ab', 'c'], n),
reason: '$n',
);
}
});
group('the STREAM', () {
final key = sha256('stream key'.codeUnits);
List<Uint8List> chunksOf(int n, List<int> steps) {
final e = AgePayloadEncryptor(Uint8List.fromList(key));
final out = <Uint8List>[];
var k = 0;
final p = pattern(n);
for (var i = 0; i < n;) {
final m = steps[k++ % steps.length];
final end = i + m < n ? i + m : n;
out.addAll(e.add(p, i, end));
i = end;
}
out.add(e.close());
return out;
}
test('chunks of 64 KiB, the last one flagged, full or short', () {
for (final n in [0, 1, 65535, 65536, 65537, 131072, 131073]) {
final chunks = chunksOf(n, [n == 0 ? 1 : n]);
final want = n == 0 ? 1 : (n + 65535) ~/ 65536;
expect(chunks, hasLength(want), reason: '$n');
for (var i = 0; i < chunks.length; i++) {
final last = i == chunks.length - 1;
final size = last ? n - 65536 * (chunks.length - 1) : 65536;
expect(chunks[i].length, size + 16, reason: '$n, chunk $i');
// Its nonce: the counter i, big-endian, and the flag of the last.
final nonce = Uint8List(12);
var c = i;
for (var k = 10; k >= 0; k--) {
nonce[k] = c & 0xff;
c ~/= 256;
}
nonce[11] = last ? 1 : 0;
expect(
chacha20Poly1305Open(key, nonce, chunks[i]),
Uint8List.sublistView(pattern(n), 65536 * i, 65536 * i + size),
reason: '$n, chunk $i',
);
}
}
});
test('pieces of every size give the same chunks', () {
final whole = chunksOf(140000, [140000]);
for (final steps in [
[1000],
[65535],
[65536],
[65537],
[1, 65535, 3],
[70000, 0, 1],
]) {
expect(chunksOf(140000, steps), whole, reason: '$steps');
}
// A full chunk is held until a later byte arrives.
final e = AgePayloadEncryptor(Uint8List.fromList(key));
expect(e.add(pattern(65536)), isEmpty);
expect(e.add(const []), isEmpty);
expect(e.add([1]), hasLength(1));
expect(e.close(), hasLength(17));
});
test('this library reads what it writes', () {
for (final n in [0, 1, 65536, 65537, 200000]) {
final d = AgePayloadDecryptor(Uint8List.fromList(key));
final out = BytesBuilder();
for (final c in chunksOf(n, [n == 0 ? 1 : 9999])) {
d.add(c).forEach(out.add);
}
out.add(d.close());
expect(out.takeBytes(), pattern(n), reason: '$n');
}
});
test('its key is 32 bytes, and it wipes it', () {
expect(() => AgePayloadEncryptor(Uint8List(31)), throwsArgumentError);
final k = Uint8List.fromList(key);
AgePayloadEncryptor(k).close();
expect(k, Uint8List(32));
final w = Uint8List.fromList(key);
AgePayloadEncryptor(w)
..add(pattern(10))
..wipe();
expect(w, Uint8List(32));
});
});
group('recipients', () {
final fileKey = sha256('file key'.codeUnits).sublist(0, 16);
test('an X25519 stanza: 98 bytes, a fresh share each time', () {
final id = labelIdentity('fresh');
final r = X25519Recipient.of(id);
final source = seeded('fresh shares');
final shares = <String>{};
for (var i = 0; i < 20; i++) {
final w = r.wrap(fileKey, source);
expect(w.labels, isEmpty);
final s = w.stanzas.single;
expect(s.type, stanzaX25519);
expect(shares.add(s.args.single), isTrue);
expect(id.unwrap([s]), fileKey);
}
final file = ageEncrypt(pattern(5), [
for (var i = 0; i < 16; i++) X25519Recipient.of(labelIdentity('$i')),
], random: seeded('sixteen'));
final stanzas = ageStanzas(file);
checkAccessStanzas(stanzas, accessSlots);
for (var i = 0; i < 16; i++) {
expect(
ageDecrypt(file, [
AccessIdentity(accessSlots, [labelIdentity('$i')]),
]),
pattern(5),
);
}
});
test('PAYLOAD_AGE opens with I_PAYLOAD, as agewrap', () {
final id = labelIdentity('payload');
final file = ageEncrypt(pattern(70000), [
X25519Recipient.of(id),
], random: seeded('payload'));
expect(ageDecrypt(file, [PayloadIdentity(id.secretKey)]), pattern(70000));
expect(
ageFileLength([x25519StanzaLength], 70000),
payloadAgeLength(70000),
);
expect(file.length, payloadAgeLength(70000));
});
test('the recipient of a raw key, and of a parsed one', () {
final id = labelIdentity('raw');
final r = X25519Recipient(id.recipient);
expect(X25519Recipient.parse(r.toString()).publicKey, id.recipient);
expect(r.toString(), id.recipientString);
final copy = r.publicKey..[0] ^= 1;
expect(r.publicKey, isNot(copy));
});
test('scrypt: alone in its file, and its passphrase opens it', () {
final r = ScryptRecipient('pass', workFactor: 2);
expect(r.workFactor, 2);
expect(ScryptRecipient('pass').workFactor, defaultScryptWorkFactor);
final w = r.wrap(fileKey, seeded('scrypt alone'));
expect(w.labels.single, matches(RegExp(r'^[0-9a-f]{32}$')));
final s = w.stanzas.single;
expect(s.args[1], '2');
expect(goBase64Decode(s.args[0].codeUnits, padded: false), hasLength(16));
expect(ScryptIdentity('pass').unwrap([s]), fileKey);
expect(
ageError(() => ScryptIdentity('wrong').unwrap([s])),
'incorrect identity for recipient block: incorrect passphrase',
);
final bytes = ScryptRecipient.bytes(utf8.encode('pass'), workFactor: 2);
expect(
ScryptIdentity('pass').unwrap(bytes.wrap(fileKey, seeded('b')).stanzas),
fileKey,
);
r.wipe();
});
test('tlock: the stanza of the round, alone, with a random label', () {
final r = TimeRecipient(quicknet(), 1000);
final w = r.wrap(fileKey, seeded('tlock label'));
expect(
w.labels.single,
matches(RegExp(r'^datekeys-tlock-[0-9a-f]{32}$')),
);
final s = w.stanzas.single;
checkTimeStanzas(
w.stanzas,
round: 1000,
chainHashHex: quicknet().chainHashHex,
profileId: quicknet().id,
);
final id = TimeIdentity(
quicknet(),
1000,
Release(1000, fromHex(releases[1000]!)),
);
expect(id.unwrap([s]), fileKey);
});
test('a recipient that throws, and stanzas that cannot be marshalled', () {
expect(
ageError(
() => ageEncrypt(const [], [
_Fails(const AgeException('nope')),
], random: seeded('x')),
),
'failed to wrap key for recipient #0: nope',
);
final e = DateKeysException(ErrorCode.integrity, 'bad');
expect(
() => ageEncrypt(const [], [
X25519Recipient.of(labelIdentity('a')),
_Fails(e),
], random: seeded('x')),
throwsA(
isA<DateKeysException>()
.having((e) => e.code, 'code', ErrorCode.integrity)
.having(
(e) => e.message,
'message',
'failed to wrap key for recipient #1: bad: ERR_INTEGRITY',
),
),
);
expect(
ageError(() => ageEncrypt(const [], [_Gives([])], random: seeded('x'))),
'failed to compute header MAC: no recipient stanzas',
);
expect(
ageError(
() => ageEncrypt(const [], [
_Gives([AgeStanza('a b', const [], Uint8List(0))]),
], random: seeded('x')),
),
'failed to compute header MAC: invalid stanza type: "a b"',
);
expect(
ageError(
() => ageEncrypt(const [], [
_Gives([
AgeStanza('t', const ['é'], Uint8List(0)),
]),
], random: seeded('x')),
),
'failed to compute header MAC: invalid stanza argument: "é"',
);
// A recipient that gives no stanza beside one that does.
final id = labelIdentity('alone');
final file = ageEncrypt(pattern(3), [
_Gives([]),
X25519Recipient.of(id),
], random: seeded('x'));
expect(ageDecrypt(file, [id]), pattern(3));
// The labels are compared sorted, and a post-quantum one alone is
// named.
expect(
ageError(
() => ageEncrypt(const [], [
_Gives([], ['b', 'a']),
_Gives([], ['a', 'c']),
], random: seeded('x')),
),
'incompatible recipients: ["a" "b"] and ["a" "c"] can\'t be mixed',
);
expect(
ageError(
() => ageEncrypt(const [], [
_Gives([], ['postquantum']),
_Gives([], []),
], random: seeded('x')),
),
"incompatible recipients: can't mix post-quantum and classic "
'recipients, or the file would be vulnerable to quantum computers',
);
final both = ageEncrypt(pattern(3), [
_Gives([], ['b', 'a']),
_Labeled(X25519Recipient.of(id), ['a', 'b']),
], random: seeded('x'));
expect(ageDecrypt(both, [id]), pattern(3));
});
test('the file key it wraps is wiped once the header is written', () {
final capture = _Capture();
final e = AgeEncryptor([capture], random: seeded('wipe'));
expect(capture.seen, hasLength(16));
expect(capture.seen, Uint8List(16));
expect(e.payloadOffset, e.header.length + 16);
expect(e.stanzas.single.type, 'X25519');
});
});
test('the default source is the CSPRNG of the platform', testOn: 'vm', () {
final id = generateX25519Identity();
final a = ageEncrypt(pattern(10), [X25519Recipient.of(id)]);
final b = ageEncrypt(pattern(10), [X25519Recipient.of(id)]);
expect(a, isNot(b));
expect(ageDecrypt(a, [id]), pattern(10));
expect(ageDecrypt(b, [id]), pattern(10));
expect(generateX25519Identity().secretKey, isNot(id.secretKey));
});
}
// A recipient that throws.
final class _Fails implements AgeRecipient {
_Fails(this.error);
final Exception error;
@override
AgeWrap wrap(Uint8List fileKey, RandomSource random) => throw error;
}
// A recipient that gives the stanzas and labels it was given.
final class _Gives implements AgeRecipient {
_Gives(this.stanzas, [this.labels = const []]);
final List<AgeStanza> stanzas;
final List<String> labels;
@override
AgeWrap wrap(Uint8List fileKey, RandomSource random) =>
(stanzas: stanzas, labels: labels);
}
// Another recipient with labels.
final class _Labeled implements AgeRecipient {
_Labeled(this.inner, this.labels);
final AgeRecipient inner;
final List<String> labels;
@override
AgeWrap wrap(Uint8List fileKey, RandomSource random) =>
(stanzas: inner.wrap(fileKey, random).stanzas, labels: labels);
}
// A recipient that keeps the file key it is given, to see it wiped.
final class _Capture implements AgeRecipient {
Uint8List seen = Uint8List(0);
@override
AgeWrap wrap(Uint8List fileKey, RandomSource random) {
seen = fileKey;
return X25519Recipient.of(labelIdentity('capture')).wrap(fileKey, random);
}
}

Powered by TurnKey Linux.