You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dateKeys-dart/lib/src/inspect.dart

487 lines
16 KiB

/// 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();
}

Powered by TurnKey Linux.