Stage 6a: age encryption, its STREAM and its recipients

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
dev 2 days ago
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,383 @@
/// The recipients that DateKeys writes age files for, with their checks and
/// texts:
/// - X25519, age's X25519Recipient: R_PAYLOAD of PAYLOAD_AGE and the 16
/// slots of INNER_ACCESS_AGE (spec §29, §37, §39), with its `age1…`
/// strings, and the rules that a writer applies to it, agewrap's
/// CheckX25519Recipient (spec §37, §62.1 rule 3);
/// - scrypt, age's ScryptRecipient: the file of an author key (spec §29.12),
/// with a random label, so that age never mixes it with another
/// recipient;
/// - tlock, agewrap's TimeRecipient: OUTER_TIME_AGE (spec §32, §35), with
/// the label `datekeys-tlock-` and 16 random bytes in hex, so that the
/// file holds its tlock stanza alone.
///
/// And the generation of X25519 identities, age's GenerateX25519Identity,
/// and the raw keys of agewrap. Every random value comes from the
/// [RandomSource] of the writer, in the order of Go: the ephemeral secret of
/// an X25519 stanza; the salt, then the label, of a scrypt stanza; sigma,
/// then the label, of the tlock stanza.
///
/// Internal: lib/datekeys.dart does not export it.
library;
import 'dart:typed_data';
import 'age.dart';
import 'age_writer.dart';
import 'agewrap.dart' show stanzaTlock, stanzaX25519;
import 'base64.dart';
import 'bech32.dart';
import 'bytes.dart';
import 'chacha20poly1305.dart';
import 'curve25519.dart';
import 'errors.dart';
import 'ibe.dart';
import 'random.dart';
import 'release.dart';
import 'scrypt.dart';
import 'sha256.dart';
import 'tlock.dart';
const _x25519Label = 'age-encryption.org/v1/X25519';
const _scryptLabel = 'age-encryption.org/v1/scrypt';
const _scryptSaltSize = 16;
const _labelSize = 16;
String _q(String s) => goQuote(utf8Bytes(s));
// age's aeadEncrypt: ChaCha20-Poly1305 with a zero nonce, for a key used
// once.
Uint8List _aeadEncrypt(Uint8List key, List<int> plaintext) =>
chacha20Poly1305Seal(key, Uint8List(chachaNonceSize), plaintext);
// ---------------------------------------------------------------------------
// Lengths
/// The length of an X25519 stanza: 98 bytes (spec §39, §62.1).
const x25519StanzaLength = 98;
/// The length of a scrypt stanza with the work factor [workFactor].
int scryptStanzaLength(int workFactor) => ageStanzaLength('scrypt', [
goBase64Encode(Uint8List(_scryptSaltSize), padded: false),
'$workFactor',
], ageFileKeySize + poly1305TagSize);
/// The length of the tlock stanza of [round] for a chain hash of 32 bytes
/// and a body of U, V and W, as Quicknet's: 249 bytes and the digits of the
/// round (spec §62.1).
int tlockStanzaLength(int round) =>
ageStanzaLength('tlock', ['$round', '0' * 64], tlockBodyLength);
// ---------------------------------------------------------------------------
// X25519
/// An X25519 recipient of age, a public key of 32 bytes: Go's
/// age.X25519Recipient. It takes any 32 bytes, as age does, and a key of
/// low order fails when it wraps a file key; the rules of spec §37 that a
/// writer applies to it are [checkX25519Recipient].
final class X25519Recipient implements AgeRecipient {
/// The recipient of the raw [publicKey], as age's
/// newX25519RecipientFromPoint.
X25519Recipient(List<int> publicKey) : _key = Uint8List.fromList(publicKey) {
if (publicKey.length != x25519Size) {
throw const AgeException('invalid X25519 public key');
}
}
/// The recipient written `age1…`, as age.ParseX25519Recipient, with its
/// texts: lowercase or uppercase Bech32 as a whole, whose HRP must be
/// `age`, which in practice requires lowercase.
factory X25519Recipient.parse(String s) {
final ({String hrp, Uint8List data}) d;
try {
d = bech32Decode(s);
} on Bech32Exception catch (e) {
throw AgeException('malformed recipient ${_q(s)}: ${e.message}');
}
if (d.hrp != 'age') {
throw AgeException(
'malformed recipient ${_q(s)}: invalid type ${_q(d.hrp)}',
);
}
if (d.data.length != x25519Size) {
throw AgeException(
'malformed recipient ${_q(s)}: invalid X25519 public key',
);
}
return X25519Recipient(d.data);
}
/// The recipient of [identity], as its Recipient method in Go.
factory X25519Recipient.of(X25519Identity identity) =>
X25519Recipient(identity.recipient);
final Uint8List _key;
/// A copy of the 32 bytes of the public key.
Uint8List get publicKey => Uint8List.fromList(_key);
/// The length of its stanza.
int get stanzaLength => x25519StanzaLength;
/// The recipient written `age1…`, as its String method in Go.
@override
String toString() => bech32Encode('age', _key);
/// age's X25519Recipient.Wrap: a fresh ephemeral secret e from [random],
/// the stanza `X25519` with the share X25519(e, 9) and the file key sealed
/// with HKDF-SHA256(X25519(e, key), share || key,
/// "age-encryption.org/v1/X25519"). No labels. A key of low order, whose
/// shared secret is zero, is refused with the text of Go's crypto/ecdh.
@override
AgeWrap wrap(Uint8List fileKey, RandomSource random) {
final ephemeral = randomBytes(random, x25519Size);
Uint8List? secret;
try {
final share = x25519PublicKey(ephemeral);
try {
secret = x25519Agree(ephemeral, _key);
} on X25519LowOrderException catch (e) {
throw AgeException(e.message);
}
final key = hkdfSha256(
secret,
concatBytes([share, _key]),
_x25519Label.codeUnits,
chachaKeySize,
);
try {
return (
stanzas: [
AgeStanza(stanzaX25519, [
goBase64Encode(share, padded: false),
], _aeadEncrypt(key, fileKey)),
],
labels: const <String>[],
);
} finally {
key.fillRange(0, key.length, 0);
}
} finally {
ephemeral.fillRange(0, ephemeral.length, 0);
secret?.fillRange(0, secret.length, 0);
}
}
}
/// A fresh X25519 identity of 32 bytes drawn from [random], as age's
/// GenerateX25519Identity.
X25519Identity generateX25519Identity([RandomSource random = secureRandom]) {
final secret = randomBytes(random, x25519Size);
try {
return X25519Identity(secret);
} finally {
secret.fillRange(0, secret.length, 0);
}
}
/// The 32 raw bytes of [identity], the canonical form in CONTROL_CBOR and in
/// a .dkk (spec §31, §38): Go's agewrap.RawX25519Identity. The caller wipes
/// them.
Uint8List rawX25519Identity(X25519Identity identity) => identity.secretKey;
/// The 32 raw bytes of [recipient]: Go's agewrap.RawX25519Recipient.
Uint8List rawX25519Recipient(X25519Recipient recipient) => recipient.publicKey;
// Any scalar: with the clamping of RFC 7748, X25519 of a point of low order
// is all zeros whatever the scalar. agewrap's lowOrderProbe.
final Uint8List _lowOrderProbe = Uint8List(x25519Size)..[0] = 1;
/// Rejects the X25519 recipients that a writer MUST NOT encrypt to (spec
/// §37, §62.1 rule 3), as agewrap.CheckX25519Recipient, with its texts: one
/// that is not canonical, with bit 255 set or with u ≥ p = 2^255 − 19, whose
/// stanza no identity opens, because age salts HKDF with the 32 bytes as
/// given; and one of low order, whose shared secret is zero, so that anyone
/// could open its stanza. Throws an [ArgumentError]: the recipient is an
/// error of the caller, and Go's error has no normative code.
void checkX25519Recipient(X25519Recipient recipient) {
final raw = recipient._key;
if (raw[31] & 0x80 != 0) {
throw ArgumentError(
'agewrap: recipient $recipient is not canonical: bit 255 is set',
);
}
var ones = true;
for (var i = 1; i < 31; i++) {
if (raw[i] != 0xff) ones = false;
}
if (raw[31] == 0x7f && raw[0] >= 0xed && ones) {
throw ArgumentError(
'agewrap: recipient $recipient is not canonical: u is not below '
'2^255 - 19',
);
}
try {
final s = x25519Agree(_lowOrderProbe, raw);
s.fillRange(0, s.length, 0);
} on X25519LowOrderException {
throw ArgumentError(
'agewrap: recipient $recipient is a point of low order: the shared '
'secret would be zero',
);
}
}
// ---------------------------------------------------------------------------
// scrypt
/// The work factor of age's ScryptRecipient when none is set: 18, about a
/// second on a modern machine, and 256 MiB of memory. The file of an author
/// key uses 16 (spec §29.12).
const defaultScryptWorkFactor = 18;
/// A passphrase recipient of age, for the scrypt stanza: Go's
/// age.ScryptRecipient. It must be the only recipient of its file: its
/// random label keeps age from mixing it with any other, another scrypt
/// recipient included. The passphrase is its UTF-8 bytes, as a Go string.
final class ScryptRecipient implements AgeRecipient {
/// The recipient of [passphrase] with the work factor [workFactor], logN,
/// from 1 to 30, as age.NewScryptRecipient and SetWorkFactor: an empty
/// passphrase is an [AgeException], and a work factor out of range an
/// [ArgumentError] with the text of Go's panic.
ScryptRecipient(String passphrase, {int workFactor = defaultScryptWorkFactor})
: this.bytes(utf8Bytes(passphrase), workFactor: workFactor);
/// The recipient of the bytes of a passphrase.
ScryptRecipient.bytes(
List<int> passphrase, {
this.workFactor = defaultScryptWorkFactor,
}) : _password = Uint8List.fromList(passphrase) {
if (passphrase.isEmpty) {
throw const AgeException("passphrase can't be empty");
}
if (workFactor > 30 || workFactor < 1) {
throw ArgumentError.value(
workFactor,
'workFactor',
'age: SetWorkFactor called with illegal value',
);
}
}
final Uint8List _password;
/// The work factor, logN.
final int workFactor;
/// The length of its stanza.
int get stanzaLength => scryptStanzaLength(workFactor);
/// Clears the passphrase; the recipient cannot be used afterwards.
void wipe() => _password.fillRange(0, _password.length, 0);
/// age's ScryptRecipient.WrapWithLabels: a fresh 16-byte salt from
/// [random], the stanza `scrypt` with the salt and the work factor, and
/// the file key sealed with scrypt(passphrase,
/// "age-encryption.org/v1/scrypt" || salt, 2^logN, 8, 1); then a label of
/// 16 more bytes from [random], in hex.
@override
AgeWrap wrap(Uint8List fileKey, RandomSource random) {
final salt = randomBytes(random, _scryptSaltSize);
final k = scrypt(
_password,
concatBytes([_scryptLabel.codeUnits, salt]),
1 << workFactor,
8,
1,
chachaKeySize,
);
final AgeStanza stanza;
try {
stanza = AgeStanza('scrypt', [
goBase64Encode(salt, padded: false),
'$workFactor',
], _aeadEncrypt(k, fileKey));
} finally {
k.fillRange(0, k.length, 0);
}
final label = randomBytes(random, _labelSize);
return (stanzas: [stanza], labels: [toHex(label)]);
}
}
// ---------------------------------------------------------------------------
// tlock
// The values of a pinned profile that the tlock recipient reads, copied
// when it is created.
final class _Pinned implements PinnedProfile {
_Pinned(PinnedProfile p)
: id = p.id,
scheme = p.scheme,
_publicKey = Uint8List.fromList(p.publicKey),
_chainHash = Uint8List.fromList(p.chainHash),
maxRound = p.maxRound;
@override
final String id;
@override
final String scheme;
final Uint8List _publicKey;
final Uint8List _chainHash;
@override
Uint8List get publicKey => Uint8List.fromList(_publicKey);
@override
Uint8List get chainHash => Uint8List.fromList(_chainHash);
@override
final int maxRound;
}
/// The tlock recipient of OUTER_TIME_AGE (spec §32, §35): Go's
/// agewrap.TimeRecipient, which wraps the file key with the IBE of tlock for
/// one round of a pinned profile, in the stanza `tlock <round> <chain
/// hash>`, byte-compatible with the stanza of the tlock library.
///
/// Its encryption is not constant time (see ibe.dart).
final class TimeRecipient implements AgeRecipient {
/// The recipient of [round] under the pinned profile [p], as
/// NewTimeRecipient, with its checks and texts: the profile first
/// (ERR_UNKNOWN_PROFILE, see [checkTlockProfile]), then the round, from 1
/// to the last round of the profile (ERR_DATEKEY_INVALID).
TimeRecipient(PinnedProfile p, this.round) : _profile = _Pinned(p) {
checkTlockProfile(_profile);
if (round < 1 || round > _profile.maxRound) {
throw DateKeysException(
ErrorCode.dateKeyInvalid,
'agewrap: round $round outside the range of ${_profile.id}',
);
}
}
final _Pinned _profile;
/// The round.
final int round;
/// The length of its stanza.
int get stanzaLength => tlockStanzaLength(round);
/// agewrap's TimeRecipient.WrapWithLabels: the stanza of
/// [wrapTlockStanza], whose sigma comes from [random], then the label
/// `datekeys-tlock-` and 16 more bytes from [random], in hex.
@override
AgeWrap wrap(Uint8List fileKey, RandomSource random) {
final (List<String>, Uint8List) stanza;
try {
stanza = wrapTlockStanza(_profile, round, fileKey, random);
} on IbeException {
// Not the error of the IBE, as in Go.
throw const AgeException('agewrap: tlock cannot wrap the file key');
}
final (args, body) = stanza;
final label = randomBytes(random, _labelSize);
return (
stanzas: [AgeStanza(stanzaTlock, args, body)],
labels: ['datekeys-tlock-${toHex(label)}'],
);
}
}

@ -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…
Cancel
Save

Powered by TurnKey Linux.