@typedstandards/verify-core 0.5.0 → 0.6.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.
@@ -29,6 +29,17 @@ export interface RekorInclusionProof {
29
29
  /** The signed checkpoint (Go note) committing to `rootHash` at `treeSize`. */
30
30
  checkpoint: string;
31
31
  }
32
+ /**
33
+ * Parse the carried Rekor inclusion proof — the JSON string in
34
+ * `verification.inclusionProof` (or the stored DB column) — into the structured proof
35
+ * `verifyRekorInclusion` folds. Only a REAL proof is usable: it must carry an audit
36
+ * path (`hashes`) AND a signed `checkpoint`. The empty `{}` some early packages carry,
37
+ * any value missing those fields, and malformed JSON all resolve to null — inclusion
38
+ * is then simply not verified, never thrown. This is the SINGLE guard the server
39
+ * route, the browser verify-flow, and the backfill script share (#119 P4), so "what
40
+ * counts as a real proof" is defined and tested in exactly one place.
41
+ */
42
+ export declare function parseInclusionProof(raw: string | null | undefined): RekorInclusionProof | null;
32
43
  export type RekorInclusionFailReason = 'leaf_index_out_of_range' | 'proof_wrong_length' | 'root_mismatch' | 'checkpoint_unparseable' | 'checkpoint_root_mismatch' | 'checkpoint_unknown_anchor' | 'checkpoint_signature_invalid';
33
44
  export interface RekorInclusionResult {
34
45
  /** The entry body + audit path reproduce the proof's `rootHash` (RFC 6962). */
@@ -42,6 +42,33 @@ export const REKOR_LOG_ANCHORS = [
42
42
  curve: 'p256',
43
43
  },
44
44
  ];
45
+ /**
46
+ * Parse the carried Rekor inclusion proof — the JSON string in
47
+ * `verification.inclusionProof` (or the stored DB column) — into the structured proof
48
+ * `verifyRekorInclusion` folds. Only a REAL proof is usable: it must carry an audit
49
+ * path (`hashes`) AND a signed `checkpoint`. The empty `{}` some early packages carry,
50
+ * any value missing those fields, and malformed JSON all resolve to null — inclusion
51
+ * is then simply not verified, never thrown. This is the SINGLE guard the server
52
+ * route, the browser verify-flow, and the backfill script share (#119 P4), so "what
53
+ * counts as a real proof" is defined and tested in exactly one place.
54
+ */
55
+ export function parseInclusionProof(raw) {
56
+ if (!raw)
57
+ return null;
58
+ try {
59
+ const parsed = JSON.parse(raw);
60
+ if (parsed !== null &&
61
+ typeof parsed === 'object' &&
62
+ Array.isArray(parsed.hashes) &&
63
+ typeof parsed.checkpoint === 'string') {
64
+ return parsed;
65
+ }
66
+ }
67
+ catch {
68
+ // malformed proof string ⇒ null
69
+ }
70
+ return null;
71
+ }
45
72
  // --- RFC 6962 Merkle hashing ----------------------------------------------
46
73
  /** Leaf hash: `SHA-256(0x00 ‖ leaf)` (RFC 6962 §2.1). */
47
74
  function leafHash(leaf) {
package/dist/rfc3161.js CHANGED
@@ -22,7 +22,7 @@ import { p384 } from '@noble/curves/nist.js';
22
22
  import { sha512 } from '@noble/hashes/sha2.js';
23
23
  import { base64ToBytes, bytesToHex } from "./primitives.js";
24
24
  import { Asn1Error, readNode, children, content, rawTlv, oidToString, readTime, } from "./asn1.js";
25
- import { parseCertificate, verifyCertSignatureRsa, bytesEqual, OID_EKU_TIMESTAMPING, } from "./x509.js";
25
+ import { parseCertificate, verifyCertChainToAnchor, bytesEqual, OID_EKU_TIMESTAMPING, } from "./x509.js";
26
26
  const OID_SIGNED_DATA = '1.2.840.113549.1.7.2';
27
27
  const OID_CT_TSTINFO = '1.2.840.113549.1.9.16.1.4';
28
28
  const OID_SHA256 = '2.16.840.1.101.3.4.2.1';
@@ -121,32 +121,38 @@ function parseTstInfo(tst) {
121
121
  genTime: readTime(tst, genTimeNode),
122
122
  };
123
123
  }
