diff --git a/lib/src/locator.dart b/lib/src/locator.dart index a2ad03f..b28ece9 100644 --- a/lib/src/locator.dart +++ b/lib/src/locator.dart @@ -1,12 +1,12 @@ -/// 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 extension datekeys.capsule of a .dkk and what it points to (spec §43 +/// to §44.1), as package locator of datekeys-go at the draft v0.12, with the +/// same checks in the same order, the same codes and the same texts: +/// - the data of the extension ([CapsuleInfo], [parseCapsuleInfo]), which +/// says what the capsule of a key is and when it opens; +/// - the locator ([Locator]), an age file sealed with tlock for that date +/// ([openLocator]), whose plaintext says where the capsule is: the +/// addresses of the rest, and the key, the header and the digests of the +/// envelope; /// - the envelope, the .dkc encrypted with age and split into a header, /// which the locator carries, and a rest, the only thing kept outside, /// alone or inside another file ([hideRest], [Locator.restIn], @@ -25,10 +25,12 @@ /// 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. +/// The errors of the locator carry no normative code, as in Go: a locator +/// that does not read or does not open, or an address that breaks the rules +/// of §44.1, is unusable (spec §44.1, §57), and each is a [LocatorException] +/// with the text of Go. The data of the extension has one code, +/// ERR_EXTENSION_DATA_INVALID: data that does not read makes the extension +/// unusable, never the .dkk (spec §54). library; import 'dart:typed_data'; @@ -38,8 +40,12 @@ import 'agewrap.dart'; import 'bytes.dart'; import 'cbor.dart'; import 'chacha20poly1305.dart'; +import 'datekey.dart'; import 'errors.dart'; +import 'extension.dart'; import 'ipaddr.dart'; +import 'note.dart'; +import 'profile.dart'; import 'release.dart'; import 'sha256.dart'; import 'tlock.dart'; @@ -83,6 +89,194 @@ LocatorException _fail(String detail) => LocatorException('locator: $detail'); DateKeysException _undefined(String what) => DateKeysException(ErrorCode.nonCanonicalCbor, what); +// --------------------------------------------------------------------------- +// The data of datekeys.capsule + +/// The data of the extension datekeys.capsule (spec §44.1), as Info of Go. +final class CapsuleInfo { + /// The data with the copy of the public note [note], `''` for none, the + /// DateKey [dateKey] of the capsule, and the sealed locator [sealed], + /// which it copies, null for none. + CapsuleInfo({this.note = '', required this.dateKey, List? sealed}) + : sealed = sealed == null ? null : Uint8List.fromList(sealed); + + /// Key 0, the copy of the public note of the capsule, `''` for none. + final String note; + + /// Key 1, the DateKey of the capsule: it says when it opens. + final DateKey dateKey; + + /// Key 2, the age file of the locator, sealed with tlock for the round of + /// [dateKey], or null for none. + final Uint8List? sealed; + + /// The extension for the noncritical array of a .dkk, as Extension of Go: + /// the DateKey must be canonical, the sealed locator of 1 byte to 1 MiB, + /// and the note must meet the rules of spec §24.1; the extension is read + /// back with the rules of a reader ([parseCapsuleInfo]), which also ties + /// the locator to the round of the DateKey (spec §72). The rules of the + /// note throw their [DateKeysException], ERR_EXTENSION_DATA_INVALID; the + /// others a [LocatorException]. + Extension toExtension() { + DateKey? d; + try { + d = parseDateKey(compactDateKey(dateKey)); + } on DateKeysException { + d = null; + } + if (d == null || d != dateKey) { + throw _fail('Info.DateKey is not a canonical DateKey'); + } + final s = sealed; + if (s != null && (s.isEmpty || s.length > _maxSealed)) { + throw _fail( + 'a sealed locator of ${s.length} bytes, not 1 to $_maxSealed', + ); + } + if (note.isNotEmpty) checkNote(note); + final e = CborEncoder(); + _encodeInfo(e, note, compactDateKey(dateKey), s); + final x = newExtension(capsuleExtensionId, 1, e.out()); + // Spec §72: the encoder reads what it writes with the rules of a reader, + // which also ties the locator to the round of the DateKey. + try { + parseCapsuleInfo(x); + } on DateKeysException catch (err) { + throw _fail( + 'self-check: a reader rejects this extension: ${err.message}', + ); + } + return x; + } + + /// The locator of the extension, opened with [release], the release of the + /// round of its own DateKey, in the profile that the DateKey names, as + /// OpenLocator of Go: a locator for another round or another chain does + /// not open, and is unusable (spec §44.1). [registry] holds the pinned + /// profiles, the default registry, Quicknet, when null, as in the opening + /// of a capsule. Throws a [LocatorException], also when the extension has + /// no locator. + Locator openLocator(Release release, {ProfileRegistry? registry}) { + final s = sealed; + if (s == null) throw _fail('the extension has no locator'); + final p = (registry ?? defaultRegistry()).lookup(dateKey.profileId); + if (p == null) throw _fail('the profile of the DateKey is not pinned'); + return _open(p, dateKey.round, release, s); + } +} + +void _encodeInfo(CborEncoder e, String note, String dk, Uint8List? sealed) { + var pairs = 1; + if (note.isNotEmpty) pairs++; + if (sealed != null) pairs++; + e.map(pairs); + if (note.isNotEmpty) { + e + ..uint(0) + ..text(note); + } + e + ..uint(1) + ..text(dk); + if (sealed != null) { + e + ..uint(2) + ..bstr(sealed); + } +} + +DateKeysException _dataInvalid(String detail) => + DateKeysException(ErrorCode.extensionDataInvalid, 'locator: $detail'); + +/// Reads the data of a datekeys.capsule extension [x], as ParseInfo of Go: a +/// map of the profile of spec §58 with the note, key 0, which meets the +/// rules of spec §24.1, the canonical DateKey, key 1, and the sealed locator, +/// key 2, an age file with one tlock stanza for the round of the DateKey; its +/// chain is checked when it opens. A failure makes the extension unusable, +/// not the .dkk (spec §54): it is a [DateKeysException] whose only code is +/// ERR_EXTENSION_DATA_INVALID. +CapsuleInfo parseCapsuleInfo(Extension x) { + final data = x.data; + if (x.id != capsuleExtensionId || x.version != 1 || data == null) { + throw _dataInvalid('not datekeys.capsule version 1 with data'); + } + var note = ''; + var dk = ''; + Uint8List? sealed; + try { + unmarshalCbor(data, (d) { + final pairs = d.map(3); + var seen = 0; + for (var i = 0; i < pairs; i++) { + final k = d.key(); + switch (k) { + case 0: + withContext('key 0', () { + note = d.text(maxNoteLen); + checkNote(note); + }); + case 1: + dk = withContext('key 1', () => d.text(1024)); + case 2: + sealed = withContext('key 2', () => d.bstr(1, _maxSealed)); + default: + throw _undefined('key $k is not defined'); + } + seen |= 1 << (k as int); + } + if (seen & 2 == 0) throw _undefined('key 1 is missing'); + d.endMap(); + }, (e) => _encodeInfo(e, note, dk, sealed)); + } on DateKeysException catch (err) { + throw _dataInvalid('datekeys.capsule: ${err.message}'); + } + DateKey? d; + try { + d = parseDateKey(dk); + } on DateKeysException { + d = null; + } + if (d == null || compactDateKey(d) != dk) { + throw _dataInvalid('compact_datekey is not a canonical DateKey'); + } + final s = sealed; + if (s != null && !_sealedFor(s, d.round)) { + throw _dataInvalid( + 'the locator is not an age file with one tlock stanza for round ' + '${d.round}, the one of its DateKey', + ); + } + return CapsuleInfo(note: note, dateKey: d, sealed: s); +} + +// Whether sealed is an age file whose header holds one tlock stanza with two +// arguments, the first of them round. +bool _sealedFor(Uint8List sealed, int round) { + final List st; + try { + st = ageStanzas(sealed); + } on DateKeysException { + return false; + } + return st.length == 1 && + st[0].type == stanzaTlock && + st[0].args.length == 2 && + st[0].args[0] == '$round'; +} + +/// The check of the data of datekeys.capsule that a reader that knows the +/// extension runs (spec §54), as the ValidateCapsule of Go's +/// locator.Standard: the rejection of [parseCapsuleInfo], or null when the +/// data reads. [StandardExtensions] runs it. +Object? checkCapsuleData(Extension e) { + try { + parseCapsuleInfo(e); + return null; + } on DateKeysException catch (err) { + return err; + } +} + // --------------------------------------------------------------------------- // Addresses diff --git a/test/locator_test.dart b/test/locator_test.dart index 52b8c10..92d7325 100644 --- a/test/locator_test.dart +++ b/test/locator_test.dart @@ -2,13 +2,17 @@ // 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. +// their release and with others, the envelope, its rest and its split, the +// data of datekeys.capsule written and read, and the registry of +// locator.Standard. 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/datekey.dart'; +import 'package:datekeys/src/errors.dart'; +import 'package:datekeys/src/extension.dart'; import 'package:datekeys/src/locator.dart'; import 'package:test/test.dart'; @@ -27,6 +31,12 @@ void locatorCases(Json v) { for (final s in (v['sealed_bases']! as List).cast()) fromHex(s['sealed']! as String), ]; + final parseBases = [ + for (final h in v['parse_bases']! as List) fromHex(h! as String), + ]; + final datas = [ + for (final h in v['datas']! as List) fromHex(h! as String), + ]; Locator base({ List? addresses, List? 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', () { final rest = fromHex(envelope['rest']! 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(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().having( + (e) => e.code, + 'code', + ErrorCode.extensionDataInvalid, + ), + ), + ); + expect(checkCapsuleData(x), isA()); + 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) 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() { diff --git a/test/locator_vm_test.dart b/test/locator_vm_test.dart index 541b458..bd58085 100644 --- a/test/locator_vm_test.dart +++ b/test/locator_vm_test.dart @@ -1,21 +1,30 @@ // 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 +// testdata/vectors/locator.json with the result, the code 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. +// io.LimitReader; and the opening of a fixture whose .dkk carries +// 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') library; import 'dart:io'; import 'dart:typed_data'; +import 'package:datekeys/src/accesskey.dart'; import 'package:datekeys/src/bytes.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/open.dart' show OpenOptions, openCapsule; import 'package:datekeys/src/profile.dart'; +import 'package:datekeys/src/release.dart' show suppliedRelease; import 'package:datekeys/src/sha256.dart'; +import 'package:datekeys/src/sink.dart'; import 'package:test/test.dart'; import 'locator_support.dart'; @@ -69,14 +78,20 @@ void main() { 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), + test('the extension reads, its locator opens with the release of its ' + 'round, and the rest in the host opens the envelope', () { + final info = parseCapsuleInfo( + Extension( + capsuleExtensionId, + 1, + 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(toHex(loc.marshal()), td['locator_plaintext']); expect(loc.marshal(), hasLength(locatorBlock)); @@ -103,6 +118,9 @@ void main() { ); expect(toHex(rest), td['rest']); 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', () { @@ -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; + for (final (i, c) + in (td['extension_cases']! as List).cast().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().having( + (e) => e.code, + 'code', + ErrorCode.extensionDataInvalid, + ), + ), + ); + } + } + }); + test('the plaintexts of the locator, with the texts of Go', () { final texts = tdTexts['plaintext_cases']! as List; 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).cast()) { + 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']); + } + }); }