// age without files, so that it also runs compiled to JavaScript: files // written here as age.Encrypt writes them, read back across the chunk // boundaries of the STREAM, with the errors of Go for its end-of-file cases; // the header parser on short inputs; the passphrase identity with small // work factors; and the rules of agewrap. The texts are those that // age_vectors_test.dart checks against Go. 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/base64.dart'; import 'package:datekeys/src/chacha20poly1305.dart'; import 'package:datekeys/src/scrypt.dart'; import 'package:datekeys/src/sha256.dart'; import 'package:test/test.dart'; import 'age_support.dart'; Uint8List seeded(String label, [int n = 32]) { final out = Uint8List(n); for (var i = 0, c = 0; i < n; c++) { final d = sha256([...label.codeUnits, c]); for (var j = 0; j < 32 && i < n; j++) { out[i++] = d[j]; } } return out; } String? failure(Uint8List file, List ids) { try { ageDecrypt(file, ids); return null; } on AgeException catch (e) { return '${e.phase.name}: ${e.message}'; } on DateKeysException catch (e) { return 'identity: ${e.message}'; } } const stream = 'failed to decrypt and authenticate payload chunk, file may be ' 'corrupted or tampered with'; void main() { final id = X25519Identity(seeded('identity')); final fileKey = seeded('file key', 16); final nonce = seeded('nonce', 16); Uint8List fileOf(Uint8List plaintext) => ageFile( [x25519Stanza(fileKey, id.recipient, seeded('ephemeral'))], fileKey, nonce, plaintext, ); test('files across the chunk boundaries read back', () { for (final n in [0, 1, 65535, 65536, 65537, 131072, 131073]) { final p = pattern(n); final file = fileOf(p); expect(ageDecrypt(file, [id]), p, reason: '$n bytes'); final h = parseAgeHeader(file); expect( file.length, h.length + 16 + n + 16 * (n == 0 ? 1 : (n + 65535) ~/ 65536), ); } }); test('the end-of-file cases of the STREAM', () { final one = fileOf(pattern(65536)); final h = parseAgeHeader(one).length + 16; expect( failure(Uint8List.sublistView(one, 0, h), [id]), 'payload: unexpected EOF', ); expect( failure( concatBytes([ one, [0], ]), [id], ), 'payload: trailing data after end of encrypted file', ); expect( failure(Uint8List.sublistView(one, 0, one.length - 1), [id]), 'payload: $stream', ); final two = fileOf(pattern(65537)); expect( failure(Uint8List.sublistView(two, 0, h + 65552), [id]), 'payload: unexpected EOF', ); expect( failure(Uint8List.sublistView(two, 0, h + 65552 + 16), [id]), 'payload: last chunk is empty, try age v1.0.0, and please consider reporting this', ); expect( failure(Uint8List.sublistView(two, 0, h - 1), [id]), 'header: failed to read nonce: unexpected EOF', ); expect( failure(Uint8List.sublistView(two, 0, h - 16), [id]), 'header: failed to read nonce: EOF', ); final bad = Uint8List.fromList(two)..[h + 100] ^= 1; expect(failure(bad, [id]), 'payload: $stream'); }); test('pieces of every size give the same plaintext', () { final p = pattern(140000); final file = fileOf(p); for (final step in [1000, 65552, 65553, 70000]) { final opened = ageOpen(file, [id]); final out = BytesBuilder(); for (var i = opened.payloadOffset; i < file.length; i += step) { final end = i + step < file.length ? i + step : file.length; opened.payload.add(file, i, end).forEach(out.add); } out.add(opened.payload.close()); expect(out.takeBytes(), p); } }); test('a delivered chunk, then the trailing data on the next call', () { final file = concatBytes([ fileOf(pattern(65536)), [1, 2], ]); final opened = ageOpen(file, [id]); final chunks = opened.payload.add(file, opened.payloadOffset); expect(chunks.single, pattern(65536)); expect( opened.payload.close, throwsA( isA().having( (e) => e.message, 'message', 'trailing data after end of encrypted file', ), ), ); }); test('the header: identities, the MAC and the parser', () { final file = fileOf(pattern(10)); final other = X25519Identity(seeded('other')); expect( failure(file, [other]), 'header: identity did not match any of the recipients: incorrect identity for recipient block', ); expect( failure(file, [other, other]), 'header: no identity matched any of the recipients', ); expect(failure(file, []), 'header: no identities specified'); expect(ageDecrypt(file, [other, id]), pattern(10)); final mac = Uint8List.fromList(file) ..[parseAgeHeader(file).length - 5] ^= 1; expect( failure(mac, [id]), anyOf( 'header: bad header MAC', startsWith('header: failed to read header'), ), ); expect( failure(Uint8List(0), [id]), 'header: failed to read header: parsing age header: file is empty', ); expect( failure(Uint8List.fromList('age-encryption.org/v1\n---'.codeUnits), [id]), 'header: failed to read header: failed to read header: EOF', ); }); test('the passphrase identity', () { final key = seeded('scrypt file key', 16); final salt = seeded('salt', 16); AgeStanza stanza(String pass, String logN) { final k = scrypt( pass.codeUnits, concatBytes(['age-encryption.org/v1/scrypt'.codeUnits, salt]), 1 << int.parse(logN), 8, 1, 32, ); return AgeStanza('scrypt', [ goBase64Encode(salt, padded: false), logN, ], chacha20Poly1305Seal(k, Uint8List(12), key)); } final file = ageFile([stanza('pass', '2')], key, nonce, pattern(20)); expect(ageDecrypt(file, [ScryptIdentity('pass')]), pattern(20)); expect( failure(file, [ScryptIdentity('wrong')]), 'header: identity did not match any of the recipients: incorrect identity for recipient block: incorrect passphrase', ); final big = ageFile([stanza('pass', '3')], key, nonce, pattern(20)); expect( failure(big, [ScryptIdentity('pass', maxWorkFactor: 2)]), 'header: scrypt work factor too large: 3', ); expect(() => ScryptIdentity(''), throwsA(isA())); expect(() => ScryptIdentity('p', maxWorkFactor: 31), throwsArgumentError); expect(defaultMaxScryptWorkFactor, 16); }); test('agewrap on files written here', () { final payload = fileOf(pattern(3)); expect(ageDecrypt(payload, [PayloadIdentity(id.secretKey)]), pattern(3)); expect( failure(payload, [PayloadIdentity(seeded('stranger'))]), "identity: agewrap: PAYLOAD_AGE is not encrypted to this control's R_PAYLOAD: ERR_INTEGRITY", ); expect( failure(payload, [ AccessIdentity(accessSlots, [id]), ]), 'identity: agewrap: INNER_ACCESS_AGE has 1 stanzas, want exactly 16: ERR_POLICY_STRUCTURE_MISMATCH', ); expect( ageDecrypt(payload, [ AccessIdentity(0, [id]), ]), pattern(3), ); expect(ageStanzas(payload).single.type, stanzaX25519); expect( () => ageStanzas(Uint8List(0)), throwsA( isA().having( (e) => e.code, 'code', ErrorCode.integrity, ), ), ); expect( () => x25519IdentityFromRaw(Uint8List(31)), throwsA( isA().having( (e) => e.message, 'message', 'agewrap: X25519 identity is 31 bytes, want 32: ERR_INTEGRITY', ), ), ); }); test('an identity written in Bech32 and back', () { final s = id.toString(); expect(s, startsWith('AGE-SECRET-KEY-1')); expect(X25519Identity.parse(s).secretKey, id.secretKey); expect( () => X25519Identity.parse(s.toLowerCase()), throwsA( isA().having( (e) => e.message, 'message', 'malformed secret key: unknown type "age-secret-key-"', ), ), ); expect(id.recipientString, startsWith('age1')); expect( () => X25519Identity.parse(id.recipientString), throwsA( isA().having( (e) => e.message, 'message', 'malformed secret key: unknown type "age"', ), ), ); }); }