@typedstandards/verify-core 0.3.1 → 0.5.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/index.d.ts CHANGED
@@ -11,5 +11,6 @@ export * from './lifecycle.ts';
11
11
  export * from './rekor.ts';
12
12
  export * from './rekor-inclusion.ts';
13
13
  export * from './asn1.ts';
14
+ export * from './x509.ts';
14
15
  export * from './rfc3161.ts';
15
16
  export * from './verify.ts';
package/dist/index.js CHANGED
@@ -25,5 +25,6 @@ export * from "./lifecycle.js";
25
25
  export * from "./rekor.js";
26
26
  export * from "./rekor-inclusion.js";
27
27
  export * from "./asn1.js";
28
+ export * from "./x509.js";
28
29
  export * from "./rfc3161.js";
29
30
  export * from "./verify.js";
@@ -81,3 +81,40 @@ export declare function resolveLifecycleFromLegacyColumns(columns: {
81
81
  reinstatedAt?: string | null;
82
82
  reinstatedReason?: string | null;
83
83
  }): LifecycleResolution;
84
+ /** A signed lifecycle attestation, carried in the bundle so the chain resolves with
85
+ * NO reference-implementation dependency (civic-ai-tools-website#119 P3). The server
86
+ * fetches these from the DB + blob; an offline verifier reads them from the bundle. */
87
+ export interface CarriedLifecycleNode {
88
+ /** The signed attestation node JSON (the envelope) — recomputed + signature-checked. */
89
+ node: Record<string, unknown>;
90
+ /** The stored nodeId (envelope hash) the recomputed hash must match. */
91
+ nodeId: string;
92
+ /** The signature envelope over the node (`{signature, publicKey, algorithm}`). */
93
+ signature?: {
94
+ signature?: string;
95
+ publicKey?: string;
96
+ algorithm?: string;
97
+ } | null;
98
+ /** Presence flags (surfaced; per-attestation TSA/Rekor depth is a follow-up). */
99
+ hasTimestamp?: boolean;
100
+ hasRekor?: boolean;
101
+ }
102
+ /**
103
+ * Resolve lifecycle (#10) INDEPENDENTLY from carried signed attestation nodes —
104
+ * the browser/offline path that reaches `source: 'attestation-chain'` with no
105
+ * reference-implementation dependency (civic-ai-tools-website#119 P3).
106
+ *
107
+ * Unlike the server (whose DB query guarantees the rows target this node and whose
108
+ * rows are platform-signed), an independent verifier must NOT trust the carrier, so
109
+ * each node is gated before it can affect status:
110
+ * - REACHABILITY (§9.2 #13): `node.targetNodeId` MUST equal the content node's id;
111
+ * an attestation about a different node is not part of this lifecycle.
112
+ * - INTEGRITY: the recomputed envelope hash MUST equal the stored `nodeId`.
113
+ * - SIGNATURE: the Ed25519(ph) signature MUST verify over that hash.
114
+ * A node failing any of these is EXCLUDED (a forged/tampered/misdirected transition
115
+ * cannot move the status). Surviving nodes go to `resolveLifecycleFromChain`, which
116
+ * applies the §8.10.3 retention asymmetry (a valid but non-signer-matched attestation
117
+ * is surfaced in the chain yet does NOT move the publisher's status). That shared
118
+ * resolver is unchanged, so the server route's output stays byte-identical.
119
+ */
120
+ export declare function verifyLifecycleChain(carried: CarriedLifecycleNode[], contentNodeId: string, targetSignerIdentifier: string): LifecycleResolution;
package/dist/lifecycle.js CHANGED
@@ -111,3 +111,53 @@ export function resolveLifecycleFromLegacyColumns(columns) {
111
111
  : {}),
112
112
  };
113
113
  }
114
+ function pickString(v) {
115
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
116
+ }
117
+ /**
118
+ * Resolve lifecycle (#10) INDEPENDENTLY from carried signed attestation nodes —
119
+ * the browser/offline path that reaches `source: 'attestation-chain'` with no
120
+ * reference-implementation dependency (civic-ai-tools-website#119 P3).
121
+ *
122
+ * Unlike the server (whose DB query guarantees the rows target this node and whose
123
+ * rows are platform-signed), an independent verifier must NOT trust the carrier, so
124
+ * each node is gated before it can affect status:
125
+ * - REACHABILITY (§9.2 #13): `node.targetNodeId` MUST equal the content node's id;
126
+ * an attestation about a different node is not part of this lifecycle.
127
+ * - INTEGRITY: the recomputed envelope hash MUST equal the stored `nodeId`.
128
+ * - SIGNATURE: the Ed25519(ph) signature MUST verify over that hash.
129
+ * A node failing any of these is EXCLUDED (a forged/tampered/misdirected transition
130
+ * cannot move the status). Surviving nodes go to `resolveLifecycleFromChain`, which
131
+ * applies the §8.10.3 retention asymmetry (a valid but non-signer-matched attestation
132
+ * is surfaced in the chain yet does NOT move the publisher's status). That shared
133
+ * resolver is unchanged, so the server route's output stays byte-identical.
134
+ */
135
+ export function verifyLifecycleChain(carried, contentNodeId, targetSignerIdentifier) {
136
+ const views = [];
137
+ for (const entry of carried) {
138
+ // Reachability: the attestation must reference THIS content node.
139
+ if (pickString(entry.node['targetNodeId']) !== contentNodeId)
140
+ continue;
141
+ const verdict = verifyAttestationNode(entry.node, entry.nodeId, entry.signature ?? null);
142
+ // Independent crypto gate: integrity + a valid signature, or it cannot count.
143
+ if (!verdict.nodeIdMatches || verdict.signatureValid !== true)
144
+ continue;
145
+ const signer = entry.node['signer'];
146
+ const metadata = entry.node['metadata'];
147
+ views.push({
148
+ nodeId: entry.nodeId,
149
+ type: pickString(entry.node['type']) ?? '',
150
+ signer,
151
+ createdAt: pickString(metadata?.['createdAt']) ?? '',
152
+ reason: pickString(entry.node['reason']),
153
+ effectiveAt: pickString(entry.node['effectiveAt']),
154
+ priorWithdrawalNodeId: pickString(entry.node['priorWithdrawalNodeId']),
155
+ signatureValid: verdict.signatureValid,
156
+ nodeIdMatches: verdict.nodeIdMatches,
157
+ hasTimestamp: !!entry.hasTimestamp,
158
+ hasRekor: !!entry.hasRekor,
159
+ signerMatchesTarget: !!signer && signer.identifier === targetSignerIdentifier,
160
+ });
161
+ }
162
+ return resolveLifecycleFromChain(views);
163
+ }
package/dist/rfc3161.d.ts CHANGED
@@ -1,41 +1,39 @@
1
1
  /**
2
- * A pinned TSA signing key — the OFFLINE trust anchor for check #7 (spec §10.3).
3
- * Pinning the SIGNING key (not the long-lived root) is the P2a "acceptable v1":
4
- * it must be re-pinned if FreeTSA rotates the signing cert. P2b anchors to the RSA
5
- * root via the token's embedded chain, which removes the re-pin.
2
+ * A pinned TSA ROOT — the OFFLINE trust anchor for check #7 (spec §10.3). Pinning the
3
+ * long-lived ROOT (not the signing cert) means a verifier trusts ANY signing cert the
4
+ * root issues: the token's embedded chain is validated up to this anchor, so FreeTSA
5
+ * rotating its signing cert needs no re-pin (the P2a fragility, now removed).
6
6
  */
