/// The extension datekeys.capsule of a .dkk and what it points to (spec §43 /// to §44.1), as package locator of datekeys-go at the draft v0.12, with the /// same checks in the same order, the same codes and the same texts: /// - the data of the extension ([CapsuleInfo], [parseCapsuleInfo]), which /// says what the capsule of a key is and when it opens; /// - the locator ([Locator]), an age file sealed with tlock for that date /// ([openLocator]), whose plaintext says where the capsule is: the /// addresses of the rest, and the key, the header and the digests of the /// envelope; /// - the envelope, the .dkc encrypted with age and split into a header, /// which the locator carries, and a rest, the only thing kept outside, /// alone or inside another file ([hideRest], [Locator.restIn], /// [Locator.openEnvelope]). /// /// It downloads nothing. An application fetches the rest only when the /// person asks, after showing her the host or the CID of the address /// ([LocatorAddress.host]), and only from an address that [checkAddressUri] /// accepts ([Locator.usable]); it follows no redirect to an address that /// [checkAddressUri] rejects, checks with [checkResolvedIp] on every /// connection that the IP a name resolves to is public, reads only the /// bytes of the rest, and gives them to [Locator.openEnvelope], which checks /// their SHA-256, decrypts the .dkc and checks its SHA-256 (spec §44.1). /// /// Sealing a locator, Seal of Go, and making an envelope, the age encryption /// of NewEnvelope, need the writer of age: [splitEnvelope] is the rest of /// NewEnvelope, the split of the age file of the envelope. /// /// The errors of the locator carry no normative code, as in Go: a locator /// that does not read or does not open, or an address that breaks the rules /// of §44.1, is unusable (spec §44.1, §57), and each is a [LocatorException] /// with the text of Go. The data of the extension has one code, /// ERR_EXTENSION_DATA_INVALID: data that does not read makes the extension /// unusable, never the .dkk (spec §54). library; import 'dart:typed_data'; import 'age.dart'; import 'agewrap.dart'; import 'bytes.dart'; import 'cbor.dart'; import 'chacha20poly1305.dart'; import 'datekey.dart'; import 'errors.dart'; import 'extension.dart'; import 'ipaddr.dart'; import 'note.dart'; import 'profile.dart'; import 'release.dart'; import 'sha256.dart'; import 'tlock.dart'; /// The most addresses of a locator (spec §44.1). const maxLocatorAddresses = 8; /// The longest address, in bytes (spec §44.1). const maxAddressUriLen = 1024; /// The longest header of an envelope, in bytes (spec §44.1). const maxEnvelopeHeaderLen = 1024; /// The unit of the plaintext of a locator: it measures exactly 4096 bytes, /// or the least multiple of 4096 that key 6 can fill (spec §44.1). const locatorBlock = 4096; // The largest sealed locator that a reader decrypts, and the most plaintext // it reads of one: Go's maxSealed. const _maxSealed = 1 << 20; const _digestSize = 32; /// A failure of the locator, without a normative code, with the text of the /// error of Go, such as `locator: the rest is 808 bytes, not 809`. final class LocatorException implements Exception { /// An exception with Go's [message]. const LocatorException(this.message); /// The text of Go's err.Error(). final String message; @override String toString() => message; } LocatorException _fail(String detail) => LocatorException('locator: $detail'); // A key or a field that the schema does not define or that is missing, as // the decoders of Go write it. DateKeysException _undefined(String what) => DateKeysException(ErrorCode.nonCanonicalCbor, what); // --------------------------------------------------------------------------- // The data of datekeys.capsule /// The data of the extension datekeys.capsule (spec §44.1), as Info of Go. final class CapsuleInfo { /// The data with the copy of the public note [note], `''` for none, the /// DateKey [dateKey] of the capsule, and the sealed locator [sealed], /// which it copies, null for none. CapsuleInfo({this.note = '', required this.dateKey, List? sealed}) : sealed = sealed == null ? null : Uint8List.fromList(sealed); /// Key 0, the copy of the public note of the capsule, `''` for none. final String note; /// Key 1, the DateKey of the capsule: it says when it opens. final DateKey dateKey; /// Key 2, the age file of the locator, sealed with tlock for the round of /// [dateKey], or null for none. final Uint8List? sealed; /// The extension for the noncritical array of a .dkk, as Extension of Go: /// the DateKey must be canonical, the sealed locator of 1 byte to 1 MiB, /// and the note must meet the rules of spec §24.1; the extension is read /// back with the rules of a reader ([parseCapsuleInfo]), which also ties /// the locator to the round of the DateKey (spec §72). The rules of the /// note throw their [DateKeysException], ERR_EXTENSION_DATA_INVALID; the /// others a [LocatorException]. Extension toExtension() { DateKey? d; try { d = parseDateKey(compactDateKey(dateKey)); } on DateKeysException { d = null; } if (d == null || d != dateKey) { throw _fail('Info.DateKey is not a canonical DateKey'); } final s = sealed; if (s != null && (s.isEmpty || s.length > _maxSealed)) { throw _fail( 'a sealed locator of ${s.length} bytes, not 1 to $_maxSealed', ); } if (note.isNotEmpty) checkNote(note); final e = CborEncoder(); _encodeInfo(e, note, compactDateKey(dateKey), s); final x = newExtension(capsuleExtensionId, 1, e.out()); // Spec §72: the encoder reads what it writes with the rules of a reader, // which also ties the locator to the round of the DateKey. try { parseCapsuleInfo(x); } on DateKeysException catch (err) { throw _fail( 'self-check: a reader rejects this extension: ${err.message}', ); } return x; } /// The locator of the extension, opened with [release], the release of the /// round of its own DateKey, in the profile that the DateKey names, as /// OpenLocator of Go: a locator for another round or another chain does /// not open, and is unusable (spec §44.1). [registry] holds the pinned /// profiles, the default registry, Quicknet, when null, as in the opening /// of a capsule. Throws a [LocatorException], also when the extension has /// no locator. Locator openLocator(Release release, {ProfileRegistry? registry}) { final s = sealed; if (s == null) throw _fail('the extension has no locator'); final p = (registry ?? defaultRegistry()).lookup(dateKey.profileId); if (p == null) throw _fail('the profile of the DateKey is not pinned'); return _open(p, dateKey.round, release, s); } } void _encodeInfo(CborEncoder e, String note, String dk, Uint8List? sealed) { var pairs = 1; if (note.isNotEmpty) pairs++; if (sealed != null) pairs++; e.map(pairs); if (note.isNotEmpty) { e ..uint(0) ..text(note); } e ..uint(1) ..text(dk); if (sealed != null) { e ..uint(2) ..bstr(sealed); } } DateKeysException _dataInvalid(String detail) => DateKeysException(ErrorCode.extensionDataInvalid, 'locator: $detail'); /// Reads the data of a datekeys.capsule extension [x], as ParseInfo of Go: a /// map of the profile of spec §58 with the note, key 0, which meets the /// rules of spec §24.1, the canonical DateKey, key 1, and the sealed locator, /// key 2, an age file with one tlock stanza for the round of the DateKey; its /// chain is checked when it opens. A failure makes the extension unusable, /// not the .dkk (spec §54): it is a [DateKeysException] whose only code is /// ERR_EXTENSION_DATA_INVALID. CapsuleInfo parseCapsuleInfo(Extension x) { final data = x.data; if (x.id != capsuleExtensionId || x.version != 1 || data == null) { throw _dataInvalid('not datekeys.capsule version 1 with data'); } var note = ''; var dk = ''; Uint8List? sealed; try { unmarshalCbor(data, (d) { final pairs = d.map(3); var seen = 0; for (var i = 0; i < pairs; i++) { final k = d.key(); switch (k) { case 0: withContext('key 0', () { note = d.text(maxNoteLen); checkNote(note); }); case 1: dk = withContext('key 1', () => d.text(1024)); case 2: sealed = withContext('key 2', () => d.bstr(1, _maxSealed)); default: throw _undefined('key $k is not defined'); } seen |= 1 << (k as int); } if (seen & 2 == 0) throw _undefined('key 1 is missing'); d.endMap(); }, (e) => _encodeInfo(e, note, dk, sealed)); } on DateKeysException catch (err) { throw _dataInvalid('datekeys.capsule: ${err.message}'); } DateKey? d; try { d = parseDateKey(dk); } on DateKeysException { d = null; } if (d == null || compactDateKey(d) != dk) { throw _dataInvalid('compact_datekey is not a canonical DateKey'); } final s = sealed; if (s != null && !_sealedFor(s, d.round)) { throw _dataInvalid( 'the locator is not an age file with one tlock stanza for round ' '${d.round}, the one of its DateKey', ); } return CapsuleInfo(note: note, dateKey: d, sealed: s); } // Whether sealed is an age file whose header holds one tlock stanza with two // arguments, the first of them round. bool _sealedFor(Uint8List sealed, int round) { final List st; try { st = ageStanzas(sealed); } on DateKeysException { return false; } return st.length == 1 && st[0].type == stanzaTlock && st[0].args.length == 2 && st[0].args[0] == '$round'; } /// The check of the data of datekeys.capsule that a reader that knows the /// extension runs (spec §54), as the ValidateCapsule of Go's /// locator.Standard: the rejection of [parseCapsuleInfo], or null when the /// data reads. [StandardExtensions] runs it. Object? checkCapsuleData(Extension e) { try { parseCapsuleInfo(e); return null; } on DateKeysException catch (err) { return err; } } // --------------------------------------------------------------------------- // Addresses /// An address of a locator: where the rest of the envelope is (spec §44.1), /// as Address of Go. final class LocatorAddress { /// The address [uri] with the [offset] of the rest in its resource, 0 when /// the rest is the whole resource. LocatorAddress(this.uri, [this.offset = 0]) { if (offset < 0) { throw ArgumentError.value(offset, 'offset', 'a negative offset'); } } /// The URI: ASCII of RFC 3986, with the scheme https or ipfs, and no /// userinfo, as [checkAddressUri] checks it. final String uri; /// The byte of the resource where the rest starts, 0 when the rest is the /// whole resource. final int offset; /// What a reader shows before it downloads (spec §44.1), as Host of Go: /// the host of an https address, without the brackets of an IPv6 literal, /// or the CID of an ipfs one, as the address writes it, with no decoding; /// `''` when [checkAddressUri] rejects the address. String get host { if (!_accepts(uri)) return ''; final (_, h) = _splitAuthority(uri); return _trimBrackets(h); } @override bool operator ==(Object other) => other is LocatorAddress && other.uri == uri && other.offset == offset; @override int get hashCode => Object.hash(uri, offset); @override String toString() => 'LocatorAddress(${goQuote(utf8Bytes(uri))}${offset == 0 ? '' : ', $offset'})'; } bool _accepts(String uri) { try { checkAddressUri(uri); return true; } on LocatorException { return false; } } /// Checks an address with the rules of spec §44.1, as CheckURI of Go, in its /// order and with its texts: /// - 1 to 1024 bytes of ASCII of RFC 3986, each percent sign followed by two /// hexadecimal digits; /// - a scheme and `://`, and an authority without a percent sign, userinfo /// or a backslash, whose port, in an https address, is a number from 1 to /// 65535 without leading zeros; /// - no `.` or `..` segment in its path, written or with `%2e`; /// - the scheme https, with a host of labels of 1 to 63 letters, digits and /// hyphens that neither start nor end with a hyphen, or a public IP /// address; a host whose last label is numeric or starts with `0x` in /// either case only as a public IPv4 address in dotted decimal without /// leading zeros; and no name of one label or that only a machine or a /// local network resolves, in either case; /// - or the scheme ipfs, with a CID v1 of at most 128 characters in /// canonical base32. /// /// The address is read as it is written, with no decoding. Throws a /// [LocatorException] with the text of Go. void checkAddressUri(String uri) { final b = utf8Bytes(uri); if (b.isEmpty || b.length > maxAddressUriLen) { throw _fail('an address of ${b.length} bytes, not 1 to $maxAddressUriLen'); } for (var i = 0; i < b.length; i++) { final c = b[i]; if (c == 0x25) { if (i + 2 >= b.length || !_isHex(b[i + 1]) || !_isHex(b[i + 2])) { throw _fail( 'an address with a percent sign not followed by two hexadecimal ' 'digits', ); } } else if (!_uriChar(c)) { throw _fail( 'an address with the character ${_quoteRune(c)}, which RFC 3986 ' 'does not allow', ); } } // From here on uri is ASCII: each code unit is a byte. final (scheme, host) = _splitAuthority(uri); if (_dotSegment(uri)) { throw _fail('an address with a "." or ".." segment in its path'); } switch (scheme) { case 'https': _checkHost(host); return; case 'ipfs': if (!_isCidV1(host)) { throw _fail('an ipfs address without a CID v1 in base32'); } return; } throw _fail('the scheme ${goQuote(utf8Bytes(scheme))}: only https and ipfs'); } const _uriPunctuation = "-._~:/?#[]@!\$&'()*+,;="; // Whether c may appear in a URI of RFC 3986, a percent sign apart: the // unreserved characters, gen-delims and sub-delims. bool _uriChar(int c) => (c >= 0x61 && c <= 0x7a) || (c >= 0x41 && c <= 0x5a) || (c >= 0x30 && c <= 0x39) || _uriPunctuation.codeUnits.contains(c); bool _isHex(int c) => (c >= 0x30 && c <= 0x39) || (c >= 0x61 && c <= 0x66) || (c >= 0x41 && c <= 0x46); // Go's %q of a byte, the rune of its value in single quotes, as // strconv.QuoteRune writes it. String _quoteRune(int c) { final String body; if (c == 0x27 || c == 0x5c) { body = '\\${String.fromCharCode(c)}'; } else if (isPrint(c)) { body = String.fromCharCode(c); } else { body = switch (c) { 0x07 => r'\a', 0x08 => r'\b', 0x0c => r'\f', 0x0a => r'\n', 0x0d => r'\r', 0x09 => r'\t', 0x0b => r'\v', _ when c < 0x20 || c == 0x7f => '\\x${_hex(c, 2)}', _ => '\\u${_hex(c, 4)}', }; } return "'$body'"; } String _hex(int v, int digits) => v.toRadixString(16).padLeft(digits, '0'); int _indexAny(String s, String chars, [int start = 0]) { for (var i = start; i < s.length; i++) { if (chars.contains(s[i])) return i; } return -1; } // Whether the path of uri has a segment "." or "..", written or // percent-encoded: a client or a gateway that resolves it would ask for // something other than what the address shows. bool _dotSegment(String uri) { final i = uri.indexOf('://'); final rest = i < 0 ? '' : uri.substring(i + 3); final j = rest.indexOf('/'); if (j < 0) return false; var path = rest.substring(j); final k = _indexAny(path, '?#'); if (k >= 0) path = path.substring(0, k); for (final s in path.split('/')) { final t = s.replaceAll('%2e', '.').replaceAll('%2E', '.'); if (t == '.' || t == '..') return true; } return false; } // The scheme and the raw host of an address, without decoding anything: a // percent sign in the authority, userinfo and a malformed port are refused, // so that the host a reader shows is the host an HTTP client would use. (String, String) _splitAuthority(String uri) { final i = uri.indexOf('://'); if (i <= 0) throw _fail('an address without a scheme and ://'); final scheme = uri.substring(0, i); final rest = uri.substring(i + 3); var authority = rest; final j = _indexAny(rest, '/?#'); if (j >= 0) authority = rest.substring(0, j); if (_indexAny(authority, '%@\\') >= 0) { throw _fail( 'an address with a percent sign, userinfo or a backslash in its ' 'authority', ); } var host = authority; if (scheme == 'https') { if (authority.startsWith('[')) { final end = authority.indexOf(']'); if (end < 0) throw _fail('an address with an unclosed IPv6 literal'); host = authority.substring(0, end + 1); final tail = authority.substring(end + 1); if (tail.isNotEmpty) _checkPort(tail); } else { final k = authority.indexOf(':'); if (k >= 0) { host = authority.substring(0, k); _checkPort(':${authority.substring(k + 1)}'); } } } return (scheme, host); } // The port of an authority, its ':' included: a number from 1 to 65535 // without leading zeros, so that a port is written in one way only. void _checkPort(String s) { if (s.length < 2 || s.codeUnitAt(0) != 0x3a || s.length > 6) { throw _fail('an address with a malformed port'); } var n = 0; for (var i = 1; i < s.length; i++) { final c = s.codeUnitAt(i); if (c < 0x30 || c > 0x39) throw _fail('an address with a malformed port'); n = n * 10 + c - 0x30; } if (n < 1 || n > 65535) { throw _fail('an address with a port outside 1 to 65535'); } if (s.codeUnitAt(1) == 0x30) { throw _fail('an address with a port written with a leading zero'); } } // Go's strings.Trim(s, "[]"): every [ and ] at either end. String _trimBrackets(String s) { var a = 0; var b = s.length; while (a < b && (s[a] == '[' || s[a] == ']')) { a++; } while (b > a && (s[b - 1] == '[' || s[b - 1] == ']')) { b--; } return s.substring(a, b); } String _lowerAscii(String s) => String.fromCharCodes([ for (final c in s.codeUnits) c >= 0x41 && c <= 0x5a ? c + 0x20 : c, ]); // The host of an https address: a name of labels of 1 to 63 letters, digits // and hyphens, none of which starts or ends with a hyphen, or an IP literal // that is public; a name whose last label is numeric or starts with 0x only // as a public IPv4 address in dotted decimal; and no name that only a // machine or a local network resolves. void _checkHost(String host) { if (host.isEmpty) throw _fail('an https address without a host'); if (host.startsWith('[')) { final a = parseIpAddress(_trimBrackets(host)); if (a == null || !a.is6 || a.zone.isNotEmpty || !isPublicIp(a)) { throw _fail('an https address with an IPv6 literal that is not public'); } return; } final labels = host.split('.'); for (final l in labels) { if (l.isEmpty || l.length > 63 || l.codeUnitAt(0) == 0x2d || l.codeUnitAt(l.length - 1) == 0x2d) { throw _fail('an https address with a malformed host'); } for (final c in l.codeUnits) { if (!((c >= 0x61 && c <= 0x7a) || (c >= 0x41 && c <= 0x5a) || (c >= 0x30 && c <= 0x39) || c == 0x2d)) { throw _fail( 'an https address whose host is not letters, digits and hyphens: ' 'write its punycode form', ); } } } final last = labels.last; if (_allDigits(last) || _lowerAscii(last).startsWith('0x')) { final a = parseIpAddress(host); if (a == null || !a.is4 || !isPublicIp(a)) { throw _fail( 'an https address with a numeric host that is not a public IPv4 ' 'address', ); } return; } if (labels.length == 1 || _localName(_lowerAscii(host))) { throw _fail( 'an https address with a name that only a machine or a local network ' 'resolves', ); } } const _localNames = [ 'localhost', 'local', 'home.arpa', 'internal', 'invalid', 'test', 'example', 'onion', ]; // Whether name, in lower case, is one of the special-use names that never // resolve on the public Internet, or below one of them: localhost (RFC // 6761), .local (RFC 6762), .home.arpa (RFC 8375), .internal, .invalid, // .test, .example and .onion (RFC 7686). bool _localName(String name) { for (final s in _localNames) { if (name == s || name.endsWith('.$s')) return true; } return false; } bool _allDigits(String s) { for (final c in s.codeUnits) { if (c < 0x30 || c > 0x39) return false; } return s.isNotEmpty; } // Whether s is a CID v1 of at most 128 characters in canonical base32, which // starts with 'b': the alphabet in lower case, without padding, the bits // left over set to zero, and decoded, minimal varints of at most 9 bytes, // the version 1, a content codec and a multihash with a digest of at least // one byte and of the length it gives, with nothing after it. bool _isCidV1(String s) { if (s.length < 2 || s.length > 128 || s.codeUnitAt(0) != 0x62) return false; final out = []; var acc = 0; var bits = 0; for (var i = 1; i < s.length; i++) { final c = s.codeUnitAt(i); final int v; if (c >= 0x61 && c <= 0x7a) { v = c - 0x61; } else if (c >= 0x32 && c <= 0x37) { v = c - 0x32 + 26; } else { return false; } // acc holds at most 4 bits before the shift: 12 bits at most. acc = (acc << 5) | v; bits += 5; if (bits >= 8) { bits -= 8; out.add(acc >> bits); acc &= (1 << bits) - 1; } } // The bits of the last character beyond a byte are zero. if (acc != 0) return false; final version = _uvarint(out, 0); if (version == null || version.$1 != BigInt.one) return false; final codec = _uvarint(out, version.$2); if (codec == null) return false; final hash = _uvarint(out, codec.$2); if (hash == null) return false; final n = _uvarint(out, hash.$2); return n != null && n.$1 > BigInt.zero && BigInt.from(out.length - n.$2) == n.$1; } // An unsigned varint of multiformats, minimal and of at most 9 bytes, from // b[at]: its value, exact as a BigInt up to 63 bits, and the offset after it. (BigInt, int)? _uvarint(List b, int at) { var v = BigInt.zero; for (var i = 0; at + i < b.length && i < 9; i++) { final x = b[at + i]; v |= BigInt.from(x & 0x7f) << (7 * i); if (x & 0x80 == 0) { if (i > 0 && x == 0) return null; return (v, at + i + 1); } } return null; } /// Checks the IP address [ip], the 4 bytes of an IPv4 address or the 16 of /// an IPv6 address, to which the name of an https address resolved: a /// reader connects only to a public one, and checks it on every connection, /// redirections included (spec §44.1). Public is what an IP literal of an /// address must be, as publicIP of Go: an IPv4 address outside the blocks of /// §44.1, or an IPv6 address of 2000::/3 outside 2001::/23, 2001:db8::/32, /// 2002::/16 and 3fff::/20. An IPv6 address that holds an IPv4 one is not, /// IPv4-mapped included: an IPv4 address is checked as its 4 bytes. /// /// Throws a [LocatorException] for an address that is not public, and an /// [ArgumentError] for any other length. Go has no such function: a reader /// of the reference does not download. void checkResolvedIp(List ip) { final a = IpAddress.fromBytes(ip); if (!isPublicIp(a)) { throw _fail( 'an https address whose name resolves to $a, an IP address that is ' 'not public', ); } } // --------------------------------------------------------------------------- // The locator /// The plaintext of the sealed locator (spec §44.1), as Locator of Go. final class Locator { /// A locator of [addresses], with I_SOBRE [envelopeKey], the raw X25519 /// identity of the envelope, which it copies, the age header /// [envelopeHeader] of the envelope, MAC line included, the SHA-256 /// [restDigest] and the length [restSize] of the rest, and the SHA-256 /// [capsuleDigest] of the .dkc. The keys and digests are 32 bytes and the /// size is not negative, as the types of Go make them; the other rules are /// those of [marshal]. Locator({ required List addresses, required List envelopeKey, required List envelopeHeader, required List restDigest, required this.restSize, required List capsuleDigest, }) : addresses = List.unmodifiable(addresses), envelopeKey = _fixed(envelopeKey, 'envelopeKey'), envelopeHeader = Uint8List.fromList(envelopeHeader), restDigest = _fixed(restDigest, 'restDigest'), capsuleDigest = _fixed(capsuleDigest, 'capsuleDigest') { if (restSize < 0) { throw ArgumentError.value(restSize, 'restSize', 'a negative size'); } } static Uint8List _fixed(List b, String name) { if (b.length != _digestSize) { throw ArgumentError.value(b.length, name, 'not $_digestSize bytes'); } return Uint8List.fromList(b); } /// Key 0, where the rest of the envelope is, in their order. final List addresses; /// Key 1, I_SOBRE, the raw X25519 identity of the envelope. SECRET: see /// [wipe]. final Uint8List envelopeKey; /// Key 2, the age header of the envelope, MAC line included. final Uint8List envelopeHeader; /// Key 3, the SHA-256 of the rest. final Uint8List restDigest; /// Key 4, the length of the rest, in bytes. final int restSize; /// Key 5, the SHA-256 of the .dkc (spec §43). final Uint8List capsuleDigest; /// This locator with [addresses] instead of its own: those where a writer /// stored the rest of the envelope of [splitEnvelope]. Locator withAddresses(List addresses) => Locator( addresses: addresses, envelopeKey: envelopeKey, envelopeHeader: envelopeHeader, restDigest: restDigest, restSize: restSize, capsuleDigest: capsuleDigest, ); /// The addresses that meet the rules of spec §44.1, in their order, as /// Usable of Go. A reader rejects each address that breaks them and uses /// the others: a locator whose addresses are all rejected has nothing to /// download. List get usable => [ for (final a in addresses) if (_accepts(a.uri)) a, ]; /// Clears [envelopeKey]; the envelope cannot be opened afterwards. void wipe() => envelopeKey.fillRange(0, envelopeKey.length, 0); // validateForm of Go: the form that a reader requires of the whole // locator; a broken address makes only that address unusable. void _validateForm() { if (addresses.isEmpty || addresses.length > maxLocatorAddresses) { throw _fail( '${addresses.length} addresses, not 1 to $maxLocatorAddresses', ); } for (final a in addresses) { final n = utf8Bytes(a.uri).length; if (n == 0 || n > maxAddressUriLen) { throw _fail('an address of $n bytes, not 1 to $maxAddressUriLen'); } } final n = envelopeHeader.length; if (n < 1 || n > maxEnvelopeHeaderLen) { throw _fail( 'an envelope header of $n bytes, not 1 to $maxEnvelopeHeaderLen', ); } if (restSize > maxSafeUint) { throw _fail('a rest larger than 2^53 - 1 bytes'); } for (final a in addresses) { if (a.offset > maxSafeUint) { throw _fail('an offset larger than 2^53 - 1'); } } } // The map; pad < 0 leaves key 6 out. void _encode(CborEncoder e, int pad) { e ..map(pad >= 0 ? 7 : 6) ..uint(0) ..array(addresses.length); for (final a in addresses) { e ..map(a.offset == 0 ? 1 : 2) ..uint(0) ..text(a.uri); if (a.offset != 0) { e ..uint(1) ..uint(a.offset); } } e ..uint(1) ..bstr(envelopeKey) ..uint(2) ..bstr(envelopeHeader) ..uint(3) ..bstr(restDigest) ..uint(4) ..uint(restSize) ..uint(5) ..bstr(capsuleDigest); if (pad >= 0) { e ..uint(6) ..bstr(Uint8List(pad)); } } /// The plaintext of the locator, as Marshal of Go: CBOR with the profile /// of spec §58, completed with zeros in key 6 up to the least multiple of /// 4096 bytes that key 6 can fill, so that its length does not tell how /// many addresses there are. It checks the form of the locator, 1 to 8 /// addresses of 1 to 1024 bytes, a header of 1 to 1024 bytes and a size /// and offsets of at most 2^53 - 1, and each address with /// [checkAddressUri]: a writer never writes one that a reader rejects. /// Throws a [LocatorException]. The plaintext holds I_SOBRE: the caller /// wipes it. Uint8List marshal() { _validateForm(); for (final a in addresses) { checkAddressUri(a.uri); } return _marshal(); } Uint8List _marshal() { final e = CborEncoder(); _encode(e, -1); final base = e.out(); final pad = _padFor(base.length); if (pad < 0) return base; // It holds I_SOBRE. final n = base.length; base.fillRange(0, n, 0); final p = CborEncoder(capacity: n + pad + 8); _encode(p, pad); final out = p.out(); if (out.length % locatorBlock != 0) { out.fillRange(0, out.length, 0); throw _fail('internal error: ${out.length} bytes of plaintext'); } return out; } /// The rest of the envelope from the resource [host], which starts at the /// [offset] of its address: only [restSize] bytes are read, whatever /// follows (spec §44.1), as RestIn of Go. Throws a [LocatorException] when /// the resource is shorter. Uint8List restIn(List host, int offset) { if (offset < 0) { throw ArgumentError.value(offset, 'offset', 'a negative offset'); } if (offset > host.length || restSize > host.length - offset) { throw _fail( 'the resource has ${host.length} bytes, and the rest is $restSize ' 'from $offset', ); } return Uint8List.fromList(host.sublist(offset, offset + restSize)); } /// Joins the header of the locator and [rest], which a reader got from an /// address, and decrypts the .dkc, as OpenEnvelope of Go. It checks the /// size and the SHA-256 of the rest, and the SHA-256 of the .dkc, before /// the caller uses it (spec §44.1): they protect against whoever stores /// the rest, not against whoever wrote the .dkk. Throws a /// [LocatorException]. Uint8List openEnvelope(List rest) { if (rest.length != restSize) { throw _fail('the rest is ${rest.length} bytes, not $restSize'); } if (!equalBytes(sha256(rest), restDigest)) { throw _fail('the SHA-256 of the rest is not the one of the locator'); } final id = x25519IdentityFromRaw(envelopeKey); final Uint8List dkc; try { dkc = ageDecrypt(concatBytes([envelopeHeader, rest]), [id]); } on AgeException catch (e) { throw _fail('the envelope: ${e.message}'); } finally { id.wipe(); } if (!equalBytes(sha256(dkc), capsuleDigest)) { dkc.fillRange(0, dkc.length, 0); throw _fail( 'the SHA-256 of the .dkc is not the capsule_digest of the locator', ); } return dkc; } } int _bstrHeadLen(int n) { if (n < 24) return 1; if (n < 256) return 2; if (n < 65536) return 3; return 5; } // padFor of Go: the length of key 6 that makes the plaintext measure the // least multiple of locatorBlock that holds it, or -1 when n0, the length // without key 6, already is one. When no length of key 6 gives a multiple, // as happens at the boundaries of the CBOR length, it takes the next one. int _padFor(int n0) { if (n0 % locatorBlock == 0) return -1; for ( var total = (n0 ~/ locatorBlock + 1) * locatorBlock; ; total += locatorBlock ) { for (var pad = 1; pad <= total - n0; pad++) { if (n0 + 1 + _bstrHeadLen(pad) + pad == total) return pad; } } } /// The length of the plaintext of a locator whose CBOR without key 6 /// measures [base] bytes, as PlaintextLength of Go: [base] when it already /// is a multiple of 4096, and otherwise the least multiple that key 6 can /// fill exactly. Key 6 takes at least 3 bytes, and the head of its byte /// string grows at 24 and at 256 bytes: a base that lacks 1, 2, 26 or 259 /// bytes for a multiple takes the next one (spec §44.1). int locatorPlaintextLength(int base) { final pad = _padFor(base); if (pad < 0) return base; return base + 1 + _bstrHeadLen(pad) + pad; } /// Something of the decoding of a locator that Go reports without a code. final class _Plain implements Exception { const _Plain(this.message); final String message; } /// Reads the plaintext of a locator, checking its profile and the length /// that [Locator.marshal] gives, as Unmarshal of Go. Its errors carry no /// normative code: a locator that does not read is unusable (spec §44.1, /// §57). An address that breaks the rules of §44.1 is kept, and /// [Locator.usable] leaves it out. Throws a [LocatorException]. Locator unmarshalLocator(List plaintext) { final b = plaintext is Uint8List ? plaintext : Uint8List.fromList(plaintext); final addresses = []; Uint8List? key; Uint8List? header; Uint8List? restDigest; var restSize = 0; Uint8List? capsuleDigest; var pad = -1; Locator? decoded; Locator locatorOf() => decoded ??= Locator( addresses: addresses, envelopeKey: key!, envelopeHeader: header!, restDigest: restDigest!, restSize: restSize, capsuleDigest: capsuleDigest!, ); try { unmarshalCbor(b, (d) { final pairs = d.map(7); var seen = 0; for (var i = 0; i < pairs; i++) { final k = d.key(); switch (k) { case 0: withContext('key 0', () => _decodeAddresses(d, addresses)); case 1: key = withContext('key 1', () => d.bstr(32, 32)); case 2: header = withContext( 'key 2', () => d.bstr(1, maxEnvelopeHeaderLen), ); case 3: restDigest = withContext('key 3', () => d.bstr(32, 32)); case 4: restSize = withContext('key 4', () => d.uint(maxSafeUint)); case 5: capsuleDigest = withContext('key 5', () => d.bstr(32, 32)); case 6: final z = withContext('key 6', () => d.bstr(1, 1 << 20)); for (final x in z) { if (x != 0) throw const _Plain('the padding is not zeros'); } pad = z.length; default: throw _undefined('key $k is not defined'); } seen |= 1 << (k as int); } if (seen & 0x3f != 0x3f) { throw _undefined('a key from 0 to 5 is missing'); } d.endMap(); }, (e) => locatorOf()._encode(e, pad)); } on DateKeysException catch (err) { key?.fillRange(0, key!.length, 0); throw _fail(err.message); } on _Plain catch (err) { key?.fillRange(0, key!.length, 0); throw _fail(err.message); } final l = locatorOf(); key!.fillRange(0, key!.length, 0); try { l._validateForm(); // The length is the one Marshal gives: nothing else is canonical. final want = l._marshal(); final same = equalBytes(want, b); want.fillRange(0, want.length, 0); if (!same) { throw _fail( 'the plaintext is not $locatorBlock or the least multiple of ' '$locatorBlock that holds it', ); } } on LocatorException { l.wipe(); rethrow; } return l; } void _decodeAddresses(CborDecoder d, List out) { final n = d.array(maxLocatorAddresses); for (var i = 0; i < n; i++) { final pairs = d.map(2); var uri = ''; var offset = 0; var seen = 0; for (var j = 0; j < pairs; j++) { final k = d.key(); switch (k) { case 0: uri = d.text(maxAddressUriLen); case 1: offset = d.uint(maxSafeUint); if (offset == 0) { throw _undefined('an offset of 0 is written by leaving it out'); } default: throw _undefined('address key $k is not defined'); } seen |= 1 << (k as int); } if (seen & 1 == 0) throw _undefined('an address without URI'); d.endMap(); out.add(LocatorAddress(uri, offset)); } } // --------------------------------------------------------------------------- // The sealed locator /// The identity that opens a sealed locator, as Go's agewrap.TimeIdentity: /// the complete stanza set and its arguments, the release again, the length /// of the body, U and then the IBE. final class _TimeIdentity implements AgeIdentity { _TimeIdentity(this._p, this._round, this._release); final PinnedProfile _p; final int _round; final Release _release; @override Uint8List unwrap(List stanzas) { checkTimeStanzas( stanzas, round: _round, chainHashHex: toHex(_p.chainHash), profileId: _p.id, ); final s = stanzas.single; return unwrapTlockStanza(_p, _round, _release, s.args, s.body); } } const _encChunk = ageChunkSize + poly1305TagSize; /// Opens the sealed locator [sealed] with [release], the release of its /// [round] in the pinned profile [p], and reads its plaintext, as Open of /// Go. A locator for another round or another chain does not open: it is /// unusable (spec §44.1). Its errors carry no normative code: a /// [LocatorException], whose text is that of Go, the texts of the checks of /// the profile, the stanza, the release and age included. /// /// As Go, it reads at most 1 MiB of plaintext, through io.LimitReader: what /// follows is neither decrypted nor checked, and the plaintext read is then /// not the length of a locator. Locator openLocator( PinnedProfile p, int round, Release release, List sealed, ) => _open(p, round, release, sealed); Locator _open(PinnedProfile p, int round, Release release, List sealed) { try { checkTlockProfile(p); } on DateKeysException catch (e) { throw _fail(e.message); } final file = sealed is Uint8List ? sealed : Uint8List.fromList(sealed); final AgeOpened opened; try { opened = ageOpen(file, [_TimeIdentity(p, round, release)]); } on AgeException catch (e) { throw _fail(e.message); } on DateKeysException catch (e) { throw _fail(e.message); } final plain = _readLimited(file, opened); try { return unmarshalLocator(plain); } finally { plain.fillRange(0, plain.length, 0); } } // The plaintext of the STREAM of file, at most _maxSealed bytes of it, as // io.ReadAll of io.LimitReader of the reader of age.Decrypt: the chunks are // decrypted one by one only while less than 1 MiB has been read, and the end // of the STREAM is checked only then. Uint8List _readLimited(Uint8List file, AgeOpened opened) { final d = opened.payload; final out = BytesBuilder(copy: false); var total = 0; var at = opened.payloadOffset; try { while (total < _maxSealed) { if (at >= file.length) { final last = d.close(); out.add(last); total += last.length; break; } final end = at + _encChunk < file.length ? at + _encChunk : file.length; for (final chunk in d.add(file, at, end)) { out.add(chunk); total += chunk.length; } at = end; } } on AgeException catch (e) { final partial = out.takeBytes(); partial.fillRange(0, partial.length, 0); throw _fail(e.message); } d.wipe(); final b = out.takeBytes(); if (b.length <= _maxSealed) return b; final cut = Uint8List.fromList(Uint8List.sublistView(b, 0, _maxSealed)); b.fillRange(0, b.length, 0); return cut; } // --------------------------------------------------------------------------- // The envelope /// The envelope of [ageFile], the age file of the .dkc [dkc] encrypted for /// the X25519 identity [envelopeKey], I_SOBRE: the locator with the key, the /// header up to and including the line feed after the MAC line, the SHA-256 /// and the length of the rest and the SHA-256 of the .dkc, without /// addresses; and the rest, the nonce and the STREAM, with no mark, which is /// what the person keeps outside. It is the part of NewEnvelope of Go after /// the encryption, which needs the writer of age; [ageFile] is not /// decrypted. A caller adds the addresses where it stored the rest /// ([Locator.withAddresses]), alone or inside another file ([hideRest]), /// and then seals the locator. /// /// The header ends at the line feed after the first line that starts with /// `--- `: no line of the header before it starts so, and the lines of the /// body of a stanza are base64, which has no '-'. Throws a /// [LocatorException] with the text of Go when there is none. ({Locator locator, Uint8List rest}) splitEnvelope( List ageFile, List envelopeKey, List dkc, ) { final file = ageFile is Uint8List ? ageFile : Uint8List.fromList(ageFile); final end = _headerEnd(file); final rest = Uint8List.fromList(Uint8List.sublistView(file, end)); final locator = Locator( addresses: const [], envelopeKey: envelopeKey, envelopeHeader: Uint8List.sublistView(file, 0, end), restDigest: sha256(rest), restSize: rest.length, capsuleDigest: sha256(dkc), ); return (locator: locator, rest: rest); } // headerEnd of Go: the length of the age header of file, up to and including // the line feed after the MAC line. int _headerEnd(Uint8List file) { const mac = [0x0a, 0x2d, 0x2d, 0x2d, 0x20]; var i = -1; for (var at = 0; at + mac.length <= file.length; at++) { var match = true; for (var k = 0; k < mac.length; k++) { if (file[at + k] != mac[k]) { match = false; break; } } if (match) { i = at; break; } } if (i < 0) throw _fail('the age file has no MAC line'); final j = file.indexOf(0x0a, i + 1); if (j < 0) throw _fail('the MAC line of the age file does not end'); return j + 1; } /// Appends [rest] to [host], a file of any kind, as Hide of Go: the result /// and the offset where the rest starts, which is what an address says /// (spec §44.1). It is hiding, not steganography: whoever analyses the host /// sees that it has extra bytes, but not what they are. Only a store that /// keeps the file byte by byte keeps it: a social network or a messaging /// app recompress or strip what is left over. ({Uint8List file, int offset}) hideRest(List host, List rest) => (file: concatBytes([host, rest]), offset: host.length);