// age against test/vectors/age.json, which filippo.io/age v1.3.2 and the // agewrap package of datekeys-go wrote and read (tool/gen_age_vectors.go): // X25519 and scrypt files, their truncations and manipulations, a corpus of // headers against the grammar and the 2 MiB limit, the stanza rules and // the identities of agewrap, with the text of Go's error for every case. @TestOn('vm') library; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; import 'package:datekeys/datekeys.dart'; import 'package:datekeys/src/age.dart'; import 'package:datekeys/src/agewrap.dart'; import 'package:datekeys/src/sha256.dart'; import 'package:test/test.dart'; import 'age_support.dart'; final Map vectors = jsonDecode( File('test/vectors/age.json').readAsStringSync(), ) as Map; List> section(String name) => (vectors[name]! as List).cast>(); Map asMap(Object? v) => v! as Map; List stanzasOf(Object? list) => [ for (final s in (list! as List).cast>()) AgeStanza( s['type']! as String, (s['args']! as List).cast(), Uint8List(0), ), ]; Map describe(AgeStanza s) => { 'type': s.type, 'args': s.args, 'body_length': s.body.length, 'body_sha256': toHex(sha256(s.body)), }; String? errorOf(void Function() f) { try { f(); return null; } on DateKeysException catch (e) { return e.message; } } void main() { final ids = asMap(vectors['identities']); final files = { for (final f in section('files')) f['name']! as String: fromHex(f['hex']! as String), }; group('the large files, rebuilt', () { for (final f in section('large')) { test(f['name'], () { final header = fromHex(f['header']! as String); final nonce = fromHex(f['nonce']! as String); final file = concatBytes([ header, nonce, streamSeal( fromHex(f['file_key']! as String), nonce, pattern(f['length']! as int), ), ]); expect(file.length, f['file_length']); expect(toHex(sha256(file)), f['file_sha256']); files[f['name']! as String] = file; }); } }); test('each file decrypts to its plaintext', () { for (final f in section('files')) { final h = parseAgeHeader(files[f['name']]!); expect(h.stanzas, isNotEmpty); } final main = X25519Identity.parse(ids['main']! as String); expect(main.recipientString, ids['main_recipient']); expect(toHex(main.recipient), ids['main_recipient_raw']); expect(main.toString(), ids['main']); expect(ageDecrypt(files['x25519_100']!, [main]), pattern(100)); }); test('the cases: decryption, truncation and manipulation, as Go', () { var n = 0; for (final c in section('cases')) { var file = Uint8List.fromList(files[c['file']]!); final e = c['edit'] as Map?; if (e != null) { if (e['truncate'] != null) { file = Uint8List.sublistView(file, 0, e['truncate']! as int); } if (e['at'] != null) { final x = fromHex(e['xor']! as String); for (var i = 0; i < x.length; i++) { file[(e['at']! as int) + i] ^= x[i]; } } if (e['append'] != null) { file = concatBytes([file, fromHex(e['append']! as String)]); } } final identities = [ for (final s in (c['identities']! as List).cast>()) identityOf(s), ]; expect( resultOf(file, identities), c['result'], reason: c['name'] as String?, ); n++; } expect(n, greaterThan(150)); }); test('the STREAM fed in pieces gives the same results', () { final r = Random(5); final main = [X25519Identity.parse(ids['main']! as String)]; for (final c in section('cases')) { if (!(c['file']! as String).startsWith('x25519_large') || c['edit'] == null) { continue; } final e = c['edit']! as Map; var file = Uint8List.fromList(files[c['file']]!); if (e['truncate'] != null) { file = Uint8List.sublistView(file, 0, e['truncate']! as int); } if (e['at'] != null) { file[e['at']! as int] ^= fromHex(e['xor']! as String)[0]; } if (e['append'] != null) { file = concatBytes([file, fromHex(e['append']! as String)]); } for (final step in [() => 1 + r.nextInt(100000), () => 65552, () => 7]) { if (step() == 7 && file.length > 70000) continue; expect( resultInPieces(file, main, step), c['result'], reason: c['name'] as String?, ); } } }); test('the headers: the grammar of spec ยง28.1 and the limits of age', () { final main = X25519Identity.parse(ids['main']! as String); for (final c in section('headers')) { final file = build(c['parts'] as List?); expect(file.length, c['length'], reason: c['name'] as String?); expect(toHex(sha256(file)), c['sha256']); expect(resultOf(file, [main]), c['result'], reason: c['name'] as String?); final probe = asMap(c['probe']); try { final ss = ageStanzas(file); expect(probe['error'], isNull, reason: c['name'] as String?); expect(ss.length, probe['count']); if (probe['stanzas'] != null) { expect([for (final s in ss) describe(s)], probe['stanzas']); } else { expect(describe(ss.first), probe['first']); expect(describe(ss.last), probe['last']); } } on DateKeysException catch (e) { expect(e.message, probe['error'], reason: c['name'] as String?); expect(e.code, ErrorCode.integrity); } } }); test('a prefix longer than 2 MiB parses as the whole file', () { // parseAgeHeader of the first 2 MiB + 1 bytes of a file gives what the // file gives, whatever follows. for (final c in section('headers')) { final file = build(c['parts'] as List?); if (file.length <= maxAgeHeaderLength + 1) continue; final prefix = Uint8List.sublistView(file, 0, maxAgeHeaderLength + 1); String outcome(Uint8List b) { try { return '${parseAgeHeader(b).length}'; } on AgeException catch (e) { return e.message; } } expect(outcome(prefix), outcome(file), reason: c['name'] as String?); } }); test('the stanza rules of agewrap', () { final time = asMap(vectors['time_stanzas']); for (final c in (time['cases']! as List).cast>()) { expect( errorOf( () => checkTimeStanzas( stanzasOf(c['stanzas']), round: c['round']! as int, chainHashHex: time['chain_hash']! as String, profileId: time['profile_id']! as String, ), ), c['error'], reason: c['name'] as String?, ); } for (final c in section('payload_stanzas')) { expect( errorOf(() => checkPayloadStanzas(stanzasOf(c['stanzas']))), c['error'], reason: c['name'] as String?, ); } for (final c in section('access_stanzas')) { expect( errorOf( () => checkAccessStanzas(stanzasOf(c['stanzas']), c['slots']! as int), ), c['error'], reason: '${c['name']}, ${c['slots']} slots', ); } }); test('the identities of agewrap', () { for (final c in section('agewrap')) { final raws = [ for (final r in (c['raw_identities']! as List).cast()) fromHex(r), ]; final file = fromHex(c['file']! as String); Map result; try { final AgeIdentity id = c['kind'] == 'payload' ? PayloadIdentity(raws.single) : AccessIdentity(c['slots']! as int, [ for (final r in raws) x25519IdentityFromRaw(r), ]); result = resultOf(file, [id]); } on DateKeysException catch (e) { result = {'error': e.message, 'phase': 'identity'}; } expect(result, c['result'], reason: c['name'] as String?); } expect( () => AccessIdentity(-1, [X25519Identity(Uint8List(32))]), throwsArgumentError, ); // Null entries are dropped, as in Go. expect( errorOf(() => AccessIdentity(16, [null])), 'agewrap: time_and_key needs an access identity: ERR_ACCESS_REQUIRED', ); }); test('age.ParseX25519Identity', () { for (final c in section('parse_identity')) { final s = c['input']! as String; try { final id = X25519Identity.parse(s); expect(c['error'], isNull, reason: s); expect(toHex(id.secretKey), c['raw']); expect(id.recipientString, c['recipient']); expect(id.toString(), c['string']); } on AgeException catch (e) { expect(e.message, c['error'], reason: s); } } }); }