7
- export interface TsaAnchor {
7
+ export interface TsaRootAnchor {
8
8
  name: string;
9
- /** EC P-384 signing public key, base64 SPKI DER. */
10
- signingKeyDer: string;
11
- /** Signing cert validity window (ISO 8601, UTC). genTime must fall inside it. */
12
- notBefore: string;
13
- notAfter: string;
9
+ /** Root public key, base64 SPKI DER. */
10
+ rootKeyDer: string;
14
11
  }
15
- export declare const FREETSA_TSA_ANCHORS: readonly TsaAnchor[];
16
- export type Rfc3161FailReason = 'parse_error' | 'not_granted' | 'unexpected_content_type' | 'unexpected_algorithm' | 'no_message_digest' | 'content_not_bound' | 'no_anchor' | 'signature_invalid' | 'imprint_mismatch' | 'genTime_outside_validity';
12
+ export declare const FREETSA_ROOT_ANCHORS: readonly TsaRootAnchor[];
13
+ export type Rfc3161FailReason = 'parse_error' | 'unexpected_algorithm' | 'no_message_digest' | 'content_not_bound' | 'imprint_mismatch' | 'no_signing_cert' | 'eku_not_timestamping' | 'genTime_outside_validity' | 'chain_incomplete' | 'chain_signature_invalid' | 'untrusted_root' | 'signature_invalid';
17
14
  export interface Rfc3161VerifyResult {
18
- /** All checks passed: a pinned TSA's signature binds this package hash to genTime. */
15
+ /** All checks passed: a chain-trusted TSA signed this package hash at genTime. */
19
16
  verified: boolean;
20
- /** messageImprint.hashedMessage == the expected package hash (sha256). */
21
17
  imprintMatches: boolean | null;
22
- /** The TSA ECDSA-P384 signature over SHA-512(signedAttrs) verifies vs the anchor. */
23
- signatureValid: boolean | null;
24
- /** signedAttrs.messageDigest == SHA-512(TSTInfo) — the sig is bound to the TSTInfo. */
25
18
  contentBound: boolean | null;
26
- /** genTime within the pinned signing cert's validity window. */
19
+ /** The embedded signing cert chains to the pinned root (each link verified). */
20
+ chainVerified: boolean | null;
21
+ /** The signing cert carries EKU id-kp-timeStamping. */
22
+ ekuTimestamping: boolean | null;
23
+ /** genTime within the signing cert's validity window. */
27
24
  withinValidity: boolean | null;
28
- /** RFC 3161 genTime, epoch ms. */
25
+ /** The TSA ECDSA-P384 signature verifies under the chain-validated leaf key. */
26
+ signatureValid: boolean | null;
29
27
  genTime?: number;
30
- /** The matched anchor name (when the signature verified). */
28
+ /** The matched root anchor name. */
31
29
  tsa?: string;
32
30
  reason?: Rfc3161FailReason;
33
31
  }
34
32
  /**
35
- * Verify an RFC 3161 timestamp token (base64) attests `expectedHashHex` (the package
36
- * SHA-256), against the pinned TSA anchors. Returns a graded verdict; a parse failure
37
- * or unknown anchor is reported (not thrown) so the caller can render it calmly,
38
- * while a real cryptographic failure (`signature_invalid` / `imprint_mismatch` /
39
- * `content_not_bound`) is the alarm signal.
33
+ * Verify an RFC 3161 token (base64) attests `expectedHashHex` (the package SHA-256),
34
+ * chaining the token's embedded signing cert to a pinned TSA root. Async (the cert
35
+ * chain uses WebCrypto RSA). A parse failure / untrusted chain is reported, not
36
+ * thrown; `imprint_mismatch` / `content_not_bound` / `chain_signature_invalid` /
37
+ * `signature_invalid` are the alarm signals.
40
38
  */
41
- export declare function verifyRfc3161Timestamp(tokenB64: string, expectedHashHex: string, anchors?: readonly TsaAnchor[]): Rfc3161VerifyResult;
39
+ export declare function verifyRfc3161Timestamp(tokenB64: string, expectedHashHex: string, anchors?: readonly TsaRootAnchor[]): Promise<Rfc3161VerifyResult>;
package/dist/rfc3161.js CHANGED
@@ -1,164 +1,164 @@
1
1
  // RFC 3161 timestamp verification (spec §9.2 check #7, deep) — browser-safe.
2
2
  //
3
- // `verifyEvidence` today does PRESENCE for #7 (`!!rfc3161Timestamp`). This closes
4
- // the cryptographic gap for the FreeTSA profile our producer uses (#119 P2a): it
5
- // parses the RFC 3161 token (a PKCS#7/CMS SignedData over a TSTInfo) and verifies,
6
- // OFFLINE, that a trusted TSA attested the package hash at a point in time:
7
- // 1. messageImprint.hashedMessage == the package hash (sha256), so the token is
3
+ // `verifyEvidence` did PRESENCE for #7. P2a added cryptographic TSA-signature
4
+ // verification; P2b (this) closes it to a FULL chain to a pinned trust anchor:
5
+ // 1. messageImprint.hashedMessage == the package hash (sha256) — the token is
8
6
  // ABOUT this package;
9
- // 2. signedAttrs.messageDigest == SHA-512(TSTInfo), so the signature is BOUND to
10
- // that TSTInfo;
11
- // 3. the TSA's ECDSA-P384 signature over SHA-512(signedAttrs) verifies against the
12
- // PINNED FreeTSA signing key (the trust anchor);
13
- // 4. genTime falls within that signing cert's validity window.
7
+ // 2. signedAttrs.messageDigest == SHA-512(TSTInfo) — the signature is BOUND to it;
8
+ // 3. the token's EMBEDDED signing cert chains to the PINNED FreeTSA RSA-4096 root
9
+ // (each link's signature verified; CA + validity-vs-genTime checked) — so the
10
+ // signer is trusted WITHOUT pinning the (rotatable) signing key;
11
+ // 4. the signing cert carries EKU id-kp-timeStamping and its validity covers
12
+ // genTime;
13
+ // 5. the TSA's ECDSA-P384 signature over SHA-512(signedAttrs) verifies under THAT
14
+ // chain-validated leaf key.
14
15
  //
15
- // SCOPE (P2a): FreeTSA uses an EC P-384 signing cert and `ecdsa-with-SHA512`, so the
16
- // TSA-signature check is pure `@noble` ECDSA — no WebCrypto/RSA. The anchor is the
17
- // PINNED signing-cert public key (re-pin on rotation — documented below). P2b adds
18
- // full embedded-cert chain validation to FreeTSA's RSA root (which removes the
19
- // re-pin), and generalizes EKU/validity from the certs themselves. The recipe here
20
- // was validated byte-for-byte against a real freetsa.org token (cross-checked with
21
- // OpenSSL `ts -verify`) before shipping; `__fixtures__/rfc3161-token.json` is that
22
- // token and the tests reproduce it offline.
16
+ // Crypto: the leaf TSA signature is `@noble` ECDSA-P384 (the cert key); the cert
17
+ // chain is RSASSA-PKCS1-v1.5/SHA-512 via WebCrypto (`globalThis.crypto.subtle`, a
18
+ // global — verify-core stays browser-safe). lowS:false on the ECDSA verify (a TSA
19
+ // emits high-S; see the #119 lowS fix). Validated byte-for-byte against real
20
+ // freetsa.org tokens (low-S + high-S fixtures) cross-checked with OpenSSL.
23
21
  import { p384 } from '@noble/curves/nist.js';
24
22
  import { sha512 } from '@noble/hashes/sha2.js';
25
23
  import { base64ToBytes, bytesToHex } from "./primitives.js";
26
24
  import { Asn1Error, readNode, children, content, rawTlv, oidToString, readTime, } from "./asn1.js";
27
- // --- OIDs ------------------------------------------------------------------
25
+ import { parseCertificate, verifyCertSignatureRsa, bytesEqual, OID_EKU_TIMESTAMPING, } from "./x509.js";
28
26
  const OID_SIGNED_DATA = '1.2.840.113549.1.7.2';
29
27
  const OID_CT_TSTINFO = '1.2.840.113549.1.9.16.1.4';
30
28
  const OID_SHA256 = '2.16.840.1.101.3.4.2.1';
31
29
  const OID_SHA512 = '2.16.840.1.101.3.4.2.3';
32
30
  const OID_ECDSA_SHA512 = '1.2.840.10045.4.3.4';
33
31
  const OID_ATTR_MESSAGE_DIGEST = '1.2.840.113549.1.9.4';
34
- // PROVENANCE (anchor pinning — spec §10.3). FreeTSA's online TSA (`freetsa.org/tsr`)
35
- // signs with this EC P-384 key (cert serial C2E986160DA8E9CD, OU=TSA, EKU
36
- // id-kp-timeStamping, valid 2026-02-15 → 2040-02-02). Captured 2026-06-07 from
37
- // `https://freetsa.org/files/tsa.crt`; a live token verified against it with both
38
- // `@noble` (here) and OpenSSL `ts -verify` against FreeTSA's published CA. The cert
39
- // chains to FreeTSA's self-signed RSA-4096 root (OU=Root CA) — pinning that root and
40
- // validating the embedded chain is P2b.
41
- export const FREETSA_TSA_ANCHORS = [
32
+ // PROVENANCE (anchor pinning — spec §10.3). FreeTSA's self-signed root CA
33
+ // (O=Free TSA/OU=Root CA, RSA-4096, SHA-256 fingerprint
34
+ // A6:37:9E:7C:EC:C0:5F:AA:3C:BF:07:60:13:D7:45:E3:27:BB:BA:A3:8C:0B:9A:F2:24:69:D4:70:1D:18:AA:BC).
35
+ // Captured 2026-06-07 from `https://freetsa.org/files/cacert.pem`; real tokens'
36
+ // embedded signing certs chain to it (verified with `@noble`/WebCrypto here and
37
+ // OpenSSL `ts -verify`). Adopters changing TSAs document their root here (§10.3).
38
+ export const FREETSA_ROOT_ANCHORS = [
42
39
  {
43
40
  name: 'freetsa.org',
44
- signingKeyDer: 'MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEohXhobLWy4cYsdKOFALpjUsB1FJTntwwb7PH2LOfNMAA1ufaKhkgsdeW6etU0pl5MC5tSVuPF52yy4sz+mbY3L7IVdx/bs9m0ud7IA14YNcC2yIQLa2gvnxwsrR3rlWr',
45
- notBefore: '2026-02-15T19:44:22Z',
46
- notAfter: '2040-02-02T19:44:22Z',
41
+ rootKeyDer: 'MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtgKODjAy8REQ2WTNqUudAnjhlCrpE6qlmQfNppeTmVvZrH4zutn+NwTaHAGpjSGv4/WRpZ1wZ3BRZ5mPUBZyLgq0YrIfQ5Fx0s/MRZPzc1r3lKWrMR9sAQx4mN4z11xFEO529L0dFJjPF9MD8Gpd2feWzGyptlelb+PqT+++fOa2oY0+NaMM7l/xcNHPOaMz0/2olk0i22hbKeVhvokPCqhFhzsuhKsmq4Of/o+t6dI7sx5h0nPMm4gGSRhfq+z6BTRgCrqQG2FOLoVFgt6iIm/BnNffUr7VDYd3zZmIwFOj/H3DKHoGik/xK3E82YA2ZulVOFRW/zj4ApjPa5OFbpIkd0pmzxzdEcL479hSA9dFiyVmSxPtY5ze1P+BE9bMU1PScpRzw8MHFXxyKqW13Qv7LWw4sbk3SciB7GACbQiVGzgkvXG6y85HOuvWNvC5GLSiyP9GlPB0V68tbxz4JVTRdw/Xn/XTFNzRBM3cq8lBOAVt/PAX5+uFcv1S9wFE8YjaBfWCP1jdBil+c4e+0tdywT2oJmYBBF/kEt1wmGwMmHunNEuQNzh1FtJY54hbUfiWi38mASE7xMtMhfj/C4SvapiDN837gYaPfs8x3KZxbX7C3YAsFnJinlwAUss1fdKar8Q/YVs7H/nU4c4Ixxxz4f67fcVqM2ITKentbCMCAwEAAQ==',
47
42
  },
48
43
  ];
49
- // EC P-384 SPKI DER is a fixed 120-byte structure: a 23-byte prefix (SEQUENCE →
50
- // AlgorithmIdentifier{ id-ecPublicKey, secp384r1 } → BIT STRING) then the 97-byte
51
- // uncompressed point (0x04 ‖ X ‖ Y).
44
+ // EC P-384 SPKI DER: 23-byte prefix + 97-byte uncompressed point (0x04 ‖ X ‖ Y).
52
45
  const EC_P384_SPKI_PREFIX = Uint8Array.from([
53
46
  0x30, 0x76, 0x30, 0x10, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01,
54
47
  0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x22, 0x03, 0x62, 0x00,
55
48
  ]);
56
49
  const EC_P384_SPKI_LENGTH = 120;
57
- function extractP384Point(publicKeyB64Der) {
58
- const der = base64ToBytes(publicKeyB64Der);
59
- if (der.length !== EC_P384_SPKI_LENGTH) {
60
- throw new Asn1Error(`Unexpected P-384 SPKI length ${der.length}`);
61
- }
50
+ function extractP384Point(spkiDer) {
51
+ if (spkiDer.length !== EC_P384_SPKI_LENGTH)
52
+ throw new Asn1Error('not a P-384 SPKI');
62
53
  for (let i = 0; i < EC_P384_SPKI_PREFIX.length; i++) {
63
- if (der[i] !== EC_P384_SPKI_PREFIX[i])
64
- throw new Asn1Error('Unexpected P-384 SPKI prefix');
54
+ if (spkiDer[i] !== EC_P384_SPKI_PREFIX[i])
55
+ throw new Asn1Error('unexpected P-384 SPKI prefix');
65
56
  }
66
- return der.slice(EC_P384_SPKI_LENGTH - 97);
57
+ return spkiDer.slice(EC_P384_SPKI_LENGTH - 97);
67
58
  }
68
- /** The AlgorithmIdentifier's OID (its first child). */
69
59
  function algorithmOid(buf, alg) {
70
60
  return oidToString(buf, children(buf, alg)[0]);
71
61
  }
72
- /** Navigate TimeStampResp → ContentInfo → SignedData → { TSTInfo, signerInfo }. */
73
62
  function parseToken(buf) {
74
63
  const resp = readNode(buf, 0);
75
64
  const respKids = children(buf, resp);
76
- // PKIStatusInfo.status: 0 granted, 1 grantedWithMods; anything else ⇒ no token.
77
- const status = children(buf, respKids[0])[0];
78
- const statusVal = content(buf, status);
79
- if (statusVal.length !== 1 || statusVal[0] > 1)
65
+ const status = content(buf, children(buf, respKids[0])[0]);
66
+ if (status.length !== 1 || status[0] > 1)
80
67
  throw new Asn1Error('PKIStatus not granted');
81
- const contentInfo = respKids[1];
82
- if (!contentInfo)
83
- throw new Asn1Error('no timeStampToken');
84
- const ciKids = children(buf, contentInfo);
68
+ const ciKids = children(buf, respKids[1]);
85
69
  if (oidToString(buf, ciKids[0]) !== OID_SIGNED_DATA)
86
70
  throw new Asn1Error('not signedData');
87
- const signedData = children(buf, ciKids[1])[0]; // [0] EXPLICIT → SignedData
88
- const sd = children(buf, signedData);
89
- const encap = sd.find((c) => c.tag === 0x30); // first SEQUENCE = encapContentInfo
90
- const signerInfos = sd.filter((c) => c.tag === 0x31).pop(); // last SET = signerInfos
71
+ const sd = children(buf, children(buf, ciKids[1])[0]);
72
+ const encap = sd.find((c) => c.tag === 0x30);
73
+ const certsNode = sd.find((c) => c.tag === 0xa0); // certificates [0] IMPLICIT
74
+ const signerInfos = sd.filter((c) => c.tag === 0x31).pop();
91
75
  if (!encap || !signerInfos)
92
76
  throw new Asn1Error('malformed SignedData');
93
77
  const encapKids = children(buf, encap);
94
78
  if (oidToString(buf, encapKids[0]) !== OID_CT_TSTINFO)
95
79
  throw new Asn1Error('eContent not TSTInfo');
96
- const tstOctet = children(buf, encapKids[1])[0]; // [0] EXPLICIT → OCTET STRING
80
+ const tstOctet = children(buf, encapKids[1])[0];
97
81
  if (tstOctet.tag !== 0x04)
98
82
  throw new Asn1Error('TSTInfo not an OCTET STRING');
99
- const tstInfo = buf.slice(tstOctet.contentStart, tstOctet.contentEnd);
100
- const signerInfo = children(buf, signerInfos)[0];
101
- const si = children(buf, signerInfo);
83
+ const si = children(buf, children(buf, signerInfos)[0]);
102
84
  const signedAttrs = si.find((c) => c.tag === 0xa0);
103
85
  const signature = si.find((c) => c.tag === 0x04);
104
86
  if (!signedAttrs || !signature)
105
87
  throw new Asn1Error('signerInfo missing signedAttrs/signature');
106
- // SignerInfo has three SEQUENCEs: sid (issuerAndSerialNumber), digestAlgorithm,
107
- // signatureAlgorithm. The digest alg is the LAST 0x30 before signedAttrs (the sid
108
- // also precedes it); the signature alg is the first 0x30 after.
109
88
  const seqs = si.filter((c) => c.tag === 0x30);
110
89
  const digestAlg = seqs.filter((c) => c.end <= signedAttrs.start).pop();
111
90
  const signatureAlg = seqs.find((c) => c.start >= signedAttrs.end);
112
91
  if (!digestAlg || !signatureAlg)
113
92
  throw new Asn1Error('signerInfo missing algorithm ids');
93
+ const certs = certsNode ? children(buf, certsNode).map((n) => parseCertificate(buf, n)) : [];
114
94
  return {
115
- tstInfo,
95
+ tstInfo: buf.slice(tstOctet.contentStart, tstOctet.contentEnd),
116
96
  signedAttrs,
117
97
  digestAlgOid: algorithmOid(buf, digestAlg),
118
98
  signatureAlgOid: algorithmOid(buf, signatureAlg),
119
99
  signature: content(buf, signature),
100
+ certs,
120
101
  };
121
102
  }
122
- /** The OCTET STRING value of the messageDigest signed attribute. */
123
103
  function messageDigestAttr(buf, signedAttrs) {
124
104
  for (const attr of children(buf, signedAttrs)) {
125
105
  const kids = children(buf, attr);
126
106
  if (oidToString(buf, kids[0]) === OID_ATTR_MESSAGE_DIGEST) {
127
- const value = children(buf, kids[1])[0]; // SET OF → OCTET STRING
128
- return content(buf, value);
107
+ return content(buf, children(buf, kids[1])[0]);
129
108
  }
130
109
  }
131
110
  return null;
132
111
  }
133
- /** Pull { hashedMessage, hashAlgOid, genTime } out of a TSTInfo. */
134
112
  function parseTstInfo(tst) {
135
- const root = readNode(tst, 0);
136
- const kids = children(tst, root); // version, policy, messageImprint, serial, genTime, …
137
- const messageImprint = kids[2];
138
- const miKids = children(tst, messageImprint);
139
- const hashAlgOid = algorithmOid(tst, miKids[0]);
140
- const hashedMessage = content(tst, miKids[1]);
113
+ const kids = children(tst, readNode(tst, 0));
114
+ const miKids = children(tst, kids[2]);
141
115
  const genTimeNode = kids.find((c) => c.tag === 0x18);
142
116
  if (!genTimeNode)
143
117
  throw new Asn1Error('TSTInfo missing genTime');
144
- return { hashedMessage, hashAlgOid, genTime: readTime(tst, genTimeNode) };
118
+ return {
119
+ hashedMessage: content(tst, miKids[1]),
120
+ hashAlgOid: algorithmOid(tst, miKids[0]),
121
+ genTime: readTime(tst, genTimeNode),
122
+ };
123
+ }
124
+ /**
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.
129
+ */
130
+ 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' };
145
150
  }
146
- function bytesEqual(a, b) {
147
- if (a.length !== b.length)
148
- return false;
149
- let diff = 0;
150
- for (let i = 0; i < a.length; i++)
151
- diff |= a[i] ^ b[i];
152
- return diff === 0;
151
+ function fail(base, reason) {
152
+ return { ...base, reason };
153
153
  }
154
154
  /**
155
- * Verify an RFC 3161 timestamp token (base64) attests `expectedHashHex` (the package
156
- * SHA-256), against the pinned TSA anchors. Returns a graded verdict; a parse failure
157
- * or unknown anchor is reported (not thrown) so the caller can render it calmly,
158
- * while a real cryptographic failure (`signature_invalid` / `imprint_mismatch` /
159
- * `content_not_bound`) is the alarm signal.
155
+ * Verify an RFC 3161 token (base64) attests `expectedHashHex` (the package SHA-256),
156
+ * chaining the token's embedded signing cert to a pinned TSA root. Async (the cert
157
+ * chain uses WebCrypto RSA). A parse failure / untrusted chain is reported, not
158
+ * thrown; `imprint_mismatch` / `content_not_bound` / `chain_signature_invalid` /
159
+ * `signature_invalid` are the alarm signals.
160
160
  */
161
- export function verifyRfc3161Timestamp(tokenB64, expectedHashHex, anchors = FREETSA_TSA_ANCHORS) {
161
+ export async function verifyRfc3161Timestamp(tokenB64, expectedHashHex, anchors = FREETSA_ROOT_ANCHORS) {
162
162
  let buf;
163
163
  let token;
164
164
  let tst;
@@ -173,74 +173,67 @@ export function verifyRfc3161Timestamp(tokenB64, expectedHashHex, anchors = FREE
173
173
  return {
174
174
  verified: false,
175
175
  imprintMatches: null,
176
- signatureValid: null,
177
176
  contentBound: null,
177
+ chainVerified: null,
178
+ ekuTimestamping: null,
178
179
  withinValidity: null,
180
+ signatureValid: null,
179
181
  reason: 'parse_error',
180
182
  };
181
183
  }
182
184
  const base = {
183
185
  verified: false,
184
186
  imprintMatches: null,
185
- signatureValid: null,
186
187
  contentBound: null,
188
+ chainVerified: null,
189
+ ekuTimestamping: null,
187
190
  withinValidity: null,
191
+ signatureValid: null,
188
192
  genTime: tst.genTime,
189
193
  };
190
- // FreeTSA profile (P2a): SHA-512 digest + ECDSA-with-SHA-512 signature.
191
194
  if (token.digestAlgOid !== OID_SHA512 || token.signatureAlgOid !== OID_ECDSA_SHA512) {
192
- return { ...base, reason: 'unexpected_algorithm' };
195
+ return fail(base, 'unexpected_algorithm');
193
196
  }
194
- // (1) message imprint is about THIS package (sha256).
195
- const imprintMatches = tst.hashAlgOid === OID_SHA256 && bytesToHex(tst.hashedMessage) === expectedHashHex.toLowerCase();
196
- base.imprintMatches = imprintMatches;
197
- if (!imprintMatches)
198
- return { ...base, reason: 'imprint_mismatch' };
199
- // (2) the signature is bound to this TSTInfo via the messageDigest attribute.
197
+ base.imprintMatches =
198
+ tst.hashAlgOid === OID_SHA256 && bytesToHex(tst.hashedMessage) === expectedHashHex.toLowerCase();
199
+ if (!base.imprintMatches)
200
+ return fail(base, 'imprint_mismatch');
200
201
  if (!mdAttr)
201
- return { ...base, reason: 'no_message_digest' };
202
- const contentBound = bytesEqual(mdAttr, sha512(token.tstInfo));
203
- base.contentBound = contentBound;
204
- if (!contentBound)
205
- return { ...base, reason: 'content_not_bound' };
206
- // (3) ECDSA-P384 signature over SHA-512(signedAttrs re-tagged [0] → SET) verifies
207
- // against a pinned anchor key. (`@noble` prehashes with SHA-384 by default, so we
208
- // hash with SHA-512 ourselves and pass `prehash: false`.)
202
+ return fail(base, 'no_message_digest');
203
+ base.contentBound = bytesEqual(mdAttr, sha512(token.tstInfo));
204
+ if (!base.contentBound)
205
+ return fail(base, 'content_not_bound');
206
+ // The signing cert is the embedded cert with EKU id-kp-timeStamping.
207
+ const leaf = token.certs.find((c) => c.ekus.includes(OID_EKU_TIMESTAMPING));
208
+ if (!leaf) {
209
+ base.ekuTimestamping = token.certs.length > 0 ? false : null;
210
+ return fail(base, token.certs.length > 0 ? 'eku_not_timestamping' : 'no_signing_cert');
211
+ }
212
+ base.ekuTimestamping = true;
213
+ base.withinValidity = tst.genTime >= leaf.notBefore && tst.genTime <= leaf.notAfter;
214
+ const chain = await validateChainToRoot(token.certs, leaf, anchors, tst.genTime);
215
+ base.chainVerified = chain.ok;
216
+ if (!chain.ok)
217
+ return fail(base, chain.reason ?? 'untrusted_root');
218
+ base.tsa = chain.tsa;
219
+ if (!base.withinValidity)
220
+ return fail(base, 'genTime_outside_validity');
221
+ // The TSA signature: ECDSA-P384 over SHA-512(signedAttrs re-tagged [0]→SET),
222
+ // under the chain-validated leaf key. lowS:false — a TSA emits high-S (#119 fix).
209
223
  const signedBytes = Uint8Array.from(rawTlv(buf, token.signedAttrs));
210
- signedBytes[0] = 0x31; // [0] IMPLICIT (0xa0) → SET OF (0x31) for the signature input
211
- const digest = sha512(signedBytes);
212
- let matched;
213
- for (const anchor of anchors) {
214
- try {
215
- const point = extractP384Point(anchor.signingKeyDer);
216
- // `lowS: false` is REQUIRED here: low-S is a signature-malleability convention
217
- // for SIGNING, not a validity rule. A TSA (like FreeTSA) legitimately emits
218
- // high-S ECDSA signatures (~half of them); `@noble`'s default `lowS: true`
219
- // would false-negative those (it rejected the real high-S da9246 token —
220
- // regression-tested). Accepting high-S is correct: both S and n−S are valid
221
- // signatures by the same key, and we only ask "did this TSA sign this hash".
222
- if (p384.verify(token.signature, digest, point, { format: 'der', prehash: false, lowS: false })) {
223
- matched = anchor;
224
- break;
225
- }
226
- }
227
- catch {
228
- // malformed anchor key ⇒ try the next
229
- }
224
+ signedBytes[0] = 0x31;
225
+ try {
226
+ const point = extractP384Point(leaf.spkiDer);
227
+ base.signatureValid = p384.verify(token.signature, sha512(signedBytes), point, {
228
+ format: 'der',
229
+ prehash: false,
230
+ lowS: false,
231
+ });
230
232
  }
231
- base.signatureValid = !!matched;
232
- if (!matched) {
233
- // Distinguish "no pinned key vouches for this token" (calm — unknown TSA) from a
234
- // signature that fails under a known key. Here, none verified ⇒ no_anchor.
235
- return { ...base, reason: 'no_anchor' };
233
+ catch {
234
+ base.signatureValid = false;
236
235
  }
237
- base.tsa = matched.name;
238
- // (4) genTime within the pinned signing cert's validity window.
239
- const notBefore = Date.parse(matched.notBefore);
240
- const notAfter = Date.parse(matched.notAfter);
241
- const withinValidity = tst.genTime >= notBefore && tst.genTime <= notAfter;
242
- base.withinValidity = withinValidity;
243
- if (!withinValidity)
244
- return { ...base, reason: 'genTime_outside_validity' };
236
+ if (!base.signatureValid)
237
+ return fail(base, 'signature_invalid');
245
238
  return { ...base, verified: true };
246
239
  }
package/dist/verify.js CHANGED
@@ -90,7 +90,7 @@ export async function verifyEvidence(input, deps) {
90
90
  // the timestamped hash against the pinned TSA anchor (#119 P2a). Pure/offline.
91
91
  let rfc3161 = null;
92
92
  if (input.rfc3161Timestamp && packageHash) {
93
- rfc3161 = verifyRfc3161Timestamp(input.rfc3161Timestamp, packageHash);
93
+ rfc3161 = await verifyRfc3161Timestamp(input.rfc3161Timestamp, packageHash);
94
94
  }
95
95
  // Step 3b — blob references embedded in the package (check #9).
96
96
  let blobRefs = [];
@@ -101,10 +101,21 @@ export async function verifyEvidence(input, deps) {
101
101
  blobRefsVerified = blobRefs.every((r) => r.ok);
102
102
  }
103
103
  }
104
- // Step 4 — key trust against the registry (check #5).
104
+ // Step 4 — key trust against the registry (check #5). A VERIFIED RFC 3161 genTime
105
+ // is a cryptographically attested upper bound on when the package was signed — a
106
+ // second time source alongside Rekor's `integratedTime` (#119 P2a). Use the
107
+ // EARLIEST attested time: the signature predates any timestamp over its hash, so
108
+ // the smallest attested time is the strongest proof a deprecated-key signature was
109
+ // made before deprecation. Both are in SECONDS for `verifyKeyTrust` (genTime is ms).
110
+ let signedBeforeTime = rekorIntegratedTime;
111
+ if (rfc3161?.verified && rfc3161.genTime !== undefined) {
112
+ const genTimeSec = Math.floor(rfc3161.genTime / 1000);
113
+ signedBeforeTime =
114
+ signedBeforeTime === undefined ? genTimeSec : Math.min(signedBeforeTime, genTimeSec);
115
+ }
105
116
  let keyTrust = null;
106
117
  if (sigPublicKey && sigKid) {
107
- keyTrust = verifyKeyTrust(sigPublicKey, sigKid, rekorIntegratedTime, deps.registry);
118
+ keyTrust = verifyKeyTrust(sigPublicKey, sigKid, signedBeforeTime, deps.registry);
108
119
  }
109
120
  else if (sigPublicKey) {
110
121
  keyTrust = legacyEmbeddedKeyTrust();
package/dist/x509.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { type DerNode } from './asn1.ts';
2
+ export declare const OID_EKU_TIMESTAMPING = "1.3.6.1.5.5.7.3.8";
3
+ export interface X509Cert {
4
+ /** The signed TBSCertificate (raw TLV) — exactly what the issuer signed. */
5
+ tbsBytes: Uint8Array;
6
+ /** Outer signatureAlgorithm OID. */
7
+ sigAlgOid: string;
8
+ /** signatureValue (BIT STRING content, the leading unused-bits octet dropped). */
9
+ signature: Uint8Array;
10
+ /** SubjectPublicKeyInfo (raw TLV) — this cert's public key. */
11
+ spkiDer: Uint8Array;
12
+ /** serialNumber content octets. */
13
+ serial: Uint8Array;
14
+ /** issuer / subject Name (raw TLV) — compared by bytes for chain linking. */
15
+ issuerDer: Uint8Array;
16
+ subjectDer: Uint8Array;
17
+ notBefore: number;
18
+ notAfter: number;
19
+ /** Extended Key Usage OIDs (empty if the extension is absent). */
20
+ ekus: string[];
21
+ /** basicConstraints cA:TRUE. */
22
+ isCA: boolean;
23
+ }
24
+ /** Parse a Certificate (SEQUENCE { tbsCertificate, signatureAlgorithm, signature }). */
25
+ export declare function parseCertificate(buf: Uint8Array, node: DerNode): X509Cert;
26
+ export declare function bytesEqual(a: Uint8Array, b: Uint8Array): boolean;
27
+ /**
28
+ * Verify `cert.signature` over `cert.tbsBytes` using `issuerSpkiDer` (RSA), via
29
+ * WebCrypto (`globalThis.crypto.subtle` — a browser/Node global, not a `node:`
30
+ * import, so verify-core stays browser-safe). Returns false for an unsupported
31
+ * algorithm or any verification error.
32
+ */
33
+ export declare function verifyCertSignatureRsa(cert: X509Cert, issuerSpkiDer: Uint8Array): Promise<boolean>;
package/dist/x509.js ADDED
@@ -0,0 +1,101 @@
1
+ // Minimal X.509 certificate parsing (civic-ai-tools-website#119 P2b) — browser-safe.
2
+ //
3
+ // Just enough of RFC 5280 to validate the RFC 3161 token's embedded cert chain: the
4
+ // signed TBSCertificate bytes, the outer signature + algorithm, the public key, the
5
+ // 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.
8
+ import { readNode, children, content, rawTlv, oidToString, readTime, Asn1Error, } from "./asn1.js";
9
+ export const OID_EKU_TIMESTAMPING = '1.3.6.1.5.5.7.3.8';
10
+ const OID_EXT_EKU = '2.5.29.37';
11
+ const OID_EXT_BASIC_CONSTRAINTS = '2.5.29.19';
12
+ /** Parse a Certificate (SEQUENCE { tbsCertificate, signatureAlgorithm, signature }). */
13
+ export function parseCertificate(buf, node) {
14
+ const [tbs, sigAlg, sigBits] = children(buf, node);
15
+ if (!tbs || !sigAlg || !sigBits)
16
+ throw new Asn1Error('malformed Certificate');
17
+ // TBSCertificate: [ version [0] EXPLICIT (optional), serialNumber, signature,
18
+ // issuer, validity, subject, subjectPublicKeyInfo, ... extensions [3] ]
19
+ const tk = children(buf, tbs);
20
+ let i = 0;
21
+ if (tk[i]?.tag === 0xa0)
22
+ i++; // optional version [0]
23
+ const serial = content(buf, tk[i++]);
24
+ i++; // inner signature AlgorithmIdentifier (redundant; skip)
25
+ const issuer = tk[i++];
26
+ const validity = tk[i++];
27
+ const subject = tk[i++];
28
+ const spki = tk[i++];
29
+ if (!issuer || !validity || !subject || !spki)
30
+ throw new Asn1Error('malformed TBSCertificate');
31
+ const [nb, na] = children(buf, validity);
32
+ if (!nb || !na)
33
+ throw new Asn1Error('malformed Validity');
34
+ const ekus = [];
35
+ let isCA = false;
36
+ const extsWrap = tk.slice(i).find((c) => c.tag === 0xa3); // extensions [3] EXPLICIT
37
+ if (extsWrap) {
38
+ for (const ext of children(buf, children(buf, extsWrap)[0])) {
39
+ const ec = children(buf, ext); // Extension { extnID OID, critical BOOL?, extnValue OCTET }
40
+ const oid = oidToString(buf, ec[0]);
41
+ const value = content(buf, ec[ec.length - 1]); // extnValue OCTET STRING content (nested DER)
42
+ if (oid === OID_EXT_EKU) {
43
+ for (const kp of children(value, readNode(value, 0)))
44
+ ekus.push(oidToString(value, kp));
45
+ }
46
+ else if (oid === OID_EXT_BASIC_CONSTRAINTS) {
47
+ const bc = children(value, readNode(value, 0));
48
+ if (bc[0]?.tag === 0x01 && content(value, bc[0])[0] === 0xff)
49
+ isCA = true;
50
+ }
51
+ }
52
+ }
53
+ return {
54
+ tbsBytes: rawTlv(buf, tbs),
55
+ sigAlgOid: oidToString(buf, children(buf, sigAlg)[0]),
56
+ signature: content(buf, sigBits).slice(1),
57
+ spkiDer: rawTlv(buf, spki),
58
+ serial,
59
+ issuerDer: rawTlv(buf, issuer),
60
+ subjectDer: rawTlv(buf, subject),
61
+ notBefore: readTime(buf, nb),
62
+ notAfter: readTime(buf, na),
63
+ ekus,
64
+ isCA,
65
+ };
66
+ }
67
+ export function bytesEqual(a, b) {
68
+ if (a.length !== b.length)
69
+ return false;
70
+ for (let i = 0; i < a.length; i++)
71
+ if (a[i] !== b[i])
72
+ return false;
73
+ return true;
74
+ }
75
+ // RSA signature-algorithm OID → WebCrypto SHA hash name. Only the SHA-2 RSASSA-
76
+ // PKCS1-v1.5 variants a TSA chain uses are accepted.
77
+ const RSA_SIG_HASH = {
78
+ '1.2.840.113549.1.1.11': 'SHA-256',
79
+ '1.2.840.113549.1.1.12': 'SHA-384',
80
+ '1.2.840.113549.1.1.13': 'SHA-512',
81
+ };
82
+ /**
83
+ * Verify `cert.signature` over `cert.tbsBytes` using `issuerSpkiDer` (RSA), via
84
+ * WebCrypto (`globalThis.crypto.subtle` — a browser/Node global, not a `node:`
85
+ * import, so verify-core stays browser-safe). Returns false for an unsupported
86
+ * algorithm or any verification error.
87
+ */
88
+ export async function verifyCertSignatureRsa(cert, issuerSpkiDer) {
89
+ const hash = RSA_SIG_HASH[cert.sigAlgOid];
90
+ if (!hash)
91
+ return false;
92
+ try {
93
+ // Copy into fresh ArrayBuffer-backed views: subarray slices are
94
+ // `Uint8Array<ArrayBufferLike>`, which WebCrypto's `BufferSource` rejects.
95
+ const key = await globalThis.crypto.subtle.importKey('spki', new Uint8Array(issuerSpkiDer), { name: 'RSASSA-PKCS1-v1_5', hash }, false, ['verify']);
96
+ return await globalThis.crypto.subtle.verify({ name: 'RSASSA-PKCS1-v1_5' }, key, new Uint8Array(cert.signature), new Uint8Array(cert.tbsBytes));
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typedstandards/verify-core",
3
- "version": "0.3.1",
3
+ "version": "0.5.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",