diff --git a/lib/datekeys.dart b/lib/datekeys.dart index 60fcddd..95aa467 100644 --- a/lib/datekeys.dart +++ b/lib/datekeys.dart @@ -30,6 +30,17 @@ export 'src/extension.dart'; export 'src/framing.dart'; export 'src/head.dart' hide decodeWrittenHead; export 'src/header.dart'; +export 'src/inspect.dart' + show + CheckResult, + Inspection, + StanzaInfo, + inspectCapsule, + inspectCapsuleSource, + inspectJson, + inspectView, + inspectedLength, + maxAccessKeyRead; export 'src/note.dart'; export 'src/padding.dart'; export 'src/profile.dart'; @@ -42,6 +53,7 @@ export 'src/release.dart' quicknetScheme, suppliedRelease, verifyRelease; +export 'src/source.dart' show ByteSource, BytesSource; export 'src/version.dart'; export 'src/wordkey.dart' show diff --git a/lib/src/inspect.dart b/lib/src/inspect.dart new file mode 100644 index 0000000..1cb8d0f --- /dev/null +++ b/lib/src/inspect.dart @@ -0,0 +1,486 @@ +/// Steps 1 to 8 of spec §63, as Inspect of package capsule of datekeys-go +/// (inspect.go) and inspect.ts and prefix.ts of datekeys-ts, and the view of +/// `datekeys inspect -json` (Go's internal/inspectview). +/// +/// The inspection never contacts a release source and never uses a secret, +/// so an invalid capsule is rejected before it can cause an observable query +/// (spec §27, §63). It reads the prelude, PUBLIC_HEADER, SEALED_CONTROL and +/// the age header of PAYLOAD_AGE; the rest of the payload is not read, so a +/// large file needs only its first [inspectedLength] bytes. +library; + +import 'dart:collection'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'age.dart'; +import 'agewrap.dart'; +import 'bytes.dart'; +import 'datekey.dart'; +import 'errors.dart'; +import 'extension.dart'; +import 'framing.dart'; +import 'header.dart'; +import 'profile.dart'; +import 'source.dart'; + +/// One step of the flow of spec §63, as CheckResult of Go. +final class CheckResult { + /// The result of [step], called [name]: passed with [detail], or failed + /// with the message of the error as [detail] and its code as [error]. + const CheckResult(this.step, this.name, this.ok, this.detail, [this.error]); + + /// The step of spec §63, 1 to 18. + final int step; + + /// The name of the step, such as `header validation`. + final String name; + + /// Whether the step passed. + final bool ok; + + /// The detail of a step that passed, informative text of the reference, or + /// the message of the error of a step that failed. + final String detail; + + /// The normative code of a step that failed, such as + /// `ERR_ROUND_MISMATCH`; null when it passed. + final String? error; + + /// The check as Go's JSON writes it, the empty fields omitted. + Map toJson() => { + 'step': step, + 'name': name, + 'ok': ok, + if (detail.isNotEmpty) 'detail': detail, + if (error != null && error!.isNotEmpty) 'error': error, + }; + + @override + String toString() => + 'step $step $name: ${ok ? 'ok' : 'FAIL'}' + '${detail.isEmpty ? '' : ', $detail'}'; +} + +/// The visible part of an age recipient stanza: its type and arguments. +final class StanzaInfo { + /// The stanza of [type] with [args]. + StanzaInfo(this.type, List args) : args = List.unmodifiable(args); + + /// The type, such as `tlock` or `X25519`. + final String type; + + /// The arguments after the type. + final List args; +} + +/// The result of steps 1 to 8 of spec §63, produced without network and +/// without secrets, as Inspection of Go. On failure, the fields of the +/// steps that passed are set, and [error] holds the failure, also recorded +/// as the last check. +final class Inspection { + Inspection._({ + required this.prelude, + required this.publicHeader, + required this.header, + required this.profile, + required this.unlockAt, + required this.outerStanzas, + required this.payloadStanzas, + required this.unusableExtensions, + required List checks, + required this.error, + }) : checks = UnmodifiableListView(checks); + + /// The prelude, once step 2 passed. Its format is the format of the + /// capsule, which a caller should show: format 1 does not hide the number + /// of credentials or the exact length of the content (spec §55.2, §70). + final Prelude? prelude; + + /// The exact bytes of PUBLIC_HEADER, once step 3 passed. + final Uint8List? publicHeader; + + /// PUBLIC_HEADER, once decoded at step 4. + final Header? header; + + /// The pinned profile of the DateKey, once found at step 4. + final Profile? profile; + + /// The round time of the DateKey (spec §15), once step 7 passed. + final Instant? unlockAt; + + /// The stanzas of OUTER_TIME_AGE, once parsed at step 5. + final List? outerStanzas; + + /// The stanzas of PAYLOAD_AGE, once parsed at step 6. + final List? payloadStanzas; + + /// The known noncritical extensions of PUBLIC_HEADER whose data the + /// extension registry rejects. The capsule stays valid; the application + /// must not use them (spec §54). + final List unusableExtensions; + + /// The checks of the steps run, in order. The opening appends those of + /// steps 9 to 18 to the inspection it returns. + final List checks; + + /// The failure of steps 1 to 8, or null when they pass. + final DateKeysException? error; + + /// Whether steps 1 to 8 pass. + bool get valid => error == null; + + /// The format of the capsule, once step 2 passed. + CapsuleFormat? get format => prelude?.format; + + /// Where PAYLOAD_AGE starts, once step 2 passed: 16 + PUBLIC_HEADER_LEN + + /// SEALED_CONTROL_LEN (spec §63). + int? get payloadOffset { + final p = prelude; + return p == null + ? null + : dkcPreludeSize + p.publicHeaderLen + p.sealedControlLen; + } + + /// The public note of PUBLIC_HEADER (spec §24.1), text of the creator that + /// nobody has checked, when it has one that is usable. + String? get publicNote => header?.publicNote; + + /// Whether PUBLIC_HEADER holds a public note that breaks the rules of + /// text, which a reader does not show and says so (spec §24.1). + bool get unusableNote => header?.unusableNote ?? false; +} + +/// The names of steps 1 to 8, as Go records them. +const _names = { + 1: 'parse DKC1', + 2: 'prelude', + 3: 'public header', + 4: 'header validation', + 5: 'sealed control structure', + 6: 'payload structure', + 7: 'condition', + 8: 'tlock stanza', +}; + +/// What the opening takes from the inspection: the inspection, the list +/// that its checks are a view of, and the sections of the capsule once steps +/// 1 to 8 pass. +typedef InspectedCapsule = ({ + Inspection inspection, + List checks, + CapsuleSections? sections, +}); + +/// Runs steps 1 to 8 on [dkc], a whole .dkc or its first bytes, at least +/// [inspectedLength] of them; [afterPrelude], when given, runs right after +/// step 2 passes, and what it throws ends the inspection there and +/// propagates, as the afterPrelude of Go's inspect. For the opening. +InspectedCapsule inspectSections( + List dkc, + ProfileRegistry registry, + ExtensionRegistry? extensions, { + void Function(Prelude prelude)? afterPrelude, +}) { + final checks = []; + Prelude? prelude; + Uint8List? publicHeader; + Header? header; + Profile? profile; + Instant? unlockAt; + List? outer; + List? payload; + var unusable = const []; + + InspectedCapsule done([DateKeysException? error, CapsuleSections? s]) => ( + inspection: Inspection._( + prelude: prelude, + publicHeader: publicHeader, + header: header, + profile: profile, + unlockAt: unlockAt, + outerStanzas: outer, + payloadStanzas: payload, + unusableExtensions: unusable, + checks: checks, + error: error, + ), + checks: checks, + sections: s, + ); + void pass(int step, String detail) => + checks.add(CheckResult(step, _names[step]!, true, detail)); + InspectedCapsule fail(int step, DateKeysException e) { + checks.add(CheckResult(step, _names[step]!, false, e.message, e.code.code)); + return done(e); + } + + void passPrelude(Prelude p) { + prelude = p; + pass( + 2, + 'DKC1 v${p.format.version}, PUBLIC_HEADER_LEN=${p.publicHeaderLen}, ' + 'SEALED_CONTROL_LEN=${p.sealedControlLen}', + ); + } + + // Steps 1 to 3: DKC1, the prelude and the exact PUBLIC_HEADER bytes. + final CapsuleSections s; + try { + s = splitCapsule(dkc); + } on FramingException catch (e) { + if (e.step > 1) pass(1, 'magic DKC1'); + final p = e.prelude; + if (p != null) { + passPrelude(p); + afterPrelude?.call(p); + } + return fail(e.step, e.error); + } + pass(1, 'magic DKC1'); + passPrelude(s.prelude); + afterPrelude?.call(s.prelude); + publicHeader = Uint8List.fromList(s.publicHeader); + pass(3, '${s.publicHeader.length} bytes'); + + // Step 4: canonical CBOR, canonical DateKey, pinned profile, known + // critical extensions with valid data. + final Header h; + try { + h = decodeHeader(s.publicHeader); + } on DateKeysException catch (e) { + return fail(4, e); + } + header = h; + final p = registry.lookup(h.dateKey.profileId); + if (p == null) { + return fail( + 4, + DateKeysException( + ErrorCode.unknownProfile, + 'capsule: profile ${goQuote(utf8Bytes(h.dateKey.profileId))} is not ' + 'pinned', + ), + ); + } + profile = p; + try { + withContext( + 'capsule: PUBLIC_HEADER', + () => checkCritical(h.critical, extensions, ExtensionObject.publicHeader), + ); + } on DateKeysException catch (e) { + return fail(4, e); + } + unusable = List.unmodifiable( + checkNoncritical(h.noncritical, extensions, ExtensionObject.publicHeader), + ); + pass( + 4, + 'capsule_id=${h.capsuleIdHex} datekey=${compactDateKey(h.dateKey)} ' + 'policy=${h.policy.label} profile=${p.id}${unusableDetail(unusable)}', + ); + + // Step 5: OUTER_TIME_AGE holds exactly one stanza, of type tlock. + final sealed = s.sealedControl; + if (sealed == null) return fail(5, truncatedSealedControl()); + final List outerStanzas; + try { + outerStanzas = ageStanzas(sealed); + } on DateKeysException catch (e) { + return fail(5, e.wrap('capsule: SEALED_CONTROL')); + } + outer = _infos(outerStanzas); + if (outerStanzas.length != 1 || outerStanzas[0].type != stanzaTlock) { + return fail( + 5, + DateKeysException( + ErrorCode.policyStructureMismatch, + 'capsule: OUTER_TIME_AGE must hold exactly one tlock stanza, found ' + '${outerStanzas.length}', + ), + ); + } + pass(5, 'one tlock stanza'); + + // Step 6: PAYLOAD_AGE holds exactly one stanza, of type X25519. Only its + // age header is read. + final List payloadStanzas; + try { + payloadStanzas = ageStanzas(s.payload); + } on DateKeysException catch (e) { + return fail(6, e.wrap('capsule: PAYLOAD_AGE')); + } + payload = _infos(payloadStanzas); + try { + checkPayloadStanzas(payloadStanzas); + } on DateKeysException catch (e) { + return fail(6, e); + } + pass(6, 'one X25519 stanza'); + + // Step 7: resolve and verify the time condition locally. + final Instant unlock; + try { + validateDateKey(h.dateKey, p); + unlock = roundTime(p, h.dateKey.round); + } on DateKeysException catch (e) { + return fail(7, e); + } + unlockAt = unlock; + pass(7, 'round ${h.dateKey.round}, unlock at ${formatRfc3339(unlock)}'); + + // Step 8: the tlock stanza names the DateKey round and the pinned chain. + try { + checkTimeStanzas( + outerStanzas, + round: h.dateKey.round, + chainHashHex: p.chainHashHex, + profileId: p.id, + ); + } on DateKeysException catch (e) { + return fail(8, e); + } + pass(8, 'round ${h.dateKey.round}, chain ${p.chainHashHex}'); + return done(null, s); +} + +/// The detail that a step adds for the unusable noncritical extensions of an +/// object, as unusable of Go: empty when there is none. +String unusableDetail(List u) => + u.isEmpty ? '' : ', ${u.length} unusable noncritical extensions'; + +List _infos(List stanzas) => + List.unmodifiable([for (final s in stanzas) StanzaInfo(s.type, s.args)]); + +/// Runs steps 1 to 8 of spec §63 on [dkc], as Inspect of Go: the framing, +/// a canonical PUBLIC_HEADER with a canonical DateKey, a pinned profile and +/// known critical extensions, the stanzas of OUTER_TIME_AGE and of +/// PAYLOAD_AGE, the time condition, and the round and the chain hash of the +/// tlock stanza. [dkc] is the whole .dkc, or its first [inspectedLength] +/// bytes, which give the same result. The profiles are those of [registry], +/// the default registry when null, with Quicknet pinned; [extensions] are +/// those the application implements, none when null, the state of the base +/// protocol V1. +/// +/// It never throws for an invalid capsule: the failure is in +/// [Inspection.error], and is the last check. +Inspection inspectCapsule( + List dkc, { + ProfileRegistry? registry, + ExtensionRegistry? extensions, +}) => + inspectSections(dkc, registry ?? defaultRegistry(), extensions).inspection; + +/// [inspectCapsule] of the .dkc that [source] reads, of which only its first +/// [inspectedLength] bytes are read. What [source] throws propagates. +Future inspectCapsuleSource( + ByteSource source, { + ProfileRegistry? registry, + ExtensionRegistry? extensions, +}) async => inspectCapsule( + await readInspected(source), + registry: registry, + extensions: extensions, +); + +/// The first [inspectedLength] bytes of the .dkc that [source] reads: its +/// first 16, and then, when its prelude passes steps 1 and 2, the rest of +/// them. +Future readInspected(ByteSource source) async { + final head = await readRange(source, 0, dkcPreludeSize); + final n = inspectedLength(source.length, head); + return n == head.length ? head : readRange(source, 0, n); +} + +/// How many leading bytes of a .dkc of [size] bytes steps 1 to 8 need, +/// given its first min([size], 16) bytes [head], as inspectedLength of +/// datekeys-ts. Inspecting that prefix gives exactly the result of +/// inspecting the whole file: +/// +/// - a file shorter than 16 bytes is read whole; +/// - a prelude that steps 1 and 2 reject fails on its 16 bytes alone, so +/// nothing more is read: a stray video or a .dkk costs 16 bytes; +/// - otherwise steps 3 and 5 compare the file length with the end of +/// PUBLIC_HEADER and of SEALED_CONTROL, whose lengths are within the limits +/// of spec §57; the prefix is the whole file whenever it ends before +/// PAYLOAD_AGE plus the bytes below; +/// - steps 5 and 6 parse the age headers of SEALED_CONTROL, whole in the +/// prefix, and of PAYLOAD_AGE, whose parser reads at most 2 MiB and +/// otherwise only asks whether the file goes further, which one more byte +/// answers. The rest of the payload is never read. +int inspectedLength(int size, List head) { + if (head.length < dkcPreludeSize) return size; + final Prelude p; + try { + p = parsePrelude(head); + } on DateKeysException { + // Steps 1 and 2 run this same check on these same 16 bytes. + return head.length; + } + final n = payloadOffset(p) + maxAgeHeaderLength + 1; + return size < n ? size : n; +} + +/// The most bytes of a .dkk that [decodeAccessKey] needs: a valid one is its +/// prelude of 12 bytes and a body of at most 16 MiB (spec §57), and one byte +/// more is enough to see data after the largest body. A longer file decodes +/// to the same error from its first [maxAccessKeyRead] bytes as whole, since +/// no framing error states a length it did not read. +const maxAccessKeyRead = dkkPreludeSize + maxDkkBodyLen + 1; + +/// The view of `datekeys inspect -json` of [inspection] of [file], as New of +/// Go's internal/inspectview: the format once step 2 passed, the fields of +/// PUBLIC_HEADER once decoded, unlock_at once step 7 passed, the public note, +/// valid, the code of the error and the checks, in the order of the Go +/// struct and with its omissions. +Map inspectView( + Inspection inspection, { + required String file, +}) { + final v = {'file': file}; + final f = inspection.prelude?.format; + if (f != null) v['format'] = f.version; + final h = inspection.header; + if (h != null) { + v['capsule_id'] = h.capsuleIdHex; + final dk = compactDateKey(h.dateKey); + if (dk.isNotEmpty) v['datekey'] = dk; + if (h.dateKey.profileId.isNotEmpty) v['profile'] = h.dateKey.profileId; + if (h.dateKey.round != 0) v['round'] = h.dateKey.round; + } + final unlock = inspection.unlockAt; + if (unlock != null) v['unlock_at'] = formatRfc3339(unlock); + if (h != null) { + v['access_policy'] = h.policy.label; + final note = h.publicNote; + if (note != null && note.isNotEmpty) v['public_note'] = note; + if (h.unusableNote) v['public_note_unusable'] = true; + } + v['valid'] = inspection.valid; + final error = inspection.error; + if (error != null) v['error'] = error.code.code; + v['checks'] = [for (final c in inspection.checks) c.toJson()]; + return v; +} + +/// The exact output of `datekeys inspect -json` for [view], as WriteJSON of +/// Go's internal/inspectview: json.Encoder with SetIndent("", " "), which +/// escapes <, > and & and U+2028 and U+2029, and ends with a newline. Keys +/// and the values that are not strings never hold those characters, so +/// escaping the whole text only touches strings; dart:convert writes every +/// other escape as Go 1.22 and later do. +String inspectJson(Map view) { + final text = const JsonEncoder.withIndent(' ').convert(view); + final out = StringBuffer(); + for (final c in text.runes) { + switch (c) { + case 0x3c || 0x3e || 0x26 || 0x2028 || 0x2029: + out.write('\\u${c.toRadixString(16).padLeft(4, '0')}'); + default: + out.writeCharCode(c); + } + } + out.write('\n'); + return out.toString(); +} diff --git a/lib/src/source.dart b/lib/src/source.dart new file mode 100644 index 0000000..f814936 --- /dev/null +++ b/lib/src/source.dart @@ -0,0 +1,67 @@ +/// The bytes of a .dkc that is not held in memory: a random-access source, +/// such as a file, that the inspection and the opening read by ranges, so +/// that a large capsule is never loaded whole (spec §57, §63). The library +/// has no dart:io: the application adapts its files, as a RandomAccessFile of +/// dart:io or a Blob of the web. +library; + +import 'dart:typed_data'; + +/// The bytes of a capsule, read by ranges. +abstract interface class ByteSource { + /// The length of the capsule, in bytes. It does not change while it is + /// read. + int get length; + + /// The [n] bytes at [offset], or the bytes from [offset] to the end when + /// fewer remain. [offset] and [n] are never negative, and [offset] is never + /// past [length]. What it throws ends the inspection or the opening, as an + /// error of the caller, and never as a verdict on the capsule. + Future read(int offset, int n); +} + +/// A [ByteSource] over bytes in memory, which it reads as views. +final class BytesSource implements ByteSource { + /// The source of [bytes], which it keeps as they are. + BytesSource(List bytes) + : _bytes = bytes is Uint8List ? bytes : Uint8List.fromList(bytes); + + final Uint8List _bytes; + + @override + int get length => _bytes.length; + + @override + Future read(int offset, int n) async { + RangeError.checkValueInInterval(offset, 0, _bytes.length, 'offset'); + RangeError.checkNotNegative(n, 'n'); + final end = n < _bytes.length - offset ? offset + n : _bytes.length; + return Uint8List.sublistView(_bytes, offset, end); + } +} + +/// Reads exactly the bytes from [offset] to [offset] + [n] of [source], or to +/// its end when it is shorter, in as many reads as it takes. A source that +/// gives fewer bytes than it has before its end breaks its contract: a +/// [StateError]. +Future readRange(ByteSource source, int offset, int n) async { + final want = n < source.length - offset ? n : source.length - offset; + if (want <= 0) return Uint8List(0); + final first = await source.read(offset, want); + if (first.length >= want) { + return first.length == want ? first : Uint8List.sublistView(first, 0, want); + } + final out = Uint8List(want)..setRange(0, first.length, first); + for (var got = first.length; got < want;) { + final piece = await source.read(offset + got, want - got); + if (piece.isEmpty) { + throw StateError( + 'source: ${source.length} bytes, but none at ${offset + got}', + ); + } + final k = piece.length < want - got ? piece.length : want - got; + out.setRange(got, got + k, piece); + got += k; + } + return out; +} diff --git a/test/open_inspect_test.dart b/test/open_inspect_test.dart new file mode 100644 index 0000000..3fa8656 --- /dev/null +++ b/test/open_inspect_test.dart @@ -0,0 +1,321 @@ +// Steps 1 to 8 against Go: fixtures/*.inspect.json, byte for byte the +// output of `datekeys inspect -json`; every mutation of +// testdata/vectors/inspect_differential.json with its code and step, and the +// text that capsule.Inspect of Go gives (test/vectors/open_inspect.json); +// the views of headers with a public note; and the prefix of +// inspectedLength, which gives the result of the whole file, whatever is +// after it, from a source read in pieces. +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:datekeys/datekeys.dart'; +import 'package:datekeys/src/age.dart' show maxAgeHeaderLength; +import 'package:test/test.dart'; + +import 'open_vectors_support.dart'; +import 'source_support.dart'; +import 'tlock_support.dart' show applyEdits; + +Json readJson(String path) => jsonDecode(File(path).readAsStringSync()) as Json; + +final _fixtures = {}; + +Uint8List fixture(String file) => _fixtures.putIfAbsent( + file, + () => File('testdata/fixtures/$file').readAsBytesSync(), +); + +ExtensionRegistry? registryOf(String name) => switch (name) { + 'standard' => const StandardExtensions(), + 'reject_all' => const RejectAll('not today'), + _ => null, +}; + +/// The last check of an inspection: [step, ok, code, detail]. +List last(Inspection r) { + final c = r.checks.last; + return [c.step, c.ok, c.error, c.detail]; +} + +Uint8List concat(List> parts) => concatBytes(parts); + +/// The prelude of a .dkc of [format] with the two lengths. +Uint8List preludeWith(int ph, int sc, {int format = 1, int flags = 0}) { + final b = Uint8List(16); + b.setRange(0, 4, 'DKC1'.codeUnits); + b[4] = format; + b[5] = flags; + ByteData.sublistView(b) + ..setUint32(8, ph) + ..setUint32(12, sc); + return b; +} + +void main() { + final names = + Directory('testdata/fixtures') + .listSync() + .map((f) => f.uri.pathSegments.last) + .where((n) => n.endsWith('.dkc')) + .toList() + ..sort(); + + group('fixtures/*.inspect.json', () { + for (final name in names) { + test(name, () async { + final want = File( + 'testdata/fixtures/${name.replaceAll('.dkc', '.inspect.json')}', + ).readAsStringSync(); + final dkc = fixture(name); + final r = inspectCapsule(dkc); + expect(inspectJson(inspectView(r, file: name)), want); + final s = await inspectCapsuleSource(ChunkySource(dkc, 1000)); + expect(inspectJson(inspectView(s, file: name)), want); + // The other fields, from the record of the fixture. + final rec = readJson( + 'testdata/fixtures/${name.replaceAll('.dkc', '.json')}', + ); + final p = parsePrelude(dkc); + List infos(List? s) => [ + for (final x in s!) {'type': x.type, 'args': x.args}, + ]; + expect( + [ + r.valid, + r.format?.version, + r.payloadOffset, + toHex(r.publicHeader!), + r.profile!.id, + formatRfc3339(r.unlockAt!), + canonical(infos(r.outerStanzas)), + canonical(infos(r.payloadStanzas)), + r.unusableExtensions, + r.publicNote, + r.unusableNote, + ], + [ + true, + rec['format'], + 16 + p.publicHeaderLen + p.sealedControlLen, + rec['public_header'], + 'datekeys:quicknet:v1', + rec['unlock_at'], + canonical(rec['outer_stanzas']), + canonical(rec['payload_stanzas']), + isEmpty, + null, + false, + ], + ); + }); + } + }); + + final inspectVectors = readJson('test/vectors/open_inspect.json'); + + test( + 'inspect_differential.json: every mutation, with the text of Go', + () async { + final f = readJson('testdata/vectors/inspect_differential.json'); + expect(f['spec'], specVersion); + final bases = [ + for (final b in (f['bases']! as List).cast()) str(b, 'file'), + ]; + final mutations = (f['mutations']! as List).cast(); + expect(mutations, hasLength(5110)); + final texts = (inspectVectors['texts']! as List).cast(); + final results = (inspectVectors['results']! as List).cast(); + expect(results, hasLength(mutations.length)); + for (var i = 0; i < mutations.length; i++) { + final m = mutations[i]; + final dkc = applyEdits( + fixture(bases[m['base']! as int]), + m['edits']! as List, + ); + final r = inspectCapsule(dkc); + final want = m['result'] == 'ok' + ? [8, true, null] + : [m['step'], false, m['result']]; + expect(last(r).sublist(0, 3), want, reason: 'mutation $i'); + expect(r.valid, m['result'] == 'ok', reason: 'mutation $i'); + if (results[i] >= 0) { + expect(r.error!.message, texts[results[i]], reason: 'mutation $i'); + } + // The prefix that inspectedLength names gives the same inspection. + if (i % 5 == 0) { + final s = await inspectCapsuleSource(ChunkySource(dkc, 777)); + expect( + inspectJson(inspectView(s, file: 'x')), + inspectJson(inspectView(r, file: 'x')), + reason: 'mutation $i', + ); + } + } + }, + ); + + test('open_inspect.json: the views of the notes and the extensions', () { + final views = (inspectVectors['views']! as List).cast(); + expect(views, hasLength(10)); + for (final v in views) { + final dkc = capsuleOf(v, fixture); + final r = inspectCapsule(dkc, extensions: registryOf(str(v, 'registry'))); + expect( + inspectJson(inspectView(r, file: 'capsule.dkc')), + v['view'], + reason: str(v, 'name'), + ); + } + }); + + group('inspectedLength', () { + final timeOnly = fixture('time_only.dkc'); + final r = inspectCapsule(timeOnly); + final payloadStart = r.payloadOffset!; + + test('stops one byte after the largest age header of PAYLOAD_AGE', () { + expect( + inspectedLength(1000000000, timeOnly.sublist(0, 16)), + payloadStart + maxAgeHeaderLength + 1, + ); + expect( + inspectedLength(timeOnly.length, timeOnly.sublist(0, 16)), + timeOnly.length, + ); + }); + + test('reads short files whole', () { + expect(inspectedLength(7, timeOnly.sublist(0, 7)), 7); + expect(inspectedLength(0, Uint8List(0)), 0); + }); + + final rejected = { + 'not DKC1': Uint8List.fromList('DKC2'.codeUnits + Uint8List(12)), + 'version 4': preludeWith(10, 10, format: 4), + 'FLAGS 1': preludeWith(10, 10, flags: 1), + 'PUBLIC_HEADER_LEN 0': preludeWith(0, 10), + 'SEALED_CONTROL_LEN 0': preludeWith(10, 0), + 'PUBLIC_HEADER_LEN above 1 MiB': preludeWith((1 << 20) + 1, 10), + 'SEALED_CONTROL_LEN above 64 MiB': preludeWith(10, (64 << 20) + 1), + 'both lengths 0xffffffff': preludeWith(0xffffffff, 0xffffffff), + }; + + test('reads 16 bytes of a prelude that steps 1 and 2 reject', () async { + final tail = Uint8List(3 << 20)..fillRange(0, 3 << 20, 0xff); + for (final MapEntry(key: what, value: head) in rejected.entries) { + expect(inspectedLength(1 << 40, head), 16, reason: what); + expect(inspectedLength(16, head), 16, reason: what); + final dkc = concat([head, tail]); + final s = ChunkySource(dkc); + final r = await inspectCapsuleSource(s); + expect(s.farthest, 16, reason: what); + expect(last(r), last(inspectCapsule(dkc)), reason: what); + } + }); + + test('reaches at most the largest sections and one age header', () { + expect( + inspectedLength(1 << 40, preludeWith(1 << 20, 64 << 20)), + 16 + (1 << 20) + (64 << 20) + maxAgeHeaderLength + 1, + ); + }); + + test('reads every fixture whole, and a .dkk in 16 bytes', () async { + for (final name in names) { + final dkc = fixture(name); + final s = ChunkySource(dkc); + await inspectCapsuleSource(s); + expect(s.farthest, dkc.length, reason: name); + } + final dkk = fixture('time_and_key_portable.dkk'); + final s = ChunkySource(dkk); + final r = await inspectCapsuleSource(s); + expect( + [s.farthest, ...last(r).sublist(0, 3)], + [16, 1, false, 'ERR_INVALID_MAGIC'], + ); + }); + + test( + 'skips the payload after its age header, with the same result', + () async { + final big = concat([ + timeOnly, + Uint8List(3 << 20)..fillRange(0, 3 << 20, 0x61), + ]); + final s = ChunkySource(big, 1 << 20); + final r = await inspectCapsuleSource(s); + expect(s.farthest, payloadStart + maxAgeHeaderLength + 1); + expect(last(r), last(inspectCapsule(big))); + }, + ); + + test('keeps the result of an age header at and around 2 MiB', () async { + final parts = splitCapsule(timeOnly); + Uint8List frame(List payload) => + concat([timeOnly.sublist(0, payloadStart), payload]); + final intro = 'age-encryption.org/v1\n-> X25519 '.codeUnits; + for (final end in [ + maxAgeHeaderLength - 2, + maxAgeHeaderLength - 1, + maxAgeHeaderLength, + maxAgeHeaderLength + 1, + ]) { + final line = Uint8List(end - intro.length) + ..fillRange(0, end - intro.length, 0x41); + final dkc = frame( + concat([intro, line, '\n\n--- AAAA\n'.codeUnits, Uint8List(1 << 20)]), + ); + final s = ChunkySource(dkc, 1 << 20); + final r = await inspectCapsuleSource(s); + expect(s.farthest, payloadStart + maxAgeHeaderLength + 1); + expect(last(r), last(inspectCapsule(dkc)), reason: '$end'); + } + final endless = frame( + concat([intro, Uint8List(3 << 20)..fillRange(0, 3 << 20, 0x41)]), + ); + final r = await inspectCapsuleSource(ChunkySource(endless, 1 << 20)); + expect(last(r), last(inspectCapsule(endless))); + expect(last(r), [ + 6, + false, + 'ERR_INTEGRITY', + 'capsule: PAYLOAD_AGE: agewrap: not an age v1 header: malformed, ' + 'truncated or beyond the parser limits: ERR_INTEGRITY', + ]); + expect(parts.prelude.format, CapsuleFormat.format1); + }); + }); + + group('maxAccessKeyRead', () { + String decodeError(Uint8List dkk) { + try { + decodeAccessKey(dkk).wipe(); + return 'ok'; + } on DateKeysException catch (e) { + return e.message; + } + } + + final dkk = fixture('time_and_key_portable.dkk'); + + test('is one byte past the largest valid .dkk, with the same error', () { + expect(maxAccessKeyRead, 12 + (16 << 20) + 1); + final long = concat([dkk, Uint8List(16 << 20)]); + final prefix = long.sublist(0, maxAccessKeyRead); + expect(decodeError(prefix), decodeError(long)); + expect(decodeError(long), contains('data after BODY_CBOR')); + final over = concat([ + dkk.sublist(0, 8), + [1, 0, 0, 1], + Uint8List((16 << 20) + 8), + ]); + expect(decodeError(over.sublist(0, maxAccessKeyRead)), decodeError(over)); + expect(decodeError(over), contains('outside 1..')); + }); + }); +} diff --git a/test/source_support.dart b/test/source_support.dart new file mode 100644 index 0000000..07ba019 --- /dev/null +++ b/test/source_support.dart @@ -0,0 +1,37 @@ +// A source of the bytes of a capsule read in pieces, for the tests of the +// inspection and of the opening from a ByteSource. It reads no file. +library; + +import 'dart:typed_data'; + +import 'package:datekeys/datekeys.dart'; + +/// A source that gives at most [step] bytes per read, so that every range +/// is read in several pieces, and counts what it reads. +final class ChunkySource implements ByteSource { + ChunkySource(this._b, [this.step = 4093]); + + final Uint8List _b; + final int step; + int reads = 0; + int bytesRead = 0; + int largest = 0; + + /// The end of the farthest range read. + int farthest = 0; + + @override + int get length => _b.length; + + @override + Future read(int offset, int n) async { + reads++; + final k = n < step ? n : step; + final end = offset + k < _b.length ? offset + k : _b.length; + bytesRead += end - offset; + if (end - offset > largest) largest = end - offset; + if (end > farthest) farthest = end; + // A copy: the opening owns nothing that the source keeps. + return Uint8List.fromList(Uint8List.sublistView(_b, offset, end)); + } +}