diff --git a/lib/datekeys.dart b/lib/datekeys.dart index 289de1d..6a81d0a 100644 --- a/lib/datekeys.dart +++ b/lib/datekeys.dart @@ -2,28 +2,28 @@ /// DateKeys Access Key (`.dkk`), as `datekeys-go` and `datekeys-ts` implement /// them, checked against the same test data. /// -/// Stages 0 to 4 of docs/PLAN_dart.md and part 5b of stage 5: the package and -/// its test data, the normative errors of spec §69, byte helpers and the CBOR -/// profile of spec §58, the primitives and the reading of age files, then -/// BLS12-381 and tlock: the check of a compressed point and the verification -/// of releases, with the sources that deliver them. Then the key of words of -/// spec §38.1, and the formats: the frames of the .dkc and the .dkk, -/// PUBLIC_HEADER, CONTROL_CBOR of the three formats, the .dkk, the -/// extensions with their registries, the Provider Profile with the pinned -/// Quicknet, the DateKey with its rounds and times, the padding, the head of -/// format 3 and the public note. And the reading of a capsule: its -/// inspection, steps 1 to 8 of spec §63, and its opening, steps 9 to 18, in -/// memory or from a source read by ranges, to a sink of bytes or of files. +/// Stages 0 to 5 of docs/PLAN_dart.md: the package and its test data, the +/// normative errors of spec §69, byte helpers and the CBOR profile of spec +/// §58, the primitives and the reading of age files, then BLS12-381 and +/// tlock: the check of a compressed point and the verification of releases, +/// with the sources that deliver them. Then the key of words of spec §38.1, +/// and the formats: the frames of the .dkc and the .dkk, PUBLIC_HEADER, +/// CONTROL_CBOR of the three formats, the .dkk, the extensions with their +/// registries, the Provider Profile with the pinned Quicknet, the DateKey +/// with its rounds and times, the padding, the head of format 3 and the +/// public note. And the reading of a capsule: its inspection, steps 1 to 8 +/// of spec §63, and its opening, steps 9 to 18, in memory or from a source +/// read by ranges, to a sink of bytes or of files. /// And the security area of format 3: what an author signs and a seal seals, -/// SECURITY_CBOR, the signature of alg 1 and the verdicts with their texts -/// and lines, which the opening evaluates as Go; the signature of alg 2 and -/// the seal of seal_type 2 wait for the reader of CMS of stage 5c, behind -/// CmsEvaluator. +/// SECURITY_CBOR, the signature of alg 1, the signature of alg 2 with +/// certificates and the seal of seal_type 2 with the reader of CMS, and the +/// verdicts with their texts and lines, which the opening evaluates as Go. /// /// DER, the primitives, age, agewrap, the curve arithmetic, the IBE of tlock /// and its stanza, the rules of paths and texts on the Unicode tables, the -/// frame of BODY, the digest of a capsule and the steps of the opening are -/// internal, as in the Go reference and datekeys-ts. +/// frame of BODY, the digest of a capsule, the steps of the opening, and +/// the reader of CMS with its ECDSA and RSA are internal, as in the Go +/// reference and datekeys-ts. library; export 'src/accesskey.dart'; @@ -65,6 +65,7 @@ export 'src/release.dart' suppliedRelease, verifyRelease; export 'src/security.dart'; +export 'src/securitycms.dart'; export 'src/sink.dart'; export 'src/source.dart' show ByteSource, BytesSource; export 'src/verdicts.dart'; diff --git a/lib/src/open.dart b/lib/src/open.dart index 03cf1b8..80dae7b 100644 --- a/lib/src/open.dart +++ b/lib/src/open.dart @@ -132,10 +132,9 @@ final class OpenOptions { /// Evaluates the security area of a capsule of format 3 (spec §29.7), as /// Go evaluates it at step 17: [evaluateSecurityInput] by default, which - /// checks the signature of alg 1 and every verdict of the form, and leaves - /// the signature of alg 2 and the seal of seal_type 2 not evaluated until - /// a reader of CMS is given (see [CmsEvaluator]). Whatever it throws, the - /// capsule opens with [Verdicts.failed] (spec §29.3). + /// checks every part as Go, the signature of alg 2 and the seal of + /// seal_type 2 with the reader of CMS ([cmsReader]). Whatever it throws, + /// the capsule opens with [Verdicts.failed] (spec §29.3). final SecurityEvaluator evaluator; /// Receives the verdicts of a capsule of format 3 after every check of diff --git a/lib/src/security.dart b/lib/src/security.dart index 9306244..1cbdcfc 100644 --- a/lib/src/security.dart +++ b/lib/src/security.dart @@ -21,8 +21,10 @@ /// 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. +/// [CmsEvaluator]: [cmsReader] of securitycms.dart by default, which joins +/// the reader of CMS of cms.dart to these verdicts, as signature2.go of Go. +/// A caller that passes none leaves their verdict not evaluated, never +/// guessed. library; import 'dart:typed_data'; @@ -36,6 +38,7 @@ import 'datekey.dart'; import 'errors.dart'; import 'pathrule.dart'; import 'schema.dart'; +import 'securitycms.dart'; import 'verdicts.dart'; /// The type tag of SECURITY_CBOR (spec §29.3). @@ -429,10 +432,11 @@ final class CmsSealVerdict { /// 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. +/// (signature2.go): [cmsReader] of securitycms.dart, the default of +/// [evaluateSecurity]. 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] @@ -515,13 +519,15 @@ const _Signature _unchecked = ( /// /// 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. +/// seal_type 2, S1. In a context it checks every part as Go: the signature +/// of alg 1 here, and the signature of alg 2 and the seal of seal_type 2 +/// with [cms], the reader of CMS, [cmsReader] by default. A caller that +/// passes null for [cms] leaves those two not evaluated, null, as a reader +/// without CMS would. Verdicts evaluateSecurity( List security, { SecurityContext? context, - CmsEvaluator? cms, + CmsEvaluator? cms = cmsReader, }) { // 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 @@ -577,11 +583,9 @@ Verdicts evaluateSecurity( } /// 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 of [input] in its [securityContext], every part as Go's +/// newSecurityContext and EvaluateSecurityIn give them, the signature of +/// alg 2 and the seal of seal_type 2 with [cmsReader]. Verdicts evaluateSecurityInput(SecurityInput input) => evaluateSecurity(input.security, context: securityContext(input)); diff --git a/lib/src/securitycms.dart b/lib/src/securitycms.dart new file mode 100644 index 0000000..ceef49b --- /dev/null +++ b/lib/src/securitycms.dart @@ -0,0 +1,271 @@ +/// The verdicts of a signature of alg 2, a CMS signature with X.509 +/// certificates, and of a seal of seal_type 2, an RFC 3161 time-stamp token +/// (spec v0.12 §29.7, §29.10, §29.11), as signature2.go of package capsule of +/// datekeys-go at the draft v0.12 (c531e93) and securitycms.ts of +/// datekeys-ts: the same order of checks, the same verdicts, the same result +/// for each signer and the same detail, which the lines of verdicts.dart +/// write as Go writes them. +/// +/// It joins the reader of CMS of cms.dart, stage 5a of docs/PLAN_dart.md, +/// to the verdicts of security.dart, stage 5b, through [CmsEvaluator]: +/// [cmsReader] is the evaluator that [evaluateSecurity] uses by default, and +/// so does the opening. What cms.dart throws for a signature or a token that +/// breaks its profile is a verdict here; anything else it throws reaches +/// [evaluateSecurity], which makes it a failure of its own part only, F1 or +/// S2, as Go recovers a panic. +/// +/// The public part is that of Go's package capsule: [maxSigners], +/// [encodeSigners] and the evaluator. cms.dart stays internal, as +/// internal/cms of Go. +library; + +import 'dart:typed_data'; + +import 'author.dart'; +import 'bytes.dart'; +import 'cbor.dart'; +import 'cms.dart' as cms; +import 'datekey.dart'; +import 'errors.dart'; +import 'security.dart'; +import 'sha256.dart'; +import 'verdicts.dart'; + +/// The most required signers of a signature of alg 2 (spec §29.10), as +/// MaxSigners of Go. +const maxSigners = 16; + +/// The size of each element of SIGNERS: the SHA-256 of a certificate. +const _signerHashSize = 32; + +/// SIGNERS, the content of key 1 of an author-signature of alg 2, as +/// EncodeSigners of Go: a CBOR array of 1 to [maxSigners] byte strings of 32 +/// bytes, [hashes], the SHA-256 of the certificate of each required signer, +/// in strictly ascending order of bytes (spec §29.10). It sorts them, and +/// throws an [ArgumentError] with the text of Go when there are none, too +/// many, or two that are equal, and when one is not 32 bytes, which Go's +/// type [32]byte rules out. +Uint8List encodeSigners(List> hashes) { + if (hashes.isEmpty || hashes.length > maxSigners) { + throw ArgumentError('capsule: SIGNERS holds from 1 to 16 certificates'); + } + for (final h in hashes) { + if (h.length != _signerHashSize) { + throw ArgumentError( + 'capsule: a SHA-256 of ${h.length} bytes, want $_signerHashSize', + ); + } + } + final sorted = [for (final h in hashes) Uint8List.fromList(h)] + ..sort(compareBytes); + for (var i = 1; i < sorted.length; i++) { + if (equalBytes(sorted[i - 1], sorted[i])) { + throw ArgumentError('capsule: SIGNERS names a certificate twice'); + } + } + final e = CborEncoder()..array(sorted.length); + for (final h in sorted) { + e.bstr(h); + } + return Uint8List.fromList(e.out()); +} + +// decodeSigners of Go: SIGNERS with the profile of spec §29.10, 1 to 16 +// strings of 32 bytes in strictly ascending order, in the CBOR profile of +// §58. Throws a DateKeysException otherwise, the verdict F1. +List _decodeSigners(Uint8List b) { + final out = []; + unmarshalCbor( + b, + (d) { + final n = d.array(maxSigners); + if (n < 1) { + throw DateKeysException(ErrorCode.nonCanonicalCbor, 'SIGNERS is empty'); + } + for (var i = 0; i < n; i++) { + final h = d.bstr(_signerHashSize, _signerHashSize); + if (out.isNotEmpty && compareBytes(out.last, h) >= 0) { + throw DateKeysException( + ErrorCode.nonCanonicalCbor, + 'SIGNERS is not in strictly ascending order', + ); + } + out.add(h); + } + }, + (e) { + e.array(out.length); + for (final h in out) { + e.bstr(h); + } + }, + ); + return out; +} + +/// The reader of CMS of the verdicts: evaluateCMS and evaluateSeal of Go +/// (signature2.go). The default of [evaluateSecurity], and so of the +/// opening; an evaluator of one's own can wrap it. +const CmsEvaluator cmsReader = _CmsReader(); + +final class _CmsReader implements CmsEvaluator { + const _CmsReader(); + + // evaluateCMS of Go (spec §29.10): null, F1, for SIGNERS or a SignedData + // that break their profile; then each required signer, in the order of + // SIGNERS, and each SignerInfo of another certificate, in the order of the + // encoding, every one checked over AUTHOR_MESSAGE with the signers_digest + // of these very SIGNERS. F2 when a required signer is invalid; F5 when one + // is anything else but valid, or when hasSeal; F6 otherwise. + @override + CmsSignatureVerdict? evaluateSignature( + Uint8List signers, + Uint8List value, + bool hasSeal, + SecurityContext context, + ) { + final List required; + final cms.SignedData sd; + try { + required = _decodeSigners(signers); + sd = cms.parseSignature(value); + } on DateKeysException { + return null; + } on cms.CmsException { + return null; + } + final message = authorMessage( + context.controlCommit, + context.headDigest, + signersDigest(algCms, signers), + ); + // ParseSignature gives each certificate one SignerInfo at most, and two + // certificates of the same bytes are one. + final byHash = {for (final s in sd.signers) toHex(s.cert.hash): s}; + var invalid = false; + var incomplete = hasSeal; + final lines = []; + for (final h in required) { + final s = byHash[toHex(h)]; + if (s == null) { + // Named by the hash that SIGNERS gives, without an issuer. + lines.add(SignerLine(holder: toHex(h), result: SignerResult.absent)); + incomplete = true; + continue; + } + final line = _signerLine(s, message, context.roundTime); + switch (line.result) { + case SignerResult.invalid: + invalid = true; + case SignerResult.valid: + break; + default: + incomplete = true; + } + lines.add(line); + } + final foreign = [ + for (final s in sd.signers) + if (!required.any((h) => equalBytes(h, s.cert.hash))) + _signerLine(s, message, context.roundTime), + ]; + return CmsSignatureVerdict( + invalid + ? Verdict.signatureInvalid + : incomplete + ? Verdict.signedIncomplete + : Verdict.signedComplete, + Detail(signers: lines, foreign: foreign), + ); + } + + // evaluateSeal of Go (spec §29.11): S2 for a token whose form breaks the + // profile, S1 for an algorithm outside the table or an imprint that is not + // SHA-256, S3 when it does not verify over SEAL_SUBJECT with the SIG_PART + // of signature, and S4 or S5 when it does, with the authority and t. + @override + CmsSealVerdict evaluateSeal( + Uint8List token, + Uint8List? signature, + SecurityContext context, + ) { + final cms.Token tok; + try { + tok = cms.parseToken(token); + } on cms.CmsFormException { + return const CmsSealVerdict(Verdict.sealUnreadable); + } on cms.CmsAlgorithmException { + return const CmsSealVerdict(Verdict.sealUnsupported); + } + if (!tok.imprintIsSha256) { + return const CmsSealVerdict(Verdict.sealUnsupported); + } + final subject = sealSubject( + context.controlCommit, + context.headDigest, + sigPart(signature), + ); + if (!tok.check(subject)) return const CmsSealVerdict(Verdict.sealInvalid); + return CmsSealVerdict( + _before(tok, context.roundTime) ? Verdict.sealed : Verdict.sealedLate, + holder: holderText(tok.tsa.holder, tok.tsa.hash), + time: tok.genTime, + ); + } +} + +// signerLine of Go: one SignerInfo as spec §29.10 orders its checks, not +// verifiable, invalid, without seal, with an invalid seal, out of validity, +// or valid with the authority and t of its seal. The certificate is valid +// or not at t, the time of the seal, both ends included. The issuer is text +// of the certificate, as the holder is: it gets the same rules, and the +// SHA-256 of the DER of its Name when it fails them, so that no escape, no +// control and no bidirectional character reaches a line of the verdicts. +SignerLine _signerLine(cms.SignerInfo s, Uint8List message, Instant? round) { + final holder = holderText(s.cert.holder, s.cert.hash); + final issuer = holderText(s.cert.issuerName, sha256(s.cert.rawIssuer)); + SignerLine line(SignerResult result) => + SignerLine(holder: holder, issuer: issuer, result: result); + switch (s.check(message)) { + case cms.CmsResult.notVerifiable: + return line(SignerResult.notVerifiable); + case cms.CmsResult.invalid: + return line(SignerResult.invalid); + case cms.CmsResult.valid: + break; + } + final token = s.token; + if (token == null) return line(SignerResult.withoutSeal); + // The token of a signer is read with the profile of §29.11 over the value + // of its signature: S2, S1 or S3 is an invalid seal. Its imprint may be of + // any hash of the table. + final cms.Token tok; + try { + tok = cms.parseToken(token); + } on cms.CmsException { + return line(SignerResult.invalidSeal); + } + if (!tok.check(s.signature)) return line(SignerResult.invalidSeal); + if (!s.cert.validAt(tok.genTime)) return line(SignerResult.outOfValidity); + return SignerLine( + holder: holder, + issuer: issuer, + result: SignerResult.valid, + sealHolder: holderText(tok.tsa.holder, tok.tsa.hash), + sealTime: tok.genTime, + before: _before(tok, round), + ); +} + +// Whether t plus the accuracy of tok is before round_time (spec §29.7): the +// seal proves something before the capsule could open. A round time that is +// not known, null or Go's zero time, which IsZero reports, has no seal +// before it. +bool _before(cms.Token tok, Instant? round) => + round != null && + !_isGoZero(round) && + compareInstants(cms.addDuration(tok.genTime, tok.accuracy), round) < 0; + +// Go's zero time.Time, 0001-01-01T00:00:00Z, which SecurityContext of Go +// reads as no round time. +bool _isGoZero(Instant t) => t.seconds == -62135596800 && t.nanos == 0; diff --git a/lib/src/verdicts.dart b/lib/src/verdicts.dart index 6a115db..c3e5672 100644 --- a/lib/src/verdicts.dart +++ b/lib/src/verdicts.dart @@ -8,9 +8,9 @@ /// area require (spec §29.2), and hands it, with what a verdict needs /// besides, to a [SecurityEvaluator]: Go's newSecurityContext and /// 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. +/// security.dart, evaluates every part as Go: the signature of alg 1, the +/// signature of alg 2 and the seal of seal_type 2, these two with the reader +/// of CMS of securitycms.dart, and every verdict of the form. /// /// 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 @@ -237,8 +237,9 @@ final class Detail { /// 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. +/// of CMS, alg 2 or seal_type 2, and the caller of evaluateSecurity gave +/// none (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), the label of the saved key @@ -377,13 +378,16 @@ final class Verdicts { /// 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. + /// that is newer than the proof that it existed. As in Go, where a time + /// that IsZero is no time, a seal of 0001-01-01T00:00:00Z gives none. 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)) { + if (t != null && + !_isGoZero(t) && + (best == null || compareInstants(t, best!) < 0)) { best = t; } } @@ -416,6 +420,10 @@ String _quoted(String name) => '«$name»'; String _instant(Instant? t) => t == null ? '0001-01-01T00:00:00Z' : formatRfc3339Nano(t); +/// Whether [t] is Go's zero time, 0001-01-01T00:00:00Z, which IsZero reports +/// as no time. +bool _isGoZero(Instant t) => t.seconds == -62135596800 && t.nanos == 0; + /// 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. diff --git a/test/open_corpus_test.dart b/test/open_corpus_test.dart index 5a544e7..17ee606 100644 --- a/test/open_corpus_test.dart +++ b/test/open_corpus_test.dart @@ -74,7 +74,6 @@ void main() { verdictDifferences( c['verdicts']! as Json, o.opened.verdicts!, - cmsParts(security), keys: false, ), isEmpty, diff --git a/test/open_support.dart b/test/open_support.dart index eff600b..180b75b 100644 --- a/test/open_support.dart +++ b/test/open_support.dart @@ -338,15 +338,7 @@ List differences(Json c, Outcome o) { same('verdicts recorded', verdicts != null, o.result == 'ok' && format3); if (verdicts != null && o.opened.verdicts != null) { same('evaluations', o.securities.length, 1); - if (o.securities.length == 1) { - out.addAll( - verdictDifferences( - verdicts, - o.opened.verdicts!, - cmsParts(o.securities.single), - ), - ); - } + out.addAll(verdictDifferences(verdicts, o.opened.verdicts!)); } return out; } diff --git a/test/security_support.dart b/test/security_support.dart index 4692acc..18b19b3 100644 --- a/test/security_support.dart +++ b/test/security_support.dart @@ -1,10 +1,8 @@ // 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. +// 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 with the reader of CMS +// of securitycms.dart, as evaluateSecurity does by default. They read no +// file, so that the tests that run on Node.js can use them. library; import 'dart:typed_data'; @@ -30,26 +28,18 @@ import 'tlock_support.dart' show applyEdits; ); } -/// 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'}; +/// Whether [t] is Go's zero time, which Go writes as no time. +bool isGoZero(Instant t) => t.seconds == -62135596800 && t.nanos == 0; -/// 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; +/// [t] as the vectors of Go write a time: RFC 3339 with its fraction, and +/// null for none or for Go's zero time. +String? timeText(Instant? t) => + t == null || isGoZero(t) ? null : formatRfc3339Nano(t); /// 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, -}) { +/// author_label and sealed_at. +List verdictDifferences(Json want, Verdicts v, {bool keys = true}) { final out = []; void same(String what, Object? got, Object? expected) { final g = canonical(got); @@ -57,46 +47,12 @@ List verdictDifferences( 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, []); - } + same('signature', v.signature?.code, want['signature']); + same('seal', v.seal?.code, want['seal']); + same('lines', v.lines, want['lines']); if (keys) { + same('sealed_at', timeText(v.sealedAt), want['sealed_at']); final key = v.authorKey; same( 'author_key', @@ -108,6 +64,31 @@ List verdictDifferences( return out; } +/// A signer as the lines section of security_vectors.json and the cases of +/// securitycms_vectors.json write it, as signerJSON of +/// tool/security_go_vectors.go. +Json signerJson(SignerLine s) => { + 'holder': s.holder, + 'issuer': s.issuer, + 'result': s.result.code, + 'seal_holder': s.sealHolder, + 'before': s.before, + if (timeText(s.sealTime) != null) 'seal_time': timeText(s.sealTime), +}; + +/// The detail of [v] as tool/security_go_vectors.go writes Go's, null +/// without one. +Json? detailJson(Verdicts v) { + final d = v.detail; + if (d == null) return null; + return { + 'signers': [for (final s in d.signers) signerJson(s)], + 'foreign': [for (final s in d.foreign) signerJson(s)], + 'seal_holder': d.sealHolder, + if (timeText(d.sealTime) != null) 'seal_time': timeText(d.sealTime), + }; +} + /// The bytes of [hex] or null. Uint8List? hexOrNull(Object? hex) => hex == null ? null : fromHex(hex as String); @@ -167,7 +148,8 @@ Uint8List securityOf(Json c, List bases) { } /// 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. +/// verdicts, lines and detail, the parts of CMS, and alg and seal_type as +/// read. List evaluateDifferences( Json c, Uint8List security, @@ -179,13 +161,13 @@ List evaluateDifferences( final context = at == null ? null : contexts[at]; final v = evaluateSecurity(security, context: context); final goCms = ((c['cms'] as List?) ?? const []).cast(); + // The parts that the reader of CMS evaluates, as this library reads the + // area: those of Go, in a context. + final mine = cmsParts(security); 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'); } @@ -205,7 +187,12 @@ List evaluateDifferences( ...c, 'lines': [for (final i in (c['lines']! as List).cast()) texts[i]], }; - out.addAll(verdictDifferences(want, v, cms)); + out.addAll(verdictDifferences(want, v)); + // Go's detail, the signers of alg 2 and the authority of a valid seal, + // which the generator writes whenever Go has one. + if (canonical(detailJson(v)) != canonical(c['detail'])) { + out.add('detail ${canonical(detailJson(v))}'); + } return out; } @@ -372,3 +359,87 @@ List encodeDifferences(List cases) { } return out; } + +// --------------------------------------------------------------------------- +// testdata/vectors/security_cms.json + +/// The results of the signers [lines] as security_cms.json and the records +/// of the fixtures write them, as vectorSignerResults of Go's +/// cmsvectors_test.go: t with its fraction, only for a seal that verifies. +List signerResults(List lines) => [ + for (final l in lines) + { + 'holder': l.holder, + 'issuer': l.issuer, + 'result': l.result.code, + if (timeText(l.sealTime) != null) 'seal_time': timeText(l.sealTime), + 'before_round_time': l.before, + }, +]; + +/// What a case of security_cms.json records of the verdicts [v], as +/// TestCMSVectors of Go reads them: the verdicts, the results of the +/// required signers and of the foreign ones, each list only when it is not +/// empty, the authority and t of a valid seal of key 3, and the lines. +Json cmsVectorOf(Verdicts v) { + final d = v.detail; + final when = d == null ? null : timeText(d.sealTime); + return { + 'signature': v.signature?.code, + 'seal': v.seal?.code, + if (d != null && d.signers.isNotEmpty) 'signers': signerResults(d.signers), + if (d != null && d.foreign.isNotEmpty) + 'foreign_signers': signerResults(d.foreign), + if (when != null) 'seal_holder': d!.sealHolder, + 'seal_time': ?when, + 'lines': v.lines, + }; +} + +/// The context of a case of security_cms.json. +SecurityContext cmsVectorContext(Json c) { + final ctx = c['context']! as Json; + return SecurityContext( + controlCommit: fromHex(str(ctx, 'control_commit')), + headDigest: fromHex(str(ctx, 'head_digest')), + roundTime: parseRfc3339(str(ctx, 'round_time')), + ); +} + +/// The differences between the case [c] of security_cms.json and its +/// evaluation in its context, as TestCMSVectors of Go checks them: the +/// verdicts, the results, the seal and the lines, byte for byte, and no line +/// with a control or a bidirectional character. +List cmsVectorDifferences(Json c) { + final v = evaluateSecurity( + fromHex(str(c, 'security_cbor')), + context: cmsVectorContext(c), + ); + final out = []; + final want = { + for (final k in const [ + 'signature', + 'seal', + 'signers', + 'foreign_signers', + 'seal_holder', + 'seal_time', + 'lines', + ]) + if (c.containsKey(k)) k: c[k], + }; + final got = cmsVectorOf(v); + if (canonical(got) != canonical(want)) out.add('got ${canonical(got)}'); + for (final line in v.lines) { + for (final r in line.runes) { + if (r < 0x20 || + r >= 0x7f && r <= 0x9f || + r >= 0x202a && r <= 0x202e || + r >= 0x2066 && r <= 0x2069 || + r == 0xfeff) { + out.add('a line with U+${r.toRadixString(16)}: $line'); + } + } + } + return out; +} diff --git a/test/security_test.dart b/test/security_test.dart index 0220073..89e1320 100644 --- a/test/security_test.dart +++ b/test/security_test.dart @@ -128,10 +128,27 @@ void main() { ); final tsaTime = parseRfc3339('2026-09-29T10:00:00Z'); + test('is cmsReader by default, and the opening evaluates with it', () { + // SIGNERS empty is F1, and a token that is not DER, S2. + final v = evaluateSecurity( + encodeSecurityWith(signature: alg2, seal: seal2), + context: context, + ); + expect( + [v.signature, v.seal, v.evaluated], + [Verdict.signatureUnchecked, Verdict.sealUnreadable, true], + ); + expect(v.lines, [ + Verdict.signatureUnchecked.text, + Verdict.sealUnreadable.text, + ]); + }); + test('without one, alg 2 and seal_type 2 are not evaluated', () { final v = evaluateSecurity( encodeSecurityWith(signature: alg2, seal: seal2), context: context, + cms: null, ); expect( [v.signature, v.seal, v.evaluated, v.lines], @@ -140,6 +157,7 @@ void main() { final s = evaluateSecurity( encodeSecurityWith(signature: alg2, seal: encodeSeal(1, [1])), context: context, + cms: null, ); expect( [s.signature, s.seal, s.lines], @@ -155,6 +173,7 @@ void main() { seal: seal2, ), context: context, + cms: null, ); expect( [f.signature, f.seal, f.lines], diff --git a/test/security_vm_test.dart b/test/security_vm_test.dart index 496014e..866d711 100644 --- a/test/security_vm_test.dart +++ b/test/security_vm_test.dart @@ -94,7 +94,7 @@ void main() { }); 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', () { + 'context', () { final f = readJson('testdata/vectors/security.json'); expect(f['spec'], specVersion); final ctx = f['context']! as Json; @@ -113,10 +113,11 @@ void main() { final v = evaluateSecurity(security, context: context); // The key of F4 is in its line. expect( - verdictDifferences(c, v, parts, keys: false), + verdictDifferences(c, v, keys: false), isEmpty, reason: str(c, 'name'), ); + expect(v.evaluated, isTrue, reason: str(c, 'name')); } // A seal of seal_type 2 whose token is not DER: S2, from the reader of // CMS. @@ -187,20 +188,43 @@ void main() { ); } - // The verdicts of the record, in the context of the capsule. + // The verdicts of the record, in the context of the capsule, with + // the results of the signers of alg 2 and the authority and t of a + // seal of seal_type 2, which are also the earliest seal. final context = SecurityContext( controlCommit: cc, headDigest: hd, roundTime: parseRfc3339(str(r, 'unlock_at')), ); + final v = evaluateSecurity(security, context: context); + final want = r['verdicts']! as Json; + expect(verdictDifferences(want, v, keys: false), isEmpty); + final key = v.authorKey; expect( - verdictDifferences( - r['verdicts']! as Json, - evaluateSecurity(security, context: context), - cmsParts(security), + key == null ? null : bech32Encode('dkauthor', key), + want['author_key'], + ); + final d = v.detail; + final results = sig?['signer_results'] as List?; + expect( + canonical( + d == null || d.signers.isEmpty ? null : signerResults(d.signers), ), - isEmpty, + canonical(results), ); + expect(d?.foreign ?? const [], isEmpty); + final sealedAt = [ + if (seal != null) parseRfc3339(str(seal, 'time')), + for (final s in (results ?? const []).cast()) + if (s['result'] == 'valid') parseRfc3339(str(s, 'seal_time')), + ]..sort(compareInstants); + if (seal != null) { + expect( + [d?.sealHolder, timeText(d?.sealTime)], + [seal['holder'], seal['time']], + ); + } + expect(v.sealedAt, sealedAt.firstOrNull); }); } }); diff --git a/test/securitycms_support.dart b/test/securitycms_support.dart new file mode 100644 index 0000000..3dc414a --- /dev/null +++ b/test/securitycms_support.dart @@ -0,0 +1,81 @@ +// Helpers of the tests of the signatures of alg 2 and the seals of +// seal_type 2 against the vectors of Go: the areas of +// test/vectors/securitycms_vectors.json, which tool/security_go_vectors.go +// makes and evaluates with package capsule of the reference, put together +// again from their pieces, or from a base and its edit. 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/sha256.dart' show sha256; + +import 'open_vectors_support.dart'; +import 'tlock_support.dart' show applyEdits; + +/// The bytes of [pieces]: the hexadecimal of some bytes, or the index of a +/// chunk of [chunks]. +Uint8List joinPieces(List pieces, List chunks) => + concatBytes([ + for (final p in pieces) p is int ? chunks[p] : fromHex(p! as String), + ]); + +/// The chunks of a vector file, each made of the ones before it. +List chunksOf(Json f) { + final out = []; + for (final c in (f['chunks']! as List).cast()) { + out.add(joinPieces(c['pieces']! as List, out)); + } + return out; +} + +/// The bases of the file, from their pieces, or their hexadecimal in the +/// part of the file that the tests compiled to JavaScript read. +List basesOf(Json f, List chunks) => [ + for (final b in f['bases']! as List) + b is String + ? fromHex(b) + : joinPieces((b as Json)['pieces']! as List, chunks), +]; + +/// The area of the case [c]: its hexadecimal, its pieces, or its base with +/// the edit of its target, the SignedData of key 2 (value), its SIGNERS +/// (signers) or the token of key 3 (token), written again with the +/// encoders of this library as Go writes it with those of capsule. The +/// first 8 bytes of its SHA-256 must be those of Go's. +Uint8List cmsAreaOf(Json c, List chunks, List bases) { + final Uint8List area; + final hex = c['hex'] as String?; + final pieces = c['pieces'] as List?; + if (hex != null) { + area = fromHex(hex); + } else if (pieces != null) { + area = joinPieces(pieces, chunks); + } else { + final w = decodeSecurity(bases[c['base']! as int])!; + final edits = (c['edits']! as List).cast(); + var signature = w.signature; + var seal = w.seal; + switch (c['target']) { + case 'value' || 'signers': + final a = decodeAuthorSignature(signature!)!; + final value = c['target'] == 'value'; + signature = encodeAuthorSignature( + algCms, + value ? a.key : applyEdits(a.key, edits), + value ? applyEdits(a.value, edits) : a.value, + ); + case 'token': + final s = decodeSeal(seal!)!; + seal = encodeSeal(sealTypeRfc3161, applyEdits(s.token, edits)); + default: + throw StateError('a target ${c['target']}'); + } + area = encodeSecurityWith(signature: signature, seal: seal); + } + if (toHex(sha256(area).sublist(0, 8)) != c['sha256']) { + throw StateError('the area of ${canonical(c)} is not the one of Go'); + } + return area; +} diff --git a/test/securitycms_test.dart b/test/securitycms_test.dart new file mode 100644 index 0000000..1fc1931 --- /dev/null +++ b/test/securitycms_test.dart @@ -0,0 +1,264 @@ +// The verdicts of a signature of alg 2 and of a seal of seal_type 2 on the +// VM and compiled to JavaScript: the part of the vectors of Go that +// test/vectors/securitycms_vectors.g.dart holds, which +// tool/security_go_vectors.go makes and evaluates with package capsule of +// the reference; the fixtures format3_signed_cms and format3_sealed opened +// to the verdicts and lines of their records; and what the API does: +// SIGNERS, the reader of CMS as the default of evaluateSecurity and of the +// opening, and Go's zero time as no time. +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:datekeys/datekeys.dart'; +import 'package:datekeys/src/bech32.dart' show bech32Encode; +import 'package:test/test.dart'; + +import 'open_support.dart'; +import 'securitycms_support.dart'; +import 'vectors/securitycms_vectors.g.dart'; + +final Json _part = jsonDecode(securityCmsVectorsJson) as Json; + +List get _cases => (_part['cases']! as List).cast(); + +/// The context of the vectors named [name]. +SecurityContext _context(String name) { + final all = (_part['contexts']! as List).cast(); + return contextsOf(_part)[all.indexWhere((c) => c['name'] == name)]; +} + +/// A sink of files that keeps them. +final class _Files implements FileSink { + final files = []; + bool committed = false; + + @override + void begin(Head head) => + files.addAll([for (final _ in head.files) BytesBuilder()]); + + @override + ByteSink create(int i) => _File(files[i]); + + @override + void commit() => committed = true; + + @override + void abort(Object reason) {} +} + +final class _File implements ByteSink { + _File(this.b); + final BytesBuilder b; + + @override + void add(Uint8List bytes) => b.add(bytes); + + @override + void close() {} + + @override + void abort(Object reason) {} +} + +void main() { + test('the part of the vectors is of this spec, from its generator', () { + expect(_part['spec'], specVersion); + expect(_part['generator'], 'tool/security_go_vectors.go'); + expect(_cases, hasLength(greaterThan(40))); + }); + + test('each case of the part, with the verdicts, the lines, the detail ' + 'and the earliest seal of Go', () { + final bases = basesOf(_part, const []); + final contexts = contextsOf(_part); + final texts = (_part['texts']! as List).cast(); + for (final c in _cases) { + expect( + evaluateDifferences(c, cmsAreaOf(c, const [], bases), contexts, texts), + isEmpty, + reason: c['name'] as String? ?? canonical(c), + ); + } + }); + + group('the fixtures', () { + final fixtures = + ((jsonDecode(securityCmsFixturesJson) as Json)['fixtures']! as List) + .cast(); + for (final x in fixtures) { + test( + '${x['name']} opens to the verdicts and lines of its record', + () async { + final rel = x['release']! as Json; + final files = _Files(); + final o = await openCapsule( + fromHex(str(x, 'dkc')), + OpenOptions( + source: suppliedRelease( + Release(rel['round']! as int, fromHex(str(rel, 'signature'))), + ), + now: () => parseRfc3339(str(x, 'unlock_at')), + sink: files, + ), + ); + expect([o.ok, files.committed], [true, true], reason: '${o.error}'); + final v = o.verdicts!; + expect( + verdictDifferences(x['verdicts']! as Json, v, keys: false), + isEmpty, + ); + final key = v.authorKey; + expect( + key == null ? null : bech32Encode('dkauthor', key), + (x['verdicts']! as Json)['author_key'], + ); + final d = v.detail!; + expect( + canonical(d.signers.isEmpty ? null : signerResults(d.signers)), + canonical(x['signer_results']), + ); + final seal = x['seal'] as Json?; + expect( + canonical( + seal == null + ? null + : {'holder': d.sealHolder, 'time': timeText(d.sealTime)}, + ), + canonical(seal), + ); + }, + ); + } + }); + + group('SIGNERS', () { + final a = Uint8List(32)..[0] = 0x61; + final b = Uint8List(32)..[0] = 0x62; + + test('encodeSigners sorts the hashes, as EncodeSigners of Go', () { + final x = encodeSigners([a, b]); + expect(encodeSigners([b, a]), x); + expect(x, hasLength(1 + 2 * 34)); + expect(x.sublist(0, 3), [0x82, 0x58, 0x20]); + expect(maxSigners, 16); + expect( + encodeSigners(List.generate(16, (i) => Uint8List(32)..[31] = i)), + hasLength(1 + 16 * 34), + ); + }); + + test('encodeSigners refuses what Go refuses, with its texts', () { + for (final (hashes, text) in [ + (>[], 'capsule: SIGNERS holds from 1 to 16 certificates'), + ([a, a], 'capsule: SIGNERS names a certificate twice'), + ( + List.generate(17, (i) => Uint8List(32)..[31] = i), + 'capsule: SIGNERS holds from 1 to 16 certificates', + ), + ([Uint8List(31)], 'capsule: a SHA-256 of 31 bytes, want 32'), + ]) { + expect( + () => encodeSigners(hashes), + throwsA( + isA().having((e) => e.message, 'message', text), + ), + ); + } + }); + }); + + group('the reader of CMS', () { + test('is the default of evaluateSecurity and of the opening', () { + for (final c in _cases.take(12)) { + final area = cmsAreaOf(c, const [], basesOf(_part, const [])); + final context = contextsOf(_part)[c['context']! as int]; + final byDefault = evaluateSecurity(area, context: context); + final given = evaluateSecurity(area, context: context, cms: cmsReader); + expect(byDefault.lines, given.lines); + expect( + [byDefault.signature, byDefault.seal], + [given.signature, given.seal], + ); + } + final options = OpenOptions( + source: suppliedRelease(Release(1, Uint8List(48))), + now: () => Instant(0), + ); + expect(options.evaluator, same(evaluateSecurityInput)); + }); + + test('reads a round time at Go\'s zero time as no round time, as ' + 'IsZero', () { + // A seal of key 3 before the round time: S4; without a round time, or + // at 0001-01-01T00:00:00Z, S5. + final c = _cases.firstWhere( + (c) => + c['seal'] == 'S4' && + ((c['cms'] as List?) ?? const []).contains('seal'), + ); + final area = cmsAreaOf(c, const [], basesOf(_part, const [])); + final context = contextsOf(_part)[c['context']! as int]; + SecurityContext at(Instant? round) => SecurityContext( + controlCommit: context.controlCommit, + headDigest: context.headDigest, + roundTime: round, + ); + expect(evaluateSecurity(area, context: context).seal, Verdict.sealed); + for (final round in [null, Instant(-62135596800)]) { + final v = evaluateSecurity(area, context: at(round)); + expect(v.seal, Verdict.sealedLate, reason: '$round'); + expect(v.detail!.sealTime, isNotNull); + } + // A nanosecond after Go's zero time is a time: the seal of 2026 is not + // before it either. + expect( + evaluateSecurity(area, context: at(Instant(-62135596800, 1))).seal, + Verdict.sealedLate, + ); + expect(_context('the same, without a round time').roundTime, isNull); + }); + }); + + test('the earliest seal ignores Go\'s zero time, as SealedAt of Go', () { + final zero = Instant(-62135596800); + final later = parseRfc3339('2026-09-30T12:00:00Z'); + SignerLine signer(Instant t) => SignerLine( + holder: 'Ana', + issuer: 'CA', + result: SignerResult.valid, + sealHolder: 'TSA', + sealTime: t, + ); + expect( + Verdicts( + signature: Verdict.noSignature, + seal: Verdict.sealed, + detail: Detail(sealHolder: 'TSA', sealTime: zero), + ).sealedAt, + isNull, + ); + expect( + Verdicts( + signature: Verdict.signedComplete, + seal: Verdict.sealed, + detail: Detail( + signers: [signer(zero), signer(later)], + sealHolder: 'TSA', + sealTime: zero, + ), + ).sealedAt, + later, + ); + // The line shows it all the same, as Go writes it. + expect( + Verdicts( + signature: Verdict.noSignature, + seal: Verdict.sealed, + detail: Detail(sealHolder: 'TSA', sealTime: zero), + ).lines.last, + contains('existía el 0001-01-01T00:00:00Z'), + ); + }); +} diff --git a/test/securitycms_vm_test.dart b/test/securitycms_vm_test.dart new file mode 100644 index 0000000..d8da993 --- /dev/null +++ b/test/securitycms_vm_test.dart @@ -0,0 +1,196 @@ +// The verdicts of a signature of alg 2 and of a seal of seal_type 2 against +// Go, on the VM: +// +// - every case of testdata/vectors/security_cms.json, frozen by the Go +// reference at the draft v0.12, read in its context as TestCMSVectors of +// Go reads it, with its verdicts, the result of each signer, the authority +// and t of a valid seal, and its lines, byte for byte; +// - every case of test/vectors/securitycms_vectors.json, which +// tool/security_go_vectors.go makes and evaluates with package capsule of +// the reference: signers of every result, required and foreign, the +// validity of a certificate at the time of its seal, t plus the accuracy +// against the round time at the nanosecond, a seal of each kind beside a +// signature of each kind, and mutations; +// - and the part of both that securitycms_vectors.g.dart holds for the +// tests compiled to JavaScript, which must be that of the files. +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:datekeys/datekeys.dart'; +import 'package:test/test.dart'; + +import 'open_vectors_support.dart'; +import 'security_support.dart'; +import 'securitycms_support.dart'; +import 'vectors/securitycms_vectors.g.dart'; + +Json readJson(String path) => jsonDecode(File(path).readAsStringSync()) as Json; + +void main() { + group('security_cms.json', () { + final f = readJson('testdata/vectors/security_cms.json'); + final cases = (f['cases']! as List).cast(); + + test('has the 135 cases of its README, which reach every verdict but X ' + 'and F3', () { + expect(f['spec'], specVersion); + expect(f['description'], contains('v0.12')); + expect(cases, hasLength(135)); + expect({for (final c in cases) c['name']}, hasLength(135)); + final reached = { + for (final c in cases) ...[c['signature'], c['seal']], + }; + expect(reached, { + 'F0', 'F1', 'F2', 'F4', 'F5', 'F6', // + 'S0', 'S1', 'S2', 'S3', 'S4', 'S5', + }); + // Every result of a signer, and a foreign one. + final results = { + for (final c in cases) + for (final s in (c['signers'] as List? ?? const []).cast()) + s['result'], + }; + expect(results, {for (final r in SignerResult.values) r.code}); + expect(cases.where((c) => c['foreign_signers'] != null), isNotEmpty); + }); + + for (final c in cases) { + test(str(c, 'name'), () { + expect(cmsVectorDifferences(c), isEmpty); + }); + } + }); + + group('securitycms_vectors.json', () { + final f = readJson('test/vectors/securitycms_vectors.json'); + final cases = (f['cases']! as List).cast(); + final chunks = chunksOf(f); + final bases = basesOf(f, chunks); + final contexts = contextsOf(f); + final texts = (f['texts']! as List).cast(); + + test('is of this spec, from its generator, with every verdict and every ' + 'result of a signer, required and foreign', () { + expect(f['spec'], specVersion); + expect(f['generator'], 'tool/security_go_vectors.go'); + expect(cases, hasLength(greaterThan(700))); + final verdicts = { + for (final c in cases) ...[c['signature'], c['seal']], + }; + expect(verdicts, containsAll(['F0', 'F1', 'F2', 'F4', 'F5'])); + expect(verdicts, containsAll(['F6', 'S0', 'S1', 'S2', 'S3'])); + expect(verdicts, containsAll(['S4', 'S5'])); + Set resultsOf(String list) => { + for (final c in cases) + if (c['detail'] case final Json d) + for (final s in (d[list]! as List).cast()) s['result'], + }; + final all = {for (final r in SignerResult.values) r.code}; + expect(resultsOf('signers'), all); + expect(resultsOf('foreign'), all.difference({'absent'})); + // A seal before the round time and one that is not, for a signer + // and for key 3, and Go's zero time, which gives no earliest seal. + final befores = { + for (final c in cases) + if (c['detail'] case final Json d) + for (final s in (d['signers']! as List).cast()) s['before'], + }; + expect(befores, {true, false}); + expect( + cases.where( + (c) => + c['seal'] == 'S4' && + c['sealed_at'] == null && + c['detail'] != null, + ), + isNotEmpty, + ); + expect(contexts.where((c) => c.roundTime == null), isNotEmpty); + }); + + test('every case, with the verdicts, the lines, the detail and the ' + 'earliest seal of Go', () { + for (final c in cases) { + final area = cmsAreaOf(c, chunks, bases); + expect( + evaluateDifferences(c, area, contexts, texts), + isEmpty, + reason: c['name'] as String? ?? canonical(c), + ); + } + }); + + test('securitycms_vectors.g.dart holds a part of it', () { + final part = jsonDecode(securityCmsVectorsJson) as Json; + for (final k in ['spec', 'generator', 'contexts', 'texts']) { + expect(canonical(part[k]), canonical(f[k]), reason: k); + } + expect( + [for (final b in basesOf(part, const [])) toHex(b)], + [for (final b in bases) toHex(b)], + ); + // Each case of the part is one of the file, in the same order, with + // the same area. + String key(Json c) => canonical({ + for (final e in c.entries) + if (e.key != 'pieces' && e.key != 'hex') e.key: e.value, + }); + final some = (part['cases']! as List).cast(); + expect(some, hasLength(greaterThan(40))); + var at = 0; + for (final c in some) { + while (at < cases.length && key(cases[at]) != key(c)) { + at++; + } + expect(at, lessThan(cases.length), reason: key(c)); + expect( + cmsAreaOf(c, const [], bases), + cmsAreaOf(cases[at], chunks, bases), + reason: key(c), + ); + } + }); + }); + + test('the fixtures of securitycms_vectors.g.dart are those of testdata/, ' + 'with what their records say', () { + final fixtures = + ((jsonDecode(securityCmsFixturesJson) as Json)['fixtures']! as List) + .cast(); + expect( + [for (final x in fixtures) x['name']], + ['format3_signed_cms', 'format3_sealed'], + ); + for (final x in fixtures) { + final name = str(x, 'name'); + final r = readJson('testdata/fixtures/$name.json'); + expect( + x['dkc'], + toHex(File('testdata/fixtures/${r['file']}').readAsBytesSync()), + reason: name, + ); + for (final k in ['release', 'unlock_at', 'verdicts']) { + expect(canonical(x[k]), canonical(r[k]), reason: '$name: $k'); + } + final sig = r['signature'] as Json?; + expect( + canonical(x['signer_results']), + canonical(sig?['signer_results']), + reason: name, + ); + final seal = r['seal'] as Json?; + expect( + canonical(x['seal']), + canonical( + seal == null + ? null + : {'holder': seal['holder'], 'time': seal['time']}, + ), + reason: name, + ); + } + }); +}