You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dateKeys-dart/lib/src/locator.dart

1252 lines
42 KiB

/// 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<int>? 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<AgeStanza> 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 = <int>[];
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<int> 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<int> 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<LocatorAddress> addresses,
required List<int> envelopeKey,
required List<int> envelopeHeader,
required List<int> restDigest,
required this.restSize,
required List<int> 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<int> 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<LocatorAddress> 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<LocatorAddress> 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<LocatorAddress> 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<int> 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<int> 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<int> plaintext) {
final b = plaintext is Uint8List ? plaintext : Uint8List.fromList(plaintext);
final addresses = <LocatorAddress>[];
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<LocatorAddress> 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<AgeStanza> 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<int> sealed,
) => _open(p, round, release, sealed);
Locator _open(PinnedProfile p, int round, Release release, List<int> 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<int> ageFile,
List<int> envelopeKey,
List<int> 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<int> host, List<int> rest) =>
(file: concatBytes([host, rest]), offset: host.length);

Powered by TurnKey Linux.