securitycms.dart ports evaluateCMS, signerLine and evaluateSeal of signature2.go of Go at the draft v0.12, on the reader of CMS of stage 5a, with the same order of checks: SIGNERS with its profile and at most 16 entries, then the SignedData; each required signer in the order of SIGNERS and each foreign one in the order of the encoding, valid, invalid, absent, not verifiable, without seal, with an invalid seal or out of validity at the time of its seal; F2, F5 and F6 with their detail; and the seal over SEAL_SUBJECT, S1 to S5 with the authority and t. A round time at Go's zero time is no round time, as IsZero, and Verdicts.sealedAt skips a seal at that time, as SealedAt. cmsReader is the default CmsEvaluator of evaluateSecurity, and so of evaluateSecurityInput and the opening: nothing that Go evaluates is left not evaluated; a caller that passes cms: null still gets the parts without CMS alone. encodeSigners and maxSigners are exported, as EncodeSigners and MaxSigners of Go. The tests compare every part with Go: the 135 cases of security_cms.json with the result of each signer, the 24 of security.json, the 56 signatures of alg 2 and 105 seals of seal_type 2 of security_vectors.json, the 755 cases of securitycms_vectors.json with their detail and earliest seal, the fixtures format3_signed_cms and format3_sealed, and their openings in open_cases.json. On Node.js, a part of the vectors and the two fixtures opened in full. 1572 tests on the VM and 332 on Node.js. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>v0.11
parent
6ad251bb25
commit
8dc45c2712
@ -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<List<int>> 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<Uint8List> _decodeSigners(Uint8List b) {
|
||||
final out = <Uint8List>[];
|
||||
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<Uint8List> 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 = <SignerLine>[];
|
||||
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;
|
||||
@ -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<Object?> pieces, List<Uint8List> 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<Uint8List> chunksOf(Json f) {
|
||||
final out = <Uint8List>[];
|
||||
for (final c in (f['chunks']! as List).cast<Json>()) {
|
||||
out.add(joinPieces(c['pieces']! as List<Object?>, 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<Uint8List> basesOf(Json f, List<Uint8List> chunks) => [
|
||||
for (final b in f['bases']! as List)
|
||||
b is String
|
||||
? fromHex(b)
|
||||
: joinPieces((b as Json)['pieces']! as List<Object?>, 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<Uint8List> chunks, List<Uint8List> bases) {
|
||||
final Uint8List area;
|
||||
final hex = c['hex'] as String?;
|
||||
final pieces = c['pieces'] as List<Object?>?;
|
||||
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<Object?>();
|
||||
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;
|
||||
}
|
||||
@ -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<Json> get _cases => (_part['cases']! as List).cast<Json>();
|
||||
|
||||
/// The context of the vectors named [name].
|
||||
SecurityContext _context(String name) {
|
||||
final all = (_part['contexts']! as List).cast<Json>();
|
||||
return contextsOf(_part)[all.indexWhere((c) => c['name'] == name)];
|
||||
}
|
||||
|
||||
/// A sink of files that keeps them.
|
||||
final class _Files implements FileSink {
|
||||
final files = <BytesBuilder>[];
|
||||
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<String>();
|
||||
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<Json>();
|
||||
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 [
|
||||
(<List<int>>[], '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<ArgumentError>().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'),
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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<Json>();
|
||||
|
||||
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<Json>())
|
||||
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<Json>();
|
||||
final chunks = chunksOf(f);
|
||||
final bases = basesOf(f, chunks);
|
||||
final contexts = contextsOf(f);
|
||||
final texts = (f['texts']! as List).cast<String>();
|
||||
|
||||
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(<String>['F0', 'F1', 'F2', 'F4', 'F5']));
|
||||
expect(verdicts, containsAll(<String>['F6', 'S0', 'S1', 'S2', 'S3']));
|
||||
expect(verdicts, containsAll(<String>['S4', 'S5']));
|
||||
Set<Object?> resultsOf(String list) => {
|
||||
for (final c in cases)
|
||||
if (c['detail'] case final Json d)
|
||||
for (final s in (d[list]! as List).cast<Json>()) 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<Json>()) 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<Json>();
|
||||
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<Json>();
|
||||
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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue