diff --git a/lib/src/locator.dart b/lib/src/locator.dart index 73864a1..a2ad03f 100644 --- a/lib/src/locator.dart +++ b/lib/src/locator.dart @@ -1,28 +1,68 @@ -/// The addresses of a locator (spec §44.1), as package locator of -/// datekeys-go at the draft v0.12, with the same checks in the same order -/// and the same texts: [checkAddressUri], CheckURI of Go, and the host that -/// a reader shows before it downloads ([LocatorAddress.host]). And the check -/// of the IP that a name resolves to, which a reader runs on every -/// connection ([checkResolvedIp]). +/// The locator of the extension datekeys.capsule of a .dkk and the envelope +/// it points to (spec §44.1), as package locator of datekeys-go at the draft +/// v0.12, with the same checks in the same order and the same texts: +/// - the locator ([Locator]), an age file sealed with tlock for the date of +/// the capsule ([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 addresses, as CheckURI of Go ([checkAddressUri]), and the host that +/// a reader shows before it downloads ([LocatorAddress.host]); +/// - 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 of an envelope -/// 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; it follows no redirect to an address that -/// [checkAddressUri] rejects, and checks with [checkResolvedIp] on every -/// connection that the IP a name resolves to is public (spec §44.1). +/// 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). /// -/// An address that breaks the rules of §44.1 is unusable, and its error -/// carries no normative code, as in Go: a [LocatorException] with the text -/// of Go. +/// 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 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. library; +import 'dart:typed_data'; + +import 'age.dart'; +import 'agewrap.dart'; import 'bytes.dart'; +import 'cbor.dart'; +import 'chacha20poly1305.dart'; +import 'errors.dart'; import 'ipaddr.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 { @@ -38,6 +78,11 @@ final class LocatorException implements Exception { 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); + // --------------------------------------------------------------------------- // Addresses @@ -449,3 +494,564 @@ void checkResolvedIp(List ip) { ); } } + +// --------------------------------------------------------------------------- +// 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); diff --git a/test/locator_support.dart b/test/locator_support.dart index c7ff2ed..38f4b01 100644 --- a/test/locator_support.dart +++ b/test/locator_support.dart @@ -1,11 +1,17 @@ // Helpers of the tests of the locator (stage 7a): the vectors of -// tool/locator_go_vectors.go and the texts of their errors. They read no +// tool/locator_go_vectors.go, their edits and their compact addresses, and +// what a reader reads of a locator, as the generator writes it. They read no // file, so that the tests that run on Node.js can use them. library; import 'dart:convert'; +import 'dart:typed_data'; +import 'package:datekeys/src/bytes.dart'; import 'package:datekeys/src/locator.dart'; +import 'package:datekeys/src/profile.dart'; +import 'package:datekeys/src/release.dart'; +import 'package:datekeys/src/sha256.dart'; typedef Json = Map; @@ -18,6 +24,122 @@ List> rows(Json v, String key) => [ String textOf(Json v, Object? i) => (v['texts']! as List)[i! as int]! as String; +/// An address as the generator writes it: the string, or [before, byte, +/// count, after] for one with a run of count copies of a byte. +String uriOf(Object? x) { + if (x is String) return x; + final l = x! as List; + return '${l[0]}${(l[1]! as String) * (l[2]! as int)}${l[3]}'; +} + +/// The edits [edits] of [base] applied in one pass: each [at, delete, +/// insert] replaces delete bytes at the offset at of the base with the bytes +/// of the hexadecimal insert, and [at, delete, byte, count] with count +/// copies of the byte. The offsets are those of the base, in order. +Uint8List applyLocatorEdits(List base, Object? edits) { + final out = BytesBuilder(copy: false); + var pos = 0; + for (final e in (edits! as List).cast>()) { + final at = e[0]! as int; + final delete = e[1]! as int; + if (at < pos || at + delete > base.length) { + throw StateError('edit at $at out of order or beyond the base'); + } + out.add(base.sublist(pos, at)); + final insert = fromHex(e[2]! as String); + final repeat = e.length > 3 ? e[3]! as int : 1; + for (var i = 0; i < repeat; i++) { + out.add(insert); + } + pos = at + delete; + } + out.add(base.sublist(pos)); + return out.takeBytes(); +} + +/// The lower-case hexadecimal SHA-256 of [b]. +String sha256Hex(List b) => toHex(sha256(b)); + +/// What a reader reads of [l], as the generator writes it: [[uri, offset, +/// host, usable]...], the key, the SHA-256 of the header, the digest of the +/// rest, its size and capsule_digest, with each address in full. +List summaryOf(Locator l) => [ + [ + for (final a in l.addresses) + [a.uri, a.offset, a.host, l.usable.contains(a)], + ], + toHex(l.envelopeKey), + sha256Hex(l.envelopeHeader), + toHex(l.restDigest), + l.restSize, + toHex(l.capsuleDigest), +]; + +/// The summary [s] of the generator, with each address in full. +List expandSummary(Object? s) { + final l = s! as List; + return [ + [ + for (final a in (l[0]! as List).cast>()) + [uriOf(a[0]), a[1], uriOf(a[2]), a[3]], + ], + ...l.sublist(1), + ]; +} + +/// The release of [round] of the vectors [v], public data of drand. +Release releaseOf(Json v, int round) { + final r = (v['releases']! as Json)['$round']! as String; + return Release(round, fromHex(r)); +} + +/// The pinned Quicknet profile, edited as the generator names the edit. +Profile profileOf(String edit) { + final p = quicknet(); + switch (edit) { + case '': + return p; + case 'key not on the curve': + final k = Uint8List.fromList(p.publicKey); + k[k.length - 1] ^= 1; + return p.copyWith(publicKey: k); + case 'key at infinity': + return p.copyWith(publicKey: [0xc0, ...List.filled(95, 0)]); + case 'key of another network': + // The generator of G2, compressed. + return p.copyWith( + publicKey: fromHex( + '93e02b6052719f607dacd3a088274f65596bd0d09920b61ab5da61bbdc7f5049' + '334cf11213945d57e5ac7d055d042b7e024aa2b2f08f0a91260805272dc51051' + 'c6e47ad4fa403b02b4510b647ae3d1770bac0326a805bbefd48056c8c121bdb8', + ), + ); + case 'chain hash of another network': + final h = Uint8List.fromList(p.chainHash); + h[0] ^= 1; + return p.copyWith(chainHash: h); + } + throw ArgumentError(edit); +} + +/// The release that an open case names: a round, or an edit of the release +/// of the case. +Release openRelease(Json v, int round, Object? release) { + if (release is int) return releaseOf(v, release); + final r = releaseOf(v, round); + switch (release) { + case 'flipped': + final s = Uint8List.fromList(r.signature); + s[s.length - 1] ^= 1; + return Release(round, s); + case 'short': + return Release(round, r.signature.sublist(0, 47)); + case 'other round': + return Release(round, releaseOf(v, 1001).signature); + } + throw ArgumentError('$release'); +} + /// The text of the error that [body] throws, `''` when it returns: a /// [LocatorException] or a DateKeysException by its message. String errorText(void Function() body) { diff --git a/test/locator_test.dart b/test/locator_test.dart new file mode 100644 index 0000000..52b8c10 --- /dev/null +++ b/test/locator_test.dart @@ -0,0 +1,234 @@ +// The locator and the envelope against Go (spec §44.1), from the part of +// locator_vectors.json that locator_vectors.g.dart holds, so that it runs on +// the VM and compiled to JavaScript: the padding of every base, plaintexts +// valid and broken, Marshal at every limit, sealed locators opened with +// their release and with others, and the envelope, its rest and its +// split. locator_vm_test.dart runs every case of the files. +library; + +import 'dart:typed_data'; + +import 'package:datekeys/src/bytes.dart'; +import 'package:datekeys/src/locator.dart'; +import 'package:test/test.dart'; + +import 'locator_support.dart'; +import 'vectors/locator_vectors.g.dart'; + +/// The cases of the locator, shared with locator_vm_test.dart, which runs +/// them on the whole file. +void locatorCases(Json v) { + final envelope = v['envelope']! as Json; + final plainBases = [ + for (final h in v['plaintext_bases']! as List) + fromHex(h! as String), + ]; + final sealedBases = [ + for (final s in (v['sealed_bases']! as List).cast()) + fromHex(s['sealed']! as String), + ]; + Locator base({ + List? addresses, + List? key, + List? header, + List? restDigest, + int? restSize, + List? capsuleDigest, + }) => Locator( + addresses: + addresses ?? [LocatorAddress('https://ejemplo.org/foto.jpg', 3000)], + envelopeKey: key ?? fromHex(envelope['key']! as String), + envelopeHeader: header ?? fromHex(envelope['header']! as String), + restDigest: restDigest ?? fromHex(envelope['rest_digest']! as String), + restSize: restSize ?? envelope['rest_size']! as int, + capsuleDigest: + capsuleDigest ?? fromHex(envelope['capsule_digest']! as String), + ); + + test('locatorPlaintextLength gives the least multiple that key 6 fills, ' + 'on every base from -4100 to 16484', () { + var n = 0; + for (final r in rows(v, 'padding')) { + for (var b = r[0]! as int; b <= (r[1]! as int); b++) { + expect(locatorPlaintextLength(b), r[2], reason: '$b'); + n++; + } + } + expect(n, 4100 + 1 + 16484); + }); + + test('unmarshalLocator reads plaintexts as Unmarshal', () { + for (final c in rows(v, 'plaintexts')) { + final b = applyLocatorEdits(plainBases[c[0]! as int], c[1]); + Locator? l; + expect( + errorText(() => l = unmarshalLocator(b)), + textOf(v, c[2]), + reason: '${c[1]}', + ); + if (c[3] != null) { + expect(summaryOf(l!), expandSummary(c[3]), reason: '${c[1]}'); + // As FuzzUnmarshal of Go: an address that a reader uses passes the + // rules, and has a host to show. + for (final a in l!.usable) { + checkAddressUri(a.uri); + expect(a.host, isNotEmpty); + } + } + } + }); + + test('marshal writes the plaintext of Marshal, or its error', () { + for (final c in rows(v, 'marshal')) { + final l = base( + addresses: [ + for (final a in (c[0]! as List).cast>()) + LocatorAddress(uriOf(a[0]), a[1]! as int), + ], + header: c[1] == null ? null : fromHex(c[1]! as String), + restSize: c[2]! as int, + ); + Uint8List? b; + expect(errorText(() => b = l.marshal()), textOf(v, c[3]), reason: '$c'); + if (b != null) { + expect([b!.length, sha256Hex(b!)], [c[4], c[5]], reason: '$c'); + // It reads back as it was written. + expect(summaryOf(unmarshalLocator(b!)), summaryOf(l)); + } + } + }); + + test('openLocator opens a sealed locator with the release of its round, ' + 'as Open', () { + for (final c in rows(v, 'open')) { + final round = c[1]! as int; + final b = applyLocatorEdits(sealedBases[c[0]! as int], c[4]); + Locator? l; + expect( + errorText( + () => l = openLocator( + profileOf(c[3]! as String), + round, + openRelease(v, round, c[2]), + b, + ), + ), + textOf(v, c[5]), + reason: '$c', + ); + if (c[6] != null) { + expect(summaryOf(l!), expandSummary(c[6]), reason: '$c'); + } + } + }); + + test('openEnvelope checks the rest and the .dkc as OpenEnvelope', () { + final rest = fromHex(envelope['rest']! as String); + final header = fromHex(envelope['header']! as String); + for (final c in rows(v, 'envelopes')) { + final l = base( + key: c[1] == '' ? null : fromHex(c[1]! as String), + header: applyLocatorEdits(header, c[2]), + restDigest: c[3] == '' ? null : fromHex(c[3]! as String), + restSize: c[4]! as int, + capsuleDigest: c[5] == '' ? null : fromHex(c[5]! as String), + ); + Uint8List? dkc; + final r = applyLocatorEdits(rest, c[6]); + expect( + errorText(() => dkc = l.openEnvelope(r)), + textOf(v, c[7]), + reason: '${c[0]}', + ); + if (dkc != null) { + expect(sha256Hex(dkc!), c[8]); + expect(toHex(dkc!), envelope['dkc']); + } + } + }); + + test('restIn reads rest_size bytes from the offset, as RestIn', () { + final resources = [fromHex(v['rest_in_resource']! as String), Uint8List(0)]; + for (final c in rows(v, 'rest_in')) { + final l = base(restSize: c[2]! as int); + Uint8List? r; + expect( + errorText(() => r = l.restIn(resources[c[0]! as int], c[1]! as int)), + textOf(v, c[3]), + reason: '$c', + ); + if (r != null) expect(sha256Hex(r!), c[4]); + } + }); + + test('hideRest appends the rest, as Hide', () { + for (final c in rows(v, 'hide')) { + final (:file, :offset) = hideRest( + fromHex(c[0]! as String), + fromHex(c[1]! as String), + ); + expect([toHex(file), offset], [c[2], c[3]]); + } + }); + + test('splitEnvelope splits an age file where NewEnvelope does', () { + final key = fromHex(envelope['key']! as String); + final dkc = fromHex(envelope['dkc']! as String); + for (final c in rows(v, 'split')) { + final file = fromHex(c[0]! as String); + ({Locator locator, Uint8List rest})? s; + expect( + errorText(() => s = splitEnvelope(file, key, dkc)), + textOf(v, c[1]), + reason: '$c', + ); + if (s != null) { + final end = c[2]! as int; + expect(toHex(s!.locator.envelopeHeader), toHex(file.sublist(0, end))); + expect(toHex(s!.rest), toHex(file.sublist(end))); + expect(s!.locator.addresses, isEmpty); + } + } + // The envelope of NewEnvelope: its locator is the one of the vectors. + final rows0 = rows(v, 'split').first; + final s = splitEnvelope(fromHex(rows0[0]! as String), key, dkc); + expect(toHex(s.locator.envelopeHeader), envelope['header']); + expect(toHex(s.rest), envelope['rest']); + expect(toHex(s.locator.restDigest), envelope['rest_digest']); + expect(s.locator.restSize, envelope['rest_size']); + expect(toHex(s.locator.capsuleDigest), envelope['capsule_digest']); + expect(s.locator.openEnvelope(s.rest), dkc); + }); +} + +void main() { + final v = decodeJson(locatorVectorsJson); + group('the part of locator_vectors.json', () => locatorCases(v)); + + test('a Locator has the types of Go: keys and digests of 32 bytes and no ' + 'negative size or offset', () { + final k = Uint8List(32); + Locator make({int key = 32, int size = 0}) => Locator( + addresses: const [], + envelopeKey: Uint8List(key), + envelopeHeader: [1], + restDigest: k, + restSize: size, + capsuleDigest: k, + ); + make(); + expect(() => make(key: 31), throwsArgumentError); + expect(() => make(size: -1), throwsArgumentError); + expect(() => LocatorAddress('https://a.org/', -1), throwsArgumentError); + expect(() => make().restIn([1, 2], -1), throwsArgumentError); + // An envelope without addresses yet does not marshal. + expect( + errorText(() => make().marshal()), + 'locator: 0 addresses, not 1 to 8', + ); + final l = make().withAddresses([LocatorAddress('https://a.org/', 5)]); + expect(l.addresses.single.offset, 5); + l.wipe(); + expect(l.envelopeKey, Uint8List(32)); + }); +} diff --git a/test/locator_vm_test.dart b/test/locator_vm_test.dart index 091d2c4..541b458 100644 --- a/test/locator_vm_test.dart +++ b/test/locator_vm_test.dart @@ -1,42 +1,272 @@ -// The addresses of a locator against files, on the VM: every address of -// testdata/vectors/locator.json with the result and the text of Go, and the -// constant of locator_uris.g.dart checked against its file. +// The locator against files, on the VM: every case of +// testdata/vectors/locator.json but the data of the extension, with the +// result and the text of Go; every case of test/vectors/locator_vectors.json, of which +// locator_test.dart runs a part also compiled to JavaScript; the sealed +// locators whose plaintext passes 1 MiB, which Go reads through +// io.LimitReader. The constants of locator_uris.g.dart and +// locator_vectors.g.dart are checked against their files. @TestOn('vm') library; import 'dart:io'; +import 'dart:typed_data'; +import 'package:datekeys/src/bytes.dart'; +import 'package:datekeys/src/chacha20poly1305.dart'; import 'package:datekeys/src/locator.dart'; +import 'package:datekeys/src/profile.dart'; +import 'package:datekeys/src/sha256.dart'; import 'package:test/test.dart'; import 'locator_support.dart'; +import 'locator_test.dart' show locatorCases; import 'vectors/locator_uris.g.dart'; +import 'vectors/locator_vectors.g.dart'; Json readJson(String path) => decodeJson(File(path).readAsStringSync()); void main() { + final v = readJson('test/vectors/locator_vectors.json'); final uris = readJson('test/vectors/locator_uris.json'); final td = readJson('testdata/vectors/locator.json'); - test('locator_uris.g.dart holds locator_uris.json', () { - expect( - locatorUrisJson, - File('test/vectors/locator_uris.json').readAsStringSync(), + group('the constants', () { + test('locator_uris.g.dart holds locator_uris.json', () { + expect( + locatorUrisJson, + File('test/vectors/locator_uris.json').readAsStringSync(), + ); + }); + + test('locator_vectors.g.dart holds a part of locator_vectors.json', () { + final part = decodeJson(locatorVectorsJson); + List every(Object? xs, int n) => [ + for (final (i, x) in (xs! as List).indexed) + if (i % n == 0) x, + ]; + for (final MapEntry(:key, :value) in part.entries) { + if (key == 'description') continue; + final want = switch (key) { + 'plaintexts' => every(v[key], 3), + 'open' => (v[key]! as List).sublist(0, 24), + 'parse' => every(v[key], 2), + _ => v[key], + }; + expect(value, want, reason: key); + } + expect( + part.keys.toSet(), + v.keys.toSet().difference({'limit', 'capsule'}), + ); + }); + }); + + group('locator_vectors.json', () => locatorCases(v)); + + group('testdata/vectors/locator.json', () { + final p = quicknet(); + final round = td['round']! as int; + final release = releaseOf(v, round); + final tdTexts = v['testdata']! as Json; + + test('its locator opens with the release of its round, and the rest ' + 'in the host opens the envelope', () { + final loc = openLocator( + p, + round, + release, + fromHex(td['locator_sealed']! as String), + ); + expect(summaryOf(loc), expandSummary(tdTexts['main'])); + expect(toHex(loc.marshal()), td['locator_plaintext']); + expect(loc.marshal(), hasLength(locatorBlock)); + expect(toHex(loc.envelopeKey), td['envelope_key']); + expect(toHex(loc.restDigest), td['rest_digest']); + expect(loc.restSize, td['rest_size']); + expect(toHex(loc.capsuleDigest), td['capsule_digest']); + expect(toHex(loc.envelopeHeader), td['envelope_header']); + final addresses = (td['addresses']! as List).cast(); + expect(loc.addresses, hasLength(addresses.length)); + for (final (i, a) in addresses.indexed) { + expect( + [ + loc.addresses[i].uri, + loc.addresses[i].offset, + loc.addresses[i].host, + ], + [a['uri'], a['offset'], a['host']], + ); + } + final rest = loc.restIn( + fromHex(td['host']! as String), + td['host_offset']! as int, + ); + expect(toHex(rest), td['rest']); + expect(toHex(loc.openEnvelope(rest)), td['dkc']); + }); + + test('the padding of each base', () { + for (final c in (td['padding_cases']! as List).cast()) { + expect(locatorPlaintextLength(c['base']! as int), c['total']); + expect((c['total']! as int) % locatorBlock, 0); + } + }); + + test('every address, with the text of Go', () { + final cases = (td['uri_cases']! as List).cast(); + final texts = rows(uris, 'testdata'); + expect(texts, hasLength(cases.length)); + for (final (i, c) in cases.indexed) { + final uri = c['uri']! as String; + expect(texts[i][0], uri); + final got = errorText(() => checkAddressUri(uri)); + expect(got.isEmpty, c['ok'], reason: uri); + expect(got, textOf(uris, texts[i][1]), reason: uri); + expect(LocatorAddress(uri).host, texts[i][2], reason: uri); + } + }); + + test( + 'the mixed locator reads, and a reader uses the address it accepts', + () { + final m = td['mixed']! as Json; + final mixed = openLocator( + p, + round, + release, + fromHex(m['locator_sealed']! as String), + ); + final back = unmarshalLocator( + fromHex(m['locator_plaintext']! as String), + ); + expect(summaryOf(mixed), expandSummary(tdTexts['mixed'])); + expect(summaryOf(back), summaryOf(mixed)); + final usable = []; + for (final (i, a) + in (m['addresses']! as List).cast().indexed) { + expect( + mixed.addresses[i], + LocatorAddress(a['uri']! as String, a['offset']! as int), + ); + if (a['usable'] == true) usable.add(mixed.addresses[i]); + } + expect(usable, isNotEmpty); + expect(mixed.usable, usable); + // It cannot be written: a writer never writes an address that a reader + // rejects. + expect(() => mixed.marshal(), throwsA(isA())); + final rest = mixed.restIn( + fromHex(td['host']! as String), + usable.first.offset, + ); + expect(toHex(mixed.openEnvelope(rest)), td['dkc']); + }, ); + + test('the rests: rest_size bytes from the offset, used only when their ' + 'SHA-256 is resto_digest, with the texts of Go', () { + final loc = openLocator( + p, + round, + release, + fromHex(td['locator_sealed']! as String), + ); + final texts = rows(tdTexts, 'rest_cases'); + for (final (i, c) + in (td['rest_cases']! as List).cast().indexed) { + Uint8List? r; + expect( + errorText( + () => r = loc.restIn( + fromHex(c['resource']! as String), + c['offset']! as int, + ), + ), + textOf(v, texts[i][0]), + reason: '${c['name']}', + ); + var opens = false; + if (r != null) { + Uint8List? dkc; + expect( + errorText(() => dkc = loc.openEnvelope(r!)), + textOf(v, texts[i][1]), + reason: '${c['name']}', + ); + opens = dkc != null && toHex(dkc!) == td['dkc']; + } + expect(opens, c['opens'], reason: '${c['name']}'); + } + }); + + test('the plaintexts of the locator, with the texts of Go', () { + final texts = tdTexts['plaintext_cases']! as List; + for (final (i, c) + in (td['plaintext_cases']! as List).cast().indexed) { + final want = textOf(v, texts[i]); + expect( + errorText( + () => unmarshalLocator(fromHex(c['locator_plaintext']! as String)), + ), + want, + reason: '${c['name']}', + ); + expect(want.isEmpty, c['ok'], reason: '${c['name']}'); + } + }); }); - test('every address of testdata/vectors/locator.json, with the text of ' - 'Go', () { - final cases = (td['uri_cases']! as List).cast(); - final texts = rows(uris, 'testdata'); - expect(texts, hasLength(cases.length)); - for (final (i, c) in cases.indexed) { - final uri = c['uri']! as String; - expect(texts[i][0], uri); - final got = errorText(() => checkAddressUri(uri)); - expect(got.isEmpty, c['ok'], reason: uri); - expect(got, textOf(uris, texts[i][1]), reason: uri); - expect(LocatorAddress(uri).host, texts[i][2], reason: uri); + test('openLocator reads at most 1 MiB of plaintext, as Go through ' + 'io.LimitReader: what follows is neither decrypted nor checked', () { + final lim = v['limit']! as Json; + final header = fromHex(lim['header']! as String); + final nonce = fromHex(lim['nonce']! as String); + final streamKey = hkdfSha256( + fromHex(lim['file_key']! as String), + nonce, + 'payload'.codeUnits, + 32, + ); + expect(toHex(streamKey), lim['stream_key']); + final plain = fromHex( + (v['plaintext_bases']! as List)[0]! as String, + ); + final release = releaseOf(v, lim['round']! as int); + for (final c in (lim['cases']! as List).cast()) { + final chunks = rows(c, 'chunks'); + final total = chunks.fold(0, (n, ch) => n + (ch[0]! as int)); + final content = Uint8List(total) + ..setRange(0, plain.length < total ? plain.length : total, plain); + final out = BytesBuilder(copy: false) + ..add(header) + ..add(nonce); + var at = 0; + for (final (i, ch) in chunks.indexed) { + final n = ch[0]! as int; + // An 11-byte big-endian counter and the flag of the last chunk. + final chunkNonce = Uint8List(12) + ..[7] = i >> 24 + ..[8] = (i >> 16) & 0xff + ..[9] = (i >> 8) & 0xff + ..[10] = i & 0xff + ..[11] = ch[1] == true ? 1 : 0; + final ct = chacha20Poly1305Seal( + streamKey, + chunkNonce, + Uint8List.sublistView(content, at, at + n), + ); + if (ch[2] == true) ct[0] ^= 1; + out.add(ct); + at += n; + } + out.add(Uint8List(c['trailing']! as int)); + final file = out.takeBytes(); + expect([file.length, sha256Hex(file)], [c['length'], c['sha256']]); + expect( + errorText(() => openLocator(quicknet(), 1000, release, file)), + textOf(v, c['text']), + reason: '${c['name']}', + ); } }); }