124
+ // Map the X.509 chain verifier's precise reason onto this module's vocabulary. The
125
+ // strict-RFC structural/policy rejections (#119 P4) collapse to `chain_incomplete`
126
+ // (the chain could not be trusted) and only a real RSA link failure surfaces as
127
+ // `chain_signature_invalid`; the chain layer keeps the fine-grained reason.
128
+ function toRfc3161ChainReason(reason) {
129
+ switch (reason) {
130
+ case 'genTime_outside_validity':
131
+ return 'genTime_outside_validity';
132
+ case 'untrusted_root':
133
+ return 'untrusted_root';
134
+ case 'link_signature_invalid':
135
+ return 'chain_signature_invalid';
136
+ case 'unsupported_critical_extension':
137
+ case 'algorithm_mismatch':
138
+ case 'issuer_not_found':
139
+ case 'issuer_not_ca':
140
+ case 'path_len_exceeded':
141
+ return 'chain_incomplete';
142
+ }
143
+ }
124
144
  /**
125
- * Validate the embedded chain from `leaf` up to a self-signed cert whose key is a
126
- * pinned root anchor, verifying each link's RSA signature, that issuers are CAs, and
127
- * that genTime falls within every cert's validity. Returns the matched anchor or a
128
- * reason. Bounded against cycles/length.
145
+ * Validate the embedded chain from `leaf` up to a pinned root anchor, delegating to
146
+ * the strict RFC 5280 verifier in `x509.ts` (each link's RSA signature; CA + keyUsage
147
+ * keyCertSign on issuers; pathLenConstraint; unknown-critical-extension and inner==
148
+ * outer-algorithm rejection; validity at genTime). Returns the matched anchor or a
149
+ * mapped reason.
129
150
  */
130
151
  async function validateChainToRoot(certs, leaf, anchors, genTime) {
131
- let current = leaf;
132
- for (let depth = 0; depth < 8; depth++) {
133
- if (genTime < current.notBefore || genTime > current.notAfter) {
134
- return { ok: false, reason: 'genTime_outside_validity' };
135
- }
136
- if (bytesEqual(current.issuerDer, current.subjectDer)) {
137
- // self-signed: trusted iff its key is pinned.
138
- const anchor = anchors.find((a) => bytesEqual(base64ToBytes(a.rootKeyDer), current.spkiDer));
139
- return anchor ? { ok: true, tsa: anchor.name } : { ok: false, reason: 'untrusted_root' };
140
- }
141
- const issuer = certs.find((c) => bytesEqual(c.subjectDer, current.issuerDer));
142
- if (!issuer || !issuer.isCA)
143
- return { ok: false, reason: 'chain_incomplete' };
144
- if (!(await verifyCertSignatureRsa(current, issuer.spkiDer))) {
145
- return { ok: false, reason: 'chain_signature_invalid' };
146
- }
147
- current = issuer;
148
- }
149
- return { ok: false, reason: 'chain_incomplete' };
152
+ const result = await verifyCertChainToAnchor(certs, leaf, anchors, genTime);
153
+ if (result.ok)
154
+ return { ok: true, tsa: result.anchorName };
155
+ return { ok: false, reason: toRfc3161ChainReason(result.reason ?? 'issuer_not_found') };
150
156
  }
