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.
284 lines
8.6 KiB
284 lines
8.6 KiB
// 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<AgeIdentity> 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<AgeException>().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<AgeException>()));
|
|
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<DateKeysException>().having(
|
|
(e) => e.code,
|
|
'code',
|
|
ErrorCode.integrity,
|
|
),
|
|
),
|
|
);
|
|
expect(
|
|
() => x25519IdentityFromRaw(Uint8List(31)),
|
|
throwsA(
|
|
isA<DateKeysException>().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<AgeException>().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<AgeException>().having(
|
|
(e) => e.message,
|
|
'message',
|
|
'malformed secret key: unknown type "age"',
|
|
),
|
|
),
|
|
);
|
|
});
|
|
}
|