@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.
- package/dist/rekor-inclusion.d.ts +11 -0
- package/dist/rekor-inclusion.js +27 -0
- package/dist/rfc3161.js +30 -24
- package/dist/x509.d.ts +36 -0
- package/dist/x509.js +122 -7
- package/package.json +1 -1
|
@@ -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). */
|
package/dist/rekor-inclusion.js
CHANGED
|
@@ -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,
|
|
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
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
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.
|
|
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",
|