diff --git a/lib/src/age_writer.dart b/lib/src/age_writer.dart new file mode 100644 index 0000000..02b6aba --- /dev/null +++ b/lib/src/age_writer.dart @@ -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 stanzas, List 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 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 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 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 stanzas, List 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 l) => + '[${l.map((s) => goQuote(utf8Bytes(s))).join(' ')}]'; + +// age's incompatibleLabelsError. +String _incompatible(List l1, List 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 a, List 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 _sorted(List 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 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 = []; + List? 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 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 add(List 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 plaintext, + List 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 add(List data, [int start = 0, int? end]) { + final stop = end ?? data.length; + RangeError.checkValidRange(start, stop, data.length); + _checkOpen(); + final out = []; + 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'); + } +} diff --git a/lib/src/recipient.dart b/lib/src/recipient.dart new file mode 100644 index 0000000..22d1a88 --- /dev/null +++ b/lib/src/recipient.dart @@ -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 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 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 [], + ); + } 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 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 `, 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, 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)}'], + ); + } +} diff --git a/test/age_writer_support.dart b/test/age_writer_support.dart new file mode 100644 index 0000000..8406bec --- /dev/null +++ b/test/age_writer_support.dart @@ -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 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 recipients, + RandomSource random, + List 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 stanzaLengths(Uint8List header) => [ + for (final s in parseAgeHeader(header).stanzas) + marshalAgeHeaderWithoutMac([s]).length - 22 - 3, +]; diff --git a/test/age_writer_test.dart b/test/age_writer_test.dart new file mode 100644 index 0000000..30c83f0 --- /dev/null +++ b/test/age_writer_test.dart @@ -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().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().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().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().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 chunksOf(int n, List steps) { + final e = AgePayloadEncryptor(Uint8List.fromList(key)); + final out = []; + 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 = {}; + 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() + .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 stanzas; + final List 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 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); + } +} diff --git a/test/age_writer_vm_test.dart b/test/age_writer_vm_test.dart new file mode 100644 index 0000000..9a9c583 --- /dev/null +++ b/test/age_writer_vm_test.dart @@ -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); + }); +}