// 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/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().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().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().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().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 chunksOf(int n, List steps) { final e = AgePayloadEncryptor(Uint8List.fromList(key)); final out = []; 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 = {}; 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() .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 stanzas; final List 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 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); } }