151
157
  function fail(base, reason) {
152
158
  return { ...base, reason };
package/dist/x509.d.ts CHANGED
@@ -5,6 +5,12 @@ export interface X509Cert {
5
5
  tbsBytes: Uint8Array;
6
6
  /** Outer signatureAlgorithm OID. */
7
7
  sigAlgOid: string;
8
+ /** Outer signatureAlgorithm AlgorithmIdentifier (raw TLV). */
9
+ sigAlgDer: Uint8Array;
10
+ /** Inner TBSCertificate.signature AlgorithmIdentifier (raw TLV). */
11
+ innerSigAlgDer: Uint8Array;
12
+ /** Inner == outer signature AlgorithmIdentifier, byte-for-byte (check (d)). */
13
+ sigAlgConsistent: boolean;
8
14
  /** signatureValue (BIT STRING content, the leading unused-bits octet dropped). */
9
15
  signature: Uint8Array;
10
16
  /** SubjectPublicKeyInfo (raw TLV) — this cert's public key. */
@@ -20,6 +26,12 @@ export interface X509Cert {
20
26
  ekus: string[];
21
27
  /** basicConstraints cA:TRUE. */
22
28
  isCA: boolean;
29
+ /** keyUsage asserts keyCertSign (false if keyUsage is absent — check (b)). */
30
+ keyCertSign: boolean;
31
+ /** basicConstraints pathLenConstraint, or null when absent/unconstrained (check (c)). */
32
+ pathLen: number | null;
33
+ /** Carries a critical extension this verifier does not recognize (check (a)). */
34
+ hasUnknownCriticalExt: boolean;
23
35
  }
24
36
  /** Parse a Certificate (SEQUENCE { tbsCertificate, signatureAlgorithm, signature }). */
25
37
  export declare function parseCertificate(buf: Uint8Array, node: DerNode): X509Cert;
@@ -31,3 +43,27 @@ export declare function bytesEqual(a: Uint8Array, b: Uint8Array): boolean;
31
43
  * algorithm or any verification error.
32
44
  */
33
45
  export declare function verifyCertSignatureRsa(cert: X509Cert, issuerSpkiDer: Uint8Array): Promise<boolean>;
46
+ /** A pinned trust anchor: a root whose public key is trusted by value. */
47
+ export interface ChainAnchor {
48
+ name: string;
49
+ /** Root public key, base64 SPKI DER. */
50
+ rootKeyDer: string;
51
+ }
52
+ export type ChainFailReason = 'unsupported_critical_extension' | 'algorithm_mismatch' | 'genTime_outside_validity' | 'issuer_not_found' | 'issuer_not_ca' | 'path_len_exceeded' | 'link_signature_invalid' | 'untrusted_root';
53
+ export interface ChainResult {
54
+ /** The path links to a pinned anchor and every strict check passed. */
55
+ ok: boolean;
56
+ /** The matched anchor's name, when `ok`. */
57
+ anchorName?: string;
58
+ /** The first check that failed, otherwise. */
59
+ reason?: ChainFailReason;
60
+ }
61
+ /**
62
+ * Validate the embedded chain from `leaf` up to a self-signed cert whose key is a
63
+ * pinned anchor, applying the strict RFC 5280 checks (a)-(d) documented at the top of
64
+ * this module. `atTime` (epoch ms) is the instant every cert's validity is checked
65
+ * against (the RFC 3161 genTime, for a TSA chain). Each link's RSA signature is
66
+ * verified via WebCrypto. Async; never throws — a structural/policy failure is
67
+ * reported as a `reason`. Bounded against cycles and runaway length.
68
+ */
69
+ export declare function verifyCertChainToAnchor(certs: X509Cert[], leaf: X509Cert, anchors: readonly ChainAnchor[], atTime: number): Promise<ChainResult>;
package/dist/x509.js CHANGED
@@ -1,14 +1,52 @@
1
- // Minimal X.509 certificate parsing (civic-ai-tools-website#119 P2b) — browser-safe.
1
+ // Minimal X.509 certificate parsing + chain validation (civic-ai-tools-website#119
2
+ // P2b/P4) — browser-safe.
2
3
  //
3
4
  // Just enough of RFC 5280 to validate the RFC 3161 token's embedded cert chain: the
4
5
  // signed TBSCertificate bytes, the outer signature + algorithm, the public key, the
5
6
  // validity window, issuer/subject (for chain linking), the serial (to match the CMS
6
- // signer), and the two extensions we gate on (EKU, basicConstraints). Built on the
7
- // strict-DER `asn1.ts` reader, so malformed certs throw rather than mis-parse.
7
+ // signer), and the extensions we gate on (EKU, basicConstraints, keyUsage). Built on
8
+ // the strict-DER `asn1.ts` reader, so malformed certs throw rather than mis-parse.
9
+ //
10
+ // STRICT RFC 5280 COMPLIANCE (#119 P4). `verifyCertChainToAnchor` is the pinned-root
11
+ // chain verifier; it fails CLOSED on four checks that a conforming verifier MUST
12
+ // enforce. Under the pinned FreeTSA root these are non-exploitable (the root issues a
13
+ // well-formed chain), so they are defence-in-depth / strict-RFC hardening, and each
14
+ // has a fail-closed negative test (mint-forged certs) in `x509.test.ts`:
15
+ // (a) UNKNOWN CRITICAL EXTENSIONS are rejected (RFC 5280 §4.2: a system MUST reject
16
+ // a certificate with a critical extension it does not recognize). The recognized
17
+ // set is { basicConstraints, keyUsage, extKeyUsage }; any other critical
18
+ // extension sets `hasUnknownCriticalExt`.
19
+ // (b) An issuer is trusted to sign certificates only if it is a CA (basicConstraints
20
+ // cA:TRUE) AND its keyUsage asserts keyCertSign (RFC 5280 §4.2.1.3) — both, or
21
+ // the link is rejected as `issuer_not_ca`.
22
+ // (c) basicConstraints pathLenConstraint is enforced DOWN the chain (§4.2.1.9): an
23
+ // issuer with pathLen=n may have at most n intermediate CAs beneath it.
24
+ // (d) The inner TBSCertificate.signature AlgorithmIdentifier MUST byte-equal the
25
+ // outer signatureAlgorithm (§4.1.1.2) — an anti algorithm-substitution check;
26
+ // a mismatch sets `sigAlgConsistent=false` and the link is rejected.
8
27
  import { readNode, children, content, rawTlv, oidToString, readTime, Asn1Error, } from "./asn1.js";
28
+ import { base64ToBytes } from "./primitives.js";
9
29
  export const OID_EKU_TIMESTAMPING = '1.3.6.1.5.5.7.3.8';
10
30
  const OID_EXT_EKU = '2.5.29.37';
11
31
  const OID_EXT_BASIC_CONSTRAINTS = '2.5.29.19';
32
+ const OID_EXT_KEY_USAGE = '2.5.29.15';
33
+ // Critical extensions a conforming verifier recognizes and processes (check (a)).
34
+ // A critical extension OUTSIDE this set means the cert MUST be rejected.
35
+ const RECOGNIZED_CRITICAL_EXTS = new Set([
36
+ OID_EXT_EKU,
37
+ OID_EXT_BASIC_CONSTRAINTS,
38
+ OID_EXT_KEY_USAGE,
39
+ ]);
40
+ // keyUsage is a DER BIT STRING; keyCertSign is bit 5 (RFC 5280 §4.2.1.3), i.e. the
41
+ // 0x04 bit of the first data octet (content is [unusedBits, byte0, …]).
42
+ const KEY_CERT_SIGN_BIT = 0x04;
43
+ /** Read a small non-negative DER INTEGER's content as a Number (used for pathLen). */
44
+ function readUint(bytes) {
45
+ let n = 0;
46
+ for (const b of bytes)
47
+ n = n * 256 + b; // *256, not <<8, to avoid 32-bit overflow
48
+ return n;
49
+ }
12
50
  /** Parse a Certificate (SEQUENCE { tbsCertificate, signatureAlgorithm, signature }). */
13
51
  export function parseCertificate(buf, node) {
14
52
  const [tbs, sigAlg, sigBits] = children(buf, node);
@@ -21,38 +59,66 @@ export function parseCertificate(buf, node) {
21
59
  if (tk[i]?.tag === 0xa0)
22
60
  i++; // optional version [0]
23
61
  const serial = content(buf, tk[i++]);
24
- i++; // inner signature AlgorithmIdentifier (redundant; skip)
62
+ const innerSigAlg = tk[i++]; // inner signature AlgorithmIdentifier (check (d))
25
63
  const issuer = tk[i++];
26
64
  const validity = tk[i++];
27
65
  const subject = tk[i++];
28
66
  const spki = tk[i++];
29
- if (!issuer || !validity || !subject || !spki)
67
+ if (!innerSigAlg || !issuer || !validity || !subject || !spki) {
30
68
  throw new Asn1Error('malformed TBSCertificate');
69
+ }
31
70
  const [nb, na] = children(buf, validity);
32
71
  if (!nb || !na)
33
72
  throw new Asn1Error('malformed Validity');
34
73
  const ekus = [];
35
74
  let isCA = false;
75
+ let keyCertSign = false;
76
+ let pathLen = null;
77
+ let hasUnknownCriticalExt = false;
36
78
  const extsWrap = tk.slice(i).find((c) => c.tag === 0xa3); // extensions [3] EXPLICIT
37
79
  if (extsWrap) {
38
80
  for (const ext of children(buf, children(buf, extsWrap)[0])) {
39
81
  const ec = children(buf, ext); // Extension { extnID OID, critical BOOL?, extnValue OCTET }
40
82
  const oid = oidToString(buf, ec[0]);
83
+ // critical BOOLEAN DEFAULT FALSE: present iff a BOOLEAN precedes extnValue.
84
+ const critical = ec.length === 3 && ec[1]?.tag === 0x01 && content(buf, ec[1])[0] === 0xff;
85
+ if (critical && !RECOGNIZED_CRITICAL_EXTS.has(oid))
86
+ hasUnknownCriticalExt = true; // (a)
41
87
  const value = content(buf, ec[ec.length - 1]); // extnValue OCTET STRING content (nested DER)
42
88
  if (oid === OID_EXT_EKU) {
43
89
  for (const kp of children(value, readNode(value, 0)))
44
90
  ekus.push(oidToString(value, kp));
45
91
  }
46
92
  else if (oid === OID_EXT_BASIC_CONSTRAINTS) {
93
+ // BasicConstraints ::= SEQUENCE { cA BOOLEAN DEFAULT FALSE,
94
+ // pathLenConstraint INTEGER (0..MAX) OPTIONAL }
47
95
  const bc = children(value, readNode(value, 0));
48
- if (bc[0]?.tag === 0x01 && content(value, bc[0])[0] === 0xff)
49
- isCA = true;
96
+ let b = 0;
97
+ if (bc[b]?.tag === 0x01) {
98
+ if (content(value, bc[b])[0] === 0xff)
99
+ isCA = true;
100
+ b++;
101
+ }
102
+ if (bc[b]?.tag === 0x02)
103
+ pathLen = readUint(content(value, bc[b])); // (c)
104
+ }
105
+ else if (oid === OID_EXT_KEY_USAGE) {
106
+ const ku = readNode(value, 0); // BIT STRING
107
+ if (ku.tag === 0x03) {
108
+ const bits = content(value, ku); // [unusedBits, byte0, …]
109
+ keyCertSign = bits.length >= 2 && (bits[1] & KEY_CERT_SIGN_BIT) !== 0; // (b)
110
+ }
50
111
  }
51
112
  }
52
113
  }
114
+ const sigAlgDer = rawTlv(buf, sigAlg);
115
+ const innerSigAlgDer = rawTlv(buf, innerSigAlg);
53
116
  return {
54
117
  tbsBytes: rawTlv(buf, tbs),
55
118
  sigAlgOid: oidToString(buf, children(buf, sigAlg)[0]),
119
+ sigAlgDer,
120
+ innerSigAlgDer,
121
+ sigAlgConsistent: bytesEqual(innerSigAlgDer, sigAlgDer), // (d)
56
122
  signature: content(buf, sigBits).slice(1),
57
123
  spkiDer: rawTlv(buf, spki),
58
124
  serial,
@@ -62,6 +128,9 @@ export function parseCertificate(buf, node) {
62
128
  notAfter: readTime(buf, na),
63
129
  ekus,
64
130
  isCA,
131
+ keyCertSign,
132
+ pathLen,
133
+ hasUnknownCriticalExt,
65
134
  };
66
135
  }
67
136
  export function bytesEqual(a, b) {
@@ -99,3 +168,49 @@ export async function verifyCertSignatureRsa(cert, issuerSpkiDer) {
99
168
  return false;
100
169
  }
101
170
  }
171
+ // A self-issued chain deeper than this is rejected (cycle / runaway guard). The real
172
+ // TSA chain is depth 1 (leaf → root); 8 is generous headroom for intermediates.
173
+ const MAX_CHAIN_DEPTH = 8;
174
+ /**
175
+ * Validate the embedded chain from `leaf` up to a self-signed cert whose key is a
176
+ * pinned anchor, applying the strict RFC 5280 checks (a)-(d) documented at the top of
177
+ * this module. `atTime` (epoch ms) is the instant every cert's validity is checked
178
+ * against (the RFC 3161 genTime, for a TSA chain). Each link's RSA signature is
179
+ * verified via WebCrypto. Async; never throws — a structural/policy failure is
180
+ * reported as a `reason`. Bounded against cycles and runaway length.
181
+ */
182
+ export async function verifyCertChainToAnchor(certs, leaf, anchors, atTime) {
183
+ let current = leaf;
184
+ // `depth` doubles as the count of intermediate CAs between the current issuer and
185
+ // the leaf: at depth 0 the issuer signs the end-entity directly (0 intermediates),
186
+ // at depth k there are k intermediates beneath it — exactly the pathLen budget (c).
187
+ for (let depth = 0; depth < MAX_CHAIN_DEPTH; depth++) {
188
+ if (current.hasUnknownCriticalExt) {
189
+ return { ok: false, reason: 'unsupported_critical_extension' }; // (a)
190
+ }
191
+ if (!current.sigAlgConsistent) {
192
+ return { ok: false, reason: 'algorithm_mismatch' }; // (d)
193
+ }
194
+ if (atTime < current.notBefore || atTime > current.notAfter) {
195
+ return { ok: false, reason: 'genTime_outside_validity' };
196
+ }
197
+ if (bytesEqual(current.issuerDer, current.subjectDer)) {
198
+ // Self-signed terminus: trusted iff its key is a pinned anchor.
199
+ const anchor = anchors.find((a) => bytesEqual(base64ToBytes(a.rootKeyDer), current.spkiDer));
200
+ return anchor ? { ok: true, anchorName: anchor.name } : { ok: false, reason: 'untrusted_root' };
201
+ }
202
+ const issuer = certs.find((c) => bytesEqual(c.subjectDer, current.issuerDer));
203
+ if (!issuer)
204
+ return { ok: false, reason: 'issuer_not_found' };
205
+ if (!issuer.isCA || !issuer.keyCertSign)
206
+ return { ok: false, reason: 'issuer_not_ca' }; // (b)
207
+ if (issuer.pathLen !== null && depth > issuer.pathLen) {
208
+ return { ok: false, reason: 'path_len_exceeded' }; // (c)
209
+ }
210
+ if (!(await verifyCertSignatureRsa(current, issuer.spkiDer))) {
211
+ return { ok: false, reason: 'link_signature_invalid' };
212
+ }
213
+ current = issuer;
214
+ }
215
+ return { ok: false, reason: 'issuer_not_found' };
216
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typedstandards/verify-core",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Portable, browser-safe verification core for Typed Standards evidence packages — the §9.2 check suite, single-sourced so it verifies identically on a server and in the browser.",
5
5
  "license": "MIT",
6
6
  "author": "Nathan Storey",