@colophon-claims/verify 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +39 -0
  2. package/dist/admission/application.d.ts +18 -0
  3. package/dist/admission/application.js +53 -0
  4. package/dist/admission/contracts.d.ts +506 -0
  5. package/dist/admission/contracts.js +459 -0
  6. package/dist/admission/index.d.ts +5 -0
  7. package/dist/admission/index.js +5 -0
  8. package/dist/admission/intake.d.ts +75 -0
  9. package/dist/admission/intake.js +70 -0
  10. package/dist/admission/result-evaluation.d.ts +18 -0
  11. package/dist/admission/result-evaluation.js +71 -0
  12. package/dist/admission/screening-sample.d.ts +56 -0
  13. package/dist/admission/screening-sample.js +126 -0
  14. package/dist/admission/verification.d.ts +102 -0
  15. package/dist/admission/verification.js +844 -0
  16. package/dist/anchor/check.d.ts +95 -0
  17. package/dist/anchor/check.js +146 -0
  18. package/dist/anchor/ports.d.ts +6 -0
  19. package/dist/anchor/ports.js +354 -0
  20. package/dist/assets.d.ts +33 -0
  21. package/dist/assets.js +885 -0
  22. package/dist/bin.d.ts +2 -0
  23. package/dist/bin.js +20 -0
  24. package/dist/cli.d.ts +14 -0
  25. package/dist/cli.js +205 -0
  26. package/dist/comparison.d.ts +64 -0
  27. package/dist/comparison.js +235 -0
  28. package/dist/index.d.ts +23 -0
  29. package/dist/index.js +23 -0
  30. package/dist/manifest.d.ts +54 -0
  31. package/dist/manifest.js +240 -0
  32. package/dist/materialize.d.ts +4 -0
  33. package/dist/materialize.js +13 -0
  34. package/dist/profile/admission-receipts.d.ts +11 -0
  35. package/dist/profile/admission-receipts.js +24 -0
  36. package/dist/profile/anchor-claims.d.ts +157 -0
  37. package/dist/profile/anchor-claims.js +330 -0
  38. package/dist/profile/artifacts.d.ts +261 -0
  39. package/dist/profile/artifacts.js +139 -0
  40. package/dist/profile/assembly-ports.d.ts +19 -0
  41. package/dist/profile/assembly-ports.js +42 -0
  42. package/dist/profile/binary-judge-manifest.d.ts +121 -0
  43. package/dist/profile/binary-judge-manifest.js +106 -0
  44. package/dist/profile/binary-qualification.d.ts +9 -0
  45. package/dist/profile/binary-qualification.js +58 -0
  46. package/dist/profile/branding-assets.d.ts +5 -0
  47. package/dist/profile/branding-assets.js +5 -0
  48. package/dist/profile/branding.d.ts +24 -0
  49. package/dist/profile/branding.js +14 -0
  50. package/dist/profile/claim-consistency.d.ts +26 -0
  51. package/dist/profile/claim-consistency.js +91 -0
  52. package/dist/profile/claim.d.ts +327 -0
  53. package/dist/profile/claim.js +742 -0
  54. package/dist/profile/errors.d.ts +49 -0
  55. package/dist/profile/errors.js +78 -0
  56. package/dist/profile/inspect-assurance.d.ts +16 -0
  57. package/dist/profile/inspect-assurance.js +30 -0
  58. package/dist/profile/inspect-disclosure.d.ts +28 -0
  59. package/dist/profile/inspect-disclosure.js +54 -0
  60. package/dist/profile/inspect-manifest.d.ts +706 -0
  61. package/dist/profile/inspect-manifest.js +397 -0
  62. package/dist/profile/isolation.d.ts +21 -0
  63. package/dist/profile/isolation.js +31 -0
  64. package/dist/profile/pinning-evidence.d.ts +41 -0
  65. package/dist/profile/pinning-evidence.js +43 -0
  66. package/dist/profile/ports.d.ts +3 -0
  67. package/dist/profile/ports.js +11 -0
  68. package/dist/profile/preview-log.d.ts +4 -0
  69. package/dist/profile/preview-log.js +3 -0
  70. package/dist/profile/run-results.d.ts +17 -0
  71. package/dist/profile/run-results.js +23 -0
  72. package/dist/profile/signing.d.ts +10 -0
  73. package/dist/profile/signing.js +46 -0
  74. package/dist/profile/trust.d.ts +13 -0
  75. package/dist/profile/trust.js +35 -0
  76. package/dist/profile/venue.d.ts +2 -0
  77. package/dist/profile/venue.js +2 -0
  78. package/dist/profile/verdict.d.ts +28 -0
  79. package/dist/profile/verdict.js +51 -0
  80. package/dist/reader-instructions.d.ts +41 -0
  81. package/dist/reader-instructions.js +52 -0
  82. package/dist/schema.d.ts +380 -0
  83. package/dist/schema.js +498 -0
  84. package/dist/verify.d.ts +72 -0
  85. package/dist/verify.js +1573 -0
  86. package/dist/version.d.ts +2 -0
  87. package/dist/version.js +2 -0
  88. package/package.json +63 -3
  89. package/schemas/assembly-row.schema.json +194 -0
  90. package/schemas/bundle-manifest.schema.json +23 -0
  91. package/schemas/claim-package.schema.json +279 -0
  92. package/schemas/dsse-envelope.schema.json +25 -0
  93. package/schemas/evidence-catalog.schema.json +43 -0
  94. package/schemas/public-trust.schema.json +44 -0
  95. package/schemas/verdict-catalog.schema.json +27 -0
  96. package/scripts/external-verify.py +238 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The `integrity-anchors` check (anchor-evidence design §8): the one shared implementation both
