age_writer.dart ports age.Encrypt of filippo.io/age v1.3.2, with its checks and texts. AgeEncryptor and ageEncrypt draw the file key, wrap it for each recipient in its order, with its labels, compute the header MAC and draw the nonce: no recipients, labels that cannot be mixed, a recipient that fails and stanzas that cannot be marshalled give Go's errors. AgePayloadEncryptor encrypts the STREAM as its plaintext arrives, as Go's EncryptWriter: a full chunk waits for the next byte, so the last one is full-length for a non-zero multiple of 64 KiB and empty only for an empty plaintext. The lengths of a file follow from its plaintext and the form of its stanzas, before anything is encrypted. recipient.dart has X25519Recipient, with its age1 strings and the texts of ParseX25519Recipient; ScryptRecipient, with its random label; TimeRecipient, with the label datekeys-tlock- of agewrap; checkX25519Recipient, with the texts of agewrap.CheckX25519Recipient; generateX25519Identity and the raw keys of agewrap. The tests write every file of age_writer.json again with the same seed, whole and in pieces, and get the same draws and bytes; open each with the readers of this library; and check the errors, the recipients, the STREAM and the lengths, on the VM and, without the expensive cases, on Node.js. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>v0.11
parent
0ff4bb937c
commit
d2ba08ef8b
@ -0,0 +1,370 @@
|
||||
/// Writing age v1 files, ported from filippo.io/age v1.3.2: age.Encrypt,
|
||||
/// with its checks and its texts, the header and its MAC as internal/format
|
||||
/// marshals them, and the STREAM of the EncryptWriter of internal/stream.
|
||||
/// DateKeys writes three age files (spec §28, §62): PAYLOAD_AGE, for
|
||||
/// R_PAYLOAD; INNER_ACCESS_AGE, for the 16 slots; and OUTER_TIME_AGE, for
|
||||
/// the tlock recipient alone. The recipients are in recipient.dart.
|
||||
///
|
||||
/// As in Go, [AgeEncryptor] draws, in this order: the file key, then what
|
||||
/// each recipient draws to wrap it, in the order of the recipients, then the
|
||||
/// nonce of the payload. Every value comes from the [RandomSource] it is
|
||||
/// given, [secureRandom] by default: with the same values, it writes the
|
||||
/// bytes that Go writes.
|
||||
///
|
||||
/// The STREAM is written as its plaintext arrives ([AgePayloadEncryptor]),
|
||||
/// in chunks of 64 KiB, as Go's EncryptWriter: a full chunk is encrypted
|
||||
/// only once a later byte shows that it is not the last one, so the last
|
||||
/// chunk is full-length when the plaintext is a non-zero multiple of 64 KiB,
|
||||
/// and empty only when the plaintext is.
|
||||
///
|
||||
/// The lengths of a file follow from the length of its plaintext and the
|
||||
/// form of its stanzas, not from their random values ([ageFileLength]):
|
||||
/// the writer of a capsule needs SEALED_CONTROL_LEN before it seals
|
||||
/// anything (spec §62.1 rule 7).
|
||||
///
|
||||
/// The errors of age.Encrypt are [AgeException]s with Go's texts; a
|
||||
/// [DateKeysException] of a recipient keeps its code, with the prefix that
|
||||
/// age puts before it.
|
||||
///
|
||||
/// Internal: lib/datekeys.dart does not export it.
|
||||
library;
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'age.dart';
|
||||
import 'base64.dart';
|
||||
import 'bytes.dart';
|
||||
import 'chacha20poly1305.dart';
|
||||
import 'errors.dart';
|
||||
import 'random.dart';
|
||||
import 'sha256.dart';
|
||||
|
||||
/// What a recipient gives to age for a file key: its stanzas, and the
|
||||
/// labels of age's RecipientWithLabels, none for a recipient without them.
|
||||
typedef AgeWrap = ({List<AgeStanza> stanzas, List<String> labels});
|
||||
|
||||
/// An age recipient: Go's age.Recipient, with the labels of
|
||||
/// age.RecipientWithLabels. age writes the stanzas of the recipients in
|
||||
/// their order, and refuses to mix recipients whose labels differ.
|
||||
abstract interface class AgeRecipient {
|
||||
/// Wraps [fileKey], 16 bytes that it must not keep, drawing every random
|
||||
/// value from [random]. Throws an [AgeException] with the text of Go's
|
||||
/// error, or a [DateKeysException] for an error with a normative code.
|
||||
AgeWrap wrap(Uint8List fileKey, RandomSource random);
|
||||
}
|
||||
|
||||
const _streamNonceSize = 16;
|
||||
const _introLength = 22; // age-encryption.org/v1 and LF
|
||||
// "---", a space, the 43 characters of the MAC and LF.
|
||||
const _footerLength = 3 + 1 + 43 + 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lengths
|
||||
|
||||
/// The length of the STREAM of [plaintextLength] bytes: the plaintext and a
|
||||
/// 16-byte tag for each chunk of 64 KiB, at least one.
|
||||
int ageStreamLength(int plaintextLength) {
|
||||
RangeError.checkNotNegative(plaintextLength, 'plaintextLength');
|
||||
final chunks = plaintextLength == 0
|
||||
? 1
|
||||
: (plaintextLength + ageChunkSize - 1) ~/ ageChunkSize;
|
||||
return plaintextLength + poly1305TagSize * chunks;
|
||||
}
|
||||
|
||||
/// The length of a stanza of [type] and [args] whose body is [bodyLength]
|
||||
/// bytes, as age marshals it: `-> `, the type and each argument after a
|
||||
/// space, LF, then the body in unpadded Base64, in lines of 64 columns, the
|
||||
/// last one shorter, maybe empty, each with its LF.
|
||||
int ageStanzaLength(String type, List<String> args, int bodyLength) {
|
||||
RangeError.checkNotNegative(bodyLength, 'bodyLength');
|
||||
var n = 3 + utf8Bytes(type).length + 1;
|
||||
for (final a in args) {
|
||||
n += 1 + utf8Bytes(a).length;
|
||||
}
|
||||
final columns = (4 * bodyLength + 2) ~/ 3;
|
||||
return n + columns + columns ~/ 64 + 1;
|
||||
}
|
||||
|
||||
/// The length of an age header whose stanzas measure [stanzaLengths]: the
|
||||
/// intro line, the stanzas and the line of the MAC.
|
||||
int ageHeaderLength(Iterable<int> stanzaLengths) {
|
||||
var n = _introLength + _footerLength;
|
||||
for (final s in stanzaLengths) {
|
||||
n += s;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/// The length of an age file whose stanzas measure [stanzaLengths], with
|
||||
/// [plaintextLength] bytes of plaintext: the header, the 16-byte nonce and
|
||||
/// the STREAM. For one X25519 stanza it is the 184 + P + 16·c(P) of spec
|
||||
/// §62.1.
|
||||
int ageFileLength(Iterable<int> stanzaLengths, int plaintextLength) =>
|
||||
ageHeaderLength(stanzaLengths) +
|
||||
_streamNonceSize +
|
||||
ageStreamLength(plaintextLength);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The header
|
||||
|
||||
/// The whole header of [stanzas] with [mac], as age's Header.Marshal: the
|
||||
/// header without its MAC, a space, the MAC in unpadded Base64 and LF. It
|
||||
/// throws an [ArgumentError] as [marshalAgeHeaderWithoutMac].
|
||||
Uint8List marshalAgeHeader(List<AgeStanza> stanzas, List<int> mac) =>
|
||||
concatBytes([
|
||||
marshalAgeHeaderWithoutMac(stanzas),
|
||||
' '.codeUnits,
|
||||
goBase64Encode(mac, padded: false).codeUnits,
|
||||
'\n'.codeUnits,
|
||||
]);
|
||||
|
||||
// Go's %q of a []string: the quoted strings between brackets, one space
|
||||
// apart.
|
||||
String _quoteList(List<String> l) =>
|
||||
'[${l.map((s) => goQuote(utf8Bytes(s))).join(' ')}]';
|
||||
|
||||
// age's incompatibleLabelsError.
|
||||
String _incompatible(List<String> l1, List<String> l2) {
|
||||
if (l1.contains('postquantum') != l2.contains('postquantum')) {
|
||||
return "incompatible recipients: can't mix post-quantum and classic "
|
||||
'recipients, or the file would be vulnerable to quantum computers';
|
||||
}
|
||||
return 'incompatible recipients: ${_quoteList(l1)} and ${_quoteList(l2)} '
|
||||
"can't be mixed";
|
||||
}
|
||||
|
||||
bool _sameLabels(List<String> a, List<String> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Go's sort.Strings: the byte order of the UTF-8.
|
||||
List<String> _sorted(List<String> labels) =>
|
||||
[...labels]..sort((a, b) => compareBytes(utf8Bytes(a), utf8Bytes(b)));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encryption
|
||||
|
||||
/// An age file being written, as age.Encrypt writes it: the header with its
|
||||
/// MAC and the nonce, known once it is created, then the STREAM of the
|
||||
/// plaintext given to [add], whose last chunk [close] returns. The bytes of
|
||||
/// the file are [header], [nonce], each chunk that [add] returns and the
|
||||
/// one of [close], in that order.
|
||||
final class AgeEncryptor {
|
||||
AgeEncryptor._(this.stanzas, this.header, this.nonce, this._payload);
|
||||
|
||||
/// Starts an age file for [recipients], drawing every random value from
|
||||
/// [random], as age.Encrypt: the file key, then the wrap of each
|
||||
/// recipient, in their order, whose labels must be those of the first
|
||||
/// one, then the header MAC and the nonce. Throws an [AgeException] with
|
||||
/// Go's text:
|
||||
/// - `no recipients specified`;
|
||||
/// - `failed to wrap key for recipient #i: ` and the error of recipient i,
|
||||
/// from 0; a [DateKeysException] of a recipient keeps its code;
|
||||
/// - `incompatible recipients: ` and the labels of the first recipient and
|
||||
/// of recipient i, when they differ;
|
||||
/// - `failed to compute header MAC: ` and the reason, for stanzas that
|
||||
/// cannot be marshalled: none at all, or a type or an argument that is
|
||||
/// not 1 or more bytes in 0x21..0x7e.
|
||||
factory AgeEncryptor(
|
||||
List<AgeRecipient> recipients, {
|
||||
RandomSource random = secureRandom,
|
||||
}) {
|
||||
// Go draws the file key before it looks at the recipients.
|
||||
final fileKey = randomBytes(random, ageFileKeySize);
|
||||
try {
|
||||
if (recipients.isEmpty) {
|
||||
throw const AgeException('no recipients specified');
|
||||
}
|
||||
final stanzas = <AgeStanza>[];
|
||||
List<String>? first;
|
||||
for (var i = 0; i < recipients.length; i++) {
|
||||
final AgeWrap w;
|
||||
try {
|
||||
w = recipients[i].wrap(fileKey, random);
|
||||
} on AgeException catch (e) {
|
||||
throw AgeException(
|
||||
'failed to wrap key for recipient #$i: ${e.message}',
|
||||
);
|
||||
} on DateKeysException catch (e) {
|
||||
throw e.wrap('failed to wrap key for recipient #$i');
|
||||
}
|
||||
final labels = _sorted(w.labels);
|
||||
if (first == null) {
|
||||
first = labels;
|
||||
} else if (!_sameLabels(first, labels)) {
|
||||
throw AgeException(_incompatible(first, labels));
|
||||
}
|
||||
stanzas.addAll(w.stanzas);
|
||||
}
|
||||
final Uint8List header;
|
||||
try {
|
||||
final mac = ageHeaderMac(fileKey, stanzas);
|
||||
header = marshalAgeHeader(stanzas, mac);
|
||||
} on ArgumentError catch (e) {
|
||||
throw AgeException('failed to compute header MAC: ${e.message}');
|
||||
}
|
||||
final nonce = randomBytes(random, _streamNonceSize);
|
||||
final key = hkdfSha256(fileKey, nonce, 'payload'.codeUnits, 32);
|
||||
return AgeEncryptor._(
|
||||
List.unmodifiable(stanzas),
|
||||
header,
|
||||
nonce,
|
||||
AgePayloadEncryptor(key),
|
||||
);
|
||||
} finally {
|
||||
fileKey.fillRange(0, fileKey.length, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The recipient stanzas of the header, in its order.
|
||||
final List<AgeStanza> stanzas;
|
||||
|
||||
/// The header, from the intro line to the line of the MAC included.
|
||||
final Uint8List header;
|
||||
|
||||
/// The 16-byte nonce of the payload, after the header.
|
||||
final Uint8List nonce;
|
||||
|
||||
final AgePayloadEncryptor _payload;
|
||||
|
||||
/// The offset of the first chunk of the STREAM: the header and the nonce.
|
||||
int get payloadOffset => header.length + nonce.length;
|
||||
|
||||
/// Encrypts the next bytes of the plaintext, [data] from [start] to
|
||||
/// [end], and returns each chunk of the STREAM they complete: see
|
||||
/// [AgePayloadEncryptor.add].
|
||||
List<Uint8List> add(List<int> data, [int start = 0, int? end]) =>
|
||||
_payload.add(data, start, end);
|
||||
|
||||
/// The end of the plaintext: returns the last chunk of the STREAM. See
|
||||
/// [AgePayloadEncryptor.close].
|
||||
Uint8List close() => _payload.close();
|
||||
|
||||
/// Abandons the file: wipes the key and the plaintext held.
|
||||
void wipe() => _payload.wipe();
|
||||
}
|
||||
|
||||
/// The age file of [plaintext] for [recipients], whole, as age.Encrypt
|
||||
/// followed by one write and Close: [AgeEncryptor] with all of it at once.
|
||||
Uint8List ageEncrypt(
|
||||
List<int> plaintext,
|
||||
List<AgeRecipient> recipients, {
|
||||
RandomSource random = secureRandom,
|
||||
}) {
|
||||
final e = AgeEncryptor(recipients, random: random);
|
||||
final out = BytesBuilder(copy: false)
|
||||
..add(e.header)
|
||||
..add(e.nonce);
|
||||
e.add(plaintext).forEach(out.add);
|
||||
out.add(e.close());
|
||||
return out.takeBytes();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// STREAM: internal/stream
|
||||
|
||||
enum _State { open, closed, abandoned }
|
||||
|
||||
/// The STREAM of an age payload, encrypted as its plaintext arrives, as
|
||||
/// Go's EncryptWriter of internal/stream: chunks of 64 KiB of plaintext and
|
||||
/// a 16-byte tag, the nonce an 11-byte big-endian counter and a last-chunk
|
||||
/// flag. A full chunk is encrypted only when a later byte arrives, so that
|
||||
/// [close] knows the last chunk: it is full-length when the plaintext is a
|
||||
/// non-zero multiple of 64 KiB, and empty only for an empty plaintext.
|
||||
final class AgePayloadEncryptor {
|
||||
/// The encryptor of the STREAM with [streamKey], 32 bytes, which it owns
|
||||
/// and wipes.
|
||||
AgePayloadEncryptor(Uint8List streamKey) : _key = streamKey {
|
||||
if (streamKey.length != chachaKeySize) {
|
||||
throw ArgumentError.value(streamKey.length, 'streamKey', 'not 32 bytes');
|
||||
}
|
||||
}
|
||||
|
||||
final Uint8List _key;
|
||||
final Uint8List _nonce = Uint8List(chachaNonceSize);
|
||||
final Uint8List _buf = Uint8List(ageChunkSize);
|
||||
int _n = 0;
|
||||
_State _state = _State.open;
|
||||
|
||||
void _checkOpen() {
|
||||
switch (_state) {
|
||||
case _State.open:
|
||||
return;
|
||||
case _State.closed:
|
||||
// The error of Go's EncryptWriter once closed.
|
||||
throw StateError('stream.Writer is already closed');
|
||||
case _State.abandoned:
|
||||
throw StateError('the encryption of the payload was abandoned');
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypts the next bytes of the plaintext, [data] from [start] to
|
||||
/// [end], and returns each chunk they complete, ciphertext and tag, that
|
||||
/// is known not to be the last one. Throws a [StateError] once closed.
|
||||
List<Uint8List> add(List<int> data, [int start = 0, int? end]) {
|
||||
final stop = end ?? data.length;
|
||||
RangeError.checkValidRange(start, stop, data.length);
|
||||
_checkOpen();
|
||||
final out = <Uint8List>[];
|
||||
var i = start;
|
||||
while (i < stop) {
|
||||
final n = ageChunkSize - _n < stop - i ? ageChunkSize - _n : stop - i;
|
||||
_buf.setRange(_n, _n + n, data, i);
|
||||
_n += n;
|
||||
i += n;
|
||||
// Go flushes a full chunk only when bytes are still to write: until
|
||||
// then it may be the last one.
|
||||
if (_n == ageChunkSize && i < stop) out.add(_flush(last: false));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// The end of the plaintext: returns the last chunk, flagged as such, and
|
||||
/// wipes the key. Throws a [StateError] once closed.
|
||||
Uint8List close() {
|
||||
_checkOpen();
|
||||
final c = _flush(last: true);
|
||||
_state = _State.closed;
|
||||
_wipeKey();
|
||||
return c;
|
||||
}
|
||||
|
||||
/// Abandons the encryption: wipes the key and the plaintext held. Every
|
||||
/// later call throws. It does nothing once closed.
|
||||
void wipe() {
|
||||
if (_state == _State.open) _state = _State.abandoned;
|
||||
_wipeKey();
|
||||
}
|
||||
|
||||
void _wipeKey() {
|
||||
_key.fillRange(0, _key.length, 0);
|
||||
_buf.fillRange(0, _buf.length, 0);
|
||||
_n = 0;
|
||||
}
|
||||
|
||||
// Go's flushChunk: the _n bytes of _buf as one chunk.
|
||||
Uint8List _flush({required bool last}) {
|
||||
if (last) _nonce[chachaNonceSize - 1] = 1;
|
||||
final c = chacha20Poly1305Seal(
|
||||
_key,
|
||||
_nonce,
|
||||
Uint8List.sublistView(_buf, 0, _n),
|
||||
);
|
||||
_incNonce();
|
||||
_n = 0;
|
||||
return c;
|
||||
}
|
||||
|
||||
// The 11-byte big-endian counter of the nonce, plus one.
|
||||
void _incNonce() {
|
||||
for (var i = chachaNonceSize - 2; i >= 0; i--) {
|
||||
_nonce[i] = (_nonce[i] + 1) & 0xff;
|
||||
if (_nonce[i] != 0) return;
|
||||
}
|
||||
throw StateError('stream: chunk counter wrapped around');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,113 @@
|
||||
// Helpers of the tests of the age writer: the recipients and identities of
|
||||
// the cases of test/vectors/age_writer.json and age_interop.json, the
|
||||
// identity of OUTER_TIME_AGE, which the reader keeps private, a file written
|
||||
// in pieces and the lengths of the stanzas of a header. They read no file.
|
||||
|
||||
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/bytes.dart' show utf8Bytes;
|
||||
import 'package:datekeys/src/random.dart';
|
||||
import 'package:datekeys/src/recipient.dart';
|
||||
import 'package:datekeys/src/sha256.dart';
|
||||
import 'package:datekeys/src/tlock.dart';
|
||||
|
||||
import 'random_support.dart';
|
||||
|
||||
export 'age_support.dart' show pattern;
|
||||
export 'random_support.dart';
|
||||
|
||||
/// The published Quicknet signatures of rounds 1000 and 1001, those of the
|
||||
/// fixtures.
|
||||
const releases = {
|
||||
1000: 'b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39',
|
||||
1001: 'b33bf3667cbd5a82de3a24b4e0e9fe5513cc1a0e840368c6e31f5fcfa79bea03f73896b25883abf2853d10337fb8fa41',
|
||||
};
|
||||
|
||||
/// The X25519 identity of a label: its raw secret is SHA-256("identity " +
|
||||
/// label), as in the generators.
|
||||
X25519Identity labelIdentity(String label) =>
|
||||
X25519Identity(sha256(utf8Bytes('identity $label')));
|
||||
|
||||
/// The recipient of a case: `{x25519: age1…}`, `{scrypt: passphrase,
|
||||
/// work_factor: n}` or `{tlock: round}`, for Quicknet.
|
||||
AgeRecipient recipientOf(Json spec) {
|
||||
final x = spec['x25519'] as String?;
|
||||
if (x != null) return X25519Recipient.parse(x);
|
||||
final pass = spec['scrypt'] as String?;
|
||||
if (pass != null) {
|
||||
return ScryptRecipient(pass, workFactor: spec['work_factor']! as int);
|
||||
}
|
||||
return TimeRecipient(quicknet(), spec['tlock']! as int);
|
||||
}
|
||||
|
||||
/// The identity that opens what [spec] wraps, when the case knows it: the
|
||||
/// X25519 identity it names, the passphrase with its work factor as the
|
||||
/// maximum, or the tlock identity of a round whose release is known.
|
||||
AgeIdentity? identityOf(Json spec) {
|
||||
final id = spec['identity'] as String?;
|
||||
if (id != null) return X25519Identity.parse(id);
|
||||
final pass = spec['scrypt'] as String?;
|
||||
if (pass != null) {
|
||||
return ScryptIdentity(pass, maxWorkFactor: spec['work_factor']! as int);
|
||||
}
|
||||
final round = spec['tlock'] as int?;
|
||||
final sig = releases[round];
|
||||
if (round == null || sig == null) return null;
|
||||
return TimeIdentity(quicknet(), round, Release(round, fromHex(sig)));
|
||||
}
|
||||
|
||||
/// The identity of OUTER_TIME_AGE, as Go's agewrap.TimeIdentity: the
|
||||
/// stanza rule of the file, then the tlock stanza opened with the release.
|
||||
/// open.dart has its own, private.
|
||||
final class TimeIdentity implements AgeIdentity {
|
||||
TimeIdentity(this._p, this._round, this._release);
|
||||
|
||||
final Profile _p;
|
||||
final int _round;
|
||||
final Release _release;
|
||||
|
||||
@override
|
||||
Uint8List unwrap(List<AgeStanza> stanzas) {
|
||||
checkTimeStanzas(
|
||||
stanzas,
|
||||
round: _round,
|
||||
chainHashHex: _p.chainHashHex,
|
||||
profileId: _p.id,
|
||||
);
|
||||
final s = stanzas.single;
|
||||
return unwrapTlockStanza(_p, _round, _release, s.args, s.body);
|
||||
}
|
||||
}
|
||||
|
||||
/// The file that [AgeEncryptor] writes when the plaintext arrives in pieces
|
||||
/// of the sizes that [step] gives, in turn.
|
||||
Uint8List encryptInPieces(
|
||||
Uint8List plaintext,
|
||||
List<AgeRecipient> recipients,
|
||||
RandomSource random,
|
||||
List<int> steps,
|
||||
) {
|
||||
final e = AgeEncryptor(recipients, random: random);
|
||||
final out = BytesBuilder(copy: false)
|
||||
..add(e.header)
|
||||
..add(e.nonce);
|
||||
var k = 0;
|
||||
for (var i = 0; i < plaintext.length;) {
|
||||
final n = steps[k++ % steps.length];
|
||||
final end = i + n < plaintext.length ? i + n : plaintext.length;
|
||||
e.add(plaintext, i, end).forEach(out.add);
|
||||
i = end;
|
||||
}
|
||||
out.add(e.close());
|
||||
return out.takeBytes();
|
||||
}
|
||||
|
||||
/// The marshalled length of each stanza of [header], an age header.
|
||||
List<int> stanzaLengths(Uint8List header) => [
|
||||
for (final s in parseAgeHeader(header).stanzas)
|
||||
marshalAgeHeaderWithoutMac([s]).length - 22 - 3,
|
||||
];
|
||||
@ -0,0 +1,607 @@
|
||||
// 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/random.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<ArgumentError>().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<StateError>().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<StateError>().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<ArgumentError>().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<Uint8List> chunksOf(int n, List<int> steps) {
|
||||
final e = AgePayloadEncryptor(Uint8List.fromList(key));
|
||||
final out = <Uint8List>[];
|
||||
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 = <String>{};
|
||||
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<DateKeysException>()
|
||||
.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<AgeStanza> stanzas;
|
||||
final List<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
// The vectors of the age writer against their file: the Dart constant that
|
||||
// the tests compiled to JavaScript read is test/vectors/age_writer.json
|
||||
// byte for byte, as tool/age_writer_go_vectors.go writes both.
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'vectors/age_writer.g.dart';
|
||||
|
||||
void main() {
|
||||
test('age_writer.g.dart holds age_writer.json', () {
|
||||
final file = File('test/vectors/age_writer.json').readAsStringSync();
|
||||
expect(ageWriterJson, file);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue