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.
283 lines
8.9 KiB
283 lines
8.9 KiB
// 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<String, Object?> vectors = jsonDecode(
|
|
File('test/vectors/age.json').readAsStringSync(),
|
|
) as Map<String, Object?>;
|
|
|
|
List<Map<String, Object?>> section(String name) =>
|
|
(vectors[name]! as List).cast<Map<String, Object?>>();
|
|
|
|
Map<String, Object?> asMap(Object? v) => v! as Map<String, Object?>;
|
|
|
|
List<AgeStanza> stanzasOf(Object? list) => [
|
|
for (final s in (list! as List).cast<Map<String, Object?>>())
|
|
AgeStanza(
|
|
s['type']! as String,
|
|
(s['args']! as List).cast<String>(),
|
|
Uint8List(0),
|
|
),
|
|
];
|
|
|
|
Map<String, Object?> 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 = <String, Uint8List>{
|
|
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<String, Object?>?;
|
|
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<Map<String, Object?>>())
|
|
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<String, Object?>;
|
|
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<Map<String, Object?>>()) {
|
|
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<String>())
|
|
fromHex(r),
|
|
];
|
|
final file = fromHex(c['file']! as String);
|
|
Map<String, Object?> 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);
|
|
}
|
|
}
|
|
});
|
|
}
|