3
+ * `bundle verify` and the workspace-side `run.verify` report through.
4
+ *
5
+ * It never throws. Every carried anchor gets an outcome, because the check's whole purpose is to
6
+ * report on all of them — one unreadable proof must not take the report down with it, and a caller
7
+ * that wants to refuse decides that from the returned statuses rather than from an exception that
8
+ * escaped mid-walk. The callers refuse `record-integrity` on any `invalid`, which is the design's
9
+ * "fails the whole verification loudly".
10
+ *
11
+ * Four rules do the work, and each is a different attack:
12
+ *
13
+ * 1. **Exact-bytes parse, strict schema.** Unknown keys fail closed — the public-bundle closure
14
+ * discipline, deliberately stricter than the protocol layer's unknown-field tolerance.
15
+ * 2. **The subject is recomputed, never read.** The digest comes from the authenticated snapshot's
16
+ * own `run.json` / `matrix.json` bytes; a stored assertion is never the comparison source.
17
+ * Selectors are digest-keyed, so a `subject.kind` label can never route an anchor onto a claim
18
+ * its digest does not back — the kind is then *checked* for equality, and a mismatch is
19
+ * `invalid`.
20
+ * 3. **Trust material is verifier-side.** None ships. Absent roots or headers yield `present`, not
21
+ * `invalid`: an operator's incomplete root set is not an accusation against the proof, and
22
+ * authority acceptability is consumer policy. Bundle-carried certificate chains are archival and
23
+ * are never used to validate.
24
+ * 4. **The splice-catch.** An `authority-time` lock anchor must satisfy `genTime <= run.closeAt`.
25
+ * It reads `facts.genTime`, which both `verified` and `present` carry — routing it through the
26
+ * evaluated `time` instead would silently disable it in the default no-roots configuration,
27
+ * which is the configuration nearly every reader is in. It catches an anchor spliced in after
28
+ * the run's own pre-registered close instant, and nothing more; it is not ordering evidence and
29
+ * is never described as such. Chain-time proofs carry no time, so the rule does not reach them.
30
+ *
31
+ * Absence is an outcome, not a silence: a subject with no carried anchor reports `absent`, and one
32
+ * whose sealed Run declared anchoring intent that the bundle does not satisfy reports
33
+ * `declared-but-absent`. Both pass. A stripped anchor cannot masquerade as never-attempted.
34
+ */
35
+ import type { AnchorProofResult, AnchorTimeBasis, OpenTimestampsTrustMaterial, Rfc3161AnchorTrustMaterial } from "@jinn-network/trust-core";
36
+ import type { CarriedAnchorRecord, ClaimAnchorSubject } from "../profile/anchor-claims.js";
37
+ /**
38
+ * Verifier-side trust material, keyed by provider profile. Strictly the verifier operator's own
39
+ * configuration: this package ships none, and an empty set can never yield `verified` (§8 step 3).
40
+ */
41
+ export interface PublicBundleAnchorTrustMaterial {
42
+ readonly rfc3161?: Rfc3161AnchorTrustMaterial;
43
+ readonly opentimestamps?: OpenTimestampsTrustMaterial;
44
+ }
45
+ /** §4.3's four proof outcomes. `absent` and `declared-but-absent` are per-subject context outcomes
46
+ * this check owns; a proof verifier never reports them. */
47
+ export type AnchorProofStatus = AnchorProofResult["status"];
48
+ /** §7.3's absence semantics, plus the positive case. */
49
+ export type AnchorSubjectOutcome = "anchored" | "absent" | "declared-but-absent";
50
+ export interface AnchorVerificationEntry {
51
+ readonly recordSha256: string;
52
+ readonly status: AnchorProofStatus;
53
+ /** The provider profile the record names. Absent only when the record did not parse at all. */
54
+ readonly provider?: string;
55
+ /** The subject the record's digest resolves to. Absent on a dangling anchor by definition. */
56
+ readonly subject?: ClaimAnchorSubject;
57
+ readonly timeBasis?: AnchorTimeBasis;
58
+ /** The evaluated instant, present only on `verified` — the one status where a time basis was
59
+ * actually evaluated against supplied material. */
60
+ readonly time?: string;
61
+ /** The profile's extracted byte-facts, reported for `verified` and `present`. */
62
+ readonly facts?: unknown;
63
+ /** Why the proof is `pending` or `invalid`. */
64
+ readonly reason?: string;
65
+ /** The verifier's own evaluation disclosure: whether this reader had material for this profile at
66
+ * all. It is never a fact about the bundle. */
67
+ readonly trustMaterial: "supplied" | "none";
68
+ }
69
+ export interface AnchorSubjectReport {
70
+ readonly subject: ClaimAnchorSubject;
71
+ readonly outcome: AnchorSubjectOutcome;
72
+ /** The declared provider profiles this bundle carries no matching anchor for. Present only on
73
+ * `declared-but-absent`. */
74
+ readonly declaredProfiles?: readonly string[];
75
+ }
76
+ export interface IntegrityAnchorsReport {
77
+ /** One entry per carried record, in record-digest order. */
78
+ readonly anchors: readonly AnchorVerificationEntry[];
79
+ readonly subjects: readonly AnchorSubjectReport[];
80
+ /** The subset the caller must refuse on. Empty means the check passes. */
81
+ readonly invalid: readonly AnchorVerificationEntry[];
82
+ }
83
+ export interface EvaluateIntegrityAnchorsInput {
84
+ readonly records: readonly CarriedAnchorRecord[];
85
+ /** sha256 hex of the authenticated `run.json` bytes. */
86
+ readonly runSha256: string;
87
+ /** sha256 hex of the authenticated `matrix.json` bytes. */
88
+ readonly matrixSha256: string;
89
+ /** The sealed Run's own `closeAt`, for the §8 step-4 splice-catch. */
90
+ readonly closeAt: string;
91
+ /** Provider profiles the sealed Run's `anchor-intent/v1` extension declares (§7.3). */
92
+ readonly declaredProfiles?: readonly string[];
93
+ readonly trust?: PublicBundleAnchorTrustMaterial;
94
+ }
95
+ export declare function evaluateIntegrityAnchors(input: EvaluateIntegrityAnchorsInput): IntegrityAnchorsReport;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The `integrity-anchors` check (anchor-evidence design §8): the one shared implementation both
3
+ * `bundle verify` and the workspace-side `run.verify` report through.
4
+ *
5
+ * It never throws. Every carried anchor gets an outcome, because the check's whole purpose is to
6
+ * report on all of them — one unreadable proof must not take the report down with it, and a caller
7
+ * that wants to refuse decides that from the returned statuses rather than from an exception that
8
+ * escaped mid-walk. The callers refuse `record-integrity` on any `invalid`, which is the design's
9
+ * "fails the whole verification loudly".
10
+ *
11
+ * Four rules do the work, and each is a different attack:
12
+ *
13
+ * 1. **Exact-bytes parse, strict schema.** Unknown keys fail closed — the public-bundle closure
14
+ * discipline, deliberately stricter than the protocol layer's unknown-field tolerance.
15
+ * 2. **The subject is recomputed, never read.** The digest comes from the authenticated snapshot's
16
+ * own `run.json` / `matrix.json` bytes; a stored assertion is never the comparison source.
17
+ * Selectors are digest-keyed, so a `subject.kind` label can never route an anchor onto a claim
18
+ * its digest does not back — the kind is then *checked* for equality, and a mismatch is
19
+ * `invalid`.
20
+ * 3. **Trust material is verifier-side.** None ships. Absent roots or headers yield `present`, not
21
+ * `invalid`: an operator's incomplete root set is not an accusation against the proof, and
22
+ * authority acceptability is consumer policy. Bundle-carried certificate chains are archival and
23
+ * are never used to validate.
24
+ * 4. **The splice-catch.** An `authority-time` lock anchor must satisfy `genTime <= run.closeAt`.
25
+ * It reads `facts.genTime`, which both `verified` and `present` carry — routing it through the
26
+ * evaluated `time` instead would silently disable it in the default no-roots configuration,
27
+ * which is the configuration nearly every reader is in. It catches an anchor spliced in after
28
+ * the run's own pre-registered close instant, and nothing more; it is not ordering evidence and
29
+ * is never described as such. Chain-time proofs carry no time, so the rule does not reach them.
30
+ *
31
+ * Absence is an outcome, not a silence: a subject with no carried anchor reports `absent`, and one
32
+ * whose sealed Run declared anchoring intent that the bundle does not satisfy reports
33
+ * `declared-but-absent`. Both pass. A stripped anchor cannot masquerade as never-attempted.
34
+ */
35
+ import { MATRIX_RECORD_KIND, RUN_RECORD_KIND, compareCalendarStrictRfc3339Instants, } from "@jinn-network/benchmarking-records";
36
+ import { OPENTIMESTAMPS_ANCHOR_PROFILE, RFC3161_TSA_ANCHOR_PROFILE, createOpenTimestampsProofVerifier, createRfc3161AnchorProofVerifier, decodeAnchorProofContent, parseExactAnchorEvidence, } from "@jinn-network/trust-core";
37
+ import { nodeCryptoAnchorPorts } from "./ports.js";
38
+ const SUBJECT_KINDS = new Map([
39
+ ["lock", RUN_RECORD_KIND],
40
+ ["matrix", MATRIX_RECORD_KIND],
41
+ ]);
42
+ function compareCodeUnits(left, right) {
43
+ return left < right ? -1 : left > right ? 1 : 0;
44
+ }
45
+ function invalidEntry(recordSha256, reason, extra = {}) {
46
+ return { recordSha256, status: "invalid", reason, trustMaterial: "none", ...extra };
47
+ }
48
+ export function evaluateIntegrityAnchors(input) {
49
+ const subjectDigests = [
50
+ ["lock", input.runSha256],
51
+ ["matrix", input.matrixSha256],
52
+ ];
53
+ const declared = input.declaredProfiles ?? [];
54
+ const anchors = [...input.records]
55
+ .sort((left, right) => compareCodeUnits(left.recordSha256, right.recordSha256))
56
+ .map((carried) => {
57
+ // 1. Exact-bytes parse under the strict schema, and the sanctioned proof decoder.
58
+ let record;
59
+ let proofBytes;
60
+ try {
61
+ record = parseExactAnchorEvidence(carried.bytes);
62
+ proofBytes = decodeAnchorProofContent(record.proof.content);
63
+ }
64
+ catch (cause) {
65
+ return invalidEntry(carried.recordSha256, `not the exact sealed encoding of a conforming AnchorEvidence record: ${cause instanceof Error ? cause.message : String(cause)}`);
66
+ }
67
+ // 2. Recompute the subject from the authenticated snapshot, then check the kind label.
68
+ const subjectDigest = record.subject.digest.sha256;
69
+ const resolved = subjectDigests.find(([, digest]) => digest === subjectDigest);
70
+ if (resolved === undefined) {
71
+ return invalidEntry(carried.recordSha256, `the anchored digest ${subjectDigest} is neither this bundle's sealed Run nor its sealed Matrix`, { provider: record.provider });
72
+ }
73
+ const [subject] = resolved;
74
+ const resolvedKind = SUBJECT_KINDS.get(subject);
75
+ if (record.subject.kind !== resolvedKind) {
76
+ return invalidEntry(carried.recordSha256, `subject.kind is ${record.subject.kind}, but the record its digest resolves to is ${resolvedKind}`, { provider: record.provider, subject });
77
+ }
78
+ // 3. Verify through the provider profile, with whatever material this reader configured.
79
+ const rfc3161 = record.provider === RFC3161_TSA_ANCHOR_PROFILE;
80
+ const openTimestamps = record.provider === OPENTIMESTAMPS_ANCHOR_PROFILE;
81
+ if (!rfc3161 && !openTimestamps) {
82
+ return invalidEntry(carried.recordSha256, `no proof verifier implements the anchor profile ${record.provider}`, { provider: record.provider, subject });
83
+ }
84
+ const material = rfc3161 ? input.trust?.rfc3161 : input.trust?.opentimestamps;
85
+ const result = rfc3161
86
+ ? createRfc3161AnchorProofVerifier(nodeCryptoAnchorPorts).verifyProof({
87
+ subjectSha256: subjectDigest,
88
+ proofBytes,
89
+ ...(input.trust?.rfc3161 === undefined ? {} : { trust: input.trust.rfc3161 }),
90
+ })
91
+ : createOpenTimestampsProofVerifier().verifyProof({
92
+ subjectSha256: subjectDigest,
93
+ proofBytes,
94
+ ...(input.trust?.opentimestamps === undefined ? {} : { trust: input.trust.opentimestamps }),
95
+ });
96
+ const trustMaterial = material === undefined ? "none" : "supplied";
97
+ const base = {
98
+ recordSha256: carried.recordSha256,
99
+ provider: record.provider,
100
+ subject,
101
+ trustMaterial,
102
+ };
103
+ if (result.status === "invalid") {
104
+ return { ...base, status: "invalid", reason: result.reason };
105
+ }
106
+ if (result.status === "pending") {
107
+ return { ...base, status: "pending", timeBasis: result.timeBasis, reason: result.reason };
108
+ }
109
+ // Exhaustive over the four proof statuses: `verified` and `present` are what remains, and the
110
+ // rest of this walk reads the `facts` only they carry. A fifth member of ANCHOR_PROOF_STATUSES
111
+ // fails here rather than arriving at the splice-catch as an unhandled shape.
112
+ result.status;
113
+ // 4. The splice-catch, on `facts.genTime` so it survives the default no-roots configuration.
114
+ if (subject === "lock" && result.timeBasis === "authority-time") {
115
+ const genTime = result.facts.genTime;
116
+ const compared = typeof genTime === "string"
117
+ ? compareCalendarStrictRfc3339Instants(genTime, input.closeAt)
118
+ : undefined;
119
+ if (compared === undefined || compared > 0) {
120
+ return invalidEntry(carried.recordSha256, typeof genTime === "string"
121
+ ? `the token's genTime ${genTime} is after this run's own pre-registered close instant ${input.closeAt}`
122
+ : "an authority-time lock anchor carries no comparable genTime", { provider: record.provider, subject });
123
+ }
124
+ }
125
+ return {
126
+ ...base,
127
+ status: result.status,
128
+ timeBasis: result.timeBasis,
129
+ ...(result.status === "verified" ? { time: result.time } : {}),
130
+ facts: result.facts,
131
+ };
132
+ });
133
+ const subjects = subjectDigests.map(([subject]) => {
134
+ const carried = anchors.filter((entry) => entry.subject === subject && entry.status !== "invalid");
135
+ // §7.3: declared intent changes absence semantics. It is a fact the sealed Run states about
136
+ // itself, so it is read against the lock subject; a matrix anchor is never declared in advance.
137
+ const missing = subject === "lock"
138
+ ? declared.filter((profile) => !carried.some((entry) => entry.provider === profile))
139
+ : [];
140
+ if (missing.length > 0) {
141
+ return { subject, outcome: "declared-but-absent", declaredProfiles: [...missing].sort(compareCodeUnits) };
142
+ }
143
+ return { subject, outcome: carried.length > 0 ? "anchored" : "absent" };
144
+ });
145
+ return { anchors, subjects, invalid: anchors.filter((entry) => entry.status === "invalid") };
146
+ }
@@ -0,0 +1,6 @@
1
+ import type { AnchorCertificateReader, AnchorChainVerifier, AnchorSignatureVerifier, Rfc3161AnchorProofVerifierPorts } from "@jinn-network/trust-core";
2
+ export declare const anchorSignatureVerifier: AnchorSignatureVerifier;
3
+ export declare const anchorCertificateReader: AnchorCertificateReader;
4
+ export declare const anchorChainVerifier: AnchorChainVerifier;
5
+ /** The three ports, ready to inject into `createRfc3161AnchorProofVerifier`. */
6
+ export declare const nodeCryptoAnchorPorts: Rfc3161AnchorProofVerifierPorts;
@@ -0,0 +1,354 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * The three `node:crypto` ports the RFC 3161 anchor rule engine injects
4
+ * (anchor-evidence design §6.1 "Placement").
5
+ *
6
+ * `trust-core` owns the rules and produces the exact bytes a signature must
7
+ * cover; it may not import a platform module, and hand-writing an X.509 parser
8
+ * to make a security decision is the invention §3 exists to prevent. So exactly
9
+ * three primitives cross the boundary here, in the standalone verifier package
10
+ * where `node:crypto` is legal and where a reader who wants to check a claim
11
+ * with no Jinn code at all can see what the verifier actually does.
12
+ *
13
+ * Three implementation facts are load-bearing and easy to get wrong:
14
+ *
15
+ * - **The verification hash never comes from a platform default.** Real
16
+ * authorities emit bare `rsaEncryption` as the SignerInfo `signatureAlgorithm`
17
+ * and leave the hash to the SignerInfo `digestAlgorithm` (the committed
18
+ * RSA production capture does exactly this). `crypto.verify` falls back to SHA-256
19
+ * when the algorithm pins no digest, so a conformant SHA-384 token would be
20
+ * verified under the wrong hash -- and the design's SHA-256-family floor would
21
+ * bind by accident of that default rather than by rule. The hash is therefore
22
+ * read from `algorithmOid` when the algorithm names one, and from
23
+ * `digestAlgorithmOid` when it does not.
24
+ * - **Node's `X509Certificate` does not expose raw issuer, serial, subject, or
25
+ * extension bytes**, and §6.1 rules 7 and 10 compare *bytes* -- distinguished
26
+ * names are deliberately kept out of string comparison because DN rendering is
27
+ * not canonical across implementations. Those four fields are therefore
28
+ * extracted structurally from the certificate DER through trust-core's reader.
29
+ * In particular `X509Certificate.serialNumber` is a hex string with the DER
30
+ * sign octet dropped, which would fail a byte comparison against any
31
+ * `SignerInfo.sid` naming a serial whose leading octet has the high bit set.
32
+ * Everything Node *does* expose exactly -- the SubjectPublicKeyInfo export,
33
+ * the validity dates, the extended key usage list -- comes from Node.
34
+ * - **Extension criticality is not checkable here.** `X509Certificate` surfaces
35
+ * no criticality flags, so §6.1 rule 9's "sole usage" half is enforced and its
36
+ * "critical" half is not. The gap is recorded (design §16, issue #2761) rather
37
+ * than papered over with a hand-rolled extension parser.
38
+ *
39
+ * Every port follows the package's port convention: one object argument, plain
40
+ * data in and out, and a `try`/`catch` that answers `false` rather than
41
+ * throwing -- a platform refusal is an answer, not an exception the rule engine
42
+ * has to interpret.
43
+ */
44
+ import { X509Certificate, constants, createPublicKey, verify } from "node:crypto";
45
+ import { DER_TAG, OID_ECDSA_WITH_SHA256, OID_ECDSA_WITH_SHA384, OID_ECDSA_WITH_SHA512, OID_RSASSA_PSS, OID_SHA256, OID_SHA256_WITH_RSA_ENCRYPTION, OID_SHA384, OID_SHA384_WITH_RSA_ENCRYPTION, OID_SHA512, OID_SHA512_WITH_RSA_ENCRYPTION, compareCalendarStrictRfc3339Instants, decodeDer, decodeDerChildren, encodeDerElement, isCalendarStrictRfc3339, readDerOid, } from "@jinn-network/trust-core";
46
+ const OID_EXT_SUBJECT_KEY_IDENTIFIER = "2.5.29.14";
47
+ const OID_EXT_SUBJECT_ALT_NAME = "2.5.29.17";
48
+ const OID_MGF1 = "1.2.840.113549.1.1.8";
49
+ const CONTEXT_CONSTRUCTED_0 = 0xa0;
50
+ const CONTEXT_CONSTRUCTED_1 = 0xa1;
51
+ const CONTEXT_CONSTRUCTED_2 = 0xa2;
52
+ const CONTEXT_CONSTRUCTED_3 = 0xa3;
53
+ /** `GeneralName ::= CHOICE { ... directoryName [4] Name ... }` -- explicit,
54
+ * because `Name` is itself a CHOICE. */
55
+ const GENERAL_NAME_DIRECTORY = 0xa4;
56
+ /** Hashes named by the signature algorithm itself. */
57
+ const ALGORITHM_HASHES = new Map([
58
+ [OID_ECDSA_WITH_SHA256, "sha256"],
59
+ [OID_ECDSA_WITH_SHA384, "sha384"],
60
+ [OID_ECDSA_WITH_SHA512, "sha512"],
61
+ [OID_SHA256_WITH_RSA_ENCRYPTION, "sha256"],
62
+ [OID_SHA384_WITH_RSA_ENCRYPTION, "sha384"],
63
+ [OID_SHA512_WITH_RSA_ENCRYPTION, "sha512"],
64
+ ]);
65
+ /** Hashes named by a digest AlgorithmIdentifier. */
66
+ const DIGEST_HASHES = new Map([
67
+ [OID_SHA256, "sha256"],
68
+ [OID_SHA384, "sha384"],
69
+ [OID_SHA512, "sha512"],
70
+ ]);
71
+ /** Which key type each admitted signature algorithm requires. A token naming an
72
+ * RSA algorithm over an EC key is a confusion this refuses outright. */
73
+ const ALGORITHM_KEY_TYPES = new Map([
74
+ [OID_ECDSA_WITH_SHA256, ["ec"]],
75
+ [OID_ECDSA_WITH_SHA384, ["ec"]],
76
+ [OID_ECDSA_WITH_SHA512, ["ec"]],
77
+ [OID_SHA256_WITH_RSA_ENCRYPTION, ["rsa"]],
78
+ [OID_SHA384_WITH_RSA_ENCRYPTION, ["rsa"]],
79
+ [OID_SHA512_WITH_RSA_ENCRYPTION, ["rsa"]],
80
+ [OID_RSASSA_PSS, ["rsa", "rsa-pss"]],
81
+ ]);
82
+ /** Bare `rsaEncryption` names no hash at all: the SignerInfo digest algorithm
83
+ * is the only statement of it anywhere in the token. */
84
+ const RSA_KEY_TYPES = ["rsa", "rsa-pss"];
85
+ function children(element) {
86
+ return decodeDerChildren(element);
87
+ }
88
+ /**
89
+ * `RSASSA-PSS-params ::= SEQUENCE { hashAlgorithm [0] DEFAULT sha1,
90
+ * maskGenAlgorithm [1] DEFAULT mgf1SHA1, saltLength [2] DEFAULT 20,
91
+ * trailerField [3] DEFAULT 1 }`, all tags explicit.
92
+ *
93
+ * The rule engine has already floored `hashAlgorithm` against the SHA-256
94
+ * family; this reads the same bytes for the salt length Node needs. The MGF1
95
+ * hash must match the signature hash, because `crypto.verify` uses one hash for
96
+ * both -- a token that used different ones would otherwise be verified under
97
+ * parameters it did not declare. `trailerField` has exactly one admitted value.
98
+ */
99
+ function readPssParameters(parameters) {
100
+ if (parameters === undefined)
101
+ return undefined;
102
+ const parts = children(decodeDer(parameters));
103
+ const tagged = (identifier) => parts.find((part) => part.identifier === identifier);
104
+ const hashAlgorithm = tagged(CONTEXT_CONSTRUCTED_0);
105
+ if (hashAlgorithm === undefined)
106
+ return undefined;
107
+ const hashName = DIGEST_HASHES.get(readDerOid(children(children(hashAlgorithm)[0])[0]));
108
+ if (hashName === undefined)
109
+ return undefined;
110
+ const maskGenAlgorithm = tagged(CONTEXT_CONSTRUCTED_1);
111
+ if (maskGenAlgorithm !== undefined) {
112
+ const mgf = children(children(maskGenAlgorithm)[0]);
113
+ if (readDerOid(mgf[0]) !== OID_MGF1)
114
+ return undefined;
115
+ if (DIGEST_HASHES.get(readDerOid(children(mgf[1])[0])) !== hashName)
116
+ return undefined;
117
+ }
118
+ else {
119
+ // An absent maskGenAlgorithm defaults to MGF1-SHA-1, which cannot pair with
120
+ // a SHA-256-family signature hash.
121
+ return undefined;
122
+ }
123
+ const saltLengthElement = tagged(CONTEXT_CONSTRUCTED_2);
124
+ const saltLength = saltLengthElement === undefined
125
+ ? 20
126
+ : readSmallInteger(children(saltLengthElement)[0]);
127
+ const trailerFieldElement = tagged(CONTEXT_CONSTRUCTED_3);
128
+ if (trailerFieldElement !== undefined && readSmallInteger(children(trailerFieldElement)[0]) !== 1) {
129
+ return undefined;
130
+ }
131
+ return { hashName, saltLength };
132
+ }
133
+ function readSmallInteger(element) {
134
+ if (element.identifier !== DER_TAG.INTEGER || element.content.length > 4) {
135
+ throw new Error("Expected a small DER INTEGER.");
136
+ }
137
+ let value = 0;
138
+ for (const octet of element.content)
139
+ value = value * 256 + octet;
140
+ return value;
141
+ }
142
+ export const anchorSignatureVerifier = {
143
+ verifySignature(input) {
144
+ try {
145
+ const key = createPublicKey({
146
+ key: Buffer.from(input.spkiDer),
147
+ format: "der",
148
+ type: "spki",
149
+ });
150
+ const message = Buffer.from(input.message);
151
+ const signature = Buffer.from(input.signature);
152
+ if (input.algorithmOid === OID_RSASSA_PSS) {
153
+ const parameters = readPssParameters(input.parameters);
154
+ if (parameters === undefined || !isKeyType(key, RSA_KEY_TYPES))
155
+ return false;
156
+ return verify(parameters.hashName, message, { key, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: parameters.saltLength }, signature);
157
+ }
158
+ // The algorithm's own digest when it names one; otherwise the SignerInfo
159
+ // digest algorithm. Never a platform default.
160
+ const named = ALGORITHM_HASHES.get(input.algorithmOid);
161
+ const hashName = named ?? DIGEST_HASHES.get(input.digestAlgorithmOid);
162
+ if (hashName === undefined)
163
+ return false;
164
+ const keyTypes = ALGORITHM_KEY_TYPES.get(input.algorithmOid) ?? RSA_KEY_TYPES;
165
+ if (!isKeyType(key, keyTypes))
166
+ return false;
167
+ return verify(hashName, message, key, signature);
168
+ }
169
+ catch {
170
+ return false;
171
+ }
172
+ },
173
+ };
174
+ function isKeyType(key, admitted) {
175
+ return key.asymmetricKeyType !== undefined && admitted.includes(key.asymmetricKeyType);
176
+ }
177
+ /**
178
+ * The four fields Node does not expose as bytes, read straight out of the
179
+ * certificate DER: `TBSCertificate ::= SEQUENCE { version [0] EXPLICIT DEFAULT
180
+ * v1, serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo,
181
+ * ..., extensions [3] EXPLICIT OPTIONAL }`.
182
+ */
183
+ function readStructuralFields(certificateDer) {
184
+ const tbs = children(children(decodeDer(certificateDer))[0]);
185
+ const offset = tbs[0].identifier === CONTEXT_CONSTRUCTED_0 ? 1 : 0;
186
+ const extensionsWrapper = tbs.slice(offset + 6).find((element) => element.identifier === CONTEXT_CONSTRUCTED_3);
187
+ return {
188
+ serialNumber: tbs[offset].content,
189
+ issuerDer: tbs[offset + 2].bytes,
190
+ subjectDer: tbs[offset + 4].bytes,
191
+ extensions: extensionsWrapper === undefined ? [] : children(children(extensionsWrapper)[0]),
192
+ };
193
+ }
194
+ /** `Extension ::= SEQUENCE { extnID, critical BOOLEAN DEFAULT FALSE,
195
+ * extnValue OCTET STRING }` -- the DER inside `extnValue`. */
196
+ function extensionValue(extensions, oid) {
197
+ for (const extension of extensions) {
198
+ const parts = children(extension);
199
+ if (readDerOid(parts[0]) === oid)
200
+ return parts.at(-1).content;
201
+ }
202
+ return undefined;
203
+ }
204
+ /** Calendar-strict RFC 3339 UTC at second precision -- the spelling every other
205
+ * trust instant uses, so one comparator judges them all. `toISOString` would add
206
+ * a `.000` fraction that says nothing the seconds do not. */
207
+ function toRfc3339(date) {
208
+ const pad = (value, width = 2) => String(value).padStart(width, "0");
209
+ return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`
210
+ + `T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}Z`;
211
+ }
212
+ export const anchorCertificateReader = {
213
+ readCertificate(certificateDer) {
214
+ const certificate = new X509Certificate(Buffer.from(certificateDer));
215
+ const structural = readStructuralFields(certificateDer);
216
+ const subjectKeyIdentifier = extensionValue(structural.extensions, OID_EXT_SUBJECT_KEY_IDENTIFIER);
217
+ const subjectAltName = extensionValue(structural.extensions, OID_EXT_SUBJECT_ALT_NAME);
218
+ const sid = [
219
+ {
220
+ kind: "issuerAndSerialNumber",
221
+ issuerDer: structural.issuerDer,
222
+ serialNumber: structural.serialNumber,
223
+ },
224
+ ];
225
+ if (subjectKeyIdentifier !== undefined) {
226
+ sid.push({
227
+ kind: "subjectKeyIdentifier",
228
+ keyIdentifier: decodeDer(subjectKeyIdentifier).content,
229
+ });
230
+ }
231
+ return {
232
+ subjectPublicKeyInfoDer: new Uint8Array(certificate.publicKey.export({ format: "der", type: "spki" })),
233
+ notBefore: toRfc3339(certificate.validFromDate),
234
+ notAfter: toRfc3339(certificate.validToDate),
235
+ // Node names the extended-key-usage OID array `keyUsage`; the key-usage
236
+ // bit string is `X509Certificate.keyUsage`'s neighbour and is not this.
237
+ extendedKeyUsageOids: certificate.keyUsage ?? [],
238
+ subjectNames: [
239
+ encodeDerElement(GENERAL_NAME_DIRECTORY, structural.subjectDer),
240
+ ...(subjectAltName === undefined
241
+ ? []
242
+ : children(decodeDer(subjectAltName)).map((element) => element.bytes)),
243
+ ],
244
+ sid,
245
+ };
246
+ },
247
+ };
248
+ // ---------------------------------------------------------------------------
249
+ // Chain validation
250
+ // ---------------------------------------------------------------------------
251
+ /** No legitimate timestamp chain is deeper than this; the bound stops a cyclic
252
+ * or adversarial certificate set from looping. */
253
+ const MAX_CHAIN_DEPTH = 8;
254
+ /**
255
+ * Validity at the instant the caller named, judged by the same calendar-strict
256
+ * RFC 3339 comparator every other trust instant goes through.
257
+ *
258
+ * `Date.parse` is deliberately not used. This port is a public export, its
259
+ * `atTime` can arrive from anywhere, and `Date.parse` accepts bare dates
260
+ * (`"2026-08-17"`), reads some spellings as local time, and falls back to
261
+ * implementation-defined parsing for the rest -- three ways for a chain to be
262
+ * validated at an instant nobody named. A string the comparator cannot judge
263
+ * yields `undefined`, which reads here as "cannot validate", never as "valid".
264
+ */
265
+ function validAt(certificate, atTime) {
266
+ const notBefore = compareCalendarStrictRfc3339Instants(atTime, toRfc3339(certificate.validFromDate));
267
+ const notAfter = compareCalendarStrictRfc3339Instants(atTime, toRfc3339(certificate.validToDate));
268
+ return notBefore !== undefined && notAfter !== undefined && notBefore >= 0 && notAfter <= 0;
269
+ }
270
+ export const anchorChainVerifier = {
271
+ verifyCertificateChain(input) {
272
+ try {
273
+ // §8 step 3: the verifier ships with no roots, and a chain validated
274
+ // solely against bundle-supplied roots would re-import the self-run
275
+ // problem with extra ceremony. An empty set can never verify.
276
+ if (input.trustAnchorsDer.length === 0)
277
+ return false;
278
+ if (!isCalendarStrictRfc3339(input.atTime))
279
+ return false;
280
+ const anchors = input.trustAnchorsDer.map((der) => new X509Certificate(Buffer.from(der)));
281
+ const anchorFingerprints = new Set(anchors.map((anchor) => anchor.fingerprint256));
282
+ const chain = input.certificateChainDer.map((der) => new X509Certificate(Buffer.from(der)));
283
+ let current = chain[0];
284
+ if (current === undefined)
285
+ return false;
286
+ const seen = new Set();
287
+ for (let depth = 0; depth < MAX_CHAIN_DEPTH; depth += 1) {
288
+ if (seen.has(current.fingerprint256))
289
+ return false;
290
+ seen.add(current.fingerprint256);
291
+ // Validity is judged at the token's own genTime, not at the wall clock:
292
+ // a historical token with an expired-but-then-valid chain must not fail
293
+ // for the wrong reason.
294
+ if (!validAt(current, input.atTime))
295
+ return false;
296
+ // A certificate that *is* one of the roots the operator supplied needs
297
+ // no issuance check: the operator trusted those exact bytes, which is
298
+ // what a trust anchor means (RFC 5280 admits a zero-length path). This
299
+ // is also the only way a directly-trusted self-signed timestamping
300
+ // certificate can ever anchor -- such a certificate asserts
301
+ // `digitalSignature` and not `keyCertSign`, so an issuance check against
302
+ // itself refuses it for a reason that does not apply.
303
+ if (anchorFingerprints.has(current.fingerprint256))
304
+ return true;
305
+ const leaf = current;
306
+ if (anchors.some((anchor) => issued(leaf, anchor) && validAt(anchor, input.atTime)))
307
+ return true;
308
+ const issuer = chain.find((candidate) => candidate.fingerprint256 !== leaf.fingerprint256 && issued(leaf, candidate));
309
+ if (issuer === undefined)
310
+ return false;
311
+ current = issuer;
312
+ }
313
+ return false;
314
+ }
315
+ catch {
316
+ return false;
317
+ }
318
+ },
319
+ };
320
+ /**
321
+ * Whether `issuer` may issue `certificate`, and did.
322
+ *
323
+ * `checkIssued` covers name linkage, the authority/subject key identifiers when
324
+ * present, and the issuer's `keyCertSign` key usage -- but **not** basic
325
+ * constraints, so a `CA:FALSE` end-entity certificate that happens to assert
326
+ * `keyCertSign` passes it. That is a measured bypass, not a theoretical one: it
327
+ * would let a leaf be spliced in as an intermediate. `issuer.ca` is therefore
328
+ * required before the name check, and the signature is verified after it,
329
+ * because `checkIssued` proves relationship and not authorship.
330
+ *
331
+ * **Disclosed gaps**, in the style §16 uses for the extension-criticality gap
332
+ * (issue #2761's family): this walk checks issuance, key usage, basic
333
+ * constraints, and validity at the caller's instant. It does **not** check
334
+ * revocation (no CRL or OCSP is fetched -- acquisition never runs at
335
+ * verification time, §4.3), `pathLenConstraint`, or name constraints. A verifier
336
+ * that needs those runs its own path validation over the same carried chain;
337
+ * naming the gap is the honest form, and papering over it with a partial
338
+ * implementation would be worse than either.
339
+ */
340
+ function issued(certificate, issuer) {
341
+ try {
342
+ return issuer.ca && certificate.checkIssued(issuer) && certificate.verify(issuer.publicKey);
343
+ }
344
+ catch {
345
+ return false;
346
+ }
347
+ }
348
+ // ---------------------------------------------------------------------------
349
+ /** The three ports, ready to inject into `createRfc3161AnchorProofVerifier`. */
350
+ export const nodeCryptoAnchorPorts = {
351
+ signatureVerifier: anchorSignatureVerifier,
352
+ certificateReader: anchorCertificateReader,
353
+ chainVerifier: anchorChainVerifier,
354
+ };
@@ -0,0 +1,33 @@
1
+ import type { MatrixRecord, ReportRecord } from "@jinn-network/benchmarking-records";
2
+ import type { ClaimPackage } from "./profile/claim.js";
3
+ import type { PublicComparisonView } from "./comparison.js";
4
+ export interface PublicAssetInput {
5
+ readonly claim: ClaimPackage;
6
+ readonly matrix: MatrixRecord;
7
+ readonly report: ReportRecord;
8
+ readonly reportSha256: string;
9
+ readonly matrixSha256: string;
10
+ /** Canonically sorted identities for every authenticated `records/<sha>.bin` closure member. */
11
+ readonly recordSha256s: readonly string[];
12
+ readonly dissentCellKeys: readonly string[];
13
+ /** Verifier-derived, authenticated human projection. Absent only for legacy bundle assets. */
14
+ readonly comparison?: PublicComparisonView;
15
+ /** Producer-verified binary admission/instrument facts. Required for claim-package/2 only. */
16
+ readonly binaryQualification?: {
17
+ readonly publicationGrade: boolean;
18
+ readonly truthAdmission: "two-human-unanimous" | "operator-only" | "screened-operator-sampled";
19
+ readonly sourceManifestSha256: string;
20
+ readonly admissionManifestSha256: string;
21
+ readonly exclusions: readonly unknown[];
22
+ readonly instruments: readonly {
23
+ armId: string;
24
+ instrumentSha256: string;
25
+ promptTemplateSha256: string;
26
+ }[];
27
+ };
28
+ }
29
+ export declare function binaryAdmissionHtml(input: PublicAssetInput): string;
30
+ export declare function binaryAdmissionMarkdown(input: PublicAssetInput): string;
31
+ /** Fixed, deterministic public-bundle/2 presentation bytes. The builder only projects already
32
+ * verified stored facts; it never computes a statistic, selects a winner, or reconciles records. */
33
+ export declare function buildPublicAssets(input: PublicAssetInput): Readonly<Record<string, Uint8Array>>;