From 1784909ed7cc7f0593eb8d542e7df0722721c96f Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 5 Oct 2026 23:01:39 +0200 Subject: [PATCH] Stage 5b: the commitments, SECURITY_CBOR and the verdicts of alg 1 author.dart ports what an author signs and a seal seals from signature.go of datekeys-go: payload_commit, control_commit over CONTROL_SIG in each format, head_digest, signers_digest, AUTHOR_MESSAGE with its prefix and its 99 bytes, its code taken byte by byte as Go takes it, SIG_PART and SEAL_SUBJECT. security.dart ports SECURITY_CBOR and EvaluateSecurityIn: the outer map, author-signature and seal with the schema and the limits of Go, their encoders, and an evaluation that never throws. X for an outer map that fails its layer 2 or 3, version 2 among them; F0 to F4 for the signature, with verifyStrict for alg 1 and the key matched against the saved ones by its dkauthor1 string; S0 to S2 for the seal; and a failure inside one part fails that part only, as Go recovers a panic. Without a context it reads as a reader of v0.10. securityContext is newSecurityContext with the head digest, control_commit at zero when CONTROL_SIG cannot be encoded, and holderText the rule of a name of a certificate. The signature of alg 2 and the seal of seal_type 2 belong to the reader of CMS of stage 5c, behind the interface CmsEvaluator: without one their verdict is null, not evaluated, never guessed. verdicts.dart gains the texts of Go at the draft v0.12, Verdicts.lines and sealedAt, Detail, SignerLine and SignerResult, and verdicts that may be evaluated in part. The tests check every case of security_vectors.json, security.json in its context, the commitments, the signature and the seal of each fixture of format 3, and the boundary with a reader of CMS. Co-Authored-By: Claude Opus 5.5 --- lib/src/author.dart | 183 ++++++++++ lib/src/security.dart | 679 +++++++++++++++++++++++++++++++++++++ lib/src/verdicts.dart | 307 ++++++++++++++++- test/security_support.dart | 376 ++++++++++++++++++++ test/security_test.dart | 386 +++++++++++++++++++++ test/security_vm_test.dart | 209 ++++++++++++ 6 files changed, 2123 insertions(+), 17 deletions(-) create mode 100644 lib/src/author.dart create mode 100644 lib/src/security.dart create mode 100644 test/security_support.dart create mode 100644 test/security_test.dart create mode 100644 test/security_vm_test.dart diff --git a/lib/src/author.dart b/lib/src/author.dart new file mode 100644 index 0000000..cfa3404 --- /dev/null +++ b/lib/src/author.dart @@ -0,0 +1,183 @@ +/// What an author signs and a seal seals (spec §29.8, §29.11), as +/// signature.go of package capsule of datekeys-go and author.ts of +/// datekeys-ts: payload_commit, control_commit over CONTROL_SIG, head_digest, +/// signers_digest, AUTHOR_MESSAGE, the ASCII text of 99 bytes that an author +/// signs, with its code, SIG_PART and SEAL_SUBJECT. Every value is +/// recomputed from the capsule once it is open; none is stored. +/// +/// Each value is SHA-256(prefix || 0x00 || parts), the prefix an ASCII +/// string of its domain, except AUTHOR_MESSAGE, where a line feed follows +/// the prefix. +library; + +import 'dart:typed_data'; + +import 'bytes.dart'; +import 'control.dart'; +import 'framing.dart'; +import 'sha256.dart'; + +// The domain prefixes of what an author signs and a seal seals (spec §29.8, +// §29.11). +const _payloadCommitPrefix = 'datekeys:dkc3:payload:v1'; +const _controlCommitPrefix = 'datekeys:dkc3:control:v1'; +const _headDigestPrefix = 'datekeys:dkc3:head:v1'; +const _signersDigestPrefix = 'datekeys:dkc3:signers:v1'; +const _sigPartPrefix = 'datekeys:dkc3:sig-part:v1'; +const _sealSubjectPrefix = 'datekeys:dkc3:seal-subject:v1'; + +/// The first line of AUTHOR_MESSAGE (spec §29.8). +const authorMessagePrefix = 'datekeys:dkc3:author-signature:v1'; + +/// The length of AUTHOR_MESSAGE: the prefix, a line feed, the 64 +/// hexadecimal digits of its digest and a line feed, 99 bytes. +const authorMessageSize = authorMessagePrefix.length + 1 + 64 + 1; + +/// The size of each commitment, a SHA-256. +const commitmentSize = sha256Size; + +// SHA-256(prefix || 0x00 || parts...). +Uint8List _domainHash(String prefix, List> parts) { + final h = Sha256() + ..add(prefix.codeUnits) + ..add(const [0]); + for (final p in parts) { + h.add(p); + } + return h.finish(); +} + +// The 32 bytes of a commitment, which Go's [32]byte types guarantee. +void _check32(List b, String what) { + if (b.length != commitmentSize) { + throw ArgumentError( + 'capsule: $what of ${b.length} bytes, want $commitmentSize', + ); + } +} + +/// payload_commit, the commitment to I_PAYLOAD that replaces it in what is +/// signed (spec §29.8), as PayloadCommit of Go: [identity] is I_PAYLOAD, 32 +/// bytes. +Uint8List payloadCommit(List identity) { + _check32(identity, 'I_PAYLOAD'); + return _domainHash(_payloadCommitPrefix, [identity]); +} + +/// control_commit, as ControlCommit of Go: the hash of CONTROL_SIG, the +/// control [c] of a capsule of [format] with payload_commit in place of +/// I_PAYLOAD and the eight bytes of L at zero (spec §29.8). It does not +/// depend on L, so the area can grow after signing. It fails as +/// [encodeControl] fails on CONTROL_SIG: a control of format 1 has no padding +/// rule, and one of formats 2 and 3 needs it. CONTROL_SIG holds the +/// commitment and not I_PAYLOAD, and is wiped. +Uint8List controlCommit(Control c, CapsuleFormat format) { + final sig = Control( + headerBinding: c.headerBinding, + payloadIdentity: payloadCommit(c.payloadIdentity), + critical: c.critical, + noncritical: c.noncritical, + // Go's Control holds L as an integer, set to 0 here, and the padding + // code as it is: 0 when unset. + payloadLength: format.isPadded ? 0 : null, + padding: c.padding, + ); + final b = encodeControl(sig, format); + try { + return _domainHash(_controlCommitPrefix, [b]); + } finally { + b.fillRange(0, b.length, 0); + } +} + +/// head_digest, the hash of HEAD_CBOR, its exact bytes [head] (spec §29.8), +/// as HeadDigest of Go. The salt of the head makes it a commitment that +/// hides the files. +Uint8List headDigest(List head) => _domainHash(_headDigestPrefix, [head]); + +/// signers_digest for [alg], 0 to 2^32 - 1, and [signers], the exact content +/// of key 1 of a signature of alg 2, SIGNERS; none with alg 1 (spec §29.8). +/// As SignersDigest of Go: u32(alg) is four bytes big-endian. +Uint8List signersDigest(int alg, [List? signers]) { + if (alg < 0 || alg > 0xffffffff) { + throw RangeError.range(alg, 0, 0xffffffff, 'alg'); + } + final a = Uint8List(4); + ByteData.sublistView(a).setUint32(0, alg); + return _domainHash(_signersDigestPrefix, [a, ?signers]); +} + +/// AUTHOR_MESSAGE, the ASCII text that an author signs, as AuthorMessage of +/// Go: [authorMessagePrefix], a line feed, the hexadecimal digest D of the +/// three commitments, D = SHA-256(control_commit || head_digest || +/// signers_digest), and a line feed (spec §29.8). 99 bytes. +Uint8List authorMessage( + List controlCommit, + List headDigest, + List signersDigest, +) { + _check32(controlCommit, 'control_commit'); + _check32(headDigest, 'head_digest'); + _check32(signersDigest, 'signers_digest'); + final d = sha256(concatBytes([controlCommit, headDigest, signersDigest])); + final m = Uint8List(authorMessageSize); + m.setRange(0, authorMessagePrefix.length, authorMessagePrefix.codeUnits); + m[authorMessagePrefix.length] = 0x0a; + m.setRange( + authorMessagePrefix.length + 1, + authorMessageSize - 1, + toHex(d).codeUnits, + ); + m[authorMessageSize - 1] = 0x0a; + return m; +} + +/// The code of AUTHOR_MESSAGE that a person compares before signing, as +/// AuthorCode of Go: the first 8 hexadecimal digits of its digest, in two +/// groups of 4, `xxxx-xxxx` (spec §29.8); '' when [message] is not +/// [authorMessageSize] bytes. The digits are taken byte by byte, whatever +/// they are, as Go takes them into a string: a byte that is not UTF-8 reads +/// as U+FFFD, as Go reads it in a string. +String authorCode(List message) { + if (message.length != authorMessageSize) return ''; + const d = authorMessagePrefix.length + 1; + return '${_goString(message, d, d + 4)}-${_goString(message, d + 4, d + 8)}'; +} + +// The bytes from start to end as Go's string of them reads: each rune of +// valid UTF-8, and U+FFFD for each byte that is not, as Go's range and +// encoding/json do. +String _goString(List b, int start, int end) { + final part = Uint8List.fromList(b.sublist(start, end)); + final runes = []; + for (var i = 0; i < part.length;) { + final (r, size) = decodeRune(part, i); + runes.add(r); + i += size; + } + return String.fromCharCodes(runes); +} + +/// SIG_PART, as SigPart of Go: 0x00 without key 2 ([signature] null), and +/// 0x01 and the hash of the exact content of key 2 otherwise, whatever its +/// alg and its verdict (spec §29.11). +Uint8List sigPart(List? signature) { + if (signature == null) return Uint8List(1); + final h = _domainHash(_sigPartPrefix, [signature]); + return Uint8List(1 + commitmentSize) + ..[0] = 1 + ..setRange(1, 1 + commitmentSize, h); +} + +/// SEAL_SUBJECT, what a seal of seal_type 2 seals (spec §29.11), as +/// SealSubject of Go: the hash of control_commit, head_digest and +/// [sigPart], SIG_PART as it is. +Uint8List sealSubject( + List controlCommit, + List headDigest, + List sigPart, +) { + _check32(controlCommit, 'control_commit'); + _check32(headDigest, 'head_digest'); + return _domainHash(_sealSubjectPrefix, [controlCommit, headDigest, sigPart]); +} diff --git a/lib/src/security.dart b/lib/src/security.dart new file mode 100644 index 0000000..9306244 --- /dev/null +++ b/lib/src/security.dart @@ -0,0 +1,679 @@ +/// The security area of a capsule of format 3 (spec §29.3, §29.7, §29.9), as +/// EncodeSecurity, EvaluateSecurityIn and newSecurityContext of package +/// capsule of datekeys-go (format3.go, signature.go, open3.go) and +/// security.ts of datekeys-ts, at the draft v0.12. SECURITY_CBOR is the map +/// +/// {0: "datekeys-security", 1: 1, ? 2: author-signature, ? 3: seal} +/// +/// whose keys 2 and 3 are byte strings of 1 to 65 536 bytes that hold CBOR +/// encoded apart: author-signature is {0: alg, 1: key, 2: signature}, and +/// seal {0: seal_type, 1: token}, alg and seal_type from 1 to 2^32 - 1. +/// +/// The area never decides the opening: [evaluateSecurity] never throws, and +/// its verdicts carry no error code. The outer map that fails its layer 2 or +/// 3 is X; otherwise the signature and the seal are evaluated apart, and for +/// each the first row of the table of spec §29.7 that holds decides. A +/// failure inside the evaluation of one of them, whatever it throws, is a +/// failure of its own part only, as a failure of its form would be: X for +/// the outer map, F1 for the signature and S2 for the seal, as Go recovers a +/// panic. +/// +/// The signature of alg 1 is checked here, with the strict profile of +/// verifyStrict. The signature of alg 2, a CMS SignedData with certificates, +/// and the seal of seal_type 2, an RFC 3161 token, are checked by a +/// [CmsEvaluator], the reader of CMS of stage 5c of docs/PLAN_dart.md: +/// without one, their verdict is not evaluated, never guessed. +library; + +import 'dart:typed_data'; + +import 'author.dart'; +import 'bech32.dart'; +import 'bytes.dart'; +import 'cbor.dart'; +import 'curve25519.dart'; +import 'datekey.dart'; +import 'errors.dart'; +import 'pathrule.dart'; +import 'schema.dart'; +import 'verdicts.dart'; + +/// The type tag of SECURITY_CBOR (spec §29.3). +const securityTypeTag = 'datekeys-security'; + +/// The version of SECURITY_CBOR, the only one this version reads. +const securityVersion = 1; + +/// The bound of the byte strings of keys 2 and 3 of SECURITY_CBOR, and of +/// those inside them (spec §29.3). +const maxSecurityItem = 65536; + +/// The bound of alg and seal_type: 2^32 - 1. +const maxAlg = 4294967295; + +/// alg 1, a strict Ed25519 signature with a key of one's own (spec §29.9). +const algEd25519 = 1; + +/// alg 2, a CMS signature with X.509 certificates (spec §29.10). +const algCms = 2; + +/// The alg reserved for tests: no version defines it, so it is F1 in every +/// version (spec §29.3). +const algTest = 4294967295; + +/// seal_type 2, an RFC 3161 time-stamp token (spec §29.11). +const sealTypeRfc3161 = 2; + +/// The seal_type reserved for tests: S1 in every version (spec §29.3). +const sealTypeTest = 4294967295; + +/// The most code points of a name of a certificate that spec §29.7 shows, +/// the upper bound of a commonName in X.520. +const maxNameLen = 64; + +// --------------------------------------------------------------------------- +// SECURITY_CBOR + +// The outer map: keys 2 and 3, null when absent. +final class _Wire { + Uint8List? signature; + Uint8List? seal; +} + +void _decodeWire(CborDecoder d, _Wire w) { + final pairs = d.map(4); + final seen = {}; + for (var i = 0; i < pairs; i++) { + final k = d.key(); + switch (k) { + case 0: + inKey(k, () => d.text(securityTypeTag.length)); + case 1: + inKey(k, () => d.uint(securityVersion)); + case 2: + w.signature = inKey(k, () => d.bstr(1, maxSecurityItem)); + case 3: + w.seal = inKey(k, () => d.bstr(1, maxSecurityItem)); + default: + throw keyNotDefined(k); + } + seen.add(k as int); + } + requireKeys(seen, 2); + d.endMap(); +} + +void _encodeWire(CborEncoder e, _Wire w) { + final signature = w.signature; + final seal = w.seal; + e + ..map(2 + (signature == null ? 0 : 1) + (seal == null ? 0 : 1)) + ..uint(0) + ..text(securityTypeTag) + ..uint(1) + ..uint(securityVersion); + if (signature != null) { + e + ..uint(2) + ..bstr(signature); + } + if (seal != null) { + e + ..uint(3) + ..bstr(seal); + } +} + +// The outer map of SECURITY_CBOR, layers 2 and 3, as decodeSecurity of Go: +// the type tag and the version first, then the profile, the schema and the +// re-encoding. Throws for X. +_Wire _decodeSecurity(Uint8List b) { + final (typeTag: tag, version: v) = peekSchema(b); + if (tag != securityTypeTag || v != securityVersion) { + throw DateKeysException( + ErrorCode.nonCanonicalCbor, + 'not SECURITY_CBOR of version $securityVersion', + ); + } + final w = _Wire(); + unmarshalCbor(b, (d) => _decodeWire(d, w), (e) => _encodeWire(e, w)); + return w; +} + +/// The contents of keys 2 and 3 of SECURITY_CBOR [b], null when absent, or +/// null for an area whose outer map fails its layer 2 or 3, the verdict X +/// (spec §29.3). An area never fails with a code: whoever reads it gets its +/// verdicts from [evaluateSecurity]. +({Uint8List? signature, Uint8List? seal})? decodeSecurity(List b) { + try { + final w = _decodeSecurity(_bytes(b)); + return (signature: w.signature, seal: w.seal); + } on DateKeysException { + return null; + } +} + +/// SECURITY_CBOR as a writer of this version writes it: empty, {0: +/// "datekeys-security", 1: 1}, 22 bytes (spec §29.3), as EncodeSecurity of +/// Go. +Uint8List encodeSecurity() => encodeSecurityWith(); + +/// SECURITY_CBOR with the contents of keys 2 and 3, null when absent (spec +/// §29.3), as EncodeSecurityWith of Go: a signature of alg 1 or 2 and a seal +/// of seal_type 2 are what this version defines; any other content only a +/// generator of test vectors writes (spec §62.1 rule 13). +Uint8List encodeSecurityWith({List? signature, List? seal}) { + final e = CborEncoder(); + _encodeWire( + e, + _Wire() + ..signature = signature == null ? null : _bytes(signature) + ..seal = seal == null ? null : _bytes(seal), + ); + return Uint8List.fromList(e.out()); +} + +Uint8List _bytes(List b) => b is Uint8List ? b : Uint8List.fromList(b); + +// --------------------------------------------------------------------------- +// author-signature and seal + +// The content of key 2: {0: alg, 1: key, 2: signature}. +final class _AuthorSignature { + int alg = 0; + Uint8List key = Uint8List(0); + Uint8List value = Uint8List(0); +} + +// The content of key 3: {0: seal_type, 1: token}. +final class _Seal { + int sealType = 0; + Uint8List token = Uint8List(0); +} + +// Reads a map of exactly n required keys, 0 to n - 1, with field, as +// decodeItem of Go. +void _decodeItem(CborDecoder d, int n, void Function(Object k) field) { + final pairs = d.map(n); + final seen = {}; + for (var i = 0; i < pairs; i++) { + final k = d.key(); + inKey(k, () => field(k)); + seen.add(k as int); + } + requireKeys(seen, n); + d.endMap(); +} + +// alg or seal_type, from 1 to 2^32 - 1, as decodeAlg of Go. +int _decodeAlg(CborDecoder d) { + final v = d.uint(maxAlg); + if (v == 0) { + throw DateKeysException(ErrorCode.nonCanonicalCbor, '0 is not defined'); + } + return v; +} + +_AuthorSignature _decodeAuthorSignature(Uint8List b) { + final a = _AuthorSignature(); + unmarshalCbor( + b, + (d) => _decodeItem(d, 3, (k) { + switch (k) { + case 0: + a.alg = _decodeAlg(d); + case 1: + a.key = d.bstr(0, maxSecurityItem); + case 2: + a.value = d.bstr(0, maxSecurityItem); + default: + throw keyNotDefined(k); + } + }), + (e) => _encodeAuthorSignature(e, a.alg, a.key, a.value), + ); + return a; +} + +void _encodeAuthorSignature( + CborEncoder e, + int alg, + List key, + List value, +) { + e + ..map(3) + ..uint(0) + ..uint(alg) + ..uint(1) + ..bstr(key) + ..uint(2) + ..bstr(value); +} + +_Seal _decodeSeal(Uint8List b) { + final s = _Seal(); + unmarshalCbor( + b, + (d) => _decodeItem(d, 2, (k) { + switch (k) { + case 0: + s.sealType = _decodeAlg(d); + case 1: + s.token = d.bstr(0, maxSecurityItem); + default: + throw keyNotDefined(k); + } + }), + (e) => _encodeSeal(e, s.sealType, s.token), + ); + return s; +} + +void _encodeSeal(CborEncoder e, int sealType, List token) { + e + ..map(2) + ..uint(0) + ..uint(sealType) + ..uint(1) + ..bstr(token); +} + +/// The alg, key 1 (the public key of alg 1, SIGNERS of alg 2) and the +/// signature value of [content], the content of key 2 of SECURITY_CBOR, as +/// DecodeAuthorSignature of Go; null for content that does not decode or +/// breaks the schema of author-signature, the verdict F1 (spec §29.7). +({int alg, Uint8List key, Uint8List value})? decodeAuthorSignature( + List content, +) { + try { + final a = _decodeAuthorSignature(_bytes(content)); + return (alg: a.alg, key: a.key, value: a.value); + } on DateKeysException { + return null; + } +} + +/// The content of key 2 of SECURITY_CBOR, {0: alg, 1: key, 2: signature} +/// (spec §29.3), as EncodeAuthorSignature of Go: alg 1, [algEd25519], with +/// the public key, and alg 2, [algCms], with SIGNERS; only a generator of +/// test vectors writes another alg, such as [algTest]. +Uint8List encodeAuthorSignature(int alg, List key, List signature) { + final e = CborEncoder(); + _encodeAuthorSignature(e, alg, key, signature); + return Uint8List.fromList(e.out()); +} + +/// The seal_type and the token of [content], the content of key 3 of +/// SECURITY_CBOR; null for content that does not decode or breaks the +/// schema of seal, the verdict S2 (spec §29.7). +({int sealType, Uint8List token})? decodeSeal(List content) { + try { + final s = _decodeSeal(_bytes(content)); + return (sealType: s.sealType, token: s.token); + } on DateKeysException { + return null; + } +} + +/// The content of key 3 of SECURITY_CBOR, {0: seal_type, 1: token} (spec +/// §29.3), as EncodeSeal of Go: seal_type 2, [sealTypeRfc3161], is what this +/// version defines; only a generator of test vectors writes another, such +/// as [sealTypeTest]. +Uint8List encodeSeal(int sealType, List token) { + final e = CborEncoder(); + _encodeSeal(e, sealType, token); + return Uint8List.fromList(e.out()); +} + +// --------------------------------------------------------------------------- +// The context and the reader of CMS + +/// What the verdicts of a signature or a seal need besides SECURITY_CBOR +/// (spec §29.7), as SecurityContext of Go: the commitments of the capsule, +/// the time of its round, and the author keys that the person saved, by +/// their dkauthor1… string, with the label she gave each (F3). A reader +/// builds it at step 17.6 with [securityContext]. +final class SecurityContext { + /// The context of [controlCommit] and [headDigest], 32 bytes each, which + /// it copies. + SecurityContext({ + required List controlCommit, + required List headDigest, + this.roundTime, + this.authorKeys = const {}, + }) : controlCommit = Uint8List.fromList(controlCommit), + headDigest = Uint8List.fromList(headDigest) { + for (final (what, b) in [ + ('control_commit', this.controlCommit), + ('head_digest', this.headDigest), + ]) { + if (b.length != commitmentSize) { + throw ArgumentError( + 'capsule: $what of ${b.length} bytes, want $commitmentSize', + ); + } + } + } + + /// control_commit (spec §29.8). + final Uint8List controlCommit; + + /// head_digest (spec §29.8). + final Uint8List headDigest; + + /// round_time, the time of the round: a seal before it proves that the + /// content existed before the capsule could open. Null when it is not + /// known, and then no seal is before it. + final Instant? roundTime; + + /// The author keys that the person saved, by their dkauthor1… string in + /// lower case, as spec §29.12 writes it, with their labels. + final Map authorKeys; +} + +/// The context of the verdicts of the capsule that [input] describes, as +/// newSecurityContext of Go with the head digest that openBody adds: the +/// control_commit of its control and its format, the head_digest of its +/// head, its round time and the author keys. A control that cannot be +/// encoded leaves control_commit at zero, as in Go: no signature verifies +/// then, and F2 is the verdict, though the control was decoded. +SecurityContext securityContext(SecurityInput input) { + Uint8List cc; + try { + cc = controlCommit(input.control, input.format); + } on DateKeysException { + cc = Uint8List(commitmentSize); + } on ArgumentError { + cc = Uint8List(commitmentSize); + } + return SecurityContext( + controlCommit: cc, + headDigest: headDigest(input.head), + roundTime: input.roundTime, + authorKeys: input.authorKeys, + ); +} + +/// The verdict of a signature of alg 2 that a [CmsEvaluator] gives: F2, F5 +/// or F6, with the detail of its signers. +final class CmsSignatureVerdict { + /// The verdict [verdict], F2, F5 or F6, with [detail], which names the + /// required signers, in the order of SIGNERS, and the foreign ones. + const CmsSignatureVerdict(this.verdict, this.detail); + + /// F2, F5 or F6. + final Verdict verdict; + + /// The signers and the foreign signers; its seal is not read. + final Detail detail; +} + +/// The verdict of a seal of seal_type 2 that a [CmsEvaluator] gives: S1 to +/// S5, with the authority and t of a valid seal. +final class CmsSealVerdict { + /// The verdict [verdict], S1 to S5, with the [holder] of the certificate + /// of the authority and the [time] t of a valid seal, S4 or S5. + const CmsSealVerdict(this.verdict, {this.holder = '', this.time}); + + /// S1, S2, S3, S4 or S5. + final Verdict verdict; + + /// The holder of the certificate of the authority, as [holderText] shows + /// it; read only with S4 and S5. + final String holder; + + /// t, genTime of the token; required with S4 and S5. + final Instant? time; +} + +/// The reader of CMS that evaluates a signature of alg 2 and a seal of +/// seal_type 2 (spec §29.10, §29.11), as evaluateCMS and evaluateSeal of Go +/// (signature2.go), which stage 5c of docs/PLAN_dart.md implements. Without +/// one, [evaluateSecurity] does not evaluate them: their verdict is null. +/// What it throws, or a verdict out of its range, is a failure of its own +/// part only: F1 for the signature and S2 for the seal, as a panic in Go. +abstract interface class CmsEvaluator { + /// The verdict of a signature of alg 2 whose key 1 is [signers], SIGNERS, + /// and whose key 2 is [value], the DER of its SignedData, in [context] + /// (spec §29.10): null when SIGNERS or the SignedData break their profile, + /// F1; F2 when a required signer is invalid; F5 when one is absent, not + /// verifiable, without a seal, with an invalid seal or out of validity, or + /// when [hasSeal], a key 3 beside it; F6 when every required signer is + /// valid and sealed. Each signer, required or foreign, is checked with + /// AUTHOR_MESSAGE of signers_digest(2, [signers]). + CmsSignatureVerdict? evaluateSignature( + Uint8List signers, + Uint8List value, + bool hasSeal, + SecurityContext context, + ); + + /// The verdict of a seal of seal_type 2 whose token is [token], the DER of + /// its SignedData, in [context] (spec §29.11): S2 or S1 for the form and + /// the algorithms of the token, S3 when it does not verify over + /// SEAL_SUBJECT with [signature], the exact content of key 2 or null + /// without it, and S4 or S5 when it does, with the authority and t. + CmsSealVerdict evaluateSeal( + Uint8List token, + Uint8List? signature, + SecurityContext context, + ); +} + +/// How spec §29.7 shows a name of a certificate, as holderText of Go: the +/// name, when it meets the rules of the declared author (spec §29.6), has at +/// most [maxNameLen] code points and no two spaces in a row, and [hash], the +/// SHA-256 that identifies its certificate, in hexadecimal otherwise. A name +/// cannot then line up, with spaces, a text of its own where a terminal +/// breaks the line. A string that holds a lone surrogate is not text, as +/// bytes that are not UTF-8 are not in Go. +String holderText(String name, List hash) { + if (hash.length != commitmentSize) { + throw ArgumentError( + 'capsule: a SHA-256 of ${hash.length} bytes, want $commitmentSize', + ); + } + if (name.isNotEmpty && + isWellFormedUtf16(name) && + name.runes.length <= maxNameLen && + !name.contains(' ')) { + try { + checkAuthor(name); + return name; + } on PathRuleException { + // Shown by its hash. + } + } + return toHex(hash); +} + +// --------------------------------------------------------------------------- +// The verdicts + +// The verdict of the signature, with what it names. +typedef _Signature = ({ + Verdict? verdict, + Uint8List? key, + String? label, + Detail? detail, +}); + +// The verdict of the seal, with the authority and t of a valid one. +typedef _SealPart = ({Verdict? verdict, String holder, Instant? time}); + +const _Signature _unchecked = ( + verdict: Verdict.signatureUnchecked, + key: null, + label: null, + detail: null, +); + +/// The verdicts of SECURITY_CBOR [security] in the capsule that [context] +/// describes (spec §29.3, §29.7), as EvaluateSecurityIn of Go. It never +/// throws: the security area never decides the opening. +/// +/// Without a context, as EvaluateSecurity of Go and as a reader of v0.10, +/// it checks only the structure: any signature is F1, and a seal of +/// seal_type 2, S1. In a context it checks the signature of alg 1, and +/// [cms], the reader of CMS, the signature of alg 2 and the seal of +/// seal_type 2; without [cms] their verdict is null, not evaluated. +Verdicts evaluateSecurity( + List security, { + SecurityContext? context, + CmsEvaluator? cms, +}) { + // A failure of any kind is a failure of the form of its own part, as Go + // recovers a panic: X for the outer map, F1 for the signature and S2 for + // the seal, each apart from the other. + final _Wire w; + try { + w = _decodeSecurity(_bytes(security)); + } catch (_) { + return Verdicts(signature: Verdict.unreadable, seal: Verdict.unreadable); + } + final signature = w.signature; + final seal = w.seal; + _Signature sig = ( + verdict: Verdict.noSignature, + key: null, + label: null, + detail: null, + ); + if (signature != null) { + sig = _unchecked; + if (context != null) { + try { + sig = _evaluateSignature(signature, seal != null, context, cms); + } catch (_) { + sig = _unchecked; + } + } + } + _SealPart sealed = (verdict: Verdict.noSeal, holder: '', time: null); + if (seal != null) { + try { + sealed = _evaluateSeal(seal, signature, context, cms); + } catch (_) { + sealed = (verdict: Verdict.sealUnreadable, holder: '', time: null); + } + } + final sigDetail = sig.detail; + final sealTime = sealed.time; + return Verdicts( + signature: sig.verdict, + seal: sealed.verdict, + authorKey: sig.key, + authorLabel: sig.label, + detail: sigDetail == null && sealTime == null + ? null + : Detail( + signers: sigDetail?.signers ?? const [], + foreign: sigDetail?.foreign ?? const [], + sealHolder: sealed.holder, + sealTime: sealTime, + ), + ); +} + +/// The evaluator of the opening, the default of OpenOptions.evaluator: the +/// verdicts of [input] in its [securityContext], with the signature of alg 1 +/// and every verdict of the form. The signature of alg 2 and the seal of +/// seal_type 2 are not evaluated until the reader of CMS of stage 5c is +/// given; an application that has one evaluates them with +/// [evaluateSecurity] and its [CmsEvaluator], in an evaluator of its own. +Verdicts evaluateSecurityInput(SecurityInput input) => + evaluateSecurity(input.security, context: securityContext(input)); + +// The verdict of the content of key 2, as evaluateSignature of Go (spec +// §29.7, §29.9): F1 for content that does not decode, an alg this reader +// does not implement or a key or a signature of another length; F2 when the +// signature does not verify; F3 or F4 when it does. alg 2 goes to the reader +// of CMS, and without one is not evaluated. +_Signature _evaluateSignature( + Uint8List content, + bool hasSeal, + SecurityContext c, + CmsEvaluator? cms, +) { + final _AuthorSignature a; + try { + a = _decodeAuthorSignature(content); + } on DateKeysException { + return _unchecked; + } + if (a.alg == algCms) { + if (cms == null) { + return (verdict: null, key: null, label: null, detail: null); + } + final r = cms.evaluateSignature(a.key, a.value, hasSeal, c); + if (r == null) return _unchecked; + if (r.verdict != Verdict.signatureInvalid && + r.verdict != Verdict.signedIncomplete && + r.verdict != Verdict.signedComplete) { + throw StateError('capsule: alg 2 gave ${r.verdict}'); + } + return (verdict: r.verdict, key: null, label: null, detail: r.detail); + } + if (a.alg != algEd25519 || + a.key.length != ed25519PublicKeySize || + a.value.length != ed25519SignatureSize) { + return _unchecked; + } + final message = authorMessage( + c.controlCommit, + c.headDigest, + signersDigest(algEd25519), + ); + if (!verifyStrict(a.key, message, a.value)) { + return ( + verdict: Verdict.signatureInvalid, + key: null, + label: null, + detail: null, + ); + } + final label = c.authorKeys[bech32Encode('dkauthor', a.key)]; + return ( + verdict: label == null ? Verdict.signedOther : Verdict.signedSaved, + key: a.key, + label: label, + detail: null, + ); +} + +// The verdict of the content of key 3, as setSeal and evaluateSeal of Go +// (spec §29.7, §29.11): S2 for content that breaks the schema of seal, S1 +// for a seal_type this reader does not implement, and for seal_type 2 +// without a context, as a reader of v0.10; seal_type 2 in a context goes to +// the reader of CMS, and without one is not evaluated. +_SealPart _evaluateSeal( + Uint8List content, + Uint8List? signature, + SecurityContext? c, + CmsEvaluator? cms, +) { + final _Seal s; + try { + s = _decodeSeal(content); + } on DateKeysException { + return (verdict: Verdict.sealUnreadable, holder: '', time: null); + } + if (s.sealType != sealTypeRfc3161 || c == null) { + return (verdict: Verdict.sealUnsupported, holder: '', time: null); + } + if (cms == null) return (verdict: null, holder: '', time: null); + final r = cms.evaluateSeal(s.token, signature, c); + switch (r.verdict) { + case Verdict.sealUnsupported || + Verdict.sealUnreadable || + Verdict.sealInvalid: + return (verdict: r.verdict, holder: '', time: null); + case Verdict.sealed || Verdict.sealedLate: + final t = r.time; + if (t == null) throw StateError('capsule: a valid seal without its t'); + return (verdict: r.verdict, holder: r.holder, time: t); + default: + throw StateError('capsule: seal_type 2 gave ${r.verdict}'); + } +} diff --git a/lib/src/verdicts.dart b/lib/src/verdicts.dart index 2595fd6..6a115db 100644 --- a/lib/src/verdicts.dart +++ b/lib/src/verdicts.dart @@ -1,19 +1,28 @@ /// The verdicts of the security area of a capsule of format 3 (spec §29.3, -/// §29.7), and the hook that evaluates them. +/// §29.7), their texts and the lines that show them, and the hook that +/// evaluates them. /// /// The security area never decides the opening: no failure of it has a /// code, stops step 17 or changes the code of another failure (spec §29.3, /// §69.1). The opening parses it only as far as the frame of BODY and the /// area require (spec §29.2), and hands it, with what a verdict needs /// besides, to a [SecurityEvaluator]: Go's newSecurityContext and -/// EvaluateSecurityIn. The signature and the seal, with their verdicts and -/// texts, are stage 5 of docs/PLAN_dart.md; until then the default evaluator, -/// [notEvaluated], evaluates nothing. +/// EvaluateSecurityIn. The default one, evaluateSecurityInput of +/// security.dart, evaluates the signature of alg 1 and every verdict of the +/// form; the signature of alg 2 and the seal of seal_type 2 need the reader +/// of CMS (stage 5c of docs/PLAN_dart.md), and without it are not evaluated. +/// +/// The texts and the lines are those of Verdict.Text and Verdicts.Lines of +/// Go (format3.go) at the draft v0.12, byte for byte: the names of a +/// certificate between « and », the authority of each seal in the lines of +/// F6 with its warning, the results in Spanish, and every time in RFC 3339 +/// with the fraction of the seal. library; import 'dart:async'; import 'dart:typed_data'; +import 'bech32.dart'; import 'control.dart'; import 'datekey.dart'; import 'framing.dart'; @@ -81,22 +90,166 @@ enum Verdict { return null; } + /// The text of the verdict that the official SDK shows, in Spanish (spec + /// §29.7), as Text of Go: '' for S0, which shows nothing, and for the + /// verdicts whose text names a key, a holder or a time, F3, F4, F6 and S4, + /// which [Verdicts.lines] writes. + String get text => switch (this) { + unreadable => + 'No se han podido comprobar la firma ni el sello: trátala como no ' + 'firmada y sin fecha probada.', + noSignature => 'Sin firma de autor.', + signatureUnchecked => + 'No se ha comprobado ninguna firma: trátala como no firmada.', + signatureInvalid => 'La firma no corresponde a este contenido.', + sealUnsupported => + 'Lleva un sello de tiempo que esta versión no sabe comprobar: aquí no ' + 'prueba nada.', + sealUnreadable => 'El sello de tiempo es ilegible: no prueba nada.', + signedIncomplete => + 'Faltan firmas o sellos que la propia cápsula exige: trátala como no ' + 'firmada.', + sealInvalid => 'El sello no corresponde a este contenido.', + sealedLate => + 'Sellado después de la fecha de apertura: no prueba nada anterior.', + signedSaved || signedOther || signedComplete || noSeal || sealed => '', + }; + + @override + String toString() => code; +} + +/// The result of a signer of a signature of alg 2 (spec §29.10), as the +/// strings of SignerLine.Result of Go, which [code] keeps, with the text of +/// spec §29.7 in Spanish. +enum SignerResult { + /// Valid, with a seal that verifies. + valid('valid', 'válida'), + + /// The signature of signedAttrs does not verify. + invalid('invalid', 'inválida'), + + /// No SignerInfo of a required certificate. + absent('absent', 'ausente'), + + /// An algorithm, a key or a curve outside the table. + notVerifiable('not verifiable', 'no verificable'), + + /// Without signature-time-stamp. + withoutSeal('without seal', 'sin sello'), + + /// A seal that is S2, S1 or S3. + invalidSeal('invalid seal', 'con el sello inválido'), + + /// The certificate is not valid at the time of the seal. + outOfValidity('out of validity', 'con el certificado fuera de validez'); + + const SignerResult(this.code, this.text); + + /// The result as Go writes it, such as `not verifiable`. + final String code; + + /// The result in the texts of spec §29.7, such as `no verificable`. + final String text; + + /// The result of [code], or null when Go defines none. + static SignerResult? fromCode(String code) { + for (final r in values) { + if (r.code == code) return r; + } + return null; + } + @override String toString() => code; } +/// A signer of a signature of alg 2 (spec §29.7, §29.10), as SignerLine of +/// Go. +final class SignerLine { + /// A signer with its [holder], [issuer] and [result], and the authority + /// and the time of its seal when it has one that verifies. + const SignerLine({ + required this.holder, + this.issuer = '', + required this.result, + this.sealHolder = '', + this.sealTime, + this.before = false, + }); + + /// The name of the certificate as spec §29.7 shows it: the subject, or the + /// SHA-256 of the certificate in hexadecimal when it does not meet the + /// rules of a name of a certificate (holderText of security.dart). + final String holder; + + /// The issuer that the certificate says, with the same rules; '' for an + /// absent signer. + final String issuer; + + /// The result of the signer. + final SignerResult result; + + /// The holder of the certificate of the authority of its seal, with the + /// same rules; '' without a seal that verifies. + final String sealHolder; + + /// t, the time of its seal; null without a seal that verifies. + final Instant? sealTime; + + /// Whether t plus the accuracy of the seal is before round_time. + final bool before; + + @override + String toString() => 'SignerLine($holder, ${result.code})'; +} + +/// What the texts of F6, S4 and S5 name (spec §29.7, §29.10), as Detail of +/// Go. +final class Detail { + /// The signers of a signature of alg 2 and the authority of a valid seal. + Detail({ + List signers = const [], + List foreign = const [], + this.sealHolder = '', + this.sealTime, + }) : signers = List.unmodifiable(signers), + foreign = List.unmodifiable(foreign); + + /// The required signers, in the order of SIGNERS. + final List signers; + + /// The SignerInfo of other certificates, which never count. + final List foreign; + + /// The holder of the certificate of the authority of a valid seal of key + /// 3, as spec §29.7 writes it; '' without one. + final String sealHolder; + + /// t, the time of a valid seal of key 3; null without one. + final Instant? sealTime; + + /// This detail without the seal of key 3. + Detail withoutSeal() => Detail(signers: signers, foreign: foreign); +} + /// The verdicts of the security area of a capsule of format 3 (spec §29.7), -/// as Verdicts of Go: one for the signature and one for the seal, or none -/// when they were not evaluated. +/// as Verdicts of Go: one for the signature and one for the seal. Either is +/// null when it was not evaluated: both when the evaluator failed, or when +/// there was none to evaluate them, and one alone when it needs the reader +/// of CMS, alg 2 or seal_type 2, and none was given (see security.dart). A +/// verdict that is not evaluated is never guessed. final class Verdicts { /// The verdicts [signature] and [seal], with the public key of a valid - /// signature of alg 1 ([authorKey], F3 and F4) and the label of the saved - /// key that signed ([authorLabel], F3). + /// signature of alg 1 ([authorKey], F3 and F4), the label of the saved key + /// that signed ([authorLabel], F3) and what the texts of F6, S4 and S5 + /// name ([detail]). A verdict is null when it was not evaluated. Verdicts({ - required Verdict this.signature, - required Verdict this.seal, + required this.signature, + required this.seal, List? authorKey, this.authorLabel, + this.detail, }) : authorKey = authorKey == null ? null : Uint8List.fromList(authorKey), error = null; @@ -106,13 +259,15 @@ final class Verdicts { : signature = null, seal = null, authorKey = null, - authorLabel = null; + authorLabel = null, + detail = null; const Verdicts._none() : signature = null, seal = null, authorKey = null, authorLabel = null, + detail = null, error = null; /// The verdicts of [notEvaluated]: none. @@ -130,19 +285,137 @@ final class Verdicts { /// The label of the saved key that signed (F3). final String? authorLabel; + /// The signers of a signature of alg 2 and the authority of a valid seal; + /// null otherwise. + final Detail? detail; + /// The failure of the evaluator, when it failed: the opening goes on, and /// the verdicts are not evaluated (spec §29.3). final Object? error; - /// Whether the verdicts were evaluated. + /// Whether both verdicts were evaluated. bool get evaluated => signature != null && seal != null; + /// The verdicts as the official SDK shows them, in order, as Lines of Go: + /// X alone, or the signature and then the seal, when it shows something + /// (spec §29.7). F3 and F4 name the key; F6 names its signers, and is + /// followed by a line for each required signer with the authority of its + /// seal, by the warning that DateKeys does not check who issued the seals + /// when one of them says that it is before the opening date, and by the + /// signers who do not count, whom any verdict with a detail shows; S4 names + /// the authority and the time. + /// + /// A verdict that was not evaluated has no line: lines are empty when + /// neither was, and hold only the seal or the signature when the other was + /// not. A caller shows then that the area was not checked, and treats the + /// capsule as unsigned and without a proven date. + List get lines { + final signature = this.signature; + final seal = this.seal; + if (signature == Verdict.unreadable) return [Verdict.unreadable.text]; + final lines = []; + final d = detail; + if (signature != null) { + switch (signature) { + case Verdict.signedSaved: + lines.add( + 'Firmado con la clave que guardaste como ${authorLabel ?? ''}.', + ); + case Verdict.signedOther: + final key = bech32Encode('dkauthor', authorKey ?? Uint8List(32)); + lines.add('Firmado con la clave $key. No prueba quién la tiene.'); + case Verdict.signedComplete when d != null: + final names = [for (final s in d.signers) _quoted(s.holder)]; + lines.add( + 'Firmado con un certificado a nombre de ${names.join(', ')}. ' + 'DateKeys no comprueba quién lo emitió: para eso, exporta la ' + 'firma a un validador oficial.', + ); + var before = false; + for (final s in d.signers) { + var when = 'no antes de la fecha de apertura'; + if (s.before) { + when = 'antes de la fecha de apertura'; + before = true; + } + lines.add( + ' ${_quoted(s.holder)} (emisor según su certificado: ' + '${_quoted(s.issuer)}), sellado por ${_quoted(s.sealHolder)} el ' + '${_instant(s.sealTime)}, $when.', + ); + } + // §29.7: whoever says that a capsule was signed before the date + // says that it does not check who issued the seal. + if (before) { + lines.add(' DateKeys no comprueba quién emitió los sellos.'); + } + default: + lines.add(signature.text); + } + if (d != null) { + for (final s in d.foreign) { + lines.add( + ' Otro firmante, ${_quoted(s.holder)}: ${s.result.text}. No ' + 'cuenta.', + ); + } + } + } + if (seal == Verdict.sealed && d != null) { + lines.add( + 'Según un sello a nombre de ${_quoted(d.sealHolder)}, existía el ' + '${_instant(d.sealTime)}, antes de que la cápsula pudiera abrirse. ' + 'DateKeys no comprueba quién emitió el sello.', + ); + } else if (seal != null && seal.text != '') { + lines.add(seal.text); + } + return lines; + } + + /// The earliest instant that a valid seal gives, the seal of key 3 or that + /// of a required signer of a signature of alg 2, and null when there is + /// none (spec §29.7), as SealedAt of Go. A reader shows an mtime later + /// than it as an inconsistency: whoever made the capsule claims a file + /// that is newer than the proof that it existed. + Instant? get sealedAt { + final d = detail; + if (d == null) return null; + Instant? best; + void take(Instant? t) { + if (t != null && (best == null || compareInstants(t, best!) < 0)) { + best = t; + } + } + + if (seal == Verdict.sealed || seal == Verdict.sealedLate) { + take(d.sealTime); + } + for (final s in d.signers) { + if (s.result == SignerResult.valid) take(s.sealTime); + } + return best; + } + @override String toString() => evaluated ? 'Verdicts(${signature!.code}, ${seal!.code})' - : 'Verdicts(not evaluated)'; + : signature == null && seal == null + ? 'Verdicts(not evaluated)' + : 'Verdicts(${signature?.code ?? 'not evaluated'}, ' + '${seal?.code ?? 'not evaluated'})'; } +/// A name of a certificate between « and », as the texts of spec §29.7 write +/// it, so that where it starts and where it ends is in view. +String _quoted(String name) => '«$name»'; + +/// [t] in UTC as spec §29.7 shows it, as instant of Go: RFC 3339, with the +/// fraction of the seal when it has one. A time that is not there is Go's +/// zero time, which no verdict that is evaluated shows. +String _instant(Instant? t) => + t == null ? '0001-01-01T00:00:00Z' : formatRfc3339Nano(t); + /// What the verdicts of the security area need besides it (spec §29.7, /// §29.8): what Go's newSecurityContext and EvaluateSecurityIn take, given /// at step 17 once the head is read, before it is decoded, as in Go. @@ -185,10 +458,10 @@ final class SecurityInput { /// Evaluates the security area of a capsule of format 3: Go's /// EvaluateSecurityIn with the context of newSecurityContext. It never /// decides the opening: whatever it throws, the opening goes on with -/// [Verdicts.failed] (spec §29.3). Stage 5 provides the evaluator of the -/// signature and the seal. +/// [Verdicts.failed] (spec §29.3). The default of the opening is +/// evaluateSecurityInput of security.dart. typedef SecurityEvaluator = FutureOr Function(SecurityInput input); -/// The evaluator of this version of the library, before stage 5: it -/// evaluates nothing and gives [Verdicts.notEvaluated]. +/// An evaluator that evaluates nothing and gives [Verdicts.notEvaluated], +/// for a caller that shows no verdict. Verdicts notEvaluated(SecurityInput input) => Verdicts.notEvaluated; diff --git a/test/security_support.dart b/test/security_support.dart new file mode 100644 index 0000000..c543981 --- /dev/null +++ b/test/security_support.dart @@ -0,0 +1,376 @@ +// Helpers of the tests of the security area against the vectors of Go: the +// verdicts of an area as the vectors record them, compared part by part. +// The signature of alg 2 and the seal of seal_type 2 need the reader of CMS +// of stage 5c, which this library does not have yet: such a part must be not +// evaluated, never guessed, and Go's verdict for it one that such a part can +// give. They read no file, so that the tests that run on Node.js can use +// them. +library; + +import 'dart:typed_data'; + +import 'package:datekeys/datekeys.dart'; +import 'package:datekeys/src/author.dart'; +import 'package:datekeys/src/bech32.dart' show bech32Encode; +import 'package:datekeys/src/bytes.dart' show utf8Bytes; +import 'package:datekeys/src/security.dart'; +import 'package:datekeys/src/sha256.dart' show sha256; + +import 'open_vectors_support.dart'; +import 'tlock_support.dart' show applyEdits; + +/// Which parts of [security] the reader of CMS evaluates in a context: a +/// signature of alg 2, and a seal of seal_type 2. +({bool signature, bool seal}) cmsParts(List security) { + final w = decodeSecurity(security); + if (w == null) return (signature: false, seal: false); + final sig = w.signature; + final seal = w.seal; + return ( + signature: sig != null && decodeAuthorSignature(sig)?.alg == algCms, + seal: seal != null && decodeSeal(seal)?.sealType == sealTypeRfc3161, + ); +} + +/// The verdicts that a signature of alg 2 gives in Go, and those of a seal +/// of seal_type 2 in a context (spec §29.7, §29.10, §29.11). +const cmsSignatureVerdicts = {'F1', 'F2', 'F5', 'F6'}; +const cmsSealVerdicts = {'S1', 'S2', 'S3', 'S4', 'S5'}; + +/// The number of lines that the seal of Go's verdicts adds: none for S0, and +/// one for any other, S4 with its authority. +int sealLines(String seal) => seal == 'S0' ? 0 : 1; + +/// The differences between [v] and [want], the verdicts of Go as the vectors +/// record them: signature, seal, lines, and with [keys] author_key, +/// author_label and sealed_at. A part that needs the reader of CMS, as [cms] +/// says, must be not evaluated, and its lines are those of Go without that +/// part; the other part must be Go's. +List verdictDifferences( + Json want, + Verdicts v, + ({bool signature, bool seal}) cms, { + bool keys = true, +}) { + final out = []; + void same(String what, Object? got, Object? expected) { + final g = canonical(got); + final w = canonical(expected); + if (g != w) out.add('$what: got $g, want $w'); + } + + final signature = want['signature']! as String; + final seal = want['seal']! as String; + final lines = (want['lines']! as List).cast(); + if (v.error != null) out.add('the evaluator failed: ${v.error}'); + if (cms.signature) { + same('signature of alg 2', v.signature?.code, null); + if (!cmsSignatureVerdicts.contains(signature)) { + out.add('Go gives $signature to a signature of alg 2'); + } + } else { + same('signature', v.signature?.code, signature); + } + if (cms.seal) { + same('seal of seal_type 2', v.seal?.code, null); + if (!cmsSealVerdicts.contains(seal)) { + out.add('Go gives $seal to a seal of seal_type 2'); + } + } else { + same('seal', v.seal?.code, seal); + } + if (!cms.signature && !cms.seal) { + same('lines', v.lines, lines); + final at = v.sealedAt; + if (keys) { + same( + 'sealed_at', + at == null ? null : formatRfc3339Nano(at), + want['sealed_at'], + ); + } + } else if (!cms.signature) { + // The seal of seal_type 2 is not evaluated: its line is not there. + same('lines', v.lines, lines.sublist(0, lines.length - sealLines(seal))); + } else if (!cms.seal) { + // The signature of alg 2 is not evaluated: only the line of the seal. + same('lines', v.lines, lines.sublist(lines.length - sealLines(seal))); + } else { + same('lines', v.lines, []); + } + if (keys) { + final key = v.authorKey; + same( + 'author_key', + key == null ? null : bech32Encode('dkauthor', key), + want['author_key'], + ); + same('author_label', v.authorLabel, want['author_label']); + } + return out; +} + +/// The bytes of [hex] or null. +Uint8List? hexOrNull(Object? hex) => + hex == null ? null : fromHex(hex as String); + +// --------------------------------------------------------------------------- +// The sections of test/vectors/security_vectors.json + +/// The contexts of the vectors, by index. +List contextsOf(Json f) => [ + for (final c in (f['contexts']! as List).cast()) + SecurityContext( + controlCommit: fromHex(str(c, 'control_commit')), + headDigest: fromHex(str(c, 'head_digest')), + roundTime: c['round_time'] == null + ? null + : parseRfc3339(str(c, 'round_time')), + authorKeys: + (c['author_keys'] as Map?)?.cast() ?? const {}, + ), +]; + +/// The bytes of [parts], [hex, repeat] runs. +Uint8List fromParts(List parts) => concatBytes([ + for (final p in parts.cast>()) + for (var i = 0; i < (p[1]! as int); i++) fromHex(p[0]! as String), +]); + +/// The bytes of the field [name] of [c], given as hex or as name_parts. +Uint8List? blobOf(Json c, String name) { + final parts = c['${name}_parts'] as List?; + if (parts != null) return fromParts(parts.cast()); + return hexOrNull(c[name]); +} + +/// SECURITY_CBOR of an evaluation: its hex or parts, or a base of [bases] +/// with its edits, of the area or of the content of its key 2 or 3, which +/// is then written again and checked against the first 8 bytes of the +/// SHA-256 of Go's. +Uint8List securityOf(Json c, List bases) { + final hex = c['hex'] as String?; + if (hex != null) return fromHex(hex); + final parts = c['parts'] as List?; + if (parts != null) return fromParts(parts.cast()); + final base = bases[c['base']! as int]; + final edits = (c['edits']! as List).cast(); + final key = c['key'] as int?; + if (key == null) return applyEdits(base, edits); + final w = decodeSecurity(base)!; + final out = encodeSecurityWith( + signature: key == 2 ? applyEdits(w.signature!, edits) : w.signature, + seal: key == 3 ? applyEdits(w.seal!, edits) : w.seal, + ); + if (toHex(sha256(out).sublist(0, 8)) != c['sha256']) { + throw StateError('the area of ${canonical(c)} is not the one of Go'); + } + return out; +} + +/// The differences between the evaluation of the case [c] and Go's: its +/// verdicts and lines, the parts of CMS, and alg and seal_type as read. +List evaluateDifferences( + Json c, + Uint8List security, + List contexts, + List texts, +) { + final out = []; + final at = c['context'] as int?; + final context = at == null ? null : contexts[at]; + final v = evaluateSecurity(security, context: context); + final goCms = ((c['cms'] as List?) ?? const []).cast(); + final cms = ( + signature: goCms.contains('signature'), + seal: goCms.contains('seal'), + ); + // The parts that only the reader of CMS evaluates, as this library reads + // the area: those of Go, in a context. + final mine = cmsParts(security); + if (context != null && mine != cms || context == null && goCms.isNotEmpty) { + out.add('cms: $mine, Go $goCms'); + } + final w = decodeSecurity(security); + final sig = w?.signature; + final seal = w?.seal; + final alg = sig == null ? null : decodeAuthorSignature(sig)?.alg; + final sealType = seal == null ? null : decodeSeal(seal)?.sealType; + if (alg != c['alg']) out.add('alg $alg, Go ${c['alg']}'); + if (sealType != c['seal_type']) { + out.add('seal_type $sealType, Go ${c['seal_type']}'); + } + if ((w == null) != (c['signature'] == 'X')) { + out.add('decodeSecurity gives ${w == null ? 'X' : 'an area'}'); + } + final want = { + ...c, + 'lines': [for (final i in (c['lines']! as List).cast()) texts[i]], + }; + out.addAll(verdictDifferences(want, v, cms)); + return out; +} + +/// The verdicts of a case of lines, as Go built them. +Verdicts verdictsOf(Json c) { + final d = c['detail'] as Json?; + SignerLine signer(Json s) => SignerLine( + holder: str(s, 'holder'), + issuer: str(s, 'issuer'), + result: SignerResult.fromCode(str(s, 'result'))!, + sealHolder: str(s, 'seal_holder'), + sealTime: s['seal_time'] == null ? null : parseRfc3339(str(s, 'seal_time')), + before: s['before']! as bool, + ); + return Verdicts( + signature: Verdict.fromCode(str(c, 'signature')), + seal: Verdict.fromCode(str(c, 'seal')), + authorKey: fromHex(str(c, 'author_key')), + authorLabel: str(c, 'author_label'), + detail: d == null + ? null + : Detail( + signers: [ + for (final s in (d['signers']! as List).cast()) signer(s), + ], + foreign: [ + for (final s in (d['foreign']! as List).cast()) signer(s), + ], + sealHolder: str(d, 'seal_holder'), + sealTime: d['seal_time'] == null + ? null + : parseRfc3339(str(d, 'seal_time')), + ), + ); +} + +/// The differences of the lines and the earliest seal of a case of lines. +List lineDifferences(Json c) { + final v = verdictsOf(c); + final out = []; + if (canonical(v.lines) != canonical(c['lines'])) { + out.add('lines ${canonical(v.lines)}'); + } + final at = v.sealedAt; + final got = at == null ? null : formatRfc3339Nano(at); + if (got != c['sealed_at']) out.add('sealed_at $got'); + return out; +} + +/// The name of a case of holder: its text, or its UTF-16 code units. +String nameOf(Json c) => c['units'] == null + ? str(c, 'name') + : String.fromCharCodes((c['units']! as List).cast()); + +/// The differences of the commitments of the vectors. +List commitmentDifferences(Json f) { + final out = []; + void same(String what, Object? got, Object? want) { + if (got != want) out.add('$what: got $got, want $want'); + } + + List section(String name) => (f[name]! as List).cast(); + for (final c in section('payload_commit')) { + same( + 'payload_commit', + toHex(payloadCommit(fromHex(str(c, 'identity')))), + c['commit'], + ); + } + for (final c in section('control_commit')) { + final decoded = decodeControl( + fromHex(str(c, 'control')), + CapsuleFormat.fromVersion(c['decode_format']! as int)!, + ); + final format = CapsuleFormat.fromVersion(c['format']! as int)!; + String got; + try { + got = toHex(controlCommit(decoded, format)); + } on ArgumentError catch (e) { + got = 'error: ${e.message}'; + } on DateKeysException catch (e) { + got = 'error: ${e.message}'; + } + same( + 'control_commit of ${c['name']} in format ${format.version}', + got, + c['commit'] ?? 'error: ${c['error']}', + ); + } + for (final c in section('head_digest')) { + same( + 'head_digest', + toHex(headDigest(fromHex(str(c, 'head')))), + c['digest'], + ); + } + for (final c in section('signers_digest')) { + same( + 'signers_digest', + toHex(signersDigest(c['alg']! as int, hexOrNull(c['signers']))), + c['digest'], + ); + } + for (final c in section('author_message')) { + final m = authorMessage( + fromHex(str(c, 'control_commit')), + fromHex(str(c, 'head_digest')), + fromHex(str(c, 'signers_digest')), + ); + same('author_message', String.fromCharCodes(m), c['message']); + same('author_code', authorCode(m), c['code']); + } + for (final c in section('author_code')) { + final code = authorCode(fromHex(str(c, 'message'))); + same('author_code of ${c['message']}', code, c['code']); + // When Go's code is UTF-8, these are its bytes. + final bytes = fromHex(str(c, 'code_hex')); + if (decodeUtf8(bytes) != null) { + same('the bytes of author_code', toHex(utf8Bytes(code)), c['code_hex']); + } + } + for (final c in section('sig_part')) { + same('sig_part', toHex(sigPart(hexOrNull(c['signature']))), c['sig_part']); + } + for (final c in section('seal_subject')) { + same( + 'seal_subject', + toHex( + sealSubject( + fromHex(str(c, 'control_commit')), + fromHex(str(c, 'head_digest')), + fromHex(str(c, 'sig_part')), + ), + ), + c['seal_subject'], + ); + } + return out; +} + +/// The differences of the encoders. +List encodeDifferences(List cases) { + final out = []; + for (final c in cases) { + final Uint8List got; + switch (c['what']) { + case 'security': + got = encodeSecurity(); + case 'security_with': + got = encodeSecurityWith( + signature: blobOf(c, 'signature'), + seal: blobOf(c, 'seal'), + ); + case 'author_signature': + got = encodeAuthorSignature( + c['alg']! as int, + fromHex(str(c, 'key')), + fromHex(str(c, 'value')), + ); + default: + got = encodeSeal(c['seal_type']! as int, fromHex(str(c, 'token'))); + } + if (!equalBytes(got, blobOf(c, 'hex')!)) out.add('encode ${canonical(c)}'); + } + return out; +} diff --git a/test/security_test.dart b/test/security_test.dart new file mode 100644 index 0000000..72554b1 --- /dev/null +++ b/test/security_test.dart @@ -0,0 +1,386 @@ +// The security area on the VM and compiled to JavaScript: the part of the +// vectors of Go that test/vectors/security_vectors.g.dart holds, which +// tool/security_go_vectors.go writes with package capsule of the reference, +// and what the API does: the boundary with the reader of CMS of alg 2 and +// seal_type 2, an evaluation that never throws, and the context that the +// opening builds. +library; + +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:datekeys/datekeys.dart'; +import 'package:datekeys/src/author.dart'; +import 'package:datekeys/src/security.dart'; +import 'package:test/test.dart'; + +import 'open_vectors_support.dart'; +import 'security_support.dart'; +import 'vectors/security_vectors.g.dart'; + +final Json _vectors = jsonDecode(securityVectorsJson) as Json; + +List _section(String name) => (_vectors[name]! as List).cast(); + +/// A reader of CMS that answers what it is given, and records its calls. +final class FakeCms implements CmsEvaluator { + FakeCms({this.signature, this.seal, this.throws = false}); + + final CmsSignatureVerdict? Function()? signature; + final CmsSealVerdict Function()? seal; + final bool throws; + final calls = []; + Uint8List? signedWith; + bool? hasSeal; + + @override + CmsSignatureVerdict? evaluateSignature( + Uint8List signers, + Uint8List value, + bool hasSeal, + SecurityContext context, + ) { + calls.add('signature'); + this.hasSeal = hasSeal; + if (throws) throw StateError('broken'); + return signature!(); + } + + @override + CmsSealVerdict evaluateSeal( + Uint8List token, + Uint8List? signature, + SecurityContext context, + ) { + calls.add('seal'); + signedWith = signature; + if (throws) throw StateError('broken'); + return seal!(); + } +} + +void main() { + test('the part of the vectors is of this spec, from its generator', () { + expect(_vectors['spec'], specVersion); + expect(_vectors['generator'], 'tool/security_go_vectors.go'); + expect(_section('evaluate'), hasLength(greaterThan(150))); + }); + + test('the commitments, as Go builds them', () { + expect(commitmentDifferences(_vectors['commitments']! as Json), isEmpty); + }); + + test('the encoders, as Go writes them', () { + expect(encodeDifferences(_section('encode')), isEmpty); + expect(encodeSecurity(), hasLength(22)); + }); + + test('every eighth evaluation, with the verdicts and lines of Go', () { + final contexts = contextsOf(_vectors); + final texts = (_vectors['texts']! as List).cast(); + final bases = [ + for (final b in (_vectors['bases']! as List).cast()) fromHex(b), + ]; + for (final c in _section('evaluate')) { + final security = securityOf(c, bases); + expect( + evaluateDifferences(c, security, contexts, texts), + isEmpty, + reason: canonical(c), + ); + } + }); + + test('the lines and the earliest seal of every verdict and detail', () { + for (final c in _section('lines')) { + expect(lineDifferences(c), isEmpty, reason: str(c, 'name')); + } + }); + + test('the name of a certificate, as holderText of Go', () { + final cases = _section('holder'); + expect(cases, hasLength(greaterThan(200))); + for (final c in cases) { + expect( + holderText(nameOf(c), fromHex(str(c, 'hash'))), + c['result'], + reason: canonical(c), + ); + } + // A hash that is not a SHA-256 is an error of the caller. + expect(() => holderText('Ana', Uint8List(31)), throwsArgumentError); + }); + + group('the reader of CMS', () { + final context = SecurityContext( + controlCommit: Uint8List(32)..[0] = 1, + headDigest: Uint8List(32)..[0] = 2, + roundTime: parseRfc3339('2030-01-01T00:00:00Z'), + ); + final alg2 = encodeAuthorSignature(algCms, [0x80], [1, 2, 3]); + final seal2 = encodeSeal(sealTypeRfc3161, [4, 5, 6]); + final ana = SignerLine( + holder: 'Ana', + issuer: 'CA', + result: SignerResult.valid, + sealHolder: 'TSA', + sealTime: parseRfc3339('2026-09-30T12:00:00.5Z'), + before: true, + ); + final tsaTime = parseRfc3339('2026-09-29T10:00:00Z'); + + test('without one, alg 2 and seal_type 2 are not evaluated', () { + final v = evaluateSecurity( + encodeSecurityWith(signature: alg2, seal: seal2), + context: context, + ); + expect( + [v.signature, v.seal, v.evaluated, v.lines], + [null, null, false, isEmpty], + ); + final s = evaluateSecurity( + encodeSecurityWith(signature: alg2, seal: encodeSeal(1, [1])), + context: context, + ); + expect( + [s.signature, s.seal, s.lines], + [ + null, + Verdict.sealUnsupported, + [Verdict.sealUnsupported.text], + ], + ); + final f = evaluateSecurity( + encodeSecurityWith( + signature: encodeAuthorSignature(9, [], []), + seal: seal2, + ), + context: context, + ); + expect( + [f.signature, f.seal, f.lines], + [ + Verdict.signatureUnchecked, + null, + [Verdict.signatureUnchecked.text], + ], + ); + expect('$f', 'Verdicts(F1, not evaluated)'); + }); + + test('without a context it is never asked, as a reader of v0.10', () { + final cms = FakeCms(); + final v = evaluateSecurity( + encodeSecurityWith(signature: alg2, seal: seal2), + cms: cms, + ); + expect( + [v.signature, v.seal], + [Verdict.signatureUnchecked, Verdict.sealUnsupported], + ); + expect(cms.calls, isEmpty); + }); + + test('gives F2, F5 or F6 with the signers, and the seal with its ' + 'authority, in one detail', () { + final cms = FakeCms( + signature: () => + CmsSignatureVerdict(Verdict.signedComplete, Detail(signers: [ana])), + seal: () => + CmsSealVerdict(Verdict.sealed, holder: 'TSA 2', time: tsaTime), + ); + final security = encodeSecurityWith(signature: alg2, seal: seal2); + final v = evaluateSecurity(security, context: context, cms: cms); + expect([v.signature, v.seal], [Verdict.signedComplete, Verdict.sealed]); + expect(cms.calls, ['signature', 'seal']); + expect([cms.hasSeal, cms.signedWith], [true, alg2]); + expect(v.detail!.signers, [ana]); + expect([v.detail!.sealHolder, v.detail!.sealTime], ['TSA 2', tsaTime]); + // The lines are those of these verdicts, which the vectors of Go + // check: those of F6 with its signer, and S4 with its authority. + final merged = Verdicts( + signature: Verdict.signedComplete, + seal: Verdict.sealed, + detail: Detail(signers: [ana], sealHolder: 'TSA 2', sealTime: tsaTime), + ); + expect(v.lines, merged.lines); + expect(v.lines, hasLength(4)); + expect(v.sealedAt, tsaTime); + // A seal without a signature seals SIG_PART 0x00. + final alone = evaluateSecurity( + encodeSecurityWith(seal: seal2), + context: context, + cms: cms, + ); + expect( + [alone.signature, alone.seal, cms.signedWith], + [Verdict.noSignature, Verdict.sealed, null], + ); + }); + + test('null is F1, and S1 to S3 name no authority', () { + final cms = FakeCms( + signature: () => null, + seal: () => + CmsSealVerdict(Verdict.sealInvalid, holder: 'x', time: tsaTime), + ); + final v = evaluateSecurity( + encodeSecurityWith(signature: alg2, seal: seal2), + context: context, + cms: cms, + ); + expect( + [v.signature, v.seal, v.detail], + [Verdict.signatureUnchecked, Verdict.sealInvalid, null], + ); + }); + + test('what it throws, or a verdict out of its range, fails its own part ' + 'only, as a panic in Go', () { + final security = encodeSecurityWith(signature: alg2, seal: seal2); + final broken = evaluateSecurity( + security, + context: context, + cms: FakeCms(throws: true), + ); + expect( + [broken.signature, broken.seal, broken.detail], + [Verdict.signatureUnchecked, Verdict.sealUnreadable, null], + ); + for (final (sig, seal) in [ + ( + () => CmsSignatureVerdict(Verdict.signedSaved, Detail()), + () => const CmsSealVerdict(Verdict.noSeal), + ), + ( + () => CmsSignatureVerdict(Verdict.unreadable, Detail()), + () => const CmsSealVerdict(Verdict.sealed, holder: 'no time'), + ), + ]) { + final v = evaluateSecurity( + security, + context: context, + cms: FakeCms(signature: sig, seal: seal), + ); + expect( + [v.signature, v.seal, v.detail], + [Verdict.signatureUnchecked, Verdict.sealUnreadable, null], + ); + } + // The seal fails, and the signers of the signature stay. + final half = evaluateSecurity( + security, + context: context, + cms: FakeCms( + signature: () => CmsSignatureVerdict( + Verdict.signedIncomplete, + Detail(signers: [ana]), + ), + seal: () => throw StateError('broken'), + ), + ); + expect( + [half.signature, half.seal], + [Verdict.signedIncomplete, Verdict.sealUnreadable], + ); + expect( + [half.detail!.signers, half.detail!.sealTime], + [ + [ana], + null, + ], + ); + }); + }); + + test('never throws, whatever the area holds', () { + final r = Random(20261005); + final valid = encodeSecurityWith( + signature: encodeAuthorSignature(1, Uint8List(32), Uint8List(64)), + seal: encodeSeal(1, [1]), + ); + final context = SecurityContext( + controlCommit: Uint8List(32), + headDigest: Uint8List(32), + ); + for (var i = 0; i < 300; i++) { + final b = Uint8List.fromList(valid); + for (var j = 0; j < 1 + r.nextInt(3); j++) { + b[r.nextInt(b.length)] = r.nextInt(256); + } + final cut = Uint8List.sublistView(b, 0, r.nextInt(b.length + 1)); + for (final area in [ + b, + cut, + Uint8List.fromList(List.generate(r.nextInt(40), (_) => r.nextInt(256))), + ]) { + for (final ctx in [null, context]) { + final v = evaluateSecurity(area, context: ctx); + expect(v.evaluated, isTrue); + expect(v.lines, isNotEmpty); + } + } + } + }); + + test('the context of the opening: control_commit, head_digest, the round ' + 'time and the keys', () { + final built = + ((_vectors['commitments']! as Json)['control_commit']! as List) + .cast() + .firstWhere((c) => c['decode_format'] == 3 && c['format'] == 3); + final control = decodeControl( + fromHex(str(built, 'control')), + CapsuleFormat.format3, + ); + final head = Uint8List.fromList(utf8.encode('a head')); + final keys = {'dkauthor1x': 'Ana'}; + final input = SecurityInput( + security: encodeSecurity(), + head: head, + control: control, + format: CapsuleFormat.format3, + roundTime: parseRfc3339('2030-01-01T00:00:00Z'), + authorKeys: keys, + ); + final c = securityContext(input); + expect(toHex(c.controlCommit), built['commit']); + expect(c.headDigest, headDigest(head)); + expect([c.roundTime, c.authorKeys], [input.roundTime, keys]); + expect( + [ + evaluateSecurityInput(input).signature, + evaluateSecurityInput(input).seal, + ], + [Verdict.noSignature, Verdict.noSeal], + ); + // A control that CONTROL_SIG cannot hold leaves control_commit at zero, + // as in Go. + final odd = securityContext( + SecurityInput( + security: input.security, + head: head, + control: control, + format: CapsuleFormat.format1, + roundTime: input.roundTime, + authorKeys: const {}, + ), + ); + expect(odd.controlCommit, Uint8List(32)); + }); + + test('the texts of the verdicts and of the results', () { + // S0 shows nothing, and the verdicts that name a key, a holder or a + // time have their text in the lines. + for (final v in Verdict.values) { + expect(v.text.isEmpty, {'S0', 'F3', 'F4', 'F6', 'S4'}.contains(v.code)); + } + for (final r in SignerResult.values) { + expect(SignerResult.fromCode(r.code), r); + } + expect(SignerResult.fromCode('valida'), isNull); + expect(Verdicts.notEvaluated.lines, isEmpty); + expect(Verdicts.failed(StateError('x')).lines, isEmpty); + }); +} diff --git a/test/security_vm_test.dart b/test/security_vm_test.dart new file mode 100644 index 0000000..3d886b8 --- /dev/null +++ b/test/security_vm_test.dart @@ -0,0 +1,209 @@ +// The security area against Go, on the VM: every case of +// test/vectors/security_vectors.json, which tool/security_go_vectors.go +// writes with package capsule of the reference, of which +// security_vectors.g.dart holds a part; testdata/vectors/security.json; and +// the security area of every fixture of format 3, with the commitments, the +// message, the signature and the seal that its record gives. +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:datekeys/datekeys.dart'; +import 'package:datekeys/src/author.dart'; +import 'package:datekeys/src/bech32.dart' show bech32Encode; +import 'package:datekeys/src/curve25519.dart' show verifyStrict; +import 'package:datekeys/src/security.dart'; +import 'package:test/test.dart'; + +import 'open_vectors_support.dart'; +import 'security_support.dart'; +import 'vectors/security_vectors.g.dart'; + +Json readJson(String path) => jsonDecode(File(path).readAsStringSync()) as Json; + +void main() { + final vectors = readJson('test/vectors/security_vectors.json'); + final evaluations = (vectors['evaluate']! as List).cast(); + + test('security_vectors.json is of this spec, with every kind of case', () { + expect(vectors['spec'], specVersion); + expect(vectors['generator'], 'tool/security_go_vectors.go'); + expect(evaluations, hasLength(greaterThan(1500))); + final verdicts = { + for (final c in evaluations) '${c['signature']} ${c['seal']}', + }; + for (final v in ['X X', 'F0 S0', 'F1 S1', 'F2 S0', 'F3 S0', 'F4 S0']) { + expect(verdicts, contains(v)); + } + for (final v in ['F0 S2', 'F2 S2', 'F3 S2', 'F4 S1']) { + expect(verdicts, contains(v)); + } + final cms = [ + for (final c in evaluations) ...((c['cms'] as List?) ?? const []), + ]; + expect(cms.where((p) => p == 'signature'), hasLength(greaterThan(20))); + expect(cms.where((p) => p == 'seal'), hasLength(greaterThan(20))); + expect( + evaluations.where((c) => c['context'] == null), + hasLength(greaterThan(100)), + ); + }); + + test('security_vectors.g.dart holds a part of the file', () { + final part = jsonDecode(securityVectorsJson) as Json; + for (final k in [ + 'spec', + 'generator', + 'contexts', + 'texts', + 'bases', + 'commitments', + 'encode', + 'lines', + 'holder', + ]) { + expect(canonical(part[k]), canonical(vectors[k]), reason: k); + } + final all = [for (final c in evaluations) canonical(c)]; + final some = (part['evaluate']! as List).cast(); + expect(some, isNotEmpty); + var at = 0; + for (final c in some) { + final k = canonical(c); + while (at < all.length && all[at] != k) { + at++; + } + expect(at, lessThan(all.length), reason: k); + } + }); + + test('every evaluation, with the verdicts and lines of Go', () { + final contexts = contextsOf(vectors); + final texts = (vectors['texts']! as List).cast(); + final bases = [ + for (final b in (vectors['bases']! as List).cast()) fromHex(b), + ]; + for (final c in evaluations) { + final security = securityOf(c, bases); + expect( + evaluateDifferences(c, security, contexts, texts), + isEmpty, + reason: canonical(c), + ); + } + }); + + test('security.json: the verdicts and the lines of each area in its ' + 'context; those of alg 2 and seal_type 2 are of stage 5c', () { + final f = readJson('testdata/vectors/security.json'); + expect(f['spec'], specVersion); + final ctx = f['context']! as Json; + final context = SecurityContext( + controlCommit: fromHex(str(ctx, 'control_commit')), + headDigest: fromHex(str(ctx, 'head_digest')), + roundTime: parseRfc3339(str(ctx, 'round_time')), + ); + final cases = (f['vectors']! as List).cast(); + expect(cases, hasLength(24)); + var cms = 0; + for (final c in cases) { + final security = fromHex(str(c, 'hex')); + final parts = cmsParts(security); + if (parts.signature || parts.seal) cms++; + final v = evaluateSecurity(security, context: context); + // The key of F4 is in its line. + expect( + verdictDifferences(c, v, parts, keys: false), + isEmpty, + reason: str(c, 'name'), + ); + } + // A seal of seal_type 2 whose token is not DER: S2, from the reader of + // CMS. + expect(cms, 1); + }); + + group('the security area of each fixture of format 3', () { + final names = + Directory('testdata/fixtures') + .listSync() + .map((f) => f.uri.pathSegments.last) + .where((n) => n.startsWith('format3_') && n.endsWith('.json')) + .where((n) => !n.endsWith('.inspect.json') && !n.contains('.dkk')) + .toList() + ..sort(); + + test('there are 14, 3 signed and 1 sealed', () { + expect(names, hasLength(14)); + final records = [for (final n in names) readJson('testdata/fixtures/$n')]; + expect(records.where((r) => r['signature'] != null), hasLength(3)); + expect(records.where((r) => r['seal'] != null), hasLength(1)); + }); + + for (final n in names) { + test(n, () { + final r = readJson('testdata/fixtures/$n'); + final control = decodeControl( + fromHex(str(r, 'control_cbor')), + CapsuleFormat.format3, + ); + final head = fromHex(str(r, 'head_cbor')); + final security = fromHex(str(r, 'security_cbor')); + final cc = controlCommit(control, CapsuleFormat.format3); + final hd = headDigest(head); + final w = decodeSecurity(security); + + final sig = r['signature'] as Json?; + if (sig != null) { + expect(toHex(cc), sig['control_commit']); + expect(toHex(hd), sig['head_digest']); + expect(toHex(w!.signature!), sig['security_key_2']); + final a = decodeAuthorSignature(w.signature!)!; + expect([a.alg, toHex(a.value)], [sig['alg'], sig['signature']]); + final sd = a.alg == algEd25519 + ? signersDigest(algEd25519) + : signersDigest(algCms, a.key); + expect(toHex(sd), sig['signers_digest']); + final m = authorMessage(cc, hd, sd); + expect(String.fromCharCodes(m), sig['author_message']); + expect(authorCode(m), sig['author_code']); + if (a.alg == algEd25519) { + expect(bech32Encode('dkauthor', a.key), sig['author_key']); + expect(verifyStrict(a.key, m, a.value), isTrue); + } else { + expect(toHex(a.key), sig['signers']); + } + } + final seal = r['seal'] as Json?; + if (seal != null) { + final s = decodeSeal(w!.seal!)!; + expect( + [s.sealType, toHex(s.token)], + [seal['seal_type'], seal['token']], + ); + expect( + toHex(sealSubject(cc, hd, sigPart(w.signature))), + seal['seal_subject'], + ); + } + + // The verdicts of the record, in the context of the capsule. + final context = SecurityContext( + controlCommit: cc, + headDigest: hd, + roundTime: parseRfc3339(str(r, 'unlock_at')), + ); + expect( + verdictDifferences( + r['verdicts']! as Json, + evaluateSecurity(security, context: context), + cmsParts(security), + ), + isEmpty, + ); + }); + } + }); +}