Stage 7a: the data of datekeys.capsule

lib/src/locator.dart gains the data of the extension, as Info, ParseInfo
and Info.Extension of Go, with the same checks in the same order and the
same texts:

- parseCapsuleInfo: the map of spec 44.1 with the note under the rules of
  spec 24.1, the canonical DateKey, and a sealed locator that is an age
  file with one tlock stanza for its round; every failure is
  ERR_EXTENSION_DATA_INVALID and nothing else, so that it makes the
  extension unusable and never the .dkk (spec 54).
- CapsuleInfo.toExtension, which reads back what it writes (spec 72), and
  CapsuleInfo.openLocator, OpenLocator of Go, with the default registry of
  pinned profiles when none is given, as the opening of a capsule takes it.
- checkCapsuleData, the ValidateCapsule of Go's locator.Standard, for
  StandardExtensions.

The tests run the extension cases of locator.json with their code, the
writing and the reading of the data, OpenLocator, the registry of
locator.Standard through checkCritical, checkNoncritical and checkWrite,
and the opening of a fixture whose .dkk carries datekeys.capsule, whose
unusable extensions and checks are those of Go.

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

@ -1,12 +1,12 @@
/// The locator of the extension datekeys.capsule of a .dkk and the envelope /// The extension datekeys.capsule of a .dkk and what it points to (spec §43
/// it points to (spec §44.1), as package locator of datekeys-go at the draft /// to §44.1), as package locator of datekeys-go at the draft v0.12, with the
/// v0.12, with the same checks in the same order and the same texts: /// same checks in the same order, the same codes and the same texts:
/// - the locator ([Locator]), an age file sealed with tlock for the date of /// - the data of the extension ([CapsuleInfo], [parseCapsuleInfo]), which
/// the capsule ([openLocator]), whose plaintext says where the capsule is: /// says what the capsule of a key is and when it opens;
/// the addresses of the rest, and the key, the header and the digests of /// - the locator ([Locator]), an age file sealed with tlock for that date
/// the envelope; /// ([openLocator]), whose plaintext says where the capsule is: the
/// - the addresses, as CheckURI of Go ([checkAddressUri]), and the host that /// addresses of the rest, and the key, the header and the digests of the
/// a reader shows before it downloads ([LocatorAddress.host]); /// envelope;
/// - the envelope, the .dkc encrypted with age and split into a header, /// - the envelope, the .dkc encrypted with age and split into a header,
/// which the locator carries, and a rest, the only thing kept outside, /// which the locator carries, and a rest, the only thing kept outside,
/// alone or inside another file ([hideRest], [Locator.restIn], /// alone or inside another file ([hideRest], [Locator.restIn],
@ -25,10 +25,12 @@
/// of NewEnvelope, need the writer of age: [splitEnvelope] is the rest of /// of NewEnvelope, need the writer of age: [splitEnvelope] is the rest of
/// NewEnvelope, the split of the age file of the envelope. /// NewEnvelope, the split of the age file of the envelope.
/// ///
/// The errors carry no normative code, as in Go: a locator that does not /// The errors of the locator carry no normative code, as in Go: a locator
/// read or does not open, or an address that breaks the rules of §44.1, is /// that does not read or does not open, or an address that breaks the rules
/// unusable (spec §44.1, §57), and each is a [LocatorException] with the /// of §44.1, is unusable (spec §44.1, §57), and each is a [LocatorException]
/// text of Go. /// 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; library;
import 'dart:typed_data'; import 'dart:typed_data';
@ -38,8 +40,12 @@ import 'agewrap.dart';
import 'bytes.dart'; import 'bytes.dart';
import 'cbor.dart'; import 'cbor.dart';
import 'chacha20poly1305.dart'; import 'chacha20poly1305.dart';
import 'datekey.dart';
import 'errors.dart'; import 'errors.dart';
import 'extension.dart';
import 'ipaddr.dart'; import 'ipaddr.dart';
import 'note.dart';
import 'profile.dart';
import 'release.dart'; import 'release.dart';
import 'sha256.dart'; import 'sha256.dart';
import 'tlock.dart'; import 'tlock.dart';
@ -83,6 +89,194 @@ LocatorException _fail(String detail) => LocatorException('locator: $detail');
DateKeysException _undefined(String what) => DateKeysException _undefined(String what) =>
DateKeysException(ErrorCode.nonCanonicalCbor, 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 // Addresses

@ -2,13 +2,17 @@
// locator_vectors.json that locator_vectors.g.dart holds, so that it runs on // 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 // the VM and compiled to JavaScript: the padding of every base, plaintexts
// valid and broken, Marshal at every limit, sealed locators opened with // valid and broken, Marshal at every limit, sealed locators opened with
// their release and with others, and the envelope, its rest and its // their release and with others, the envelope, its rest and its split, the
// split. locator_vm_test.dart runs every case of the files. // data of datekeys.capsule written and read, and the registry of
// locator.Standard. locator_vm_test.dart runs every case of the files.
library; library;
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:datekeys/src/bytes.dart'; import 'package:datekeys/src/bytes.dart';
import 'package:datekeys/src/datekey.dart';
import 'package:datekeys/src/errors.dart';
import 'package:datekeys/src/extension.dart';
import 'package:datekeys/src/locator.dart'; import 'package:datekeys/src/locator.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
@ -27,6 +31,12 @@ void locatorCases(Json v) {
for (final s in (v['sealed_bases']! as List<Object?>).cast<Json>()) for (final s in (v['sealed_bases']! as List<Object?>).cast<Json>())
fromHex(s['sealed']! as String), fromHex(s['sealed']! as String),
]; ];
final parseBases = [
for (final h in v['parse_bases']! as List<Object?>) fromHex(h! as String),
];
final datas = [
for (final h in v['datas']! as List<Object?>) fromHex(h! as String),
];
Locator base({ Locator base({
List<LocatorAddress>? addresses, List<LocatorAddress>? addresses,
List<int>? key, List<int>? key,
@ -122,6 +132,24 @@ void locatorCases(Json v) {
} }
}); });
test('the locator of an extension opens with the default registry, as '
'OpenLocator', () {
for (final c in rows(v, 'open_info')) {
final s = c[2];
final info = CapsuleInfo(
dateKey: DateKey(c[0]! as String, c[1]! as int),
sealed: s == null ? null : sealedBases[s as int],
);
Locator? l;
expect(
errorText(() => l = info.openLocator(releaseOf(v, c[3]! as int))),
textOf(v, c[4]),
reason: '$c',
);
if (c[5] != null) expect(summaryOf(l!), expandSummary(c[5]));
}
});
test('openEnvelope checks the rest and the .dkc as OpenEnvelope', () { test('openEnvelope checks the rest and the .dkc as OpenEnvelope', () {
final rest = fromHex(envelope['rest']! as String); final rest = fromHex(envelope['rest']! as String);
final header = fromHex(envelope['header']! as String); final header = fromHex(envelope['header']! as String);
@ -199,6 +227,119 @@ void locatorCases(Json v) {
expect(toHex(s.locator.capsuleDigest), envelope['capsule_digest']); expect(toHex(s.locator.capsuleDigest), envelope['capsule_digest']);
expect(s.locator.openEnvelope(s.rest), dkc); expect(s.locator.openEnvelope(s.rest), dkc);
}); });
test('toExtension writes the data of datekeys.capsule as Info.Extension', () {
for (final c in rows(v, 'info')) {
final sealed = switch (c[3]) {
null => null,
final int i => sealedBases[i],
final String h => fromHex(h),
final Json z => Uint8List(z['zeros']! as int),
_ => throw StateError('${c[3]}'),
};
final info = CapsuleInfo(
note: c[0]! as String,
dateKey: DateKey(c[1]! as String, c[2]! as int),
sealed: sealed,
);
Extension? x;
expect(
errorText(() => x = info.toExtension()),
textOf(v, c[4]),
reason: '${c.take(3)}',
);
if (x != null) {
expect([x!.id, x!.version], [capsuleExtensionId, 1]);
expect([x!.data!.length, sha256Hex(x!.data!)], [c[5], c[6]]);
// A reader reads it back.
final back = parseCapsuleInfo(x!);
expect([back.note, back.dateKey], [info.note, info.dateKey]);
expect(back.sealed, info.sealed);
}
}
});
test('parseCapsuleInfo reads the data of datekeys.capsule as ParseInfo, '
'with ERR_EXTENSION_DATA_INVALID only', () {
for (final c in rows(v, 'parse')) {
final b = c[2]! as int;
final data = b < 0 ? null : applyLocatorEdits(parseBases[b], c[3]);
CapsuleInfo? info;
final x = Extension(c[0]! as String, c[1]! as int, data);
final want = textOf(v, c[4]);
expect(errorText(() => info = parseCapsuleInfo(x)), want, reason: '$c');
if (want.isNotEmpty) {
expect(
() => parseCapsuleInfo(x),
throwsA(
isA<DateKeysException>().having(
(e) => e.code,
'code',
ErrorCode.extensionDataInvalid,
),
),
);
expect(checkCapsuleData(x), isA<DateKeysException>());
continue;
}
expect(checkCapsuleData(x), isNull);
expect(
[info!.note, compactDateKey(info!.dateKey), info!.dateKey.round],
[c[5], c[6], c[7]],
);
expect(info!.sealed == null ? null : sha256Hex(info!.sealed!), c[8]);
}
});
test('the registry of locator.Standard checks the data of datekeys.capsule '
'in a .dkk, and an encoder only its presence', () {
const std = StandardExtensions(validateCapsule: checkCapsuleData);
const presence = StandardExtensions(validateCapsule: null);
for (final c in rows(v, 'registry')) {
final d = c[2]! as int;
final x = Extension(
c[0]! as String,
c[1]! as int,
d < 0 ? null : datas[d],
);
expect(
[
for (final u in checkNoncritical([x], std, ExtensionObject.accessKey))
u.error.message,
],
[for (final i in c[3]! as List<Object?>) textOf(v, i)],
reason: '$c',
);
final texts = [
errorText(() => checkCritical([x], std, ExtensionObject.accessKey)),
errorText(
() => checkWrite(
std,
ExtensionObject.accessKey,
ExtensionArray.noncritical,
[x],
),
),
errorText(
() => checkWrite(
std,
ExtensionObject.accessKey,
ExtensionArray.critical,
[x],
),
),
errorText(
() => checkWrite(
presence,
ExtensionObject.accessKey,
ExtensionArray.noncritical,
[x],
),
),
];
expect(texts, [for (final i in c.sublist(4)) textOf(v, i)], reason: '$c');
}
});
} }
void main() { void main() {

@ -1,21 +1,30 @@
// The locator against files, on the VM: every case of // The locator against files, on the VM: every case of
// testdata/vectors/locator.json but the data of the extension, with the // testdata/vectors/locator.json with the result, the code and the text of
// result and the text of Go; every case of test/vectors/locator_vectors.json, of which // Go; every case of test/vectors/locator_vectors.json, of which
// locator_test.dart runs a part also compiled to JavaScript; the sealed // locator_test.dart runs a part also compiled to JavaScript; the sealed
// locators whose plaintext passes 1 MiB, which Go reads through // locators whose plaintext passes 1 MiB, which Go reads through
// io.LimitReader. The constants of locator_uris.g.dart and // io.LimitReader; and the opening of a fixture whose .dkk carries
// locator_vectors.g.dart are checked against their files. // datekeys.capsule, with the registry of locator.Standard. The constants of
// locator_uris.g.dart and locator_vectors.g.dart are checked against their
// files.
@TestOn('vm') @TestOn('vm')
library; library;
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:datekeys/src/accesskey.dart';
import 'package:datekeys/src/bytes.dart'; import 'package:datekeys/src/bytes.dart';
import 'package:datekeys/src/chacha20poly1305.dart'; import 'package:datekeys/src/chacha20poly1305.dart';
import 'package:datekeys/src/datekey.dart';
import 'package:datekeys/src/errors.dart';
import 'package:datekeys/src/extension.dart';
import 'package:datekeys/src/locator.dart'; import 'package:datekeys/src/locator.dart';
import 'package:datekeys/src/open.dart' show OpenOptions, openCapsule;
import 'package:datekeys/src/profile.dart'; import 'package:datekeys/src/profile.dart';
import 'package:datekeys/src/release.dart' show suppliedRelease;
import 'package:datekeys/src/sha256.dart'; import 'package:datekeys/src/sha256.dart';
import 'package:datekeys/src/sink.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
import 'locator_support.dart'; import 'locator_support.dart';
@ -69,14 +78,20 @@ void main() {
final release = releaseOf(v, round); final release = releaseOf(v, round);
final tdTexts = v['testdata']! as Json; final tdTexts = v['testdata']! as Json;
test('its locator opens with the release of its round, and the rest ' test('the extension reads, its locator opens with the release of its '
'in the host opens the envelope', () { 'round, and the rest in the host opens the envelope', () {
final loc = openLocator( final info = parseCapsuleInfo(
p, Extension(
round, capsuleExtensionId,
release, 1,
fromHex(td['locator_sealed']! as String), fromHex(td['extension_data']! as String),
),
); );
expect(info.note, td['note']);
expect(compactDateKey(info.dateKey), td['datekey']);
expect(info.dateKey.round, round);
expect(toHex(info.sealed!), td['locator_sealed']);
final loc = info.openLocator(release);
expect(summaryOf(loc), expandSummary(tdTexts['main'])); expect(summaryOf(loc), expandSummary(tdTexts['main']));
expect(toHex(loc.marshal()), td['locator_plaintext']); expect(toHex(loc.marshal()), td['locator_plaintext']);
expect(loc.marshal(), hasLength(locatorBlock)); expect(loc.marshal(), hasLength(locatorBlock));
@ -103,6 +118,9 @@ void main() {
); );
expect(toHex(rest), td['rest']); expect(toHex(rest), td['rest']);
expect(toHex(loc.openEnvelope(rest)), td['dkc']); expect(toHex(loc.openEnvelope(rest)), td['dkc']);
// The same with the top-level opening of Go's Open.
final again = openLocator(p, round, release, info.sealed!);
expect(summaryOf(again), summaryOf(loc));
}); });
test('the padding of each base', () { test('the padding of each base', () {
@ -199,6 +217,38 @@ void main() {
} }
}); });
test('the data of the extension: a reader cannot use some, and that has '
'ERR_EXTENSION_DATA_INVALID only', () {
final texts = tdTexts['extension_cases']! as List<Object?>;
for (final (i, c)
in (td['extension_cases']! as List<Object?>).cast<Json>().indexed) {
final x = Extension(
capsuleExtensionId,
1,
fromHex(c['extension_data']! as String),
);
final want = textOf(v, texts[i]);
expect(
errorText(() => parseCapsuleInfo(x)),
want,
reason: '${c['name']}',
);
expect(want.isEmpty, c['ok'], reason: '${c['name']}');
if (want.isNotEmpty) {
expect(
() => parseCapsuleInfo(x),
throwsA(
isA<DateKeysException>().having(
(e) => e.code,
'code',
ErrorCode.extensionDataInvalid,
),
),
);
}
}
});
test('the plaintexts of the locator, with the texts of Go', () { test('the plaintexts of the locator, with the texts of Go', () {
final texts = tdTexts['plaintext_cases']! as List<Object?>; final texts = tdTexts['plaintext_cases']! as List<Object?>;
for (final (i, c) for (final (i, c)
@ -269,4 +319,59 @@ void main() {
); );
} }
}); });
test('openCapsule with a .dkk that carries datekeys.capsule reports its '
'data as Go does with locator.Standard', () async {
final cap = v['capsule']! as Json;
final name = cap['fixture']! as String;
final dkc = File('testdata/fixtures/$name.dkc').readAsBytesSync();
final dkk = decodeAccessKey(
File('testdata/fixtures/$name.dkk').readAsBytesSync(),
);
final now = parseRfc3339(cap['now']! as String);
for (final c in (cap['cases']! as List<Object?>).cast<Json>()) {
final k = AccessKey(
credentialId: dkk.credentialId,
capsuleId: dkk.capsuleId,
type: dkk.type,
material: Uint8List.fromList(dkk.material),
verification: dkk.verification,
critical: dkk.critical,
noncritical: [
for (final x in rows(c, 'noncritical'))
Extension(x[0]! as String, x[1]! as int, fromHex(x[3]! as String)),
],
);
final file = encodeAccessKey(k);
expect(sha256Hex(file), c['dkk_sha256']);
final output = MemoryByteSink();
final opened = await openCapsule(
dkc,
OpenOptions(
source: suppliedRelease(releaseOf(v, 1000)),
now: () => now,
accessKeyFile: file,
output: output,
extensions: c['standard'] == true
? const StandardExtensions(validateCapsule: checkCapsuleData)
: null,
),
);
expect(opened.error?.message ?? '', textOf(v, c['text']));
expect(
[
for (final u in opened.unusableAccessKeyExtensions)
[u.id, u.version, u.error.message],
],
[
for (final u in rows(c, 'unusable')) [u[0], u[1], textOf(v, u[2])],
],
);
expect([
for (final ch in opened.checks)
[ch.step, ch.name, ch.ok, ch.detail, ch.error ?? ''],
], c['checks']);
expect(sha256Hex(output.bytes ?? Uint8List(0)), c['content']);
}
});
} }

Loading…
Cancel
Save

Powered by TurnKey Linux.