Stage 7a: the locator, its opening and the envelope

lib/src/locator.dart gains the rest of package locator of datekeys-go but
Seal and the data of the extension, with the same checks in the same
order and the same texts:

- Locator: unmarshalLocator, the map of spec 44.1 with the length that
  Marshal gives and nothing else, and marshal, its form, its addresses and
  key 6 up to the least multiple of 4096 that it fills, as padFor and
  PlaintextLength; usable, the addresses that a reader uses.
- openLocator, Open of Go: the profile, then age with the tlock identity
  of agewrap, and at most 1 MiB of plaintext, as io.LimitReader: the
  chunks after it are neither decrypted nor checked.
- The envelope: restIn, openEnvelope with the size and the SHA-256 of the
  rest and of the .dkc, hideRest, and splitEnvelope, the part of
  NewEnvelope after its age encryption, which needs the writer of age.

A Locator keeps the types of Go: keys and digests of 32 bytes, and no
negative size or offset. Its errors carry no code, as in Go.

The tests run the cases of locator.json, the padding of every base, 482
plaintexts, Marshal at its limits, 118 openings, the files past 1 MiB, the
envelope, the rests and the split; a part also on Node.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.11
dev 2 days ago
parent bd421e0317
commit b2a495c0c9

@ -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<int> 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<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);

@ -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<String, Object?>;
@ -18,6 +24,122 @@ List<List<Object?>> rows(Json v, String key) => [
String textOf(Json v, Object? i) =>
(v['texts']! as List<Object?>)[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<Object?>;
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<int> base, Object? edits) {
final out = BytesBuilder(copy: false);
var pos = 0;
for (final e in (edits! as List<Object?>).cast<List<Object?>>()) {
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<int> 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<Object?> 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<Object?> expandSummary(Object? s) {
final l = s! as List<Object?>;
return [
[
for (final a in (l[0]! as List<Object?>).cast<List<Object?>>())
[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) {

@ -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<Object?>)
fromHex(h! as String),
];
final sealedBases = [
for (final s in (v['sealed_bases']! as List<Object?>).cast<Json>())
fromHex(s['sealed']! as String),
];
Locator base({
List<LocatorAddress>? addresses,
List<int>? key,
List<int>? header,
List<int>? restDigest,
int? restSize,
List<int>? 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<Object?>).cast<List<Object?>>())
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));
});
}

@ -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<Object?> every(Object? xs, int n) => [
for (final (i, x) in (xs! as List<Object?>).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<Object?>).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<Object?>).cast<Json>();
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<Object?>).cast<Json>()) {
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<Object?>).cast<Json>();
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 = <LocatorAddress>[];
for (final (i, a)
in (m['addresses']! as List<Object?>).cast<Json>().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<LocatorException>()));
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<Object?>).cast<Json>().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<Object?>;
for (final (i, c)
in (td['plaintext_cases']! as List<Object?>).cast<Json>().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<Object?>).cast<Json>();
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<Object?>)[0]! as String,
);
final release = releaseOf(v, lim['round']! as int);
for (final c in (lim['cases']! as List<Object?>).cast<Json>()) {
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']}',
);
}
});
}

Loading…
Cancel
Save

Powered by TurnKey Linux.