@typedstandards/verify-core 0.3.1 → 0.4.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 +1 -0
- package/dist/index.js +1 -0
- package/dist/rfc3161.d.ts +24 -26
- package/dist/rfc3161.js +131 -138
- package/dist/verify.js +14 -3
- package/dist/x509.d.ts +33 -0
- package/dist/x509.js +101 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/rfc3161.d.ts
CHANGED
|
@@ -1,41 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A pinned TSA
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
7
|
+
export interface TsaRootAnchor {
|
|
8
8
|
name: string;
|
|
9
|
-
/**
|
|
10
|
-
|
|
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
|
|
16
|
-
export type Rfc3161FailReason = 'parse_error' | '
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
28
|
+
/** The matched root anchor name. */
|
|
31
29
|
tsa?: string;
|
|
32
30
|
reason?: Rfc3161FailReason;
|
|
33
31
|
}
|
|
34
32
|
/**
|
|
35
|
-
* Verify an RFC 3161
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* `
|
|
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
|
|
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`
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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)
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// 4.
|
|
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
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
-
|
|
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
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// `https://freetsa.org/files/
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
58
|
-
|
|
59
|
-
|
|
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 (
|
|
64
|
-
throw new Asn1Error('
|
|
54
|
+
if (spkiDer[i] !== EC_P384_SPKI_PREFIX[i])
|
|
55
|
+
throw new Asn1Error('unexpected P-384 SPKI prefix');
|
|
65
56
|
}
|
|
66
|
-
return
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
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
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
const signerInfos = sd.filter((c) => c.tag === 0x31).pop();
|
|
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];
|
|
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
|
|
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
|
-
|
|
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
|
|
136
|
-
const
|
|
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 {
|
|
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
|
|
147
|
-
|
|
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
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* `
|
|
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 =
|
|
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
|
|
195
|
+
return fail(base, 'unexpected_algorithm');
|
|
193
196
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
base.imprintMatches
|
|
197
|
-
|
|
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
|
|
202
|
-
|
|
203
|
-
base.contentBound
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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.
|
|
238
|
-
|
|
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,
|
|
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
|
+
"version": "0.4.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",
|