Stage 4c: steps 1 to 8, the inspection, its prefix and its view

lib/src/inspect.dart ports Inspect of package capsule of datekeys-go at
c531e93: the steps 1 to 8 of spec §63, without network and without
secrets, with the check of each step as Go records it, its name and its
detail, and Inspection with the fields of the steps that passed. The
opening runs them with a hook right after step 2, as the afterPrelude of
Go. inspectedLength, from prefix.ts of datekeys-ts, names the first bytes
that give the inspection of a whole file, so that a large capsule is read
up to one byte after the largest age header of PAYLOAD_AGE, and
maxAccessKeyRead the most bytes of a .dkk that its decoder needs. And
inspectView and inspectJson, the exact output of datekeys inspect -json
of internal/inspectview, with the public note.

lib/src/source.dart has ByteSource, the bytes of a capsule read by ranges,
which the application adapts from a file or a Blob, and BytesSource, over
bytes in memory; inspectCapsuleSource reads only the prefix.

The tests compare, byte for byte, the 24 fixtures/*.inspect.json and the
views of open_inspect.json, and each of the 5110 mutations of
inspect_differential.json with its code, its step and the text of Go,
also from a source read in pieces; and the prefix at the limits of age.

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

@ -30,6 +30,17 @@ export 'src/extension.dart';
export 'src/framing.dart'; export 'src/framing.dart';
export 'src/head.dart' hide decodeWrittenHead; export 'src/head.dart' hide decodeWrittenHead;
export 'src/header.dart'; export 'src/header.dart';
export 'src/inspect.dart'
show
CheckResult,
Inspection,
StanzaInfo,
inspectCapsule,
inspectCapsuleSource,
inspectJson,
inspectView,
inspectedLength,
maxAccessKeyRead;
export 'src/note.dart'; export 'src/note.dart';
export 'src/padding.dart'; export 'src/padding.dart';
export 'src/profile.dart'; export 'src/profile.dart';
@ -42,6 +53,7 @@ export 'src/release.dart'
quicknetScheme, quicknetScheme,
suppliedRelease, suppliedRelease,
verifyRelease; verifyRelease;
export 'src/source.dart' show ByteSource, BytesSource;
export 'src/version.dart'; export 'src/version.dart';
export 'src/wordkey.dart' export 'src/wordkey.dart'
show show

@ -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<String, Object?> 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<String> args) : args = List.unmodifiable(args);
/// The type, such as `tlock` or `X25519`.
final String type;
/// The arguments after the type.
final List<String> 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<CheckResult> 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<StanzaInfo>? outerStanzas;
/// The stanzas of PAYLOAD_AGE, once parsed at step 6.
final List<StanzaInfo>? 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<Unusable> 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<CheckResult> 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<CheckResult> 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<int> dkc,
ProfileRegistry registry,
ExtensionRegistry? extensions, {
void Function(Prelude prelude)? afterPrelude,
}) {
final checks = <CheckResult>[];
Prelude? prelude;
Uint8List? publicHeader;
Header? header;
Profile? profile;
Instant? unlockAt;
List<StanzaInfo>? outer;
List<StanzaInfo>? payload;
var unusable = const <Unusable>[];
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<AgeStanza> 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<AgeStanza> 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<Unusable> u) =>
u.isEmpty ? '' : ', ${u.length} unusable noncritical extensions';
List<StanzaInfo> _infos(List<AgeStanza> 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<int> 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<Inspection> 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<Uint8List> 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<int> 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<String, Object?> inspectView(
Inspection inspection, {
required String file,
}) {
final v = <String, Object?>{'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<String, Object?> 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();
}

@ -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<Uint8List> 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<int> bytes)
: _bytes = bytes is Uint8List ? bytes : Uint8List.fromList(bytes);
final Uint8List _bytes;
@override
int get length => _bytes.length;
@override
Future<Uint8List> 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<Uint8List> 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;
}

@ -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 = <String, Uint8List>{};
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<Object?> last(Inspection r) {
final c = r.checks.last;
return [c.step, c.ok, c.error, c.detail];
}
Uint8List concat(List<List<int>> 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<Object?> infos(List<StanzaInfo>? 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<Json>()) str(b, 'file'),
];
final mutations = (f['mutations']! as List).cast<Json>();
expect(mutations, hasLength(5110));
final texts = (inspectVectors['texts']! as List).cast<String>();
final results = (inspectVectors['results']! as List).cast<int>();
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<Object?>,
);
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<Json>();
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 = <String, Uint8List>{
'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<int> 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..'));
});
});
}

@ -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<Uint8List> 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));
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.